package handlers import ( "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "git.threesix.ai/jordan/persona-community-2/pkg/album" "git.threesix.ai/jordan/persona-community-2/pkg/app" "git.threesix.ai/jordan/persona-community-2/pkg/auth" "git.threesix.ai/jordan/persona-community-2/pkg/httperror" "git.threesix.ai/jordan/persona-community-2/pkg/httpresponse" "git.threesix.ai/jordan/persona-community-2/pkg/logging" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service" ) // Album handles HTTP requests for album CRUD and generation endpoints. // All generation endpoints are async: they enqueue a job and return 202. // Results arrive via SSE events on the user: channel. type Album struct { albums *service.AlbumService logger *logging.Logger } // NewAlbum creates a new Album handler. func NewAlbum(albums *service.AlbumService, logger *logging.Logger) *Album { return &Album{ albums: albums, logger: logger.WithComponent("AlbumHandler"), } } // --------------------------------------------------------------------------- // Request/response types // --------------------------------------------------------------------------- // CreateAlbumRequest is the request body for POST /albums. type CreateAlbumRequest struct { Name string `json:"name" validate:"required,min=1,max=100"` SubjectDesc string `json:"subjectDesc" validate:"required,min=1,max=500"` Shots []ShotTemplateBody `json:"shots" validate:"required,min=1,max=20"` TemplateSet string `json:"templateSet"` // Optional: "portrait", "product", "character" } // ShotTemplateBody is a single shot spec in the create request. type ShotTemplateBody struct { Label string `json:"label" validate:"required"` Direction string `json:"direction" validate:"required"` } // AlbumJobResponse is the response for generation enqueue endpoints. type AlbumJobResponse struct { JobID string `json:"jobId"` } // AlbumJobsResponse is the response for bulk generation enqueue. type AlbumJobsResponse struct { JobIDs []string `json:"jobIds"` } // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- // Create handles POST /albums — creates a new album with shot specs. func (h *Album) Create(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } var req CreateAlbumRequest if err := app.BindAndValidate(r, &req); err != nil { return err } // If a template set was provided, use it (overrides explicit shots). var shots []album.ShotTemplate if req.TemplateSet != "" { set, ok := album.ShotTemplateSets[req.TemplateSet] if !ok { return httperror.BadRequest("unknown template set: " + req.TemplateSet) } shots = set } else { // Convert body shots to ShotTemplate. shots = make([]album.ShotTemplate, len(req.Shots)) for i, s := range req.Shots { shots[i] = album.ShotTemplate{Label: s.Label, Direction: s.Direction} } } a, err := h.albums.Create(r.Context(), user.ID, req.Name, req.SubjectDesc, shots) if err != nil { h.logger.Error("failed to create album", "error", err, "user_id", user.ID) return httperror.BadRequest(err.Error()) } httpresponse.Created(w, r, a) return nil } // List handles GET /albums — returns all albums for the authenticated user. func (h *Album) List(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } albums, err := h.albums.List(r.Context(), user.ID) if err != nil { h.logger.Error("failed to list albums", "error", err, "user_id", user.ID) return httperror.Internal("failed to list albums") } if albums == nil { albums = []album.Album{} } httpresponse.OK(w, r, albums) return nil } // Get handles GET /albums/{id} — returns a single album with all shot statuses. func (h *Album) Get(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } id := album.AlbumID(chi.URLParam(r, "id")) if id == "" { return httperror.BadRequest("album ID is required") } a, err := h.albums.Get(r.Context(), id, user.ID) if err != nil { return httperror.NotFound("album not found") } httpresponse.OK(w, r, a) return nil } // Delete handles DELETE /albums/{id} — deletes an album. func (h *Album) Delete(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } id := album.AlbumID(chi.URLParam(r, "id")) if id == "" { return httperror.BadRequest("album ID is required") } if err := h.albums.Delete(r.Context(), id, user.ID); err != nil { return httperror.NotFound("album not found") } httpresponse.NoContent(w) return nil } // --------------------------------------------------------------------------- // Generation (async — returns 202) // --------------------------------------------------------------------------- // GenerateAnchor handles POST /albums/{id}/anchor — enqueues anchor generation. // Returns 202 with job ID. Result arrives via album_anchor_complete SSE event. func (h *Album) GenerateAnchor(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } id := album.AlbumID(chi.URLParam(r, "id")) if id == "" { return httperror.BadRequest("album ID is required") } jobID, err := h.albums.GenerateAnchor(r.Context(), id, user.ID) if err != nil { h.logger.Error("failed to enqueue anchor job", "error", err, "album_id", string(id)) return httperror.NotFound("album not found") } h.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID) httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID}) return nil } // GenerateAllShots handles POST /albums/{id}/shots — enqueues all pending shots. // Returns 422 if the album has no anchor yet. // Returns 202 with job IDs for all enqueued shots. func (h *Album) GenerateAllShots(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } id := album.AlbumID(chi.URLParam(r, "id")) if id == "" { return httperror.BadRequest("album ID is required") } jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID) if err != nil { if errors.Is(err, album.ErrAnchorRequired) { return httperror.UnprocessableEntity("anchor must be generated before shots") } return httperror.NotFound("album not found") } if jobIDs == nil { jobIDs = []string{} } h.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs)) httpresponse.Accepted(w, r, AlbumJobsResponse{JobIDs: jobIDs}) return nil } // GenerateShot handles POST /albums/{id}/shots/{index} — enqueues a single shot (for regeneration). func (h *Album) GenerateShot(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } id := album.AlbumID(chi.URLParam(r, "id")) if id == "" { return httperror.BadRequest("album ID is required") } shotIndex, err := parseShotIndex(chi.URLParam(r, "index")) if err != nil { return httperror.BadRequest("shot index must be a non-negative integer") } jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex) if err != nil { if errors.Is(err, album.ErrAnchorRequired) { return httperror.UnprocessableEntity("anchor must be generated before shots") } return httperror.NotFound("album or shot not found") } httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID}) return nil } // ResetShot handles DELETE /albums/{id}/shots/{index} — resets a shot to pending. func (h *Album) ResetShot(w http.ResponseWriter, r *http.Request) error { user := auth.GetUser(r.Context()) if user == nil { return httperror.Unauthorized("authentication required") } id := album.AlbumID(chi.URLParam(r, "id")) if id == "" { return httperror.BadRequest("album ID is required") } shotIndex, err := parseShotIndex(chi.URLParam(r, "index")) if err != nil { return httperror.BadRequest("shot index must be a non-negative integer") } if err := h.albums.ResetShot(r.Context(), id, user.ID, shotIndex); err != nil { return httperror.NotFound("album or shot not found") } httpresponse.NoContent(w) return nil } // parseShotIndex parses and validates the shot index URL parameter. // Returns an error if the value is missing, non-numeric, or negative. func parseShotIndex(idx string) (int, error) { if idx == "" { return 0, errors.New("missing shot index") } n, err := strconv.Atoi(idx) if err != nil || n < 0 { return 0, errors.New("shot index must be a non-negative integer") } return n, nil }