Initial import — snapshot from admin host /srv/gosec/gsc-ops-api
This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.
The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.
Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.
Future changes go through PRs against this repo — no more bin-only
deploys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
413
internal/service/database.go
Normal file
413
internal/service/database.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// DatabaseService handles tenant and user database operations
|
||||
type DatabaseService struct {
|
||||
pool *pgxpool.Pool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
// NewDatabaseService creates a new database service
|
||||
func NewDatabaseService(pool *pgxpool.Pool, logger zerolog.Logger) *DatabaseService {
|
||||
return &DatabaseService{
|
||||
pool: pool,
|
||||
logger: logger.With().Str("service", "database").Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
// ListTenants lists tenants with optional filters
|
||||
func (s *DatabaseService) ListTenants(ctx context.Context, params types.ListParams) ([]types.Tenant, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM admin.tenants WHERE 1=1`
|
||||
listQuery := `SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color,
|
||||
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at
|
||||
FROM admin.tenants WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if params.Status != "" {
|
||||
if params.Status == "active" {
|
||||
countQuery += " AND is_active = true"
|
||||
listQuery += " AND is_active = true"
|
||||
} else if params.Status == "inactive" {
|
||||
countQuery += " AND is_active = false"
|
||||
listQuery += " AND is_active = false"
|
||||
}
|
||||
}
|
||||
if params.Search != "" {
|
||||
countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
listQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit, params.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tenants := make([]types.Tenant, 0)
|
||||
for rows.Next() {
|
||||
var t types.Tenant
|
||||
var metadataJSON []byte
|
||||
if err := rows.Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain,
|
||||
&t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours,
|
||||
&t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &t.Metadata)
|
||||
}
|
||||
tenants = append(tenants, t)
|
||||
}
|
||||
|
||||
return tenants, total, nil
|
||||
}
|
||||
|
||||
// GetTenant gets a tenant by ID
|
||||
func (s *DatabaseService) GetTenant(ctx context.Context, id uuid.UUID) (*types.Tenant, error) {
|
||||
var t types.Tenant
|
||||
var metadataJSON []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color,
|
||||
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at
|
||||
FROM admin.tenants WHERE id = $1`, id).
|
||||
Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain,
|
||||
&t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours,
|
||||
&t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &t.Metadata)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// CreateTenant creates a new tenant
|
||||
func (s *DatabaseService) CreateTenant(ctx context.Context, req *types.TenantCreate) (*types.Tenant, error) {
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
var metadataJSON []byte
|
||||
if req.Metadata != nil {
|
||||
var err error
|
||||
metadataJSON, err = json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO admin.tenants (id, customer_id, code, name, display_name, domain, logo_url, primary_color,
|
||||
max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, true, $12, $13, $13)`,
|
||||
id, req.CustomerID, req.Code, req.Name, nilIfEmpty(req.DisplayName), nilIfEmpty(req.Domain),
|
||||
nilIfEmpty(req.LogoURL), nilIfEmpty(req.PrimaryColor),
|
||||
req.MaxUsers, req.MaxStorageGB, req.MaxRecordingHours, metadataJSON, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetTenant(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateTenant updates a tenant
|
||||
func (s *DatabaseService) UpdateTenant(ctx context.Context, id uuid.UUID, req *types.TenantUpdate) (*types.Tenant, error) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Name != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx))
|
||||
args = append(args, *req.Name)
|
||||
argIdx++
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("display_name = $%d", argIdx))
|
||||
args = append(args, *req.DisplayName)
|
||||
argIdx++
|
||||
}
|
||||
if req.Domain != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("domain = $%d", argIdx))
|
||||
args = append(args, *req.Domain)
|
||||
argIdx++
|
||||
}
|
||||
if req.LogoURL != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("logo_url = $%d", argIdx))
|
||||
args = append(args, *req.LogoURL)
|
||||
argIdx++
|
||||
}
|
||||
if req.PrimaryColor != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("primary_color = $%d", argIdx))
|
||||
args = append(args, *req.PrimaryColor)
|
||||
argIdx++
|
||||
}
|
||||
if req.MaxUsers != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_users = $%d", argIdx))
|
||||
args = append(args, *req.MaxUsers)
|
||||
argIdx++
|
||||
}
|
||||
if req.MaxStorageGB != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_storage_gb = $%d", argIdx))
|
||||
args = append(args, *req.MaxStorageGB)
|
||||
argIdx++
|
||||
}
|
||||
if req.MaxRecordingHours != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_recording_hours = $%d", argIdx))
|
||||
args = append(args, *req.MaxRecordingHours)
|
||||
argIdx++
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx))
|
||||
args = append(args, *req.IsActive)
|
||||
argIdx++
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
metadataJSON, err := json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx))
|
||||
args = append(args, metadataJSON)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetTenant(ctx, id)
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx))
|
||||
args = append(args, time.Now().UTC())
|
||||
argIdx++
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE admin.tenants SET %s WHERE id = $%d",
|
||||
join(setClauses, ", "), argIdx)
|
||||
|
||||
_, err := s.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetTenant(ctx, id)
|
||||
}
|
||||
|
||||
// SoftDeleteTenant deactivates a tenant
|
||||
func (s *DatabaseService) SoftDeleteTenant(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE admin.tenants SET is_active = false, updated_at = $1 WHERE id = $2`,
|
||||
time.Now().UTC(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUsers lists users with optional filters
|
||||
func (s *DatabaseService) ListUsers(ctx context.Context, params types.ListParams) ([]types.DBUser, int64, error) {
|
||||
params = types.DefaultListParams(params)
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM admin.users WHERE 1=1`
|
||||
listQuery := `SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status,
|
||||
last_login_at, last_activity_at, metadata, created_at, updated_at
|
||||
FROM admin.users WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if params.Status != "" {
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
listQuery += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||
args = append(args, params.Status)
|
||||
argIdx++
|
||||
}
|
||||
if params.Search != "" {
|
||||
countQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
listQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx)
|
||||
args = append(args, "%"+params.Search+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit, params.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]types.DBUser, 0)
|
||||
for rows.Next() {
|
||||
var u types.DBUser
|
||||
var metadataJSON []byte
|
||||
if err := rows.Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status,
|
||||
&u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &u.Metadata)
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// GetUser gets a user by ID
|
||||
func (s *DatabaseService) GetUser(ctx context.Context, id uuid.UUID) (*types.DBUser, error) {
|
||||
var u types.DBUser
|
||||
var metadataJSON []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status,
|
||||
last_login_at, last_activity_at, metadata, created_at, updated_at
|
||||
FROM admin.users WHERE id = $1`, id).
|
||||
Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status,
|
||||
&u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
json.Unmarshal(metadataJSON, &u.Metadata)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user record
|
||||
func (s *DatabaseService) CreateUser(ctx context.Context, req *types.DBUserCreate) (*types.DBUser, error) {
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
var metadataJSON []byte
|
||||
if req.Metadata != nil {
|
||||
var err error
|
||||
metadataJSON, err = json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO admin.users (id, gscsid, first_name, last_name, display_name, email, timezone, locale, status, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $10)`,
|
||||
id, req.GscSID, nilIfEmpty(req.FirstName), nilIfEmpty(req.LastName), nilIfEmpty(req.DisplayName), nilIfEmpty(req.Email), nilIfEmpty(req.Timezone), nilIfEmpty(req.Locale), metadataJSON, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateUser updates a user record
|
||||
func (s *DatabaseService) UpdateUser(ctx context.Context, id uuid.UUID, req *types.DBUserUpdate) (*types.DBUser, error) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Timezone != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("timezone = $%d", argIdx))
|
||||
args = append(args, *req.Timezone)
|
||||
argIdx++
|
||||
}
|
||||
if req.Locale != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("locale = $%d", argIdx))
|
||||
args = append(args, *req.Locale)
|
||||
argIdx++
|
||||
}
|
||||
if req.Status != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx))
|
||||
args = append(args, *req.Status)
|
||||
argIdx++
|
||||
}
|
||||
if req.LastLoginAt != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("last_login_at = $%d", argIdx))
|
||||
args = append(args, *req.LastLoginAt)
|
||||
argIdx++
|
||||
}
|
||||
if req.LastActivityAt != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("last_activity_at = $%d", argIdx))
|
||||
args = append(args, *req.LastActivityAt)
|
||||
argIdx++
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
metadataJSON, err := json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx))
|
||||
args = append(args, metadataJSON)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx))
|
||||
args = append(args, time.Now().UTC())
|
||||
argIdx++
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE admin.users SET %s WHERE id = $%d",
|
||||
join(setClauses, ", "), argIdx)
|
||||
|
||||
_, err := s.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
// DeactivateUser deactivates a user
|
||||
func (s *DatabaseService) DeactivateUser(ctx context.Context, id uuid.UUID) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE admin.users SET status = 'inactive', updated_at = $1 WHERE id = $2`,
|
||||
now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func join(strs []string, sep string) string {
|
||||
result := ""
|
||||
for i, s := range strs {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user