Files
root c091ad7aed feat(server/ops-api): add operations console backend
Stand up an internal ops-api service with operator account, auth, and
audit log subsystems backed by a dedicated migrations_ops migration set.
Wire Makefile targets and the migrate Dockerfile stage so the new schema
ships alongside the existing tenant + monitoring databases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:32:50 +08:00

195 lines
5.3 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/ops/domain"
)
var ErrAccountNotFound = errors.New("operator account not found")
type AccountRepository struct {
pool *pgxpool.Pool
}
func NewAccountRepository(pool *pgxpool.Pool) *AccountRepository {
return &AccountRepository{pool: pool}
}
const accountColumns = `id, username, email, password_hash, display_name, role, status,
last_login_at, created_by, created_at, updated_at`
func scanAccount(row pgx.Row) (*domain.OperatorAccount, error) {
var a domain.OperatorAccount
err := row.Scan(
&a.ID, &a.Username, &a.Email, &a.PasswordHash, &a.DisplayName,
&a.Role, &a.Status, &a.LastLoginAt, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAccountNotFound
}
return nil, err
}
return &a, nil
}
func (r *AccountRepository) GetByUsername(ctx context.Context, username string) (*domain.OperatorAccount, error) {
row := r.pool.QueryRow(ctx, `
SELECT `+accountColumns+`
FROM ops.operator_accounts
WHERE username = $1 AND deleted_at IS NULL`, username)
return scanAccount(row)
}
func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*domain.OperatorAccount, error) {
row := r.pool.QueryRow(ctx, `
SELECT `+accountColumns+`
FROM ops.operator_accounts
WHERE id = $1 AND deleted_at IS NULL`, id)
return scanAccount(row)
}
func (r *AccountRepository) Count(ctx context.Context) (int64, error) {
var n int64
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM ops.operator_accounts WHERE deleted_at IS NULL`).Scan(&n)
return n, err
}
type ListFilter struct {
Keyword string
Status string
Limit int
Offset int
}
func (r *AccountRepository) List(ctx context.Context, f ListFilter) ([]domain.OperatorAccount, int64, error) {
args := []any{}
where := "deleted_at IS NULL"
if f.Keyword != "" {
args = append(args, "%"+f.Keyword+"%")
where += fmt.Sprintf(" AND (username ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)",
len(args), len(args), len(args))
}
if f.Status != "" {
args = append(args, f.Status)
where += fmt.Sprintf(" AND status = $%d", len(args))
}
var total int64
if err := r.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM ops.operator_accounts WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 20
}
offset := f.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
limitArg := len(args) - 1
offsetArg := len(args)
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT %s
FROM ops.operator_accounts
WHERE %s
ORDER BY created_at DESC
LIMIT $%d OFFSET $%d`, accountColumns, where, limitArg, offsetArg), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]domain.OperatorAccount, 0, limit)
for rows.Next() {
var a domain.OperatorAccount
if err := rows.Scan(
&a.ID, &a.Username, &a.Email, &a.PasswordHash, &a.DisplayName,
&a.Role, &a.Status, &a.LastLoginAt, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt,
); err != nil {
return nil, 0, err
}
out = append(out, a)
}
return out, total, rows.Err()
}
type CreateAccountInput struct {
Username string
Email *string
PasswordHash string
DisplayName string
Role string
CreatedBy *int64
}
func (r *AccountRepository) Create(ctx context.Context, in CreateAccountInput) (*domain.OperatorAccount, error) {
row := r.pool.QueryRow(ctx, `
INSERT INTO ops.operator_accounts (username, email, password_hash, display_name, role, status, created_by)
VALUES ($1, $2, $3, $4, $5, 'active', $6)
RETURNING `+accountColumns,
in.Username, in.Email, in.PasswordHash, in.DisplayName, in.Role, in.CreatedBy)
return scanAccount(row)
}
func (r *AccountRepository) UpdateStatus(ctx context.Context, id int64, status string) error {
cmd, err := r.pool.Exec(ctx, `
UPDATE ops.operator_accounts
SET status = $1, updated_at = NOW()
WHERE id = $2 AND deleted_at IS NULL`, status, id)
if err != nil {
return err
}
if cmd.RowsAffected() == 0 {
return ErrAccountNotFound
}
return nil
}
func (r *AccountRepository) UpdatePassword(ctx context.Context, id int64, passwordHash string) error {
cmd, err := r.pool.Exec(ctx, `
UPDATE ops.operator_accounts
SET password_hash = $1, updated_at = NOW()
WHERE id = $2 AND deleted_at IS NULL`, passwordHash, id)
if err != nil {
return err
}
if cmd.RowsAffected() == 0 {
return ErrAccountNotFound
}
return nil
}
func (r *AccountRepository) UpdateProfile(ctx context.Context, id int64, displayName string, email *string) error {
cmd, err := r.pool.Exec(ctx, `
UPDATE ops.operator_accounts
SET display_name = $1, email = $2, updated_at = NOW()
WHERE id = $3 AND deleted_at IS NULL`, displayName, email, id)
if err != nil {
return err
}
if cmd.RowsAffected() == 0 {
return ErrAccountNotFound
}
return nil
}
func (r *AccountRepository) MarkLogin(ctx context.Context, id int64) error {
_, err := r.pool.Exec(ctx, `
UPDATE ops.operator_accounts
SET last_login_at = NOW(), updated_at = NOW()
WHERE id = $1`, id)
return err
}