Files
Behavision/server/internal/store/api_admin_monitor.go
Suriyakumarvijayanayagam 4db71e9381 A wrong URL answered 500, and only the real database said so
/api/admin/clients/not-a-uuid/sites returned 500. `c.id = $1::uuid` makes
Postgres cast the path segment, and casting a malformed string - or the
empty one the shape check handed back in its place - is an ERROR, not a
miss. `c.id::text = $1` cannot fail: an id that is not a uuid matches
nothing, which is the 404 a wrong URL should get.

The two sibling resolvers were already written this way and correctly
404'd the same input. I applied the rule to two of three places, which is
the shape of a rule that holds until somebody adds the next write path.
The shape check is gone with it - it existed only to produce the empty
string that then broke the cast.

The in-memory fake could not have caught this and did not: it resolves a
merchant with a map lookup, so every handler test passed, including the
one named for the case. That test stays, because 404-not-500 is still the
contract, but the property belongs to Postgres - so
api_admin_monitor_live_test.go asserts it where it lives, over every
free-text identifier these queries take. It skips without
TEST_DATABASE_URL, like the rest of the live store tests.

Also in deploy.sh, found by reading its own output: step 3 reported the
WRONG backup. `ls | tail -1` sorts alphabetically, so pre-...-demo-12
sorts before pre-...-demo-6 and it printed a dump from four days earlier.
A deploy that names the wrong safety net is worse than one that names
none, because that is the file somebody reaches for at the worst possible
moment. It echoes the filename it just wrote, and refuses to continue on
an empty one - pipefail catches a failing pg_dump, but a zero-byte gzip
would still have satisfied it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 19:14:26 +05:30

135 lines
5.6 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.
//
// `c.id::text = $1`, for the same reason the two resolvers below use it, and
// this one learned it the hard way: as `c.id = $1::uuid` it answered 500 to
// /api/admin/clients/not-a-uuid/sites on the first real database, because
// casting a malformed string - or the empty one a shape check hands back - to
// uuid is an ERROR in Postgres rather than a miss. Comparing the column as
// text cannot fail: an id that is not a uuid simply matches nothing, which is
// the 404 a wrong URL should get. The sibling queries were already written
// this way and correctly 404'd; only this one was not.
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::text = $1`, 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
}