c091ad7aed
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>
195 lines
5.3 KiB
Go
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
|
|
}
|