package repository import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) var ( // ErrAdminNotFound is returned when an admin account row is missing. ErrAdminNotFound = errors.New("admin not found") // ErrPushJobNotFound is returned when a push_jobs row is missing. ErrPushJobNotFound = errors.New("push job not found") // ErrUserStatusNotFound is returned when users row is missing for status update. ErrUserStatusNotFound = errors.New("user not found") ) // AdminListItem is a public admin row (no password). type AdminListItem struct { ID uuid.UUID `json:"id"` Username string `json:"username"` Role string `json:"role"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } // PushJob is a push campaign stub (never dispatched). type PushJob struct { ID uuid.UUID `json:"id"` Title string `json:"title"` Body string `json:"body"` Audience string `json:"audience"` Status string `json:"status"` CreatedBy uuid.UUID `json:"created_by"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // SetUserStatusWithAudit updates users.status and writes audit. func (r *AdminRepo) SetUserStatusWithAudit( ctx context.Context, adminID, userID uuid.UUID, status, action 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`, userID, status) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrUserStatusNotFound } if status == "banned" { _, _ = tx.Exec(ctx, ` UPDATE user_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, userID) } 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,$2,'user',$3,$4)`, adminID, action, userID.String(), meta); err != nil { return err } return tx.Commit(ctx) } // ListAdmins returns non-deleted admin accounts. func (r *AdminRepo) ListAdmins(ctx context.Context) ([]AdminListItem, error) { rows, err := r.Pool.Query(ctx, ` SELECT id, username, role, status, created_at FROM admin_accounts WHERE deleted_at IS NULL ORDER BY created_at ASC`) if err != nil { return nil, err } defer rows.Close() var out []AdminListItem for rows.Next() { var a AdminListItem if err := rows.Scan(&a.ID, &a.Username, &a.Role, &a.Status, &a.CreatedAt); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } // CountAdminsByRole counts non-deleted admins with the given role. func (r *AdminRepo) CountAdminsByRole(ctx context.Context, role string) (int, error) { var n int err := r.Pool.QueryRow(ctx, ` SELECT count(*) FROM admin_accounts WHERE deleted_at IS NULL AND role=$1`, role).Scan(&n) return n, err } // UpdateAdminRoleWithAudit sets role for an admin account. func (r *AdminRepo) UpdateAdminRoleWithAudit( ctx context.Context, actorID, targetID uuid.UUID, role 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 admin_accounts SET role=$2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, targetID, role) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrAdminNotFound } 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,'admin.role_update','admin',$2,$3)`, actorID, targetID.String(), meta); err != nil { return err } return tx.Commit(ctx) } // ListPushJobs returns newest push stubs. func (r *AdminRepo) ListPushJobs(ctx context.Context, limit, offset int) ([]PushJob, error) { if limit <= 0 || limit > 100 { limit = 20 } if offset < 0 { offset = 0 } rows, err := r.Pool.Query(ctx, ` SELECT id, title, body, audience, status, created_by, created_at, updated_at FROM push_jobs ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []PushJob for rows.Next() { var j PushJob if err := rows.Scan( &j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt, ); err != nil { return nil, err } out = append(out, j) } return out, rows.Err() } // CreatePushJobWithAudit inserts a draft push job. func (r *AdminRepo) CreatePushJobWithAudit( ctx context.Context, adminID uuid.UUID, title, body, audience string, meta json.RawMessage, ) (*PushJob, error) { if audience == "" { audience = "all" } tx, err := r.Pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var j PushJob err = tx.QueryRow(ctx, ` INSERT INTO push_jobs(title, body, audience, status, created_by) VALUES ($1,$2,$3,'draft',$4) RETURNING id, title, body, audience, status, created_by, created_at, updated_at`, title, body, audience, adminID, ).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt) if err != nil { return nil, 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,'push_job.create','push_job',$2,$3)`, adminID, j.ID.String(), meta); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return &j, nil } // UpdatePushJobWithAudit updates title/body/status (draft|cancelled only). func (r *AdminRepo) UpdatePushJobWithAudit( ctx context.Context, adminID, jobID uuid.UUID, title, body, status *string, meta json.RawMessage, ) (*PushJob, error) { tx, err := r.Pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var j PushJob err = tx.QueryRow(ctx, ` SELECT id, title, body, audience, status, created_by, created_at, updated_at FROM push_jobs WHERE id=$1`, jobID, ).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrPushJobNotFound } if err != nil { return nil, err } if title != nil { j.Title = *title } if body != nil { j.Body = *body } if status != nil { j.Status = *status } err = tx.QueryRow(ctx, ` UPDATE push_jobs SET title=$2, body=$3, status=$4, updated_at=now() WHERE id=$1 RETURNING id, title, body, audience, status, created_by, created_at, updated_at`, jobID, j.Title, j.Body, j.Status, ).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt) if err != nil { return nil, 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,'push_job.update','push_job',$2,$3)`, adminID, jobID.String(), meta); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return &j, nil } // UserStatus returns users.status or empty if missing. func (r *AdminRepo) UserStatus(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 "", errors.New("user not found") } return status, err }