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
149 lines
4.4 KiB
Go
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)
|
|
}
|