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
This commit is contained in:
2026-09-19 12:36:21 +05:30
parent c93fbff31f
commit 8786a5b0b4
8 changed files with 686 additions and 9 deletions

60
API.md
View File

@@ -49,13 +49,13 @@ user; the tenant is always taken from the session and never from the request.
| `DELETE /api/visitors/{id}` — erasure | manager |
| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed |
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
| `POST /api/sites` — open a shop | owner |
| `POST /api/sites` — open a shop · `DELETE /api/sites/{site}` — remove an empty one | owner |
| `POST /api/sites/{site}/enrolment-code` | manager |
| `GET /api/team` | authed (tenant users only) |
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed |
| `POST /api/assistant` | authed |
| `GET` / `POST /api/admin/clients` | **platform admin** |
| `GET` / `POST /api/admin/clients` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **platform admin** |
| `/api/agent/*` | **shop PC token** — never a user |
A role that may not call something gets **403 `forbidden`** with a message
@@ -102,6 +102,9 @@ travels over chat.
```
GET /api/admin/clients → every merchant, with site and user counts
PATCH /api/admin/clients/{id} {"active": false} → suspend (or true: reinstate)
POST /api/admin/clients/{id}/owner-password → new owner password, shown once
DELETE /api/admin/clients/{id} {"confirm": "<slug>"} → delete a SUSPENDED company
```
### Tier 2 — the merchant owner registers sales staff
@@ -218,9 +221,10 @@ somebody else's account.
- **There is no mobile app in this repository.** Tier 3 is a complete API with
no client yet. Everything above is what that app will call.
- **The admin cannot reset a merchant owner's password over HTTP**, nor suspend
or delete a merchant. Today that is `behavision-server provision` on the
server.
- **A shop with visit history cannot be deleted**, only its cameras removed.
`DELETE /api/sites/{site}` is for the shop opened by mistake (no visits, no
cameras); taking away footfall and faces is an erasure decision, and there
is no endpoint for it yet.
---
@@ -261,10 +265,13 @@ GET /api/reports/footfall?from=&to= → the numbers, with their confidenc
POST /api/auth/login (an account with no company)
GET /api/admin/clients → every company
POST /api/admin/clients → create one, with its owner
PATCH /api/admin/clients/{id} → suspend / reinstate
POST /api/admin/clients/{id}/owner-password → reset the owner's password
DELETE /api/admin/clients/{id} → delete, once suspended
```
That is the whole admin surface today. Everything inside a company is the
company's own business and is reached by signing in as one of its users.
That is the whole admin surface. Everything inside a company is the company's
own business and is reached by signing in as one of its users.
---
@@ -1045,6 +1052,45 @@ in as the company's owner, or by `behavision-server provision` on the server.
---
### `PATCH /api/admin/clients/{id}` — suspend or reinstate
```
{ "active": false }
→ 200 { "client": { "id": "…", "slug": "acme", "active": false, "sites": 2, "users": 5, … },
"sessions_revoked": 3 }
```
Suspension is complete the moment it returns: the company's users cannot sign
in, every session they hold is revoked in the same transaction (so a live
access token stops working now, not at expiry), and visits from its shop PCs
are dropped at ingest. `{"active": true}` reinstates; sessions are not
restored — people sign in again.
### `POST /api/admin/clients/{id}/owner-password` — reset the owner's password
```
{ "email": "owner@acme.com" } ← optional when the company has exactly one owner
→ 200 { "email": "owner@acme.com", "password": "n7xw…" } ← shown ONCE
```
For the owner who has locked themselves out with nobody above them. Generated,
never chosen; every session that owner held is revoked. With several owners
and no `email`, 400 listing them.
### `DELETE /api/admin/clients/{id}` — delete a company
```
{ "confirm": "acme" }
→ 200 { "deleted": "acme", "images_deleted": 12 }
```
Irreversible, and the data is biometric, so it is a two-step decision: the
company must already be **suspended** (`409 still_active` otherwise) and the
body must repeat its slug. Stored face images are deleted from object storage
first — a failure there is `502 storage_error` and nothing else is touched —
then the shop PCs' broker logins, then every row (templates, visits, users,
sessions, cameras) by cascade.
## 12. Errors
```json

