package repository import ( "context" "encoding/json" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ScaleListItem is a published 探索测试. type ScaleListItem struct { Slug string `json:"slug"` Title string `json:"title"` Description string `json:"description"` } // ScaleQuestion is one item in a scale. type ScaleQuestion struct { ID uuid.UUID `json:"id"` Sort int `json:"sort"` Body json.RawMessage `json:"body"` } // ScaleDetail is scale + questions. type ScaleDetail struct { Slug string `json:"slug"` Title string `json:"title"` Description string `json:"description"` Access string `json:"access,omitempty"` // free | membership Locked bool `json:"locked,omitempty"` Questions []ScaleQuestion `json:"questions"` } // ScaleResultRow stored result. type ScaleResultRow struct { ID uuid.UUID `json:"id"` ScaleSlug string `json:"scale_slug"` Result json.RawMessage `json:"result"` } // ScaleRepo loads scales and results. type ScaleRepo struct { Pool *pgxpool.Pool } // ListPublished returns published scales. func (r *ScaleRepo) ListPublished(ctx context.Context) ([]ScaleListItem, error) { rows, err := r.Pool.Query(ctx, ` SELECT slug, title, description FROM scales WHERE status='published' AND deleted_at IS NULL ORDER BY created_at`) if err != nil { return nil, err } defer rows.Close() var out []ScaleListItem for rows.Next() { var it ScaleListItem if err := rows.Scan(&it.Slug, &it.Title, &it.Description); err != nil { return nil, err } out = append(out, it) } return out, rows.Err() } // GetBySlug loads a published scale with questions. func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, error) { d := &ScaleDetail{Slug: slug} var scaleID uuid.UUID err := r.Pool.QueryRow(ctx, ` SELECT id, title, description FROM scales WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug, ).Scan(&scaleID, &d.Title, &d.Description) if err != nil { return nil, err } rows, err := r.Pool.Query(ctx, ` SELECT id, sort, body FROM scale_questions WHERE scale_id=$1 AND deleted_at IS NULL ORDER BY sort`, scaleID) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var q ScaleQuestion if err := rows.Scan(&q.ID, &q.Sort, &q.Body); err != nil { return nil, err } d.Questions = append(d.Questions, q) } return d, rows.Err() } // ScaleAdminItem is a scale row for ops. type ScaleAdminItem struct { ID uuid.UUID `json:"id"` Slug string `json:"slug"` Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` } // ListAllAdmin returns all non-deleted scales. func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) { rows, err := r.Pool.Query(ctx, ` SELECT id, slug, title, description, status FROM scales WHERE deleted_at IS NULL ORDER BY created_at LIMIT 500`) if err != nil { return nil, err } defer rows.Close() var out []ScaleAdminItem for rows.Next() { var it ScaleAdminItem if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status); err != nil { return nil, err } out = append(out, it) } return out, rows.Err() } // GetAdmin loads one scale by id for ops read. func (r *ScaleRepo) GetAdmin(ctx context.Context, id uuid.UUID) (*ScaleAdminItem, error) { var it ScaleAdminItem err := r.Pool.QueryRow(ctx, ` SELECT id, slug, title, description, status FROM scales WHERE id=$1 AND deleted_at IS NULL`, id, ).Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status) if err != nil { return nil, err } return &it, nil } // UpdateStatus sets published|draft. func (r *ScaleRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error { return r.UpdateStatusWithAudit(ctx, id, status, uuid.Nil, nil) } // UpdateStatusWithAudit updates status and optionally writes audit in one tx. func (r *ScaleRepo) UpdateStatusWithAudit( ctx context.Context, id uuid.UUID, status string, adminID uuid.UUID, meta json.RawMessage, ) error { tx, err := r.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) tag, err := tx.Exec(ctx, ` UPDATE scales SET status=$2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, id, status) if err != nil { return err } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } if adminID != uuid.Nil { if meta == nil { meta = json.RawMessage(`{}`) } if _, err := tx.Exec(ctx, ` INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta) VALUES ($1,'scale.status','scale',$2,$3)`, adminID, id.String(), meta); err != nil { return err } } return tx.Commit(ctx) } // SaveResult stores scoring output. func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID uuid.UUID, answers, result json.RawMessage) (uuid.UUID, error) { var id uuid.UUID err := r.Pool.QueryRow(ctx, ` INSERT INTO scale_results(user_id, scale_id, profile_id, answers, result) VALUES ($1,$2,$3,$4,$5) RETURNING id`, userID, scaleID, profileID, answers, result, ).Scan(&id) return id, err } // LatestResult returns the newest result for user + published slug. func (r *ScaleRepo) LatestResult(ctx context.Context, userID uuid.UUID, slug string) (*ScaleResultRow, error) { var row ScaleResultRow err := r.Pool.QueryRow(ctx, ` SELECT sr.id, s.slug, sr.result FROM scale_results sr JOIN scales s ON s.id = sr.scale_id WHERE sr.user_id = $1 AND s.slug = $2 AND s.deleted_at IS NULL AND sr.deleted_at IS NULL ORDER BY sr.created_at DESC LIMIT 1`, userID, slug, ).Scan(&row.ID, &row.ScaleSlug, &row.Result) if err != nil { return nil, err } return &row, nil } // ScaleIDBySlug resolves id for a published scale. func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) { var id uuid.UUID err := r.Pool.QueryRow(ctx, ` SELECT id FROM scales WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id) return id, err } // EnsurePublished upserts scale metadata (curated bank stubs; questions served from embed). func (r *ScaleRepo) EnsurePublished(ctx context.Context, slug, title, description string) (uuid.UUID, error) { var id uuid.UUID err := r.Pool.QueryRow(ctx, ` INSERT INTO scales (slug, title, description, status) VALUES ($1,$2,$3,'published') ON CONFLICT (slug) DO UPDATE SET title=EXCLUDED.title, description=EXCLUDED.description, status='published', updated_at=now(), deleted_at=NULL RETURNING id`, slug, title, description, ).Scan(&id) return id, err }