package domain import ( "context" "fmt" "time" archivalRules "git.nakama.town/fmartingr/hako/internal/archival/rules" archivalStore "git.nakama.town/fmartingr/hako/internal/archival/store" "git.nakama.town/fmartingr/hako/internal/dependencies" "git.nakama.town/fmartingr/hako/internal/model" "github.com/google/uuid" ) // LinkDomain handles business logic for links type LinkDomain struct { deps model.Dependencies } // NewLinkDomain creates a new LinkDomain func NewLinkDomain(deps model.Dependencies) model.LinkDomain { return &LinkDomain{deps: deps} } // CreateLink creates a new link and enqueues an archive job func (d *LinkDomain) CreateLink(ctx context.Context, url, userID string) (*model.Link, error) { // Access dependencies deps := d.deps.(*dependencies.Dependencies) // Clean URL by removing tracking parameters before processing url = archivalRules.CleanURL(url) // Check if link already exists for this user existing, err := deps.LinkStore.GetByURL(ctx, url) if err != nil { return nil, fmt.Errorf("failed to check existing link: %w", err) } if existing != nil && existing.UserID == userID { return existing, nil } // Create new link now := time.Now() link := &model.Link{ ID: uuid.New().String(), URL: url, UserID: userID, TotalSize: 0, // Initialize to 0, will be updated when archives complete CreatedAt: now, UpdatedAt: now, } if err := deps.LinkStore.Create(ctx, link); err != nil { return nil, fmt.Errorf("failed to create link: %w", err) } // Analyze URL using HEAD request metadata, err := archivalRules.AnalyzeURL(ctx, url) if err != nil { return nil, fmt.Errorf("failed to analyze URL: %w", err) } // Evaluate rules to determine archivers archivers, err := deps.RuleEngine.Evaluate(ctx, metadata) if err != nil { return nil, fmt.Errorf("failed to evaluate rules: %w", err) } // Log which archivers were selected deps.Logger().Info("Rule engine selected archivers", "url", url, "archiverCount", len(archivers), "archivers", archivers) // If no archivers returned (empty array), skip archiving if len(archivers) == 0 { // Link created but no archive - return link without creating archive deps.Logger().Info("No archivers selected, skipping archiving", "url", url) return link, nil } // Create archive record with pending status archive := &model.Archive{ ID: uuid.New().String(), LinkID: link.ID, UserID: userID, Status: model.ArchiveStatusPending, CreatedAt: now, } // Store now accepts model types directly if err := deps.ArchiveStore.Create(ctx, archive); err != nil { return nil, fmt.Errorf("failed to create archive: %w", err) } // Enqueue archive job with archiver keys archiverKeys := make([]string, len(archivers)) for i, arch := range archivers { archiverKeys[i] = arch.Key } payload := model.ArchiveLinkPayload{ ArchiveID: archive.ID, LinkID: link.ID, ArchiverKeys: archiverKeys, } if err := deps.Queue.Enqueue(ctx, model.JobTypeArchiveLink, payload); err != nil { return nil, fmt.Errorf("failed to enqueue archive job: %w", err) } return link, nil } // GetLink retrieves a link by ID func (d *LinkDomain) GetLink(ctx context.Context, id string) (*model.Link, error) { deps := d.deps.(*dependencies.Dependencies) link, err := deps.LinkStore.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get link: %w", err) } return link, nil } // ListLinks retrieves links for a user with pagination func (d *LinkDomain) ListLinks(ctx context.Context, req model.LinkListRequest) ([]model.LinkListItem, int, error) { req.Defaults() if err := req.IsValid(); err != nil { return nil, 0, fmt.Errorf("invalid request: %w", err) } deps := d.deps.(*dependencies.Dependencies) listOpts := convertLinkListRequestToOptions(req) countOpts := convertLinkListRequestToCountOptions(req) storeLinks, err := deps.LinkStore.List(ctx, listOpts) if err != nil { return nil, 0, fmt.Errorf("failed to list links: %w", err) } count, err := deps.LinkStore.Count(ctx, countOpts) if err != nil { return nil, 0, fmt.Errorf("failed to count links: %w", err) } // Convert to domain structs items := make([]model.LinkListItem, 0, len(storeLinks)) for _, link := range storeLinks { item := model.LinkListItem{ Link: model.Link{ ID: link.ID, URL: link.URL, UserID: link.UserID, TotalSize: link.TotalSize, CreatedAt: link.CreatedAt, UpdatedAt: link.UpdatedAt, }, } // Get latest archive for this link latestArchive, err := deps.ArchiveStore.GetLatestByLinkID(ctx, link.ID) if err == nil && latestArchive != nil { item.LatestArchiveTitle = latestArchive.Title item.LatestArchiveStatus = string(latestArchive.Status) // Get thumbnail (always check, but only include URL if requested) thumbnailFile, err := deps.ArchiveFileStore.GetThumbnailByLinkID(ctx, link.ID) if err == nil && thumbnailFile != nil { item.HasThumbnail = true // Only construct URL if requested if req.IncludeThumbnail { item.ThumbnailURL = fmt.Sprintf("/api/v1/archives/%s/files/%s/download", latestArchive.ID, thumbnailFile.ID) } } else { item.HasThumbnail = false } } items = append(items, item) } return items, count, nil } // DeleteLink deletes a link and all its associated data (cascade delete) func (d *LinkDomain) DeleteLink(ctx context.Context, id string) error { deps := d.deps.(*dependencies.Dependencies) if err := deps.Storage.DeleteDirectory(ctx, id); err != nil { // Log error but continue - directory might not exist deps.Logger().Error("Failed to delete link directory", "linkID", id, "error", err) } if err := deps.LinkStore.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete link: %w", err) } return nil } // Helper functions to convert LinkListRequest to store options func convertLinkListRequestToOptions(req model.LinkListRequest) archivalStore.LinkListOptions { return archivalStore.LinkListOptions{ UserID: req.UserID, Limit: req.Limit, Offset: req.Offset, CategoryID: req.CategoryID, SearchQuery: req.SearchQuery, } } func convertLinkListRequestToCountOptions(req model.LinkListRequest) archivalStore.LinkCountOptions { return archivalStore.LinkCountOptions{ UserID: req.UserID, CategoryID: req.CategoryID, SearchQuery: req.SearchQuery, } }