feat: P1 profile/portrait API and freeze local-dev environment
Add host-first environment contracts (Local vs CI vs Prod), deps-only compose, and the Profile → Portrait → deep-access mock payment slice with device identity and auto-migrate on API startup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service creates and reads growth reports with entitlement trimming.
|
||||
type Service struct {
|
||||
Profiles *repository.ProfileRepo
|
||||
Reports *repository.ReportRepo
|
||||
}
|
||||
|
||||
// CreatePortrait builds and stores a portrait report.
|
||||
func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) {
|
||||
p, err := s.Profiles.GetForUser(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
out := portrait.Build(p.BirthDate, p.DisplayName)
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, profileID, "portrait", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// Get returns a report with detail gated.
|
||||
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) {
|
||||
deep, err := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vip, err := s.Reports.HasActiveMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.HasDeep = deep || vip
|
||||
if !rep.HasDeep {
|
||||
rep.Detail = nil
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// CreateOrderInput for commerce.
|
||||
type CreateOrderInput struct {
|
||||
Kind string
|
||||
Plan string
|
||||
ReportID *uuid.UUID
|
||||
}
|
||||
|
||||
// CreateOrder starts membership or deep_access order.
|
||||
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) {
|
||||
if in.Kind != "membership" && in.Kind != "deep_access" {
|
||||
return uuid.Nil, errors.New("invalid kind")
|
||||
}
|
||||
if in.Kind == "deep_access" && in.ReportID == nil {
|
||||
return uuid.Nil, errors.New("report_id required")
|
||||
}
|
||||
amount := 990
|
||||
if in.Kind == "membership" {
|
||||
amount = 2500
|
||||
}
|
||||
return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount)
|
||||
}
|
||||
|
||||
// PayMock completes mock payment.
|
||||
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
|
||||
return s.Reports.PayMock(ctx, userID, orderID)
|
||||
}
|
||||
Reference in New Issue
Block a user