package imagecard import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/imagecard" "github.com/yuxingu/digital-psychology/apps/api/internal/model" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) // Service draws image cards with quota + entitlement. type Service struct { Profiles *repository.ProfileRepo Reports *repository.ReportRepo Quotas *repository.ImageCardRepo } // DrawInput is POST /image-cards/draw body. type DrawInput struct { Scene string ProfileID uuid.UUID Depth bool } // DrawResult wraps report + cards. type DrawResult struct { Report *model.GrowthReport `json:"report"` Scene string `json:"scene"` Cards []imagecard.Card `json:"cards"` QuotaLeft int `json:"quota_left"` } // Scenes returns reflection scenes. func (s *Service) Scenes() []map[string]string { return imagecard.Scenes() } // Quota returns remaining draws today. func (s *Service) Quota(ctx context.Context, userID uuid.UUID) (map[string]any, error) { vip, err := s.Reports.HasActiveMembership(ctx, userID) if err != nil { return nil, err } left, err := s.Quotas.RemainingToday(ctx, userID, time.Now(), vip) if err != nil { return nil, err } return map[string]any{ "remaining": left, "daily_free": repository.DailyFreeLimit(), "unlimited": vip, }, nil } // Draw consumes quota (unless membership), builds cards, stores report. func (s *Service) Draw(ctx context.Context, userID uuid.UUID, in DrawInput) (*DrawResult, error) { p, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID) if err != nil { return nil, errors.New("profile not found") } vip, err := s.Reports.HasActiveMembership(ctx, userID) if err != nil { return nil, err } now := time.Now() var left int if vip { left, _ = s.Quotas.RemainingToday(ctx, userID, now, true) } else { left, err = s.Quotas.TryConsume(ctx, userID, now) if err != nil { return nil, err } } wantDeep := in.Depth && vip out := imagecard.Draw(userID.String(), in.Scene, wantDeep || in.Depth, now) // Persist full detail when depth requested; entitlement strips on read. sum, _ := json.Marshal(out.Summary) detPayload := out.Detail if in.Depth && detPayload == nil { // force 3-card detail generation for unlock-after-pay path full := imagecard.Draw(userID.String(), in.Scene, true, now) detPayload = full.Detail out.Cards = full.Cards sum, _ = json.Marshal(full.Summary) } det, _ := json.Marshal(detPayload) if detPayload == nil { det = []byte("{}") } rep, err := s.Reports.Create(ctx, userID, p.ID, "image_card", sum, det) if err != nil { return nil, err } deep, _ := s.Reports.HasDeepAccess(ctx, userID, rep.ID) rep.HasDeep = deep || vip if !rep.HasDeep { rep.Detail = nil if !wantDeep { // free single-card: already summary-only } } cards := out.Cards if !rep.HasDeep && len(cards) > 1 { cards = cards[:1] } return &DrawResult{ Report: rep, Scene: out.Scene, Cards: cards, QuotaLeft: left, }, nil }