package service import ( "context" "fmt" "git.threesix.ai/jordan/persona-community-2/pkg/album" "git.threesix.ai/jordan/persona-community-2/pkg/logging" "git.threesix.ai/jordan/persona-community-2/pkg/queue" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port" ) // AlbumService handles album creation, retrieval, and generation orchestration. // All generation is async: service enqueues jobs and returns immediately. // Results arrive via SSE on the user: channel. type AlbumService struct { albums port.AlbumRepository queue queue.Producer logger *logging.Logger } // NewAlbumService creates a new AlbumService. func NewAlbumService(albums port.AlbumRepository, q queue.Producer, logger *logging.Logger) *AlbumService { return &AlbumService{ albums: albums, queue: q, logger: logger.WithComponent("AlbumService"), } } // Create creates a new album with the given shots and persists it. // Shots are provided as ShotTemplate slices (Label + Direction). func (s *AlbumService) Create(ctx context.Context, userID, name, subjectDesc string, shots []album.ShotTemplate) (*album.Album, error) { if name == "" { return nil, fmt.Errorf("album name is required") } if subjectDesc == "" { return nil, fmt.Errorf("subject description is required") } if len(shots) == 0 { return nil, fmt.Errorf("at least one shot is required") } if len(shots) > 20 { return nil, fmt.Errorf("maximum 20 shots per album") } shotList := make([]album.Shot, len(shots)) for i, tmpl := range shots { shotList[i] = album.Shot{ Index: i, Label: tmpl.Label, Direction: tmpl.Direction, Status: album.ShotPending, } } a := &album.Album{ ID: album.AlbumID("alb_" + generateID()), UserID: userID, Name: name, SubjectDesc: subjectDesc, Shots: shotList, } if err := s.albums.Create(ctx, a); err != nil { return nil, fmt.Errorf("create album: %w", err) } s.logger.Info("album created", "album_id", string(a.ID), "user_id", userID, "shots", len(shotList)) return a, nil } // Get returns an album by ID, enforcing user ownership. func (s *AlbumService) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) { a, err := s.albums.Get(ctx, id, userID) if err != nil { return nil, fmt.Errorf("album not found: %w", err) } return a, nil } // List returns all albums for a user. func (s *AlbumService) List(ctx context.Context, userID string) ([]album.Album, error) { return s.albums.List(ctx, userID) } // Delete removes an album. Does NOT delete stored images. func (s *AlbumService) Delete(ctx context.Context, id album.AlbumID, userID string) error { return s.albums.Delete(ctx, id, userID) } // GenerateAnchor enqueues an anchor generation job for an album. // Returns the job ID. Result arrives via album_anchor_complete SSE event. func (s *AlbumService) GenerateAnchor(ctx context.Context, id album.AlbumID, userID string) (string, error) { a, err := s.albums.Get(ctx, id, userID) if err != nil { return "", fmt.Errorf("album not found: %w", err) } jobID, err := s.queue.Enqueue(ctx, "generate_anchor", map[string]any{ "albumId": string(a.ID), "userId": userID, "subjectDesc": a.SubjectDesc, }) if err != nil { return "", fmt.Errorf("enqueue anchor job: %w", err) } if err := s.albums.UpdateAnchorJobID(ctx, id, userID, jobID); err != nil { s.logger.Warn("failed to persist anchor job ID", "error", err, "album_id", string(id)) } s.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID) return jobID, nil } // GenerateAllShots enqueues generation jobs for all pending shots. // Returns 422 if the album has no anchor yet (shots require an anchor reference). func (s *AlbumService) GenerateAllShots(ctx context.Context, id album.AlbumID, userID string) ([]string, error) { a, err := s.albums.Get(ctx, id, userID) if err != nil { return nil, fmt.Errorf("album not found: %w", err) } if a.AnchorURL == "" { return nil, album.ErrAnchorRequired } var jobIDs []string for _, shot := range a.Shots { if shot.Status != album.ShotPending && shot.Status != album.ShotFailed { continue } jobID, err := s.enqueueShotJob(ctx, a, shot.Index) if err != nil { s.logger.Error("failed to enqueue shot", "error", err, "album_id", string(id), "shot_index", shot.Index) continue } jobIDs = append(jobIDs, jobID) } s.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs)) return jobIDs, nil } // GenerateShot enqueues a generation job for a single shot (for regeneration). func (s *AlbumService) GenerateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) (string, error) { a, err := s.albums.Get(ctx, id, userID) if err != nil { return "", fmt.Errorf("album not found: %w", err) } if a.AnchorURL == "" { return "", fmt.Errorf("anchor must be generated before shots") } if shotIndex < 0 || shotIndex >= len(a.Shots) { return "", fmt.Errorf("shot index out of range: %d", shotIndex) } return s.enqueueShotJob(ctx, a, shotIndex) } // ResetShot clears a shot back to pending so it can be regenerated. func (s *AlbumService) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error { return s.albums.ResetShot(ctx, id, userID, shotIndex) } // enqueueShotJob is the internal helper that enqueues a single shot generation job. func (s *AlbumService) enqueueShotJob(ctx context.Context, a *album.Album, shotIndex int) (string, error) { shot := a.Shots[shotIndex] jobID, err := s.queue.Enqueue(ctx, "generate_shot", map[string]any{ "albumId": string(a.ID), "userId": a.UserID, "shotIndex": shotIndex, "anchorUrl": a.AnchorURL, "subjectDesc": a.SubjectDesc, "direction": shot.Direction, }) if err != nil { return "", fmt.Errorf("enqueue shot job: %w", err) } if err := s.albums.UpdateShotJobID(ctx, a.ID, a.UserID, shotIndex, jobID); err != nil { s.logger.Warn("failed to persist shot job ID", "error", err, "shot_index", shotIndex) } return jobID, nil }