package repository import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // AdminRepo persists ops-admin accounts, sessions, and audit logs. type AdminRepo struct { Pool *pgxpool.Pool } // AdminAccount is an internal operator account. type AdminAccount struct { ID uuid.UUID Username string PasswordHash string Status string } // CountAccounts returns non-deleted admin count. func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) { var n int err := r.Pool.QueryRow(ctx, ` SELECT count(*) FROM admin_accounts WHERE deleted_at IS NULL`).Scan(&n) return n, err } // CreateAccount inserts an admin account. 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) VALUES ($1,$2) RETURNING id`, username, hash).Scan(&id) return id, err } // FindByUsername loads an active admin by username. func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*AdminAccount, error) { var a AdminAccount err := r.Pool.QueryRow(ctx, ` SELECT id, username, password_hash, status FROM admin_accounts WHERE username=$1 AND deleted_at IS NULL`, username, ).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &a, nil } // FindAccountByID loads admin by id. func (r *AdminRepo) FindAccountByID(ctx context.Context, id uuid.UUID) (*AdminAccount, error) { var a AdminAccount err := r.Pool.QueryRow(ctx, ` SELECT id, username, password_hash, status FROM admin_accounts WHERE id=$1 AND deleted_at IS NULL`, id, ).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &a, nil } // CreateSession stores an opaque admin session token. func (r *AdminRepo) CreateSession(ctx context.Context, adminID uuid.UUID, token string, expires time.Time) error { _, err := r.Pool.Exec(ctx, ` INSERT INTO admin_sessions(admin_id, token, expires_at) VALUES ($1,$2,$3)`, adminID, token, expires) return err } // ResolveSession returns admin_id for a valid token. func (r *AdminRepo) ResolveSession(ctx context.Context, token string) (uuid.UUID, error) { var id uuid.UUID err := r.Pool.QueryRow(ctx, ` SELECT s.admin_id FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id AND a.deleted_at IS NULL AND a.status='active' WHERE s.token=$1 AND s.expires_at > now()`, token).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, errors.New("invalid session") } return id, err } // DeleteSession removes a session by token. func (r *AdminRepo) DeleteSession(ctx context.Context, token string) error { _, err := r.Pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token=$1`, token) return err } // InsertAudit appends an immutable audit row. func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action, targetType, targetID string, meta json.RawMessage) error { if meta == nil { meta = json.RawMessage(`{}`) } _, err := r.Pool.Exec(ctx, ` INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta) VALUES ($1,$2,$3,$4,$5)`, adminID, action, targetType, targetID, meta) return err } // UserListItem is a compact user row for admin tables. type UserListItem struct { ID uuid.UUID `json:"id"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } // ListUsers returns users newest first; q matches id when UUID. func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) { if limit <= 0 || limit > 100 { limit = 20 } if offset < 0 { offset = 0 } rows, err := r.Pool.Query(ctx, ` SELECT id, status, created_at FROM users WHERE deleted_at IS NULL AND ($1 = '' OR id::text = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3`, q, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []UserListItem for rows.Next() { var u UserListItem if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } // UserExists reports whether user id is present. func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) { var n int err := r.Pool.QueryRow(ctx, ` SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL`, id).Scan(&n) if errors.Is(err, pgx.ErrNoRows) { return false, nil } return err == nil, err } // ProfileBrief for admin user detail. type ProfileBrief struct { ID uuid.UUID `json:"id"` Relation string `json:"relation"` DisplayName string `json:"display_name"` } // ListProfilesForUser returns profile briefs. func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) ([]ProfileBrief, error) { rows, err := r.Pool.Query(ctx, ` SELECT id, relation, display_name FROM profiles WHERE user_id=$1 AND deleted_at IS NULL ORDER BY created_at ASC`, userID) if err != nil { return nil, err } defer rows.Close() var out []ProfileBrief for rows.Next() { var p ProfileBrief if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // OrderListItem for admin order tables. type OrderListItem struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` Kind string `json:"kind"` Plan *string `json:"plan,omitempty"` AmountCents int `json:"amount_cents"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } // ListOrders lists orders; optional user filter. func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]OrderListItem, error) { if limit <= 0 || limit > 100 { limit = 20 } if offset < 0 { offset = 0 } rows, err := r.Pool.Query(ctx, ` SELECT id, user_id, kind, plan, amount_cents, status, created_at FROM orders WHERE deleted_at IS NULL AND ($1::uuid IS NULL OR user_id = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3`, userID, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []OrderListItem for rows.Next() { var o OrderListItem if err := rows.Scan(&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status, &o.CreatedAt); err != nil { return nil, err } out = append(out, o) } return out, rows.Err() } // GrantMembershipWithAudit upserts membership and appends audit in one transaction. func (r *AdminRepo) GrantMembershipWithAudit( ctx context.Context, adminID, userID uuid.UUID, plan string, days int, meta json.RawMessage, ) error { tx, err := r.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) 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 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.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil { return err } return tx.Commit(ctx) } // AuditListItem for admin audit table. type AuditListItem struct { ID uuid.UUID `json:"id"` AdminID uuid.UUID `json:"admin_id"` Action string `json:"action"` TargetType string `json:"target_type"` TargetID string `json:"target_id"` Meta json.RawMessage `json:"meta"` CreatedAt time.Time `json:"created_at"` } // ListAuditLogs returns newest audit rows. func (r *AdminRepo) ListAuditLogs(ctx context.Context, limit, offset int) ([]AuditListItem, error) { if limit <= 0 || limit > 100 { limit = 20 } if offset < 0 { offset = 0 } rows, err := r.Pool.Query(ctx, ` SELECT id, admin_id, action, target_type, target_id, meta, created_at FROM admin_audit_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []AuditListItem for rows.Next() { var a AuditListItem if err := rows.Scan(&a.ID, &a.AdminID, &a.Action, &a.TargetType, &a.TargetID, &a.Meta, &a.CreatedAt); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() }