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,146 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
)
|
||||
|
||||
// ReportRepo persists growth reports and access checks.
|
||||
type ReportRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Create inserts a growth report.
|
||||
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
|
||||
userID, profileID, typ, summary, detail,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// GetForUser loads a report owned by user.
|
||||
func (r *ReportRepo) GetForUser(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, profile_id, type, summary, detail, created_at
|
||||
FROM growth_reports WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
|
||||
reportID, userID,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// HasDeepAccess reports whether user purchased deep access for report.
|
||||
func (r *ReportRepo) HasDeepAccess(ctx context.Context, userID, reportID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM deep_accesses
|
||||
WHERE user_id=$1 AND report_id=$2 AND deleted_at IS NULL
|
||||
)`, userID, reportID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// HasActiveMembership checks growth membership.
|
||||
func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM memberships
|
||||
WHERE user_id=$1 AND status='active' AND expires_at > now() AND deleted_at IS NULL
|
||||
)`, userID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// CreateOrder inserts an order.
|
||||
func (r *ReportRepo) CreateOrder(ctx context.Context, userID uuid.UUID, kind, plan string, reportID *uuid.UUID, amount int) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO orders(user_id, kind, plan, report_id, amount_cents, status)
|
||||
VALUES ($1,$2,$3,$4,$5,'created') RETURNING id`,
|
||||
userID, kind, nullIfEmpty(plan), reportID, amount,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// PayMock marks order paid and grants entitlement.
|
||||
func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var kind string
|
||||
var reportID *uuid.UUID
|
||||
var plan *string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT kind, report_id, plan FROM orders
|
||||
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL FOR UPDATE`,
|
||||
orderID, userID,
|
||||
).Scan(&kind, &reportID, &plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE orders SET status='paid', updated_at=now() WHERE id=$1`, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments(order_id, channel, status) VALUES ($1,'mock','paid')`, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
switch kind {
|
||||
case "deep_access":
|
||||
if reportID == nil {
|
||||
return errMissingReport
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO deep_accesses(user_id, report_id, order_id)
|
||||
VALUES ($1,$2,$3)
|
||||
ON CONFLICT (user_id, report_id) DO NOTHING`, userID, *reportID, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "membership":
|
||||
p := "month"
|
||||
if plan != nil && *plan != "" {
|
||||
p = *plan
|
||||
}
|
||||
days := 31
|
||||
if p == "quarter" {
|
||||
days = 92
|
||||
} else if p == "year" {
|
||||
days = 366
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
|
||||
VALUES ($1,$2,'active', now() + ($3::text || ' days')::interval, 100)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
plan=EXCLUDED.plan, status='active',
|
||||
expires_at=EXCLUDED.expires_at, ask_quota_left=100, updated_at=now()`,
|
||||
userID, p, days); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
var errMissingReport = errString("report_id required for deep_access")
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
Reference in New Issue
Block a user