// 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 }