merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)

保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:45:53 +08:00
co-authored by Cursor
593 changed files with 21918 additions and 328 deletions
@@ -0,0 +1,104 @@
package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// AccountTransition is an append-only UserStatus change.
type AccountTransition struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
FromStatus string `json:"from_status"`
ToStatus string `json:"to_status"`
AdminID uuid.UUID `json:"admin_id"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
// GetUserStatus returns users.status or empty if missing.
func (r *AdminRepo) GetUserStatus(ctx context.Context, userID uuid.UUID) (string, error) {
var status string
err := r.Pool.QueryRow(ctx, `
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return status, err
}
// TransitionUserStatusWithAudit updates status, inserts transition + audit in one tx.
func (r *AdminRepo) TransitionUserStatusWithAudit(
ctx context.Context,
adminID, userID uuid.UUID,
fromStatus, toStatus, reason string,
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 users SET status=$2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL AND status=$3`,
userID, toStatus, fromStatus,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errString("status conflict")
}
if _, err := tx.Exec(ctx, `
INSERT INTO account_state_transitions(user_id, from_status, to_status, admin_id, reason)
VALUES ($1,$2,$3,$4,$5)`,
userID, fromStatus, toStatus, adminID, reason,
); err != nil {
return err
}
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,'users.status.transition','user',$2,$3)`,
adminID, userID.String(), meta,
); err != nil {
return err
}
return tx.Commit(ctx)
}
// ListStatusTransitions returns newest first.
func (r *AdminRepo) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]AccountTransition, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, from_status, to_status, admin_id, reason, created_at
FROM account_state_transitions
WHERE user_id=$1
ORDER BY created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AccountTransition
for rows.Next() {
var t AccountTransition
if err := rows.Scan(&t.ID, &t.UserID, &t.FromStatus, &t.ToStatus, &t.AdminID, &t.Reason, &t.CreatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
@@ -0,0 +1,131 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// AdminRole is an ops RBAC role.
type AdminRole struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
System bool `json:"system"`
CreatedAt time.Time `json:"created_at"`
}
// ListAdminRoles returns all roles.
func (r *AdminRepo) ListAdminRoles(ctx context.Context) ([]AdminRole, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, name, system, created_at FROM admin_roles ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AdminRole
for rows.Next() {
var a AdminRole
if err := rows.Scan(&a.ID, &a.Name, &a.System, &a.CreatedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// GetAdminRole loads one role.
func (r *AdminRepo) GetAdminRole(ctx context.Context, id uuid.UUID) (*AdminRole, error) {
var a AdminRole
err := r.Pool.QueryRow(ctx, `
SELECT id, name, system, created_at FROM admin_roles WHERE id=$1`, id,
).Scan(&a.ID, &a.Name, &a.System, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
// ListRolePermissions returns permission codes for a role.
func (r *AdminRepo) ListRolePermissions(ctx context.Context, roleID uuid.UUID) ([]string, error) {
rows, err := r.Pool.Query(ctx, `
SELECT code FROM admin_role_permissions WHERE role_id=$1 ORDER BY code`, roleID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ReplaceRolePermissions replaces the full permission set for a role.
func (r *AdminRepo) ReplaceRolePermissions(ctx context.Context, roleID uuid.UUID, codes []string) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM admin_role_permissions WHERE role_id=$1`, roleID); err != nil {
return err
}
for _, code := range codes {
if _, err := tx.Exec(ctx, `
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,$2)`, roleID, code); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// ListPermissionsForAdmin returns permission codes for an admin account.
func (r *AdminRepo) ListPermissionsForAdmin(ctx context.Context, adminID uuid.UUID) ([]string, error) {
rows, err := r.Pool.Query(ctx, `
SELECT p.code
FROM admin_accounts a
JOIN admin_role_permissions p ON p.role_id = a.role_id
WHERE a.id=$1 AND a.deleted_at IS NULL AND a.role_id IS NOT NULL
ORDER BY p.code`, adminID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetAdminRoleMeta returns role id/name for an account.
func (r *AdminRepo) GetAdminRoleMeta(ctx context.Context, adminID uuid.UUID) (roleID *uuid.UUID, name string, err error) {
var id uuid.UUID
err = r.Pool.QueryRow(ctx, `
SELECT r.id, r.name
FROM admin_accounts a
JOIN admin_roles r ON r.id = a.role_id
WHERE a.id=$1 AND a.deleted_at IS NULL`, adminID,
).Scan(&id, &name)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
return &id, name, nil
}
+6 -3
View File
@@ -34,12 +34,15 @@ func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
return n, err
}
// CreateAccount inserts an admin account (role defaults to super).
// CreateAccount inserts an admin account with seeded super_admin role and legacy super column.
func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
INSERT INTO admin_accounts(username, password_hash, role)
VALUES ($1,$2,'super') RETURNING id`, username, hash).Scan(&id)
INSERT INTO admin_accounts(username, password_hash, role, role_id)
VALUES (
$1, $2, 'super',
(SELECT id FROM admin_roles WHERE name = 'super_admin' LIMIT 1)
) RETURNING id`, username, hash).Scan(&id)
return id, err
}
@@ -0,0 +1,118 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// SystemPromptRow is AICoreConfig SystemPrompt catalog row.
type SystemPromptRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Scene *string `json:"scene,omitempty"`
Body string `json:"body"`
Version int `json:"version"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListSystemPrompts returns prompt catalog (body included for ops read).
func (r *AdminRepo) ListSystemPrompts(ctx context.Context) ([]SystemPromptRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, scene, body, version, active, system, updated_at
FROM system_prompts
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SystemPromptRow
for rows.Next() {
var p SystemPromptRow
if err := rows.Scan(
&p.ID, &p.Code, &p.Title, &p.Scene, &p.Body, &p.Version, &p.Active, &p.System, &p.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// GetSystemPrompt loads one prompt by id.
func (r *AdminRepo) GetSystemPrompt(ctx context.Context, id uuid.UUID) (*SystemPromptRow, error) {
var p SystemPromptRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, scene, body, version, active, system, updated_at
FROM system_prompts WHERE id=$1`, id,
).Scan(&p.ID, &p.Code, &p.Title, &p.Scene, &p.Body, &p.Version, &p.Active, &p.System, &p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &p, nil
}
// KnowledgeSourceRow is AICoreConfig KnowledgeSource catalog row.
type KnowledgeSourceRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
SourceKind string `json:"source_kind"`
Version int `json:"version"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListKnowledgeSources returns knowledge source catalog.
func (r *AdminRepo) ListKnowledgeSources(ctx context.Context) ([]KnowledgeSourceRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, description, source_kind, version, active, system, updated_at
FROM knowledge_sources
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []KnowledgeSourceRow
for rows.Next() {
var k KnowledgeSourceRow
if err := rows.Scan(
&k.ID, &k.Code, &k.Title, &k.Description, &k.SourceKind,
&k.Version, &k.Active, &k.System, &k.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// GetKnowledgeSource loads one source by id.
func (r *AdminRepo) GetKnowledgeSource(ctx context.Context, id uuid.UUID) (*KnowledgeSourceRow, error) {
var k KnowledgeSourceRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, description, source_kind, version, active, system, updated_at
FROM knowledge_sources WHERE id=$1`, id,
).Scan(
&k.ID, &k.Code, &k.Title, &k.Description, &k.SourceKind,
&k.Version, &k.Active, &k.System, &k.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &k, nil
}
@@ -0,0 +1,104 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// AskSessionView is ops read meta for one ask thread.
type AskSessionView struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
ProfileID uuid.UUID `json:"profile_id"`
Scene *string `json:"scene,omitempty"`
MessageCount int `json:"message_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AskMessageView is a read-only message row for ops.
type AskMessageView struct {
ID uuid.UUID `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// ListAskSessions returns recent ask threads (optional user filter).
func (r *AdminRepo) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]AskSessionView, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
rows, err := r.Pool.Query(ctx, `
SELECT t.id, t.user_id, t.profile_id, t.scene, t.created_at, t.updated_at,
(SELECT count(*)::int FROM ask_messages m
WHERE m.thread_id=t.id AND m.deleted_at IS NULL) AS message_count
FROM ask_threads t
WHERE t.deleted_at IS NULL
AND ($1::uuid IS NULL OR t.user_id=$1)
ORDER BY t.updated_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AskSessionView
for rows.Next() {
var s AskSessionView
if err := rows.Scan(
&s.ID, &s.UserID, &s.ProfileID, &s.Scene, &s.CreatedAt, &s.UpdatedAt, &s.MessageCount,
); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// GetAskSession loads one thread meta or ErrNoRows.
func (r *AdminRepo) GetAskSession(ctx context.Context, threadID uuid.UUID) (*AskSessionView, error) {
var s AskSessionView
err := r.Pool.QueryRow(ctx, `
SELECT t.id, t.user_id, t.profile_id, t.scene, t.created_at, t.updated_at,
(SELECT count(*)::int FROM ask_messages m
WHERE m.thread_id=t.id AND m.deleted_at IS NULL) AS message_count
FROM ask_threads t
WHERE t.id=$1 AND t.deleted_at IS NULL`, threadID,
).Scan(&s.ID, &s.UserID, &s.ProfileID, &s.Scene, &s.CreatedAt, &s.UpdatedAt, &s.MessageCount)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &s, nil
}
// ListAskMessagesForAdmin returns messages oldest-first.
func (r *AdminRepo) ListAskMessagesForAdmin(ctx context.Context, threadID uuid.UUID) ([]AskMessageView, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, role, content, created_at
FROM ask_messages
WHERE thread_id=$1 AND deleted_at IS NULL
ORDER BY created_at ASC`, threadID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AskMessageView
for rows.Next() {
var m AskMessageView
if err := rows.Scan(&m.ID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
@@ -0,0 +1,58 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// BlockPolicyRow is BlockPolicy catalog row.
type BlockPolicyRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Action string `json:"action"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListBlockPolicies returns BlockPolicy catalog.
func (r *AdminRepo) ListBlockPolicies(ctx context.Context) ([]BlockPolicyRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, action, active, system, updated_at
FROM block_policies
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []BlockPolicyRow
for rows.Next() {
var row BlockPolicyRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Action, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetBlockPolicy loads one by id.
func (r *AdminRepo) GetBlockPolicy(ctx context.Context, id uuid.UUID) (*BlockPolicyRow, error) {
var row BlockPolicyRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, action, active, system, updated_at
FROM block_policies WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Action, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
+118
View File
@@ -0,0 +1,118 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// BannerRow is OpsCMS Banner catalog row.
type BannerRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Placement string `json:"placement"`
ImageURL *string `json:"image_url,omitempty"`
LinkPath *string `json:"link_path,omitempty"`
SortOrder int `json:"sort_order"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListBanners returns banner catalog.
func (r *AdminRepo) ListBanners(ctx context.Context) ([]BannerRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
FROM ops_banners
ORDER BY active DESC, sort_order ASC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []BannerRow
for rows.Next() {
var b BannerRow
if err := rows.Scan(
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// GetBanner loads one banner by id.
func (r *AdminRepo) GetBanner(ctx context.Context, id uuid.UUID) (*BannerRow, error) {
var b BannerRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
FROM ops_banners WHERE id=$1`, id,
).Scan(
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &b, nil
}
// FeedSlotRow is OpsCMS FeedSlot catalog row.
type FeedSlotRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
SlotKey string `json:"slot_key"`
Placement string `json:"placement"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListFeedSlots returns feed slot catalog.
func (r *AdminRepo) ListFeedSlots(ctx context.Context) ([]FeedSlotRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, slot_key, placement, active, system, updated_at
FROM ops_feed_slots
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FeedSlotRow
for rows.Next() {
var s FeedSlotRow
if err := rows.Scan(
&s.ID, &s.Code, &s.Title, &s.SlotKey, &s.Placement, &s.Active, &s.System, &s.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// GetFeedSlot loads one feed slot by id.
func (r *AdminRepo) GetFeedSlot(ctx context.Context, id uuid.UUID) (*FeedSlotRow, error) {
var s FeedSlotRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, slot_key, placement, active, system, updated_at
FROM ops_feed_slots WHERE id=$1`, id,
).Scan(&s.ID, &s.Code, &s.Title, &s.SlotKey, &s.Placement, &s.Active, &s.System, &s.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &s, nil
}
@@ -0,0 +1,125 @@
package repository
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// MembershipPlanAmountCents returns catalog price or fallback for membership plans.
func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) {
var amount int
var active bool
err := r.Pool.QueryRow(ctx, `
SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan,
).Scan(&amount, &active)
if errors.Is(err, pgx.ErrNoRows) {
return membershipAmountFallback(plan), nil
}
if err != nil {
return 0, err
}
if !active {
return 0, errString("plan inactive")
}
return amount, nil
}
func membershipAmountFallback(plan string) int {
switch plan {
case "month":
return 2500
case "quarter":
return 6800
case "year":
return 19800
default:
return 2500
}
}
// MembershipPlanDurationDays returns catalog days or fallback.
func (r *ReportRepo) MembershipPlanDurationDays(ctx context.Context, plan string) (int, error) {
var days int
var active bool
err := r.Pool.QueryRow(ctx, `
SELECT duration_days, active FROM membership_plans WHERE code=$1`, plan,
).Scan(&days, &active)
if errors.Is(err, pgx.ErrNoRows) {
return membershipDaysFallback(plan), nil
}
if err != nil {
return 0, err
}
if !active || days <= 0 {
return membershipDaysFallback(plan), nil
}
return days, nil
}
func membershipDaysFallback(plan string) int {
switch plan {
case "month":
return 31
case "quarter":
return 92
case "year":
return 366
default:
return 31
}
}
// RedeemCode applies an unused redemption code to user membership.
func (r *ReportRepo) RedeemCode(ctx context.Context, userID uuid.UUID, rawCode string) (plan string, err error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return "", err
}
defer tx.Rollback(ctx)
var codeID uuid.UUID
var status string
err = tx.QueryRow(ctx, `
SELECT id, plan_code, status FROM redemption_codes
WHERE code=$1 FOR UPDATE`, rawCode,
).Scan(&codeID, &plan, &status)
if errors.Is(err, pgx.ErrNoRows) {
return "", errString("invalid code")
}
if err != nil {
return "", err
}
if status != "unused" {
return "", errString("code not redeemable")
}
days, err := r.MembershipPlanDurationDays(ctx, plan)
if err != nil {
return "", err
}
if _, err := tx.Exec(ctx, `
UPDATE redemption_codes
SET status='redeemed', redeemed_by=$2, redeemed_at=now()
WHERE id=$1 AND status='unused'`, codeID, userID); err != nil {
return "", err
}
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=(CASE
WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now()
THEN memberships.expires_at ELSE now()
END) + ($3 * interval '1 day'),
ask_quota_left=100, updated_at=now()`,
userID, plan, days); err != nil {
return "", err
}
if err := tx.Commit(ctx); err != nil {
return "", err
}
return plan, nil
}
@@ -0,0 +1,95 @@
package repository
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// FilterRuleRow is ContentSafety FilterRule persistence.
type FilterRuleRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Category string `json:"category"`
Pattern string `json:"pattern"`
Action string `json:"action"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// FilterMatch is one evaluate hit.
type FilterMatch struct {
Code string `json:"code"`
Title string `json:"title"`
Category string `json:"category"`
Action string `json:"action"`
}
// ListFilterRules returns active-first filter rules.
func (r *AdminRepo) ListFilterRules(ctx context.Context) ([]FilterRuleRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, category, pattern, action, active, system, updated_at
FROM filter_rules
ORDER BY active DESC, category ASC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FilterRuleRow
for rows.Next() {
var f FilterRuleRow
if err := rows.Scan(
&f.ID, &f.Code, &f.Title, &f.Category, &f.Pattern, &f.Action, &f.Active, &f.System, &f.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// GetFilterRule loads one rule by id.
func (r *AdminRepo) GetFilterRule(ctx context.Context, id uuid.UUID) (*FilterRuleRow, error) {
var f FilterRuleRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, category, pattern, action, active, system, updated_at
FROM filter_rules WHERE id=$1`, id,
).Scan(&f.ID, &f.Code, &f.Title, &f.Category, &f.Pattern, &f.Action, &f.Active, &f.System, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &f, nil
}
// EvaluateFilterRules runs simple substring match on active rules (ops preview).
func (r *AdminRepo) EvaluateFilterRules(ctx context.Context, text string) ([]FilterMatch, error) {
rules, err := r.ListFilterRules(ctx)
if err != nil {
return nil, err
}
lower := strings.ToLower(text)
var out []FilterMatch
for _, rule := range rules {
if !rule.Active || rule.Pattern == "" {
continue
}
if strings.Contains(lower, strings.ToLower(rule.Pattern)) {
out = append(out, FilterMatch{
Code: rule.Code, Title: rule.Title, Category: rule.Category, Action: rule.Action,
})
}
}
if out == nil {
out = []FilterMatch{}
}
return out, nil
}
@@ -0,0 +1,58 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// CrisisEventRow is CrisisEvent catalog row.
type CrisisEventRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Severity string `json:"severity"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListCrisisEvents returns CrisisEvent catalog.
func (r *AdminRepo) ListCrisisEvents(ctx context.Context) ([]CrisisEventRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, severity, active, system, updated_at
FROM crisis_events
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CrisisEventRow
for rows.Next() {
var row CrisisEventRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Severity, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetCrisisEvent loads one by id.
func (r *AdminRepo) GetCrisisEvent(ctx context.Context, id uuid.UUID) (*CrisisEventRow, error) {
var row CrisisEventRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, severity, active, system, updated_at
FROM crisis_events WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Severity, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
+102
View File
@@ -0,0 +1,102 @@
package repository
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// CrisisPolicyRow is CrisisCare CrisisPolicy catalog.
type CrisisPolicyRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Severity string `json:"severity"`
Pattern string `json:"pattern"`
Action string `json:"action"`
HelplineText *string `json:"helpline_text,omitempty"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// CrisisMatch is one evaluate hit.
type CrisisMatch struct {
Code string `json:"code"`
Title string `json:"title"`
Severity string `json:"severity"`
Action string `json:"action"`
HelplineText *string `json:"helpline_text,omitempty"`
}
// ListCrisisPolicies returns policies active-first.
func (r *AdminRepo) ListCrisisPolicies(ctx context.Context) ([]CrisisPolicyRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at
FROM crisis_policies
ORDER BY active DESC, severity DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CrisisPolicyRow
for rows.Next() {
var p CrisisPolicyRow
if err := rows.Scan(
&p.ID, &p.Code, &p.Title, &p.Severity, &p.Pattern, &p.Action, &p.HelplineText,
&p.Active, &p.System, &p.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// GetCrisisPolicy loads one policy.
func (r *AdminRepo) GetCrisisPolicy(ctx context.Context, id uuid.UUID) (*CrisisPolicyRow, error) {
var p CrisisPolicyRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at
FROM crisis_policies WHERE id=$1`, id,
).Scan(
&p.ID, &p.Code, &p.Title, &p.Severity, &p.Pattern, &p.Action, &p.HelplineText,
&p.Active, &p.System, &p.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &p, nil
}
// EvaluateCrisisPolicies runs substring match preview (ops only).
func (r *AdminRepo) EvaluateCrisisPolicies(ctx context.Context, text string) ([]CrisisMatch, error) {
policies, err := r.ListCrisisPolicies(ctx)
if err != nil {
return nil, err
}
lower := strings.ToLower(text)
var out []CrisisMatch
for _, p := range policies {
if !p.Active || p.Pattern == "" {
continue
}
if strings.Contains(lower, strings.ToLower(p.Pattern)) {
out = append(out, CrisisMatch{
Code: p.Code, Title: p.Title, Severity: p.Severity,
Action: p.Action, HelplineText: p.HelplineText,
})
}
}
if out == nil {
out = []CrisisMatch{}
}
return out, nil
}
@@ -0,0 +1,52 @@
package repository
import (
"context"
"time"
"github.com/google/uuid"
)
// DeepAccessBrief is one deep_access row for ops Entitlement.
type DeepAccessBrief struct {
ID uuid.UUID `json:"id"`
ReportID uuid.UUID `json:"report_id"`
ReportType string `json:"report_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ListDeepAccessForUser returns recent deep accesses with report type.
func (r *AdminRepo) ListDeepAccessForUser(ctx context.Context, userID uuid.UUID, limit int) ([]DeepAccessBrief, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
rows, err := r.Pool.Query(ctx, `
SELECT d.id, d.report_id, coalesce(g.type, ''), d.created_at
FROM deep_accesses d
LEFT JOIN growth_reports g ON g.id = d.report_id AND g.deleted_at IS NULL
WHERE d.user_id=$1 AND d.deleted_at IS NULL
ORDER BY d.created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DeepAccessBrief
for rows.Next() {
var b DeepAccessBrief
if err := rows.Scan(&b.ID, &b.ReportID, &b.ReportType, &b.CreatedAt); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// CountDeepAccessForUser counts non-deleted deep accesses.
func (r *AdminRepo) CountDeepAccessForUser(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT count(*)::int FROM deep_accesses
WHERE user_id=$1 AND deleted_at IS NULL`, userID).Scan(&n)
return n, err
}
@@ -0,0 +1,57 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// FunnelDefinitionRow is FunnelDefinition catalog row.
type FunnelDefinitionRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListFunnelDefinitions returns FunnelDefinition catalog.
func (r *AdminRepo) ListFunnelDefinitions(ctx context.Context) ([]FunnelDefinitionRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM funnel_definitions
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FunnelDefinitionRow
for rows.Next() {
var row FunnelDefinitionRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetFunnelDefinition loads one by id.
func (r *AdminRepo) GetFunnelDefinition(ctx context.Context, id uuid.UUID) (*FunnelDefinitionRow, error) {
var row FunnelDefinitionRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, active, system, updated_at
FROM funnel_definitions WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,58 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// HandoffCaseRow is HandoffCase catalog row.
type HandoffCaseRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Status string `json:"status"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListHandoffCases returns HandoffCase catalog.
func (r *AdminRepo) ListHandoffCases(ctx context.Context) ([]HandoffCaseRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, status, active, system, updated_at
FROM ask_handoff_cases
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HandoffCaseRow
for rows.Next() {
var row HandoffCaseRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetHandoffCase loads one by id.
func (r *AdminRepo) GetHandoffCase(ctx context.Context, id uuid.UUID) (*HandoffCaseRow, error) {
var row HandoffCaseRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, status, active, system, updated_at
FROM ask_handoff_cases WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,57 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ImageCardDeckRow is ImageCardDeck catalog row.
type ImageCardDeckRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListImageCardDecks returns ImageCardDeck catalog.
func (r *AdminRepo) ListImageCardDecks(ctx context.Context) ([]ImageCardDeckRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM image_card_decks
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ImageCardDeckRow
for rows.Next() {
var row ImageCardDeckRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetImageCardDeck loads one by id.
func (r *AdminRepo) GetImageCardDeck(ctx context.Context, id uuid.UUID) (*ImageCardDeckRow, error) {
var row ImageCardDeckRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, active, system, updated_at
FROM image_card_decks WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,58 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// InterventionOutcomeRow is InterventionOutcome catalog row.
type InterventionOutcomeRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Outcome string `json:"outcome"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListInterventionOutcomes returns InterventionOutcome catalog.
func (r *AdminRepo) ListInterventionOutcomes(ctx context.Context) ([]InterventionOutcomeRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, outcome, active, system, updated_at
FROM intervention_outcomes
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []InterventionOutcomeRow
for rows.Next() {
var row InterventionOutcomeRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Outcome, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetInterventionOutcome loads one by id.
func (r *AdminRepo) GetInterventionOutcome(ctx context.Context, id uuid.UUID) (*InterventionOutcomeRow, error) {
var row InterventionOutcomeRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, outcome, active, system, updated_at
FROM intervention_outcomes WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Outcome, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,59 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// KnowledgeChunkRow is KnowledgeChunk catalog row.
type KnowledgeChunkRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
SourceCode string `json:"source_code"`
Title string `json:"title"`
Body string `json:"body"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListKnowledgeChunks returns KnowledgeChunk catalog.
func (r *AdminRepo) ListKnowledgeChunks(ctx context.Context) ([]KnowledgeChunkRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, source_code, title, body, active, system, updated_at
FROM knowledge_chunks
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []KnowledgeChunkRow
for rows.Next() {
var row KnowledgeChunkRow
if err := rows.Scan(&row.ID, &row.Code, &row.SourceCode, &row.Title, &row.Body, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetKnowledgeChunk loads one by id.
func (r *AdminRepo) GetKnowledgeChunk(ctx context.Context, id uuid.UUID) (*KnowledgeChunkRow, error) {
var row KnowledgeChunkRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, source_code, title, body, active, system, updated_at
FROM knowledge_chunks WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.SourceCode, &row.Title, &row.Body, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,94 @@
package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// MembershipPlanRow is a configurable growth membership SKU.
type MembershipPlanRow struct {
Code string `json:"code"`
Title string `json:"title"`
DurationDays int `json:"duration_days"`
AmountCents int `json:"amount_cents"`
Active bool `json:"active"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListMembershipPlans returns all plans ordered by code.
func (r *AdminRepo) ListMembershipPlans(ctx context.Context) ([]MembershipPlanRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT code, title, duration_days, amount_cents, active, updated_at
FROM membership_plans ORDER BY code`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MembershipPlanRow
for rows.Next() {
var p MembershipPlanRow
if err := rows.Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// GetMembershipPlan loads one plan by code.
func (r *AdminRepo) GetMembershipPlan(ctx context.Context, code string) (*MembershipPlanRow, error) {
var p MembershipPlanRow
err := r.Pool.QueryRow(ctx, `
SELECT code, title, duration_days, amount_cents, active, updated_at
FROM membership_plans WHERE code=$1`, code,
).Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// UpdateMembershipPlanWithAudit updates mutable fields and audits.
func (r *AdminRepo) UpdateMembershipPlanWithAudit(
ctx context.Context,
adminID uuid.UUID,
code, title string,
days, amountCents int,
active bool,
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 membership_plans
SET title=$2, duration_days=$3, amount_cents=$4, active=$5, updated_at=now()
WHERE code=$1`, code, title, days, amountCents, active)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errString("plan not found")
}
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,'membership.plans.update','membership_plan',$2,$3)`,
adminID, code, meta,
); err != nil {
return err
}
return tx.Commit(ctx)
}
@@ -0,0 +1,58 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ModerationCaseRow is ModerationCase catalog row.
type ModerationCaseRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Status string `json:"status"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListModerationCases returns ModerationCase catalog.
func (r *AdminRepo) ListModerationCases(ctx context.Context) ([]ModerationCaseRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, status, active, system, updated_at
FROM moderation_cases
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ModerationCaseRow
for rows.Next() {
var row ModerationCaseRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetModerationCase loads one by id.
func (r *AdminRepo) GetModerationCase(ctx context.Context, id uuid.UUID) (*ModerationCaseRow, error) {
var row ModerationCaseRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, status, active, system, updated_at
FROM moderation_cases WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Status, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,59 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// PrivacyRequestRow is PrivacyRequest catalog row.
type PrivacyRequestRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Kind string `json:"kind"`
Status string `json:"status"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListPrivacyRequests returns PrivacyRequest catalog.
func (r *AdminRepo) ListPrivacyRequests(ctx context.Context) ([]PrivacyRequestRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, kind, status, active, system, updated_at
FROM privacy_requests
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PrivacyRequestRow
for rows.Next() {
var row PrivacyRequestRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Kind, &row.Status, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetPrivacyRequest loads one by id.
func (r *AdminRepo) GetPrivacyRequest(ctx context.Context, id uuid.UUID) (*PrivacyRequestRow, error) {
var row PrivacyRequestRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, kind, status, active, system, updated_at
FROM privacy_requests WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Kind, &row.Status, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,170 @@
package repository
import (
"context"
"encoding/json"
"errors"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// QualityFeedbackRow is AskOperations QualityFeedback.
type QualityFeedbackRow struct {
ID uuid.UUID `json:"id"`
ThreadID uuid.UUID `json:"thread_id"`
MessageID *uuid.UUID `json:"message_id,omitempty"`
Source string `json:"source"`
Rating int `json:"rating"`
Tag *string `json:"tag,omitempty"`
Note *string `json:"note,omitempty"`
CreatedByAdmin *uuid.UUID `json:"created_by_admin,omitempty"`
CreatedByUser *uuid.UUID `json:"created_by_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ListQualityFeedback returns recent feedback rows.
func (r *AdminRepo) ListQualityFeedback(ctx context.Context, limit, offset int) ([]QualityFeedbackRow, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
rows, err := r.Pool.Query(ctx, `
SELECT id, thread_id, message_id, source, rating, tag, note,
created_by_admin, created_by_user, created_at
FROM ask_quality_feedback
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
return scanQualityFeedback(rows)
}
func scanQualityFeedback(rows pgx.Rows) ([]QualityFeedbackRow, error) {
var out []QualityFeedbackRow
for rows.Next() {
var f QualityFeedbackRow
if err := rows.Scan(
&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// CreateAdminQualityFeedback inserts ops feedback + audit.
func (r *AdminRepo) CreateAdminQualityFeedback(
ctx context.Context, adminID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string,
) (*QualityFeedbackRow, error) {
if err := validateFeedback(rating, tag, note); err != nil {
return nil, err
}
tag, note = cleanTag(tag), cleanNote(note)
ok, err := r.askThreadExists(ctx, threadID)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("ask thread not found")
}
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var f QualityFeedbackRow
err = tx.QueryRow(ctx, `
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_admin)
VALUES ($1,$2,'admin',$3,$4,$5,$6)
RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`,
threadID, messageID, rating, tag, note, adminID,
).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt)
if err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{"rating": rating, "thread_id": threadID.String()})
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'ask.feedback.create','ask_thread',$2,$3)`,
adminID, threadID.String(), meta); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &f, nil
}
// CreateUserQualityFeedback inserts C-end feedback for owned thread.
func (r *AskRepo) CreateUserQualityFeedback(
ctx context.Context, userID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string,
) (*QualityFeedbackRow, error) {
if err := validateFeedback(rating, tag, note); err != nil {
return nil, err
}
tag, note = cleanTag(tag), cleanNote(note)
_, err := r.GetThreadForUser(ctx, userID, threadID)
if err != nil {
return nil, errors.New("ask thread not found")
}
var f QualityFeedbackRow
err = r.Pool.QueryRow(ctx, `
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_user)
VALUES ($1,$2,'user',$3,$4,$5,$6)
RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`,
threadID, messageID, rating, tag, note, userID,
).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt)
return &f, err
}
func (r *AdminRepo) askThreadExists(ctx context.Context, threadID uuid.UUID) (bool, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT 1 FROM ask_threads WHERE id=$1 AND deleted_at IS NULL`, threadID).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func validateFeedback(rating int, tag, note *string) error {
if rating < 1 || rating > 5 {
return errors.New("rating must be 1-5")
}
if tag != nil && *tag != "" {
switch *tag {
case "helpful", "off_topic", "unsafe", "other":
default:
return errors.New("invalid tag")
}
}
if note != nil && utf8.RuneCountInString(*note) > 500 {
return errors.New("note too long")
}
return nil
}
func cleanTag(tag *string) *string {
if tag == nil || *tag == "" {
return nil
}
return tag
}
func cleanNote(note *string) *string {
if note == nil || *note == "" {
return nil
}
return note
}
@@ -0,0 +1,168 @@
package repository
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// RedemptionBatch is a generation batch of codes.
type RedemptionBatch struct {
ID uuid.UUID `json:"id"`
Label string `json:"label"`
PlanCode string `json:"plan_code"`
Quantity int `json:"quantity"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// RedemptionCodeRow is one redeemable code.
type RedemptionCodeRow struct {
ID uuid.UUID `json:"id"`
BatchID uuid.UUID `json:"batch_id"`
Code string `json:"code"`
PlanCode string `json:"plan_code"`
Status string `json:"status"`
RedeemedBy *uuid.UUID `json:"redeemed_by,omitempty"`
RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CreateRedemptionBatchWithCodes inserts batch + codes + audit.
func (r *AdminRepo) CreateRedemptionBatchWithCodes(
ctx context.Context,
adminID uuid.UUID,
label, planCode string,
codes []string,
meta json.RawMessage,
) (*RedemptionBatch, []RedemptionCodeRow, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, nil, err
}
defer tx.Rollback(ctx)
var b RedemptionBatch
err = tx.QueryRow(ctx, `
INSERT INTO redemption_batches(label, plan_code, quantity, created_by)
VALUES ($1,$2,$3,$4)
RETURNING id, label, plan_code, quantity, created_by, created_at`,
label, planCode, len(codes), adminID,
).Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt)
if err != nil {
return nil, nil, err
}
out := make([]RedemptionCodeRow, 0, len(codes))
for _, code := range codes {
var row RedemptionCodeRow
err = tx.QueryRow(ctx, `
INSERT INTO redemption_codes(batch_id, code, plan_code, status)
VALUES ($1,$2,$3,'unused')
RETURNING id, batch_id, code, plan_code, status, created_at`,
b.ID, code, planCode,
).Scan(&row.ID, &row.BatchID, &row.Code, &row.PlanCode, &row.Status, &row.CreatedAt)
if err != nil {
return nil, nil, err
}
out = append(out, row)
}
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,'redemption.batch.create','redemption_batch',$2,$3)`,
adminID, b.ID.String(), meta,
); err != nil {
return nil, nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, nil, err
}
return &b, out, nil
}
// ListRedemptionBatches newest first.
func (r *AdminRepo) ListRedemptionBatches(ctx context.Context, limit int) ([]RedemptionBatch, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := r.Pool.Query(ctx, `
SELECT id, label, plan_code, quantity, created_by, created_at
FROM redemption_batches ORDER BY created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RedemptionBatch
for rows.Next() {
var b RedemptionBatch
if err := rows.Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// ListRedemptionCodesByBatch returns codes for a batch.
func (r *AdminRepo) ListRedemptionCodesByBatch(ctx context.Context, batchID uuid.UUID) ([]RedemptionCodeRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, batch_id, code, plan_code, status, redeemed_by, redeemed_at, created_at
FROM redemption_codes WHERE batch_id=$1 ORDER BY created_at`, batchID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRedemptionCodes(rows)
}
// DisableRedemptionCode marks unused code disabled.
func (r *AdminRepo) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE redemption_codes SET status='disabled'
WHERE id=$1 AND status='unused'`, codeID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errString("code not unused")
}
meta, _ := json.Marshal(map[string]string{"code_id": codeID.String()})
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
VALUES ($1,'redemption.code.disable','redemption_code',$2,$3)`,
adminID, codeID.String(), meta,
); err != nil {
return err
}
return tx.Commit(ctx)
}
func scanRedemptionCodes(rows pgx.Rows) ([]RedemptionCodeRow, error) {
var out []RedemptionCodeRow
for rows.Next() {
var c RedemptionCodeRow
if err := rows.Scan(&c.ID, &c.BatchID, &c.Code, &c.PlanCode, &c.Status, &c.RedeemedBy, &c.RedeemedAt, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// BatchExists reports whether batch id exists.
func (r *AdminRepo) BatchExists(ctx context.Context, id uuid.UUID) (bool, error) {
var ok bool
err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM redemption_batches WHERE id=$1)`, id).Scan(&ok)
return ok, err
}
@@ -0,0 +1,58 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ReportTemplateRow is ReportTemplate catalog row.
type ReportTemplateRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Scene string `json:"scene"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListReportTemplates returns ReportTemplate catalog.
func (r *AdminRepo) ListReportTemplates(ctx context.Context) ([]ReportTemplateRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, scene, active, system, updated_at
FROM report_templates
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReportTemplateRow
for rows.Next() {
var row ReportTemplateRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetReportTemplate loads one by id.
func (r *AdminRepo) GetReportTemplate(ctx context.Context, id uuid.UUID) (*ReportTemplateRow, error) {
var row ReportTemplateRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, scene, active, system, updated_at
FROM report_templates WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,57 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// RhythmConfigRow is RhythmConfig catalog row.
type RhythmConfigRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListRhythmConfigs returns RhythmConfig catalog.
func (r *AdminRepo) ListRhythmConfigs(ctx context.Context) ([]RhythmConfigRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM rhythm_configs
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RhythmConfigRow
for rows.Next() {
var row RhythmConfigRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetRhythmConfig loads one by id.
func (r *AdminRepo) GetRhythmConfig(ctx context.Context, id uuid.UUID) (*RhythmConfigRow, error) {
var row RhythmConfigRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, active, system, updated_at
FROM rhythm_configs WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -122,6 +122,19 @@ func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error)
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)
@@ -0,0 +1,59 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ScheduledPublicationRow is ScheduledPublication catalog row.
type ScheduledPublicationRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
TargetKind string `json:"target_kind"`
TargetCode string `json:"target_code"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListScheduledPublications returns ScheduledPublication catalog.
func (r *AdminRepo) ListScheduledPublications(ctx context.Context) ([]ScheduledPublicationRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, target_kind, target_code, active, system, updated_at
FROM ops_scheduled_publications
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ScheduledPublicationRow
for rows.Next() {
var row ScheduledPublicationRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.TargetKind, &row.TargetCode, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetScheduledPublication loads one by id.
func (r *AdminRepo) GetScheduledPublication(ctx context.Context, id uuid.UUID) (*ScheduledPublicationRow, error) {
var row ScheduledPublicationRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, target_kind, target_code, active, system, updated_at
FROM ops_scheduled_publications WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.TargetKind, &row.TargetCode, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,57 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// StarConfigRow is StarConfig catalog row.
type StarConfigRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListStarConfigs returns StarConfig catalog.
func (r *AdminRepo) ListStarConfigs(ctx context.Context) ([]StarConfigRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM star_configs
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StarConfigRow
for rows.Next() {
var row StarConfigRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetStarConfig loads one by id.
func (r *AdminRepo) GetStarConfig(ctx context.Context, id uuid.UUID) (*StarConfigRow, error) {
var row StarConfigRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, active, system, updated_at
FROM star_configs WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,58 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ToolDefinitionRow is ToolDefinition catalog row.
type ToolDefinitionRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListToolDefinitions returns ToolDefinition catalog.
func (r *AdminRepo) ListToolDefinitions(ctx context.Context) ([]ToolDefinitionRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, description, active, system, updated_at
FROM tool_definitions
ORDER BY active DESC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ToolDefinitionRow
for rows.Next() {
var row ToolDefinitionRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Description, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetToolDefinition loads one by id.
func (r *AdminRepo) GetToolDefinition(ctx context.Context, id uuid.UUID) (*ToolDefinitionRow, error) {
var row ToolDefinitionRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, description, active, system, updated_at
FROM tool_definitions WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Description, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,86 @@
package repository
import (
"context"
"time"
"github.com/google/uuid"
)
// ReportTypeCount aggregates growth_reports by type.
type ReportTypeCount struct {
Type string `json:"type"`
Count int `json:"count"`
}
// BehaviorEventBrief is a recent analytics event for ops insight.
type BehaviorEventBrief struct {
Name string `json:"name"`
PagePath string `json:"page_path,omitempty"`
ReceivedAt time.Time `json:"received_at"`
}
// CountReportsByType groups non-deleted reports for a user.
func (r *AdminRepo) CountReportsByType(ctx context.Context, userID uuid.UUID) ([]ReportTypeCount, error) {
rows, err := r.Pool.Query(ctx, `
SELECT type, count(*)::int FROM growth_reports
WHERE user_id=$1 AND deleted_at IS NULL
GROUP BY type ORDER BY count(*) DESC, type ASC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReportTypeCount
for rows.Next() {
var c ReportTypeCount
if err := rows.Scan(&c.Type, &c.Count); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CountProfilesForUser returns active profile count.
func (r *AdminRepo) CountProfilesForUser(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT count(*)::int FROM profiles
WHERE user_id=$1 AND deleted_at IS NULL`, userID).Scan(&n)
return n, err
}
// CountAskThreadsForUser returns non-deleted ask threads.
func (r *AdminRepo) CountAskThreadsForUser(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT count(*)::int FROM ask_threads
WHERE user_id=$1 AND deleted_at IS NULL`, userID).Scan(&n)
return n, err
}
// ListRecentEventsForUser returns recent analytics events (may be empty).
func (r *AdminRepo) ListRecentEventsForUser(ctx context.Context, userID uuid.UUID, limit int) ([]BehaviorEventBrief, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
rows, err := r.Pool.Query(ctx, `
SELECT name, coalesce(page_path, ''), received_at
FROM analytics_events
WHERE user_id=$1
ORDER BY received_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []BehaviorEventBrief
for rows.Next() {
var e BehaviorEventBrief
if err := rows.Scan(&e.Name, &e.PagePath, &e.ReceivedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}