package store import ( "context" "encoding/json" "time" "github.com/jackc/pgx/v5" "github.com/loyaly/behavision-server/internal/api" ) // RequestCheck queues a check for the shop PC to run on its next sync. // // Overwrites any previous request for the same camera rather than queuing a // second: an operator who presses Check twice wants one answer, now, not two // answers several minutes apart in an order they cannot predict. func (s *Store) RequestCheck(ctx context.Context, clientID, id, kind string, seconds int) error { tag, err := s.pool.Exec(ctx, ` UPDATE site_cameras SET check_kind = $3, check_seconds = $4, check_requested_at = now(), check_started_at = NULL, check_finished_at = NULL, check_result = NULL, check_image_key = '' WHERE client_id = $1 AND id = $2::uuid AND deleted_at IS NULL`, clientID, id, kind, seconds) if err != nil { return err } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } // ClaimChecks hands a site its pending checks and marks them started. // // One statement, so two syncs racing cannot both claim the same job. A // placement check asks a human to walk about for 25 seconds; running it twice // because the agent polled while the first was still going would give the // operator two contradictory verdicts for one walk. func (s *Store) ClaimChecks(ctx context.Context, siteID string) ([]api.AgentCheckJob, error) { rows, err := s.pool.Query(ctx, ` UPDATE site_cameras SET check_started_at = now() WHERE site_id = $1::uuid AND check_requested_at IS NOT NULL AND check_finished_at IS NULL AND check_started_at IS NULL RETURNING camera_id, check_kind, check_seconds`, siteID) if err != nil { return nil, err } defer rows.Close() var out []api.AgentCheckJob for rows.Next() { var j api.AgentCheckJob if err := rows.Scan(&j.CameraID, &j.Kind, &j.Seconds); err != nil { return nil, err } out = append(out, j) } return out, rows.Err() } // RecordCheckResult stores what the shop PC found. func (s *Store) RecordCheckResult(ctx context.Context, siteID string, res api.AgentCheckResult) error { detail := res.Detail if detail == nil { detail = map[string]any{} } body, err := json.Marshal(map[string]any{ "ok": res.OK, "verdict": res.Verdict, "headline": res.Headline, "advice": res.Advice, "detail": detail, }) if err != nil { return err } _, err = s.pool.Exec(ctx, ` UPDATE site_cameras SET check_finished_at = now(), check_result = $3::jsonb, check_image_key = CASE WHEN $4 = '' THEN check_image_key ELSE $4 END WHERE site_id = $1::uuid AND camera_id = $2`, siteID, res.CameraID, body, res.ImageKey) return err } // ReleaseStaleChecks un-claims checks a shop PC took and never finished. // // Without this a PC that is restarted mid-check leaves the camera showing // "checking..." for ever, and the operator's only recourse is to guess that // pressing Check again will help - which it would not, because the request is // still marked started. func (s *Store) ReleaseStaleChecks(ctx context.Context, olderThan time.Duration) error { _, err := s.pool.Exec(ctx, ` UPDATE site_cameras SET check_started_at = NULL WHERE check_requested_at IS NOT NULL AND check_finished_at IS NULL AND check_started_at < now() - $1::interval`, olderThan.String()) return err } // checkOf reads the stored check for one camera row. func checkOf(kind string, requested, started, finished *time.Time, seconds int, result []byte, imageKey string) api.CameraCheck { if requested == nil { return api.CameraCheck{} } c := api.CameraCheck{ Kind: kind, Seconds: seconds, RequestedAt: requested.UTC().Format(time.RFC3339), State: "requested", } if started != nil { c.State = "running" } if finished != nil { c.State = "done" c.FinishedAt = finished.UTC().Format(time.RFC3339) } if len(result) > 0 { var body struct { OK bool `json:"ok"` Verdict string `json:"verdict"` Headline string `json:"headline"` Advice []string `json:"advice"` Detail map[string]any `json:"detail"` } if err := json.Unmarshal(result, &body); err == nil { c.OK, c.Verdict, c.Headline = body.OK, body.Verdict, body.Headline c.Advice, c.Detail = body.Advice, body.Detail } } c.Image.Key = imageKey return c }