Files
Behavision/server/internal/store/api_admin_clients.go
Suriyakumarvijayanayagam 8786a5b0b4 The platform admin's last shell-only jobs are endpoints
Suspend or reinstate a company (PATCH /api/admin/clients/{id}), reset
its owner's password (shown once), and delete it - and an owner can
remove a shop opened by mistake (DELETE /api/sites/{site}, empty only).

Suspension ends every session the company holds in the same
transaction: login and ingest already refused an inactive client, but a
live access token would have kept reading for up to twelve hours, so
'suspend' would have meant 'suspend some time tomorrow'. Deletion is
deliberately two steps - the company must already be suspended and the
request repeats the slug - because the data under it is biometric.
Face images go first (a storage failure aborts with nothing touched),
then the broker logins, then the rows by cascade.

Exercised against the local Postgres and broker: create, open a shop,
remove it (two plugin commands), refuse delete while active, suspend
(owner's token 401 immediately), reset, delete, zero rows left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:36:21 +05:30

149 lines
4.4 KiB
Go

package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
func (s *Store) SetClientActive(ctx context.Context, clientID string, active bool) (api.ClientRow, int, error) {
var row api.ClientRow
tx, err := s.pool.Begin(ctx)
if err != nil {
return row, 0, err
}
defer tx.Rollback(ctx) //nolint:errcheck
var at time.Time
if err := tx.QueryRow(ctx, `
UPDATE clients SET active = $2 WHERE id = $1::uuid
RETURNING id::text, slug, name, active, created_at,
(SELECT count(*) FROM sites WHERE client_id = clients.id),
(SELECT count(*) FROM app_users WHERE client_id = clients.id)`, clientID, active).
Scan(&row.ID, &row.Slug, &row.Name, &row.Active, &at, &row.Sites, &row.Users); err != nil {
return row, 0, err
}
row.CreatedAt = at.UTC().Format(time.RFC3339)
revoked := 0
if !active {
tag, err := tx.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE revoked_at IS NULL
AND user_id IN (SELECT id FROM app_users WHERE client_id = $1::uuid)`, clientID)
if err != nil {
return row, 0, fmt.Errorf("revoke sessions: %w", err)
}
revoked = int(tag.RowsAffected())
}
return row, revoked, tx.Commit(ctx)
}
func (s *Store) ClientOwners(ctx context.Context, clientID string) ([]api.TeamMember, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, email, full_name, role, active
FROM app_users
WHERE client_id = $1::uuid AND role = 'owner' AND active
ORDER BY created_at`, clientID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []api.TeamMember
for rows.Next() {
var m api.TeamMember
if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) ClientImageKeys(ctx context.Context, clientID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT image_key FROM visits
WHERE client_id = $1::uuid AND image_key <> '' AND image_deleted_at IS NULL`, clientID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
// DeleteClient relies on ON DELETE CASCADE from clients, which every tenant
// table declares (001-011). The broker usernames are read before the rows go,
// because afterwards nothing remembers them.
func (s *Store) DeleteClient(ctx context.Context, clientID string) (api.ClientRow, []string, error) {
var row api.ClientRow
tx, err := s.pool.Begin(ctx)
if err != nil {
return row, nil, err
}
defer tx.Rollback(ctx) //nolint:errcheck
if err := tx.QueryRow(ctx, `SELECT id::text, slug, name, active FROM clients WHERE id = $1::uuid FOR UPDATE`, clientID).
Scan(&row.ID, &row.Slug, &row.Name, &row.Active); err != nil {
return row, nil, err
}
if row.Active {
return row, nil, fmt.Errorf("client %s is active; suspend it first", row.Slug)
}
rows, err := tx.Query(ctx, `SELECT mqtt_username FROM agents WHERE client_id = $1::uuid`, clientID)
if err != nil {
return row, nil, err
}
var users []string
for rows.Next() {
var u string
if err := rows.Scan(&u); err != nil {
rows.Close()
return row, nil, err
}
users = append(users, u)
}
rows.Close()
if _, err := tx.Exec(ctx, `DELETE FROM clients WHERE id = $1::uuid`, clientID); err != nil {
return row, nil, fmt.Errorf("delete client: %w", err)
}
return row, users, tx.Commit(ctx)
}
func (s *Store) DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", err
}
defer tx.Rollback(ctx) //nolint:errcheck
var used bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM visits WHERE site_id = $1::uuid)
OR EXISTS (SELECT 1 FROM site_cameras WHERE site_id = $1::uuid AND deleted_at IS NULL)`, siteID).Scan(&used); err != nil {
return "", err
}
if used {
return "", api.ErrSiteInUse
}
var username string
if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT mqtt_username FROM agents WHERE site_id = $1::uuid LIMIT 1), '')`, siteID).Scan(&username); err != nil {
return "", err
}
tag, err := tx.Exec(ctx, `DELETE FROM sites WHERE id = $1::uuid AND client_id = $2::uuid`, siteID, clientID)
if err != nil {
return "", err
}
if tag.RowsAffected() == 0 {
return "", pgx.ErrNoRows
}
return username, tx.Commit(ctx)
}