View File

@@ -0,0 +1,128 @@
package api
import (
"encoding/json"
"net/http"
"testing"
)
func seedTenantWithOwner(fs *fakeStore) {
fs.clients = []ClientRow{{ID: "client-acme", Slug: "acme", Name: "Acme Retail", Active: true}}
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true, Email: "owner@acme.com",
})
}
func TestSuspendingACompanyEndsItsSessionsNow(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
seedTenantWithOwner(fs)
owner := login(t, s, "owner@acme.com", "correct horse battery")
admin := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out struct {
SessionsRevoked int `json:"sessions_revoked"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.SessionsRevoked != 1 {
t.Fatalf("expected the owner's one session revoked, got %d", out.SessionsRevoked)
}
// The owner's token stops working immediately, not at expiry.
if rec := do(t, s, "GET", "/api/team", owner.Token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("suspended tenant's session still works: %d", rec.Code)
}
}
func TestDeletingACompanyIsATwoStepDecision(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
seedTenantWithOwner(fs)
b := &fakeBroker{}
s.Broker = b
admin := login(t, s, "root@loyaly.ai", "admin123")
// Active: refused, whatever the confirmation says.
rec := do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
if rec.Code != http.StatusConflict {
t.Fatalf("deleted an active company: %d %s", rec.Code, rec.Body.String())
}
do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
// Suspended but the slug is wrong: refused.
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acm"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("deleted without the slug: %d %s", rec.Code, rec.Body.String())
}
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(fs.clients) != 0 {
t.Fatal("company row survived")
}
if len(b.deleted) != 1 || b.deleted[0] != "acme.shop1" {
t.Fatalf("broker logins not removed: %v", b.deleted)
}
}
func TestAdminResetsTheOwnersPasswordAndItIsShownOnce(t *testing.T) {
s, fs := newServer(t)
seedPlatformAdmin(fs)
seedTenantWithOwner(fs)
admin := login(t, s, "root@loyaly.ai", "admin123")
rec := do(t, s, "POST", "/api/admin/clients/client-acme/owner-password", admin.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out struct{ Email, Password string }
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Email != "owner@acme.com" || out.Password == "" {
t.Fatalf("unexpected result: %s", rec.Body.String())
}
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "owner@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
t.Fatalf("old password still works: %d", rec.Code)
}
login(t, s, "owner@acme.com", out.Password)
}
func TestATenantUserCannotReachTheAdminClientRoutes(t *testing.T) {
s, fs := newServer(t)
seedTenantWithOwner(fs)
owner := login(t, s, "owner@acme.com", "correct horse battery")
for _, c := range []struct{ method, path string }{
{"PATCH", "/api/admin/clients/client-acme"},
{"POST", "/api/admin/clients/client-acme/owner-password"},
{"DELETE", "/api/admin/clients/client-acme"},
} {
if rec := do(t, s, c.method, c.path, owner.Token, map[string]any{"active": false, "confirm": "acme"}); rec.Code != http.StatusNotFound {
t.Errorf("%s %s: tenant user got %d, want 404", c.method, c.path, rec.Code)
}
}
}
func TestAnOwnerRemovesAnEmptyShopButNotOneWithCameras(t *testing.T) {
s, fs := newServer(t)
b := &fakeBroker{}
s.Broker = b
seedTenantWithOwner(fs)
fs.sites = []SiteHealth{
{SiteID: "site-empty", Slug: "empty", Name: "Empty"},
{SiteID: siteA, Slug: "chennai", Name: "TeNext Chennai"},
}
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
owner := login(t, s, "owner@acme.com", "correct horse battery")
if rec := do(t, s, "DELETE", "/api/sites/chennai", owner.Token, nil); rec.Code != http.StatusConflict {
t.Fatalf("removed a shop with a camera: %d %s", rec.Code, rec.Body.String())
}
if rec := do(t, s, "DELETE", "/api/sites/empty", owner.Token, nil); rec.Code != http.StatusNoContent {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if len(b.deleted) != 1 {
t.Fatalf("broker login not removed: %v", b.deleted)
}
}

View File

@@ -137,6 +137,28 @@ type Store interface {
// --- platform administration ---
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
ListClients(ctx context.Context) ([]ClientRow, error)
// SetClientActive suspends or reinstates a company. Suspending revokes every
// session its users hold in the same transaction - login and ingest already
// refuse an inactive client, but a live access token would otherwise keep
// reading for up to twelve hours. Returns the slug and how many sessions
// were ended.
SetClientActive(ctx context.Context, clientID string, active bool) (ClientRow, int, error)
// ClientOwners lists the active owners of a company, for a platform admin
// resetting one of their passwords.
ClientOwners(ctx context.Context, clientID string) ([]TeamMember, error)
// ClientImageKeys is every face image a company holds - the first step of
// deleting the company, for the same reason it is the first step of erasing
// a person: once the rows are gone nothing knows which objects to remove.
ClientImageKeys(ctx context.Context, clientID string) ([]string, error)
// DeleteClient removes a SUSPENDED company and everything under it, and
// returns the broker usernames of its sites so their logins can be removed.
// Refuses an active company: suspension first is what makes this a
// two-step decision instead of one click.
DeleteClient(ctx context.Context, clientID string) (ClientRow, []string, error)
// DeleteEmptySite removes a shop that has no visits and no cameras - the
// one opened by mistake - and returns its broker username. A shop with
// history is closed, not deleted.
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
// --- enrolment ---
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
@@ -278,6 +300,7 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
// Cameras, onboarded from head office. The shop PC still does the
// connecting - it is the only thing on the camera's network - so these
@@ -322,6 +345,9 @@ func (s *Server) Routes() *http.ServeMux {
// because creating the first admin cannot require being signed in as one.
mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients))
mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient))
mux.HandleFunc("PATCH /api/admin/clients/{id}", s.adminOnly(s.handleSetClientActive))
mux.HandleFunc("POST /api/admin/clients/{id}/owner-password", s.adminOnly(s.handleResetOwnerPassword))
mux.HandleFunc("DELETE /api/admin/clients/{id}", s.adminOnly(s.handleDeleteClient))
// Not session-authenticated: this is how a PC with no credentials gets
// some. The enrolment token is the credential.

View File

@@ -502,6 +502,75 @@ func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error
return nil
}
func (f *fakeStore) SetClientActive(_ context.Context, clientID string, active bool) (ClientRow, int, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.clients {
if f.clients[i].ID != clientID {
continue
}
f.clients[i].Active = active
revoked := 0
if !active {
for _, sess := range f.sessions {
if sess.p.ClientID == clientID && !sess.revoked {
sess.revoked = true
revoked++
}
}
}
return f.clients[i], revoked, nil
}
return ClientRow{}, 0, pgx.ErrNoRows
}
func (f *fakeStore) ClientOwners(_ context.Context, clientID string) ([]TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []TeamMember
for _, u := range f.users {
if u.ClientID == clientID && u.Role == "owner" && u.Active {
out = append(out, TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName, Role: u.Role, Active: u.Active})
}
}
return out, nil
}
func (f *fakeStore) ClientImageKeys(_ context.Context, _ string) ([]string, error) { return nil, nil }
func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow, []string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i, c := range f.clients {
if c.ID != clientID {
continue
}
if c.Active {
return c, nil, errors.New("client is active")
}
f.clients = append(f.clients[:i], f.clients[i+1:]...)
return c, []string{c.Slug + ".shop1"}, nil
}
return ClientRow{}, nil, pgx.ErrNoRows
}
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.cameras {
if c.SiteID == siteID {
return "", ErrSiteInUse
}
}
for i, s := range f.sites {
if s.SiteID == siteID {
f.sites = append(f.sites[:i], f.sites[i+1:]...)
return "acme." + s.Slug, nil
}
}
return "", pgx.ErrNoRows
}
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
f.mu.Lock()
defer f.mu.Unlock()

View File

@@ -0,0 +1,259 @@
package api
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/auth"
)
// The platform administrator's remaining shell-only jobs, as endpoints:
// suspend or reinstate a company, reset its owner's password, delete it.
//
// All three are behind adminOnly (a principal with the role AND no client), and
// all three read the company id from the path. None takes a client id from a
// body - the same rule every tenant handler follows.
// PATCH /api/admin/clients/{id} {"active": false}
//
// Suspension is the reversible step and it is complete: login refuses the
// company's users, the broker's visits are dropped at ingest, and every live
// session is revoked in the same transaction. Without the last, "suspend" would
// mean "suspend some time tomorrow", which is not what anybody pressing it
// believes they did.
func (s *Server) handleSetClientActive(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
var in struct {
Active *bool `json:"active"`
}
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
if in.Active == nil {
badRequest(w, `"active" is required: true to reinstate, false to suspend`)
return
}
row, revoked, err := s.Store.SetClientActive(r.Context(), r.PathValue("id"), *in.Active)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
return
}
s.serverError(w, "set client active", err)
return
}
action := "client.suspended"
if *in.Active {
action = "client.reinstated"
}
s.Store.Audit(r.Context(), AuditEntry{
ActorID: p.UserID, ActorKind: "user", Action: action,
Entity: "client", EntityID: row.ID,
Detail: map[string]any{"slug": row.Slug, "sessions_revoked": revoked},
})
writeJSON(w, http.StatusOK, map[string]any{"client": row, "sessions_revoked": revoked})
}
// POST /api/admin/clients/{id}/owner-password {"email": "…"}
//
// The support case this exists for: the owner has locked themselves out and
// there is nobody above them in the company to reset it. The new password is
// generated, shown once, and every session that owner held is revoked. `email`
// picks the owner when the company has more than one; with exactly one it may
// be omitted.
func (s *Server) handleResetOwnerPassword(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
clientID := r.PathValue("id")
var in struct {
Email string `json:"email"`
}
if err := decodeOptional(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
owners, err := s.Store.ClientOwners(r.Context(), clientID)
if err != nil {
s.serverError(w, "list owners", err)
return
}
var target *TeamMember
switch {
case len(owners) == 0:
writeErr(w, http.StatusNotFound, "not_found", "That company has no active owner.")
return
case in.Email != "":
want := auth.NormalizeEmail(in.Email)
for i := range owners {
if owners[i].Email == want {
target = &owners[i]
}
}
if target == nil {
writeErr(w, http.StatusNotFound, "not_found", "No active owner with that address.")
return
}
case len(owners) == 1:
target = &owners[0]
default:
emails := make([]string, 0, len(owners))
for _, o := range owners {
emails = append(emails, o.Email)
}
badRequest(w, "That company has several owners; say which with \"email\": "+strings.Join(emails, ", "))
return
}
password, err := auth.RandomPassword()
if err != nil {
s.serverError(w, "generate password", err)
return
}
hash, err := auth.HashPassword(password)
if err != nil {
s.serverError(w, "hash password", err)
return
}
m, err := s.Store.ResetMemberPassword(r.Context(), clientID, target.ID, hash)
if err != nil {
s.serverError(w, "reset owner password", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ActorID: p.UserID, ActorKind: "user", Action: "admin.reset_owner_password",
Entity: "user", EntityID: m.ID, Detail: map[string]any{"email": m.Email, "client_id": clientID},
})
writeJSON(w, http.StatusOK, map[string]any{"email": m.Email, "password": password})
}
// DELETE /api/admin/clients/{id} {"confirm": "<slug>"}
//
// Irreversible, and the data is biometric, so it is deliberately hard to do by
// accident: the company must already be suspended, and the request must repeat
// the slug. Objects go first - once the rows are gone nothing knows which files
// to remove - then the broker logins, then the rows (everything cascades from
// clients). A storage failure aborts before anything else is touched.
func (s *Server) handleDeleteClient(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
clientID := r.PathValue("id")
var in struct {
Confirm string `json:"confirm"`
}
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
rows, err := s.Store.ListClients(r.Context())
if err != nil {
s.serverError(w, "list clients", err)
return
}
var row *ClientRow
for i := range rows {
if rows[i].ID == clientID {
row = &rows[i]
}
}
if row == nil {
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
return
}
if row.Active {
writeErr(w, http.StatusConflict, "still_active",
"Suspend the company first (PATCH active=false). Deleting is the second step, not the first.")
return
}
if strings.TrimSpace(in.Confirm) != row.Slug {
badRequest(w, "Repeat the company's slug in \"confirm\" to delete it.")
return
}
keys, err := s.Store.ClientImageKeys(r.Context(), clientID)
if err != nil {
s.serverError(w, "list images for client delete", err)
return
}
if s.Blob != nil {
for _, key := range keys {
if isDBKey(key) {
continue // goes with the rows
}
if err := s.Blob.Delete(r.Context(), key); err != nil {
s.logf("ERROR delete client %s: cannot delete %s: %v", row.Slug, key, err)
writeErr(w, http.StatusBadGateway, "storage_error",
"A stored photo could not be deleted, so the company was not deleted. Try again.")
return
}
}
}
_, brokerUsers, err := s.Store.DeleteClient(r.Context(), clientID)
if err != nil {
s.serverError(w, "delete client", err)
return
}
if s.Broker != nil {
for _, u := range brokerUsers {
if err := s.Broker.DeleteSite(r.Context(), u); err != nil {
// The rows are gone and the login cannot publish anywhere the
// server will accept (ingest resolves the site and finds none),
// so this is a leftover to tidy, not a failure to report as one.
s.logf("delete client %s: broker login %s not removed: %v", row.Slug, u, err)
}
}
}
s.Store.Audit(r.Context(), AuditEntry{
ActorID: p.UserID, ActorKind: "user", Action: "client.deleted",
Entity: "client", EntityID: clientID,
Detail: map[string]any{"slug": row.Slug, "images_deleted": len(keys), "broker_logins": brokerUsers},
})
s.logf("WARNING company %s deleted by %s: %d images, %d broker logins", row.Slug, p.UserID, len(keys), len(brokerUsers))
writeJSON(w, http.StatusOK, map[string]any{"deleted": row.Slug, "images_deleted": len(keys)})
}
// DELETE /api/sites/{site} - an owner removes a shop opened by mistake.
//
// Only a shop with no visits and no cameras. A shop with history holds the
// tenant's footfall and, through its visits, faces; taking that away is an
// erasure decision, not a tidy-up, and there is no endpoint for it yet.
func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if p.Role != "owner" || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can remove a shop.")
return
}
site, ok := s.resolveSite(w, r, r.PathValue("site"))
if !ok {
return
}
username, err := s.Store.DeleteEmptySite(r.Context(), p.ClientID, site)
if err != nil {
if errors.Is(err, ErrSiteInUse) {
writeErr(w, http.StatusConflict, "in_use",
"This shop has cameras or visits, so it cannot simply be removed. Remove its cameras first; a shop with visit history is kept.")
return
}
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
return
}
s.serverError(w, "delete site", err)
return
}
if s.Broker != nil && username != "" {
if err := s.Broker.DeleteSite(r.Context(), username); err != nil {
s.logf("delete site %s: broker login %s not removed: %v", site, username, err)
}
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "site.deleted", Entity: "site", EntityID: site,
})
w.WriteHeader(http.StatusNoContent)
}
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
// under it.
var ErrSiteInUse = errors.New("site has cameras or visits")

View File

@@ -416,6 +416,7 @@ type ClientRow struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Active bool `json:"active"`
Sites int `json:"sites"`
Users int `json:"users"`
CreatedAt string `json:"created_at"`

View File

@@ -76,7 +76,7 @@ func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput
// 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.created_at,
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
@@ -90,7 +90,7 @@ func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
for rows.Next() {
var c api.ClientRow
var at time.Time
if err := rows.Scan(&c.ID, &c.Slug, &c.Name, &at, &c.Sites, &c.Users); err != nil {
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)

View File

@@ -0,0 +1,148 @@
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)
}