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
161 lines
4.9 KiB
Go
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
|
|
}
|