package repository import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "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 (optional peer for pair reports). func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) { return r.CreateWithPeer(ctx, userID, profileID, nil, typ, summary, detail) } // CreateWithPeer inserts a report with optional peer_profile_id. func (r *ReportRepo) CreateWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *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, peer_profile_id, type, summary, detail) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, user_id, profile_id, type, summary, detail, created_at`, userID, profileID, peer, typ, summary, detail, ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt) if err != nil { return nil, err } rep.PeerProfileID = peer return rep, nil } // SoftDeleteMatching soft-deletes prior reports for overwrite semantics. func (r *ReportRepo) SoftDeleteMatching(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string) error { if peer == nil { _, err := r.Pool.Exec(ctx, ` UPDATE growth_reports SET deleted_at=now(), updated_at=now() WHERE user_id=$1 AND profile_id=$2 AND type=$3 AND peer_profile_id IS NULL AND deleted_at IS NULL`, userID, profileID, typ) return err } _, err := r.Pool.Exec(ctx, ` UPDATE growth_reports SET deleted_at=now(), updated_at=now() WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL AND ( (profile_id=$3 AND peer_profile_id=$4) OR (profile_id=$4 AND peer_profile_id=$3) )`, userID, typ, profileID, *peer) return err } // UpsertWithPeer soft-deletes matching then inserts. func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) { if err := r.SoftDeleteMatching(ctx, userID, profileID, peer, typ); err != nil { return nil, err } return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail) } // GetLatest returns newest non-deleted report for profile+type(+peer). func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) { rep := &model.GrowthReport{} var peerOut *uuid.UUID var err error if peer == nil { err = r.Pool.QueryRow(ctx, ` SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at FROM growth_reports WHERE user_id=$1 AND profile_id=$2 AND type=$3 AND peer_profile_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1`, userID, profileID, typ, ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt) } else { err = r.Pool.QueryRow(ctx, ` SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at FROM growth_reports WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL AND ( (profile_id=$3 AND peer_profile_id=$4) OR (profile_id=$4 AND peer_profile_id=$3) ) ORDER BY created_at DESC LIMIT 1`, userID, typ, profileID, *peer, ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt) } if err != nil { return nil, err } rep.PeerProfileID = peerOut return rep, nil } // SoftDeleteForProfile marks all reports involving a profile as deleted. func (r *ReportRepo) SoftDeleteForProfile(ctx context.Context, userID, profileID uuid.UUID) error { _, err := r.Pool.Exec(ctx, ` UPDATE growth_reports SET deleted_at=now(), updated_at=now() WHERE user_id=$1 AND deleted_at IS NULL AND (profile_id=$2 OR peer_profile_id=$2)`, userID, profileID) return 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 } // ListForUser returns recent reports for a user (summary only usage at service layer). func (r *ReportRepo) ListForUser(ctx context.Context, userID uuid.UUID, limit int) ([]model.GrowthReport, error) { if limit <= 0 || limit > 100 { limit = 50 } rows, err := r.Pool.Query(ctx, ` SELECT id, user_id, profile_id, type, summary, detail, created_at FROM growth_reports WHERE user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $2`, userID, limit) if err != nil { return nil, err } defer rows.Close() var out []model.GrowthReport for rows.Next() { var rep model.GrowthReport if err := rows.Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt); err != nil { return nil, err } out = append(out, rep) } return out, rows.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 } // MembershipRow is the current membership snapshot for a user. type MembershipRow struct { Plan string `json:"plan,omitempty"` Status string `json:"status"` ExpiresAt *time.Time `json:"expires_at,omitempty"` AskQuotaLeft int `json:"ask_quota_left,omitempty"` Active bool `json:"active"` } // GetMembership returns membership status; missing row → inactive. func (r *ReportRepo) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipRow, error) { var plan, status string var expires *time.Time var quota int err := r.Pool.QueryRow(ctx, ` SELECT plan, status, expires_at, ask_quota_left FROM memberships WHERE user_id=$1 AND deleted_at IS NULL`, userID, ).Scan(&plan, &status, &expires, "a) if errors.Is(err, pgx.ErrNoRows) { return &MembershipRow{Active: false, Status: "none"}, nil } if err != nil { return nil, err } active := status == "active" && expires != nil && expires.After(time.Now()) return &MembershipRow{ Plan: plan, Status: status, ExpiresAt: expires, AskQuotaLeft: quota, Active: active, }, nil } // 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 * interval '1 day'), 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 } case "ask_pack": p := "pack10" if plan != nil && *plan != "" { p = *plan } delta := AskPackQuota(p) if delta <= 0 { return errString("invalid ask_pack plan") } if _, err := tx.Exec(ctx, ` UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, userID, delta); err != nil { return err } } return tx.Commit(ctx) } // AskPackQuota returns how many ask replies a pack plan grants. func AskPackQuota(plan string) int { switch plan { case "pack10": return 10 case "pack30": return 30 case "pack100": return 100 default: return 0 } } // AskPackAmountCents is mock price for an ask pack plan. func AskPackAmountCents(plan string) int { switch plan { case "pack10": return 990 case "pack30": return 1980 case "pack100": return 4990 default: return 0 } } 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 }