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
107 lines
3.5 KiB
Go
107 lines
3.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/loyaly/behavision-server/internal/api"
|
|
"github.com/loyaly/behavision-server/internal/auth"
|
|
)
|
|
|
|
// CreateClientWithOwner creates a tenant and the account that owns it.
|
|
//
|
|
// ONE transaction, deliberately. A client row with no owner is a tenant nobody
|
|
// can sign into, and it is invisible: it looks exactly like a normal client in
|
|
// every list, so the operator finds out weeks later when the customer says
|
|
// their login does not work. Rolling the whole thing back on a duplicate email
|
|
// is the only outcome that leaves the database describing something real.
|
|
func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput) (
|
|
api.NewClientResult, error) {
|
|
|
|
var out api.NewClientResult
|
|
|
|
password := in.Password
|
|
if password == "" {
|
|
// Generated rather than defaulted. An operator inventing a password for
|
|
// somebody else invents a weak one and then sends it over chat.
|
|
p, err := auth.RandomPassword()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
password = p
|
|
}
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
// The policy message is user-facing text an operator can act on
|
|
// ("password must be at least 8 characters"), so it travels out as-is.
|
|
return out, err
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
|
|
|
// No ON CONFLICT DO UPDATE here, unlike the provisioning CLI. On this path
|
|
// a clashing slug means the operator is about to hand someone else's tenant
|
|
// to a new owner; it has to fail and say so.
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO clients (slug, name) VALUES ($1, $2)
|
|
RETURNING id::text`, in.Slug, in.CompanyName).Scan(&out.ClientID); err != nil {
|
|
return out, fmt.Errorf("create client: %w", err)
|
|
}
|
|
|
|
// The owner, not a manager: this is the account the customer runs their
|
|
// business from, and it must be able to add the staff who come after it.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
|
VALUES ($1::uuid, $2, $3, $4, 'owner')`,
|
|
out.ClientID, in.OwnerEmail, hash, in.OwnerName); err != nil {
|
|
return out, fmt.Errorf("create owner: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return out, err
|
|
}
|
|
out.Slug, out.OwnerEmail, out.Password = in.Slug, in.OwnerEmail, password
|
|
return out, nil
|
|
}
|
|
|
|
// ListClients is the platform-admin overview.
|
|
//
|
|
// Counts come from correlated subqueries rather than joins: a client with two
|
|
// sites and three users would otherwise appear six times and be counted wrong
|
|
// in whichever direction the operator's eye went first.
|
|
func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT c.id::text, c.slug, c.name, c.active, c.created_at,
|
|
(SELECT count(*) FROM sites si WHERE si.client_id = c.id),
|
|
(SELECT count(*) FROM app_users au WHERE au.client_id = c.id)
|
|
FROM clients c
|
|
ORDER BY c.created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []api.ClientRow
|
|
for rows.Next() {
|
|
var c api.ClientRow
|
|
var at time.Time
|
|
if err := rows.Scan(&c.ID, &c.Slug, &c.Name, &c.Active, &at, &c.Sites, &c.Users); err != nil {
|
|
return nil, err
|
|
}
|
|
c.CreatedAt = at.UTC().Format(time.RFC3339)
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// randomPassword mints an owner's first password.
|
|
//
|
|
// base32 without padding, matching the rest of this system's generated
|
|
// secrets: it gets read down a phone line and pasted into a form, and base64's
|
|
// + / = survive neither.
|