Compare commits

..

13 Commits

Author SHA1 Message Date
Flea Flicker 10b78d810d Merge pull request 'feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE' (#212) from feature/2357-p2-portal-clients-from-auth into dev
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 41s
GRO-2359 (api): feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (#212)
2026-06-11 16:34:34 +00:00
Flea Flicker cdeebec021 feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (web)
CI / Test (pull_request) Successful in 29s
CI / Lint & Typecheck (pull_request) Successful in 41s
CI / Build & Push Docker Images (pull_request) Successful in 1m40s
The OOBE flow on the web portal calls this endpoint to create a fresh
`clients` row bound to the Better Auth user's email when the SSO
bridge returns 404. Returns 201 on success, 409 if a client with that
email already exists (portal-selection case), 401/503 on auth issues,
400 on invalid body.

The OOBE success path navigates the user back to `/` and lets the
existing `session-from-auth` re-bridge; the new client is now
resolvable by email, so the bridge mints a real portal session.

Tests cover: 401 (no session), 400 (zod), 201 + persisted values
(name trimmed, optional fields normalized to null), 409 (existing
client or unique-constraint race), 503 (auth not configured).

Paired with the web PR on `feature/2357-p2-sso-to-oobe-routing`.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-11 16:17:16 +00:00
Flea Flicker 1d6b906202 Merge pull request 'fix(GRO-2342): portal waitlist card populates service {id, name}' (#208) from feat/GRO-2342-portal-waitlist-servicename into dev
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 33s
2026-06-10 09:13:55 +00:00
Flea Flicker 277f459237 fix(GRO-2342): portal waitlist card populates service {id, name}
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Images (pull_request) Successful in 1m15s
Cosmetic follow-up to GRO-2319 (Phase 4 review by CTO). The synthetic
waitlist card on GET /portal/appointments returned service: {id} only,
so the portal fell back to the literal 'Service' label. CMPO spec did
not call for a service name on the waitlist card, but populating the
real name is non-urgent and closes the cosmetic gap.

- src/routes/portal.ts: include a services SELECT (in addition to
  pets and staff) covering both appointment and waitlist serviceIds.
  serviceMap feeds a service.name lookup. The synthetic waitlist
  card's service object is now {id, name} — same shape the
  appointments join returns — so the portal renders the real name.
  The appointments join also gains a name (consistent shape, no
  regression for the existing path).
- src/__tests__/portal.test.ts: mock the services table and assert
  service: {id, name} on both the synthetic waitlist card and the
  appointment card.
- UAT_PLAYBOOK.md: TC-API-8.20 covering the waitlist card service
  name (TC-API-8.19 retained verbatim for the original GRO-2319
  surfacing contract).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-10 09:11:08 +00:00
Flea Flicker ef18ed7376 feat(GRO-2319): surface active waitlist entries on portal appointments + seed (#204)
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 33s
CI / Build & Push Docker Images (push) Successful in 45s
2026-06-09 10:41:08 +00:00
Flea Flicker d61607f4c5 feat(seed): seed upcoming appointments across statuses for UAT portal customer (GRO-2311) (#201)
CI / Test (push) Successful in 31s
CI / Lint & Typecheck (push) Successful in 2m35s
CI / Build & Push Docker Images (push) Successful in 1m25s
2026-06-09 09:53:04 +00:00
Flea Flicker 2853ce73a5 GRO-2172: add missing extended pet fields to create/update schemas (#199)
CI / Lint & Typecheck (push) Successful in 1m13s
CI / Test (push) Successful in 2m31s
CI / Build & Push Docker Images (push) Successful in 35s
2026-06-09 08:56:22 +00:00
Flea Flicker 1e0747324d fix(GRO-2139): serialize reset→migrate→seed under the seed advisory lock (#160)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 37s
CI / Build & Push Docker Images (push) Successful in 36s
Serialize the entire db:reset chain (DROP → migrate → seed) inside one withSeedAdvisoryLock callback so a concurrent same-PRNG seeder cannot interleave and collide on invoices_pkey. Pool sized max:6 (1 reserved for the lock + work headroom) to avoid the connection-starvation deadlock the CTO caught. Verified with three end-to-end live db:reset runs against a throwaway Postgres.

cc @cpfarhood
2026-06-09 08:44:58 +00:00
Flea Flicker b4b48f7b50 fix(GRO-2299): redact googleMapsApiKey from PATCH /api/admin/settings response (#195)
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Images (push) Successful in 38s
2026-06-09 06:52:48 +00:00
Flea Flicker fe412933ea GRO-2294: Route Optimization security hardening (geocode-batch limit cap + redact settings secret) (#193)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 35s
CI / Build & Push Docker Images (push) Successful in 38s
2026-06-09 06:17:42 +00:00
Flea Flicker cd2f60e282 feat(GRO-2157): navigation export endpoints (Phase 2.3) (#190)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 40s
CI / Build & Push Docker Images (push) Successful in 26s
2026-06-09 00:16:42 +00:00
Flea Flicker 6702086c7b fix(GRO-2235): return 409 on duplicate portal waitlist submit (#189)
CI / Test (push) Failing after 14m19s
CI / Lint & Typecheck (push) Failing after 14m19s
CI / Build & Push Docker Images (push) Has been skipped
2026-06-08 23:50:21 +00:00
Flea Flicker 27e6674b9a feat(GRO-2225): UAT seed route cohort + receptionist credential (#187)
CI / Test (push) Successful in 30s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 45s
2026-06-08 23:15:51 +00:00
5 changed files with 123 additions and 217 deletions
-54
View File
@@ -1,54 +0,0 @@
# AGENTS.md
This repository (`groombook/api`) is part of the GroomBook application stack. The
authoritative process, quality bar, and safety rules live in the shared
[`groombook/org`](https://git.farh.net/groombook/org) skills repository. Read
those first; this file is only a pointer.
## Authoritative skills
- **SDLC (branching, PRs, phases, handoffs):**
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
- **Coding standards (priority ordering, PR discipline, tests, no-hardcoded-values, CalVer):**
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
- **Safety (no plaintext secrets, no direct `kubectl apply` to `groombook`, no self-merge, board approval for destructive actions):**
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
For human contributors and humans reviewing agent work, see
[`CONTRIBUTING.md`](./CONTRIBUTING.md) in this repo for the phase-by-phase PR
flow and the `uat→main` merge-gate policy summary.
## Non-negotiable operational rules
These mirror the org skills; they are restated here so any agent landing in
this repo sees them without a cross-repo fetch.
- **All changes go through a PR.** Never push directly to `dev`, `uat`, or `main`.
- **Branch strategy:** `feature/<name>``dev``uat``main`. Engineers
always target `dev` first.
- **No self-merge contract.** The engineer who opened a PR clicks merge only
after the named reviewer (CI / QA / UAT / Security / CTO per phase)
approves. Issue-thread QA / UAT / security approvals do **not** clear the
Gitea `required_approvals` gate on `uat→main` — only a Gitea **Approve**
click from a member of the `approvals_whitelist_username` does. On this
repo that whitelist is `["gb_flea", "gb_dogfather"]` (engineer team).
Board-level accounts cannot give the Approve click by policy.
- **Always include `cc @cpfarhood`** at the bottom of every PR body for
board visibility (not as a reviewer).
- **Secrets in code are forbidden.** Use Bitnami Sealed Secrets; never commit
plaintext. See the `safety` skill.
- **Production (`groombook` namespace) is Flux-managed.** Never
`kubectl apply` directly. Infrastructure changes go through PRs in
`groombook/infra`.
## Local development
See the repo's own README, package scripts, and CI workflow. The
authoritative pipeline (Gitea Actions, image build, deploy hooks) is the
shared `groombook/infra` overlay; do not reimplement it here.
## When uncertain
If a task conflicts with the org skills, **the org skills win**. Open an
issue in `groombook/org` to propose a change rather than encoding a local
exception.
-117
View File
@@ -1,117 +0,0 @@
# Contributing to `groombook/api`
Thanks for contributing. This document is the human-facing companion to
[`AGENTS.md`](./AGENTS.md) and the authoritative
[`groombook/org`](https://git.farh.net/groombook/org) skills. The org skills
govern; this file is a quick-reference for the human/agent PR flow in this
repo.
## Branch strategy
Three long-lived branches; one PR per promotion step.
| Branch | Environment | Who merges | Prerequisites for merge |
|---------|-------------|-----------|-------------------------|
| `dev` | Dev | Engineer | CI passes |
| `uat` | UAT | Engineer | QA code review approval |
| `main` | Production | Engineer | UAT validation + CTO Gitea Approve when the `uat→main` merge-gate policy applies (see below) |
Engineers always target `dev` first. Feature branches: `<agent-name>/<short-description>`.
## Phase-by-phase PR flow
### Phase 1 — Dev
1. Branch from `dev`: `git checkout -b <name>/<short-description> origin/dev`.
2. Write code + tests. Run unit tests, type check, and lint locally (or rely on CI).
3. Open a PR against `dev`:
```bash
tea pr create --base dev --title "..." --body "..."
```
Include `cc @cpfarhood` at the bottom of the body for board visibility.
4. CI must pass. CI green → engineer self-merges.
5. CI builds and deploys to Dev automatically.
### Phase 2 — UAT promotion
1. Open a PR from `dev` to `uat`.
2. CI must pass.
3. **QA (Lint Roller)** reviews and approves on the Gitea PR.
4. QA approved → engineer self-merges.
5. CI builds and deploys to UAT automatically.
### Phase 3 — UAT regression + Security review
1. **UAT (Shedward Scissorhands)** runs full regression against UAT — every
feature, old and new, no exceptions.
2. **Security (Barkley Trimsworth)** reviews the changes.
3. Failures in either gate bounce back to Phase 1.
### Phase 4 — Production promotion (`uat → main`)
This is the gate the org PR
[`groombook/org#13`](https://git.farh.net/groombook/org/pulls/13) defines.
The full rule is in
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
and
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md);
the summary is below.
**The CTO Gitea Approve click is NOT the default gate.** Once the four
pre-gates (QA, UAT deploy, UAT regression, security) are green, the engineer
self-merges.
**A CTO Gitea Approve click IS required** only for PRs in one of three
categories:
1. **Novel auth / session paths** — login, OIDC, OOBE, session middleware,
token issuance, password reset, MFA, new auth provider integrations.
Routine auth-gated UI (button styling, error messages, form layout) is
**not** in this category.
2. **Infra / prod-affecting merges** — deploys, infra manifests, secrets,
GitOps overlays, CI/CD, `main` branch protection, production
routing/ingress, prod state mutations. All Phase 5 infra overlay PRs in
`groombook/infra` require CTO Gitea Approve without exception.
3. **Risk-flagged merges** — `risk:cto-approve` label, or explicit CTO/CEO
sign-off request in the PR or issue thread.
The engineer opens the `uat→main` PR, classifies it against the three
categories above, and adds `cc @cpfarhood`. If the PR is in scope, the CTO
clicks Approve; once approved (and the four pre-gates are green), the
engineer merges.
### Phase 5 — Production deployment
A separate PR in `groombook/infra` bumps the overlay image tag for prod.
Handed to QA (Lint Roller) for review, then self-merged by the engineer.
## The four pre-gates (uat→main)
A `uat→main` PR is mergeable when **all four** are green:
1. **QA code review** — done on the dev→uat promotion PR.
2. **UAT deploy** — the UAT image built from the uat tip is live in UAT.
3. **UAT regression** — Shedward's full-feature UAT pass is green (no
pre-existing defects, no new defects).
4. **Security review** — Barkley's security code review is green.
Issue-thread QA / UAT / security approvals do **not** clear the Gitea
`required_approvals` gate. Only a Gitea **Approve** click from a member of
the `approvals_whitelist_username` for `main` clears it. In this repo that
whitelist is the engineer team (`gb_flea`, `gb_dogfather`).
## Style, tests, and quality bar
See
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
for the engineering priority ordering, test requirements, no-hardcoded-values
rules, CalVer versioning policy, and the `git.farh.net` container registry
policy.
## Safety
See
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
for the non-negotiable rules: no plaintext secrets, no `kubectl apply` to
`groombook`, no self-merge, no direct `tofu` runs, board approval for
destructive actions, escalation protocol.
+1 -1
View File
@@ -21,7 +21,7 @@
"wait-for-db": "node ./scripts/wait-for-db.mjs", "wait-for-db": "node ./scripts/wait-for-db.mjs",
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate", "migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts", "seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", "reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts",
"studio": "drizzle-kit studio", "studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
+81 -6
View File
@@ -1,13 +1,52 @@
/** /**
* reset.ts — Drop all application tables and re-run migrations + seed. * reset.ts — Drop all application tables, re-run migrations, and re-seed.
* *
* Intended for local development only. Never run against production. * Intended for local development only. Never run against production.
* *
* Usage: * Usage:
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
*
* GRO-2139: the entire drop→migrate→seed chain runs inside a single
* Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent
* `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at
* the top of the hour) cannot interleave between `reset.ts` (DROP)
* and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`.
*
* Why this matters: `seed.ts` derives every primary key from a single
* shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and
* `uuid()` in seed.ts). Two concurrent same-profile seeders therefore
* emit *identical* ids for the same logical row, and any moment
* between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the
* window in which the second seeder's INSERT can hit a pkey already
* taken by the first. Pre-GRO-2123 this raced unconditionally;
* GRO-2123 added the advisory lock around `runSeedBody` but left
* `reset.ts` and `drizzle-kit migrate` outside the lock. This script
* now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock`
* pins the lock to one reserved session and the DROP → migrate → seed
* work runs on the rest of the pool, so the lock guarantees mutual
* exclusion against any concurrent seeder for the entire chain.
*
* See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and
* `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same
* `seed.ts` code path on the same database in `groombook-dev`.
*/ */
import postgres from "postgres"; import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import * as schema from "./schema.js";
import {
SEED_ADVISORY_LOCK_KEY,
withSeedAdvisoryLock,
getProfile,
runSeedBody,
profiles,
} from "./seed.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const MIGRATIONS_FOLDER = resolve(__dirname, "../migrations");
async function reset() { async function reset() {
const url = process.env.DATABASE_URL; const url = process.env.DATABASE_URL;
@@ -16,16 +55,37 @@ async function reset() {
process.exit(1); process.exit(1);
} }
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") { if (
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true."); process.env.NODE_ENV === "production" &&
process.env.ALLOW_RESET !== "true"
) {
console.error(
"[FATAL] db:reset must not be run in production without ALLOW_RESET=true.",
);
process.exit(1); process.exit(1);
} }
const client = postgres(url, { max: 1 }); // Pool sizing is load-bearing here. `withSeedAdvisoryLock` does
// `pool.reserve()` to pin the advisory lock to one dedicated session
// (a session-level lock released on a *different* pooled connection is
// a no-op), and the DROP / migrate / seed work then runs on the
// *remaining* pooled connections. The lock provides mutual exclusion
// across processes regardless of how many connections the work uses —
// it does NOT require the work to share the lock's session.
//
// Therefore `max` must be ≥ 2: 1 reserved for the lock + ≥1 free for
// the work. `max: 1` would let `reserve()` consume the only connection
// and every query inside the callback would block forever waiting for
// a connection that never frees (connection-starvation deadlock). We
// use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work).
const client = postgres(url, { max: 6 });
const db = drizzle(client, { schema });
try {
await withSeedAdvisoryLock(client, async () => {
console.log("Dropping all application tables...\n"); console.log("Dropping all application tables...\n");
// Drop in dependency order (children before parents) // Drop dependencies (tables) first
await client` await client`
DO $$ DECLARE DO $$ DECLARE
r RECORD; r RECORD;
@@ -61,8 +121,23 @@ async function reset() {
console.log("✓ All tables and enums dropped\n"); console.log("✓ All tables and enums dropped\n");
console.log("Running migrations...");
await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
console.log("✓ Migrations applied\n");
console.log("Seeding database...");
const profile = getProfile();
const cfg = profiles[profile];
await runSeedBody(client, db, profile, cfg);
});
console.log(
`\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`,
);
} finally {
await client.end(); await client.end();
} }
}
reset().catch((err) => { reset().catch((err) => {
console.error("Reset failed:", err); console.error("Reset failed:", err);
+8 -6
View File
@@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types";
// ── Seed profile configuration ───────────────────────────────────────────── // ── Seed profile configuration ─────────────────────────────────────────────
type SeedProfile = "dev" | "uat" | "demo"; export type SeedProfile = "dev" | "uat" | "demo";
interface ProfileConfig { export interface ProfileConfig {
staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
clientCount: number; clientCount: number;
appointmentsBackDays: number; appointmentsBackDays: number;
@@ -35,7 +35,7 @@ interface ProfileConfig {
includeUatClients: boolean; includeUatClients: boolean;
} }
const profiles: Record<SeedProfile, ProfileConfig> = { export const profiles: Record<SeedProfile, ProfileConfig> = {
dev: { dev: {
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
clientCount: 100, clientCount: 100,
@@ -70,6 +70,8 @@ function getProfile(): SeedProfile {
return "uat"; return "uat";
} }
export { getProfile };
// ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── // ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
/** /**
@@ -1400,7 +1402,7 @@ async function seedKnownUsers() {
// from runbooks without ambiguity and binds to the single-argument // from runbooks without ambiguity and binds to the single-argument
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain // `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
// number (no bigint type plumbing required). // number (no bigint type plumbing required).
const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
/** /**
* Reserve a dedicated connection from `pool`, take the seed advisory lock * Reserve a dedicated connection from `pool`, take the seed advisory lock
@@ -1413,7 +1415,7 @@ const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, sta
* for the lock and release it from the same reserved connection. The * for the lock and release it from the same reserved connection. The
* seed work itself still runs on the pooled connections. * seed work itself still runs on the pooled connections.
*/ */
async function withSeedAdvisoryLock<T>( export async function withSeedAdvisoryLock<T>(
pool: ReturnType<typeof postgres>, pool: ReturnType<typeof postgres>,
fn: () => Promise<T>, fn: () => Promise<T>,
): Promise<T> { ): Promise<T> {
@@ -1471,7 +1473,7 @@ async function seed() {
await client.end(); await client.end();
} }
async function runSeedBody( export async function runSeedBody(
client: ReturnType<typeof postgres>, client: ReturnType<typeof postgres>,
db: ReturnType<typeof drizzle>, db: ReturnType<typeof drizzle>,
profile: SeedProfile, profile: SeedProfile,