292 lines
8.9 KiB
Go
292 lines
8.9 KiB
Go
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:<userId> 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
|
|
}
|