Files
Behavision/server/internal/store/api_admin_clients.go
Suriyakumarvijayanayagam f88d441bbf A shop can be renamed and, while empty, removed - from head office
The display name was always meant to be editable and the slug frozen;
until now neither had a way in. PATCH /api/sites/{site} takes a name
and a timezone (manager and above), DELETE removes an empty shop
(owner). The shop drawer in head office gets both, with the short name
shown read-only and the reason beside it.

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

161 lines
4.9 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)
}
func (s *Store) UpdateSite(ctx context.Context, clientID, siteID string, in api.SiteUpdate) (api.SiteHealth, error) {
var out api.SiteHealth
err := s.pool.QueryRow(ctx, `
UPDATE sites
SET name = COALESCE($3, name),
timezone = COALESCE($4, timezone)
WHERE id = $1::uuid AND client_id = $2::uuid
RETURNING id::text, slug, name, timezone`, siteID, clientID, in.Name, in.Timezone).
Scan(&out.SiteID, &out.Slug, &out.Name, &out.Timezone)
return out, err
}