persona-community-3/services/persona-api/internal/service/album.go
2026-02-23 11:10:52 +00:00

187 lines
6.0 KiB
Go

package service
import (
"context"
"fmt"
"git.threesix.ai/jordan/persona-community-3/pkg/album"
"git.threesix.ai/jordan/persona-community-3/pkg/logging"
"git.threesix.ai/jordan/persona-community-3/pkg/queue"
"git.threesix.ai/jordan/persona-community-3/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:<userId> 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
}