diff --git a/API.md b/API.md index a083d35..e87a206 100644 --- a/API.md +++ b/API.md @@ -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": ""} → 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 diff --git a/server/internal/api/admin_clients_test.go b/server/internal/api/admin_clients_test.go new file mode 100644 index 0000000..87cde4b --- /dev/null +++ b/server/internal/api/admin_clients_test.go @@ -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) + } +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go index d40d42b..463a18c 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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. diff --git a/server/internal/api/fake_test.go b/server/internal/api/fake_test.go index 93455e9..69e48e0 100644 --- a/server/internal/api/fake_test.go +++ b/server/internal/api/fake_test.go @@ -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() diff --git a/server/internal/api/handlers_admin_clients.go b/server/internal/api/handlers_admin_clients.go new file mode 100644 index 0000000..5b611b9 --- /dev/null +++ b/server/internal/api/handlers_admin_clients.go @@ -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": ""} +// +// 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") diff --git a/server/internal/api/types.go b/server/internal/api/types.go index ab63f4c..3182932 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -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"` diff --git a/server/internal/store/api_admin.go b/server/internal/store/api_admin.go index 5dc1cec..94faab7 100644 --- a/server/internal/store/api_admin.go +++ b/server/internal/store/api_admin.go @@ -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) diff --git a/server/internal/store/api_admin_clients.go b/server/internal/store/api_admin_clients.go new file mode 100644 index 0000000..e0fa5d9 --- /dev/null +++ b/server/internal/store/api_admin_clients.go @@ -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) +}