Six read-only routes: merchant detail, its shops, one shop, its cameras, one camera, and the platform totals. The console drills down merchant -> store -> camera and every level below the first showed 'Backend integration required'. They cannot be the tenant routes, and the reason is structural rather than incidental. Every tenant handler derives the client from the SESSION - that is what makes cross-tenant access impossible rather than merely disallowed - and a platform admin has no client at all. The three workarounds each make it worse: passing a company id to a tenant route puts a caller-chosen tenant back in the one place this system refuses to take one, filtering the estate in the browser ships every merchant's data to render one, and signing in as the owner audits the wrong person. So the tenant STORE functions are reused with an explicit client id - they already take one - and the scoping the tenant handlers get from the session is done in the handler instead. AdminCamera is a separate type from Camera, for the same reason AgentCamera is. It cannot carry host, port, path, username or has_password. A tenant seeing those for their own camera is correct; a platform admin browsing another company's estate is a different question, and an RTSP host with a username beside it is most of a live path into a customer's camera. Blanking fields on a shared struct leaves 'remember to redact, on every path, forever' as the only thing preventing a leak. The test asserts on the raw JSON, because decoding into the struct would discard exactly what it is looking for. An unowned site is 404, never an empty list. The tenant resolver returns a uuid untouched and lets client_id = downstream scope it, which is sound only because that id comes from a session; here the caller names both halves, so an unowned uuid would reach a query that quietly returns nothing - 'this shop has no cameras' when the truth is 'not your shop'. Both resolvers check the whole chain in one statement. Two things the in-memory fake could not have caught, so neither was left to it. The fake ignored clientID in SiteHealth and Cameras, which would have made every cross-merchant test pass while returning another company's shops; it is client-aware now for these paths. And the SQL was written to make the documented $2-deduced-as-two-types bug impossible rather than to be caught by a database later: id::text = $2 in place of id = $2::uuid, one type per parameter, which also turns a malformed path segment into the 404 it should be instead of a cast error. Every read below the merchant list writes an audit row naming the admin and the merchant - an admin is the one account for which nothing else here leaves a trace. The counts-only summary does not: a console refreshes it on a timer, and logging that buries the reads worth finding. A suspended merchant stays readable, because that is precisely what an admin opens the console to look at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
126 lines
5.0 KiB
Go
126 lines
5.0 KiB
Go
// Platform-admin reads BELOW the merchant level: one company, its shops, its
|
|
// cameras, and the estate-wide totals.
|
|
//
|
|
// Every function here takes the merchant's client id as an ARGUMENT, because
|
|
// the caller is a platform admin who has no client of their own. That is the
|
|
// whole reason these exist rather than reusing the tenant handlers: those
|
|
// derive the tenant from the session, and an admin session carries none. The
|
|
// tenant STORE functions already take a client id explicitly, so this file
|
|
// adds the scoping the tenant handlers get for free and nothing else.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/loyaly/behavision-server/internal/api"
|
|
)
|
|
|
|
// ClientDetail is one merchant, with the owner a support conversation starts
|
|
// from. ListClients cannot carry it: an owner lookup per row would be a query
|
|
// per merchant on a screen that only needs the name.
|
|
func (s *Store) ClientDetail(ctx context.Context, clientID string) (api.ClientDetail, error) {
|
|
var c api.ClientDetail
|
|
var at time.Time
|
|
err := s.pool.QueryRow(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),
|
|
COALESCE((SELECT au.email FROM app_users au
|
|
WHERE au.client_id = c.id AND au.role = 'owner'
|
|
AND au.active ORDER BY au.created_at LIMIT 1), ''),
|
|
COALESCE((SELECT au.full_name FROM app_users au
|
|
WHERE au.client_id = c.id AND au.role = 'owner'
|
|
AND au.active ORDER BY au.created_at LIMIT 1), '')
|
|
FROM clients c
|
|
WHERE c.id = $1::uuid`, clientID).
|
|
Scan(&c.ID, &c.Slug, &c.Name, &c.Active, &at, &c.Sites, &c.Users,
|
|
&c.OwnerEmail, &c.OwnerName)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return api.ClientDetail{}, nil
|
|
}
|
|
if err != nil {
|
|
return api.ClientDetail{}, err
|
|
}
|
|
c.CreatedAt = at.UTC().Format(time.RFC3339)
|
|
return c, nil
|
|
}
|
|
|
|
// AdminSiteID resolves a shop reference WITHIN one merchant, by slug or uuid.
|
|
//
|
|
// The uuid branch is the point. The tenant resolver returns a uuid untouched
|
|
// and lets every downstream query's `client_id = $1` do the scoping, which is
|
|
// sound there because the client id comes from the session and cannot be
|
|
// chosen. Here the caller names BOTH, so an unowned uuid would otherwise reach
|
|
// a query that quietly returns nothing - an empty shop rather than "no such
|
|
// shop". Resolving through the database with both halves is what makes a
|
|
// broken chain a 404.
|
|
func (s *Store) AdminSiteID(ctx context.Context, clientID, ref string) (string, error) {
|
|
var id string
|
|
// `id::text = $2`, never `id = $2::uuid`. Using one parameter as both text
|
|
// and uuid in the same statement is how Postgres ends up deducing two
|
|
// types for it and refusing the whole query - the identical shape that
|
|
// broke `'Visitor ' || $2::text` beside `number = $2`, which compiled,
|
|
// passed every in-memory test and failed on the first real database.
|
|
// Casting the COLUMN keeps one type per parameter, and it cannot raise an
|
|
// invalid-uuid error on a malformed path segment either: it simply misses,
|
|
// which is the 404 the caller should get anyway.
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id::text FROM sites
|
|
WHERE client_id = $1::uuid
|
|
AND (slug = $2 OR id::text = $2)`,
|
|
clientID, ref).Scan(&id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
// AdminCameraID resolves a camera within one shop of one merchant.
|
|
//
|
|
// All three links are checked in the one statement, so there is no ordering in
|
|
// which a caller learns that a camera exists somewhere else.
|
|
func (s *Store) AdminCameraID(ctx context.Context, clientID, siteID, ref string) (string, error) {
|
|
var id string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT c.id::text
|
|
FROM site_cameras c
|
|
JOIN sites si ON si.id = c.site_id
|
|
WHERE si.client_id = $1::uuid
|
|
AND c.site_id = $2::uuid
|
|
AND c.deleted_at IS NULL
|
|
AND (c.camera_id = $3 OR c.id::text = $3)`,
|
|
clientID, siteID, ref).Scan(&id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
// PlatformSummary is counts and nothing else.
|
|
//
|
|
// Deliberately not a list: it backs a header strip, and an endpoint that
|
|
// returns every camera on the platform to render four numbers is one that gets
|
|
// slower with every customer signed.
|
|
func (s *Store) PlatformSummary(ctx context.Context) (api.PlatformSummary, error) {
|
|
var out api.PlatformSummary
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
(SELECT count(*) FROM site_cameras WHERE deleted_at IS NULL),
|
|
(SELECT count(*) FROM site_cameras
|
|
WHERE deleted_at IS NULL AND connected IS TRUE),
|
|
(SELECT count(*) FROM clients WHERE active),
|
|
(SELECT count(*) FROM sites),
|
|
(SELECT count(*) FROM visits WHERE occurred_at >= date_trunc('day', now()))
|
|
`).Scan(&out.CamerasTotal, &out.CamerasOnline, &out.MerchantsActive,
|
|
&out.SitesTotal, &out.EventsToday)
|
|
if err != nil {
|
|
return api.PlatformSummary{}, err
|
|
}
|
|
out.AsOf = time.Now().UTC().Format(time.RFC3339)
|
|
return out, nil
|
|
}
|