Compare commits

..

6 Commits

Author SHA1 Message Date
Flea Flicker 37e42b3104 ci: re-trigger checks (transient pnpm/action-setup runner flake)
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Images (pull_request) Successful in 27s
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 00:21:03 +00:00
Flea Flicker d617c69571 Merge remote-tracking branch 'origin/dev' into promote/dev-to-uat-gro-2225
CI / Test (pull_request) Failing after 5s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Images (pull_request) Has been skipped
2026-06-09 00:18:24 +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 76d9850464 Promote dev→uat: GRO-2225 UAT seed route cohort + receptionist credential
CI / Test (pull_request) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Failing after 15s
2026-06-08 23:16:51 +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
14 changed files with 12 additions and 1182 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
-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.
+2 -5
View File
@@ -133,7 +133,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) |
| TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) |
| TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` |
| TC-API-2.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500**`processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit |
| TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden |
| TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field |
| TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) |
@@ -287,8 +286,6 @@ This means:
| TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted |
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. |
| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "<serviceId>", name: "<serviceName>"}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. |
### 4.9 Waitlist
@@ -334,8 +331,8 @@ This means:
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present |
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned |
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned |
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
| TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored |
| TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned |
| TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed |
-210
View File
@@ -830,208 +830,6 @@ async function seedUatGroomerLinkage(
);
}
// ── GRO-2311 / GRO-2313: portal customer StatusBadge coverage ────────────────
/**
* GRO-2311 / GRO-2313: give the UAT portal customer (`uat-customer@groombook.dev`)
* a deterministic spread of appointments so the customer-portal StatusBadge
* palette can be LIVE-observed (not just code-verified against the bundle).
*
* `appointment_status` enum is (`scheduled, confirmed, in_progress, completed,
* cancelled, no_show`) — the portal's <StatusBadge> renders `appointment.status`
* verbatim. `pending` and `waitlisted` are NOT valid appointment statuses, so
* GRO-2319 derives them in the portal: `pending` from an upcoming appointment's
* `confirmationStatus` (the `scheduled` row below carries `pending`), and
* `waitlisted` from an ACTIVE `waitlist_entries` row (seeded at the end of this
* function) which `GET /api/portal/appointments` surfaces as a synthetic card.
* The `no_show`→`no-show` badge-key fix is the web side of GRO-2319.
*
* - confirmed → future startTime → renders as an Upcoming card (Confirmed badge)
* - scheduled → future startTime → renders as an Upcoming card (Scheduled badge)
* - cancelled → past startTime → Past tab (isUpcoming excludes cancelled)
* - no_show → past startTime → Past tab (raw `no_show` label until GRO-2319)
*
* The existing GRO-2100 `completed` appointment (a0000001-…-0001) is left
* untouched (AC #4), so Completed is also covered.
*
* Idempotent: each appointment uses a fixed UUID and is upserted with
* onConflictDoNothing, so the hourly reset-demo-data CronJob (which TRUNCATEs
* then re-seeds) and non-truncating dev re-seeds never dup-key
* (see GRO-2033 for the dup-key class).
*/
async function seedUatCustomerPortalAppointments(
db: ReturnType<typeof drizzle>,
customerClientId: string | null,
): Promise<void> {
const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha
// Skip silently outside the UAT persona profile (e.g. a dev/test seed that
// never created the UAT Customer client).
if (!customerClientId) {
return;
}
// The customer's pet must exist (pets are NOT truncated on reset, so this is
// stable). Defensive: bail cleanly if the persona pet is absent.
const [linkedPet] = await db
.select({ id: schema.pets.id })
.from(schema.pets)
.where(eq(schema.pets.id, LINKED_PET_ID))
.limit(1);
if (!linkedPet) {
console.warn(`⚠ GRO-2311: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping portal appointment seed`);
return;
}
// Stable "Bath & Brush" service; fall back to any active service.
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
const [bathService] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
.limit(1);
let serviceId: string;
if (bathService) {
serviceId = bathService.id;
} else {
const [fallback] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.active, true))
.limit(1);
if (!fallback) {
console.warn(`⚠ GRO-2311: no active services found — skipping portal appointment seed`);
return;
}
serviceId = fallback.id;
}
// Attach the UAT groomer when present (nicer "with <groomer>" card); else null
// ("First Available"). Either way these are the customer's own appointments —
// no new groomer↔pet linkage invariant is created (uses the already-linked
// Pup Alpha), so GRO-1987 TC-UAT-3 (403 on the UNLINKED Pup Beta) is unaffected.
const [uatGroomerStaff] = await db
.select({ id: schema.staff.id })
.from(schema.staff)
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
.limit(1);
const staffId = uatGroomerStaff?.id ?? null;
// Anchor all times to local wall-clock so future/past holds regardless of the
// hourly reset cadence.
const at = (deltaDays: number, hour: number): Date => {
const d = new Date();
d.setDate(d.getDate() + deltaDays);
d.setHours(hour, 0, 0, 0);
return d;
};
const DURATION_MS = 45 * 60 * 1000;
const rows = [
{
id: "a0000001-0000-0000-0000-000000000002",
status: "confirmed" as const,
start: at(3, 10),
confirmationStatus: "confirmed",
confirmedAt: new Date(),
cancelledAt: null as Date | null,
notes: "GRO-2311: upcoming confirmed appointment for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000003",
status: "scheduled" as const,
start: at(5, 14),
confirmationStatus: "pending",
confirmedAt: null as Date | null,
cancelledAt: null as Date | null,
notes: "GRO-2311: upcoming scheduled appointment for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000004",
status: "cancelled" as const,
start: at(-3, 11),
confirmationStatus: "cancelled",
confirmedAt: null as Date | null,
cancelledAt: new Date(),
notes: "GRO-2311: cancelled appointment (Past tab) for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000005",
status: "no_show" as const,
start: at(-10, 9),
confirmationStatus: "confirmed",
confirmedAt: null as Date | null,
cancelledAt: null as Date | null,
notes: "GRO-2311: no_show appointment (Past tab) for portal StatusBadge coverage.",
},
];
await db
.insert(schema.appointments)
.values(
rows.map((r) => ({
id: r.id,
clientId: customerClientId,
petId: LINKED_PET_ID,
serviceId,
staffId,
batherStaffId: null,
status: r.status,
startTime: r.start,
endTime: new Date(r.start.getTime() + DURATION_MS),
notes: r.notes,
priceCents: null,
confirmationStatus: r.confirmationStatus,
confirmedAt: r.confirmedAt,
cancelledAt: r.cancelledAt,
})),
)
.onConflictDoNothing({ target: schema.appointments.id });
console.log(
`✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`,
);
// GRO-2319 item 2: seed one ACTIVE waitlist entry so the portal's `waitlisted`
// card (surfaced by GET /api/portal/appointments) is live-observable. Unlike
// appointments, `waitlist_entries` is NOT truncated on the hourly reset, so we
// upsert by fixed id and REFRESH the preferred date to a future-relative value
// each reset — otherwise the date would go stale and the card would drop out of
// the Upcoming list. (The seeded `scheduled` appointment above already carries
// `confirmationStatus: "pending"`, which drives the live Pending badge.)
const WAITLIST_ENTRY_ID = "e0000001-0000-0000-0000-000000000001";
const pad2 = (n: number): string => String(n).padStart(2, "0");
const wlStart = at(7, 13); // 7 days out, 1pm — comfortably "upcoming"
const wlPreferredDate = `${wlStart.getFullYear()}-${pad2(wlStart.getMonth() + 1)}-${pad2(wlStart.getDate())}`;
const wlPreferredTime = `${pad2(wlStart.getHours())}:00:00`;
await db
.insert(schema.waitlistEntries)
.values({
id: WAITLIST_ENTRY_ID,
clientId: customerClientId,
petId: LINKED_PET_ID,
serviceId,
preferredDate: wlPreferredDate,
preferredTime: wlPreferredTime,
status: "active",
})
.onConflictDoUpdate({
target: schema.waitlistEntries.id,
set: {
preferredDate: wlPreferredDate,
preferredTime: wlPreferredTime,
status: "active",
updatedAt: new Date(),
},
});
console.log(
`✓ GRO-2319: seeded 1 active waitlist entry (${wlPreferredDate} ${wlPreferredTime}) for UAT customer portal Waitlisted card`,
);
}
// ── GRO-2225: deterministic route-optimization cohort ────────────────────────
/**
@@ -1313,10 +1111,6 @@ async function seedKnownUsers() {
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
// appointment statuses only). Runs after the groomer linkage so the customer
// client + Pup Alpha already exist.
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
// ── Client: Demo Client ──
const [existingClient] = await db
@@ -1579,10 +1373,6 @@ async function runSeedBody(
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
// appointment statuses only). Runs after the groomer linkage so the customer
// client + Pup Alpha already exist.
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
// GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments
// for the UAT groomer. Must run AFTER services are seeded (it looks up a
-89
View File
@@ -1,89 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ──────────────────────────────────────────────────────────────────
// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the
// documented maximum (500) before invoking the geocoding service. We mock the
// service to capture the exact limit the route forwards.
const geocodeUngeocodedClients = vi.fn(async () => ({
totalRemaining: 0,
processed: 0,
geocoded: 0,
failed: 0,
remaining: 0,
}));
vi.mock("../services/clientGeocoding.js", () => ({
geocodeUngeocodedClients,
geocodeClient: vi.fn(),
resolveClientGeocodingProvider: vi.fn(),
}));
vi.mock("@groombook/db", () => {
const tableProxy = (name: string) =>
new Proxy(
{ _name: name },
{ get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
return {
getDb: () => ({}),
clients: tableProxy("clients"),
appointments: tableProxy("appointments"),
and: vi.fn(),
eq: vi.fn(),
or: vi.fn(),
exists: vi.fn(),
};
});
const { clientsRouter } = await import("../routes/clients.js");
const app = new Hono();
app.route("/clients", clientsRouter);
function postBatch(query: string) {
return app.request(`/clients/geocode-batch${query}`, { method: "POST" });
}
describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => {
beforeEach(() => {
geocodeUngeocodedClients.mockClear();
});
it("defaults to 50 when no ?limit is supplied", async () => {
const res = await postBatch("");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50);
});
it("passes through a value within the cap", async () => {
const res = await postBatch("?limit=120");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120);
});
it("clamps an over-cap value to 500", async () => {
const res = await postBatch("?limit=100000");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500);
});
it("floors a fractional value before clamping", async () => {
const res = await postBatch("?limit=49.9");
expect(res.status).toBe(200);
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49);
});
it("rejects a non-positive limit with 400", async () => {
const res = await postBatch("?limit=0");
expect(res.status).toBe(400);
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
});
it("rejects a non-numeric limit with 400", async () => {
const res = await postBatch("?limit=abc");
expect(res.status).toBe(400);
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
});
});
-130
View File
@@ -39,19 +39,11 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let selectWaitlistRows: Record<string, unknown>[] = [];
let selectPetRows: Record<string, unknown>[] = [];
let selectStaffRows: Record<string, unknown>[] = [];
let selectServiceRows: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
selectWaitlistRows = [];
selectPetRows = [];
selectStaffRows = [];
selectServiceRows = [];
updatedValues = [];
}
@@ -80,13 +72,6 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const mkTable = (name: string) =>
new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) });
const waitlistEntries = mkTable("waitlistEntries");
const pets = mkTable("pets");
const staff = mkTable("staff");
const services = mkTable("services");
return {
getDb: () => ({
select: () => ({
@@ -97,18 +82,6 @@ vi.mock("@groombook/db", () => {
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "waitlistEntries") {
return makeChainable(selectWaitlistRows);
}
if (table._name === "pets") {
return makeChainable(selectPetRows);
}
if (table._name === "staff") {
return makeChainable(selectStaffRows);
}
if (table._name === "services") {
return makeChainable(selectServiceRows);
}
return makeChainable([]);
},
}),
@@ -129,13 +102,8 @@ vi.mock("@groombook/db", () => {
}),
impersonationSessions,
appointments,
waitlistEntries,
pets,
staff,
services,
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
@@ -157,104 +125,6 @@ function jsonPatch(path: string, body: unknown, headers?: Record<string, string>
beforeEach(() => resetMock());
// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as
// synthetic `waitlisted` cards, so GET /portal/appointments must surface them.
describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => {
it("returns active waitlist entries as synthetic waitlisted cards", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [
{
id: "11111111-1111-1111-1111-111111111111",
petId: "pet-1",
serviceId: "svc-1",
preferredDate: "2099-01-01",
preferredTime: "13:00:00",
},
];
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const waitlistCard = body.appointments.find(
(a: { status: string }) => a.status === "waitlisted",
);
expect(waitlistCard).toBeTruthy();
expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111");
expect(waitlistCard.pet.name).toBe("Rex");
expect(waitlistCard.confirmationStatus).toBeNull();
// startTime is derived from preferredDate + preferredTime so the card sorts
// and classifies as Upcoming.
expect(waitlistCard.startTime).toBeTruthy();
});
it("omits the waitlist section when the client has no active entries", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false);
});
});
// GRO-2342: GET /portal/appointments must populate the synthetic waitlist
// card's `service` object with the full service record (id + name) — same
// shape the appointments join returns — so the portal renders the real
// service name in place of the fallback "Service" label.
describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => {
it("returns service {id, name} on the synthetic waitlist card", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [
{
id: "22222222-2222-2222-2222-222222222222",
petId: "pet-1",
serviceId: "svc-1",
preferredDate: "2099-01-01",
preferredTime: "13:00:00",
},
];
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
selectServiceRows = [{ id: "svc-1", name: "Full Groom" }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const waitlistCard = body.appointments.find(
(a: { status: string }) => a.status === "waitlisted",
);
expect(waitlistCard).toBeTruthy();
expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" });
});
it("returns service {id, name} on the appointment card (same shape)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" };
selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const apptCard = body.appointments.find(
(a: { status: string }) => a.status === "scheduled",
);
expect(apptCard).toBeTruthy();
expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" });
});
});
describe("PATCH /portal/appointments/:id/notes", () => {
it("returns updated appointment with safe fields only", async () => {
selectSessionRow = ACTIVE_SESSION;
-201
View File
@@ -1,201 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import { getAuth } from "../lib/auth.js";
const NEW_USER_EMAIL = "new-sso-user@example.com";
const NEW_USER_NAME = "New SSO User";
const NEW_USER_ID = "11111111-2222-3333-4444-555555555555";
const BETTER_AUTH_SESSION = {
user: {
id: "auth-user-new",
email: NEW_USER_EMAIL,
name: NEW_USER_NAME,
},
session: {
id: "ba-session-new",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
};
let mockGetAuth: ReturnType<typeof vi.fn>;
let mockGetSession: ReturnType<typeof vi.fn>;
let existingClientRow: Record<string, unknown> | null = null;
let insertedClientValues: Record<string, unknown> | null = null;
let insertShouldThrow: { code?: string } | null = null;
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
return new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => makeChainable(target);
}
// @ts-expect-error proxy
return target[prop];
},
});
}
vi.mock("@groombook/db", () => {
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "clients") {
return makeChainable(existingClientRow ? [existingClientRow] : []);
}
return makeChainable([]);
},
}),
insert: (table: { _name: string }) => ({
values: (vals: Record<string, unknown>) => {
if (insertShouldThrow) {
const err = new Error("unique violation") as Error & { code?: string };
err.code = insertShouldThrow.code;
throw err;
}
return {
returning: () => {
if (table._name === "clients") {
insertedClientValues = { id: NEW_USER_ID, ...vals };
return [insertedClientValues];
}
return [];
},
};
},
}),
}),
clients,
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
vi.mock("../lib/auth.js", () => ({
getAuth: vi.fn(),
}));
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
describe("POST /portal/clients-from-auth (GRO-2359)", () => {
beforeEach(() => {
existingClientRow = null;
insertedClientValues = null;
insertShouldThrow = null;
mockGetSession = vi.fn();
mockGetAuth = vi.fn(() => ({
api: {
getSession: mockGetSession,
},
}));
vi.mocked(getAuth).mockImplementation(mockGetAuth);
});
it("returns 401 when no Better Auth session is present", async () => {
mockGetSession.mockResolvedValue(null);
const res = await app.request("/portal/clients-from-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test User" }),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 400 when body fails zod validation (empty name)", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
const res = await app.request("/portal/clients-from-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "" }),
});
expect(res.status).toBe(400);
});
it("creates a new client row bound to the auth user's email and returns 201", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
const res = await app.request("/portal/clients-from-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: " New SSO User ",
phone: "555-1234",
address: "1 Main St",
notes: "test note",
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
id: NEW_USER_ID,
name: "New SSO User",
email: NEW_USER_EMAIL,
});
// Trim must be applied to the persisted values.
expect(insertedClientValues).not.toBeNull();
expect((insertedClientValues as Record<string, unknown>).name).toBe("New SSO User");
expect((insertedClientValues as Record<string, unknown>).email).toBe(NEW_USER_EMAIL);
expect((insertedClientValues as Record<string, unknown>).phone).toBe("555-1234");
});
it("normalizes empty optional fields to null on insert", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
await app.request("/portal/clients-from-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test", phone: "", address: " " }),
});
expect(insertedClientValues).not.toBeNull();
expect((insertedClientValues as Record<string, unknown>).phone).toBeNull();
expect((insertedClientValues as Record<string, unknown>).address).toBeNull();
});
it("returns 409 when a client row already exists for this email", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL };
const res = await app.request("/portal/clients-from-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test" }),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/already exists/i);
expect(insertedClientValues).toBeNull();
});
it("returns 409 on unique constraint race (23505)", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
insertShouldThrow = { code: "23505" };
const res = await app.request("/portal/clients-from-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test" }),
});
expect(res.status).toBe(409);
});
it("returns 503 when auth is not configured", async () => {
mockGetAuth.mockImplementation(() => {
throw new Error("Auth not initialized");
});
const res = await app.request("/portal/clients-from-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test" }),
});
expect(res.status).toBe(503);
});
});
-145
View File
@@ -1,145 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ──────────────────────────────────────────────────────────────────
// GRO-2294: GET /api/admin/settings must not return the encrypted
// googleMapsApiKey ciphertext, on either the existing-row or auto-create branch.
let selectRows: Record<string, unknown>[] = [];
let insertReturning: Record<string, unknown>[] = [];
let updateReturning: Record<string, unknown>[] = [];
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy passthrough
return target[prop];
},
});
return chain;
}
vi.mock("@groombook/db", () => {
const businessSettings = new Proxy(
{ _name: "business_settings" },
{ get: (_t, p) => (p === "_name" ? "business_settings" : { column: p }) }
);
return {
getDb: () => ({
select: () => ({ from: () => makeChainable(selectRows) }),
insert: () => ({
values: () => ({ returning: () => insertReturning }),
}),
update: () => ({
set: () => ({ where: () => ({ returning: () => updateReturning }) }),
}),
}),
businessSettings,
eq: vi.fn(),
};
});
vi.mock("../lib/s3.js", () => ({
getPresignedUploadUrl: vi.fn(),
deleteObject: vi.fn(),
putObject: vi.fn(),
getObject: vi.fn(),
}));
const { settingsRouter } = await import("../routes/settings.js");
const app = new Hono();
app.route("/settings", settingsRouter);
// PATCH /settings is guarded by requireSuperUser(), which reads the staff record
// from context. Inject a super-user staff row so the handler runs.
const patchApp = new Hono<{
Variables: { staff: { id: string; isSuperUser: boolean } };
}>();
patchApp.use("*", async (c, next) => {
c.set("staff", { id: "staff-1", isSuperUser: true });
await next();
});
patchApp.route("/settings", settingsRouter);
const FULL_ROW = {
id: "settings-uuid-1",
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
routeOptimizationProvider: "google",
googleMapsApiKey: "ENCRYPTED::super-secret-ciphertext",
createdAt: new Date(),
updatedAt: new Date(),
};
describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => {
beforeEach(() => {
selectRows = [];
insertReturning = [];
});
it("omits googleMapsApiKey from an existing settings row", async () => {
selectRows = [{ ...FULL_ROW }];
const res = await app.request("/settings", { method: "GET" });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
// Non-secret fields are still returned.
expect(body.businessName).toBe("GroomBook");
expect(body.routeOptimizationProvider).toBe("google");
});
it("omits googleMapsApiKey from the auto-create branch", async () => {
selectRows = [];
insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }];
const res = await app.request("/settings", { method: "GET" });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
expect(body.id).toBe("settings-uuid-new");
});
});
describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => {
beforeEach(() => {
selectRows = [];
insertReturning = [];
updateReturning = [];
});
function patchRequest(body: Record<string, unknown>) {
return patchApp.request("/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("omits googleMapsApiKey from the PATCH response", async () => {
selectRows = [{ ...FULL_ROW }];
updateReturning = [{ ...FULL_ROW, businessName: "Updated Name" }];
const res = await patchRequest({ businessName: "Updated Name" });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
// Non-secret updated fields are still returned.
expect(body.businessName).toBe("Updated Name");
expect(body.routeOptimizationProvider).toBe("google");
});
it("omits googleMapsApiKey on the auto-create-then-update branch", async () => {
selectRows = [];
insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }];
updateReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }];
const res = await patchRequest({ primaryColor: "#123456" });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
expect(body.id).toBe("settings-uuid-new");
});
});
+1 -10
View File
@@ -12,12 +12,6 @@ import {
export const clientsRouter = new Hono<AppEnv>();
// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how
// long one synchronous request stays open and the per-request external API cost
// when routeOptimizationProvider = "google".
const GEOCODE_BATCH_DEFAULT_LIMIT = 50;
const GEOCODE_BATCH_MAX_LIMIT = 500;
type ClientRow = typeof clients.$inferSelect;
/**
@@ -191,15 +185,12 @@ clientsRouter.post("/:clientId/geocode", async (c) => {
clientsRouter.post("/geocode-batch", async (c) => {
const db = getDb();
const limitRaw = c.req.query("limit");
let limit = GEOCODE_BATCH_DEFAULT_LIMIT;
let limit = 50;
if (limitRaw !== undefined) {
limit = Number(limitRaw);
if (!Number.isFinite(limit) || limit <= 0) {
return c.json({ error: "limit must be a positive integer" }, 400);
}
// Clamp to the documented maximum to bound synchronous request duration
// and (for the Google provider) per-request external API cost.
limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT);
}
const summary = await geocodeUngeocodedClients(db, limit);
return c.json(summary);
+2 -26
View File
@@ -57,23 +57,6 @@ const createPetSchema = z.object({
customFields: z.record(z.string(), z.string()).optional(),
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
// Extended pet profile fields (api/#39, GRO-1178).
// GRO-2172: these were missing from the schema, causing POST/PATCH to
// silently drop them even though migrations 0034/0036 and seed data
// populate them. GRO-1472 was the original UAT regression.
temperamentScore: z.number().int().min(1).max(5).optional(),
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
medicalAlerts: z
.array(
z.object({
type: z.string().max(100),
description: z.string().max(1000),
severity: z.enum(["low", "medium", "high"]),
})
)
.max(50)
.optional(),
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -350,8 +333,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.insert(pets)
.values({
@@ -359,10 +341,6 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
customFields: customFields ?? {},
// GRO-2172: medicalAlerts shape from the API request is
// { type, description, severity } — the @groombook/types MedicalAlert
// has an optional server-generated `id`, so cast for the jsonb column.
medicalAlerts: medicalAlerts as never,
})
.returning();
return c.json(row, 201);
@@ -373,8 +351,7 @@ petsRouter.patch(
zValidator("json", updatePetSchema),
async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.update(pets)
.set({
@@ -382,7 +359,6 @@ petsRouter.patch(
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
...(customFields !== undefined ? { customFields } : {}),
medicalAlerts: medicalAlerts as never,
updatedAt: new Date(),
})
.where(eq(pets.id, c.req.param("id")))
+4 -168
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, inArray } from "@groombook/db";
import { eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
@@ -147,114 +147,6 @@ portalRouter.post("/session-from-auth", async (c) => {
);
});
// GRO-2359 — register a brand-new SSO user. The post-auth handler in the
// web portal redirects here when `session-from-auth` returns 404, so the
// OOBE can complete a customer record for the new user. Auth is via the
// Better Auth session (same shape as `session-from-auth`), so this is
// registered BEFORE the `validatePortalSession` middleware.
//
// Contract:
// POST /api/portal/clients-from-auth
// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null }
// 201: { id, name, email }
// 400: invalid body (zod failure)
// 401: no Better Auth session
// 409: a `clients` row already exists for this email (portal selection case)
// 500: insert failed
//
// We do NOT auto-link the user's auth account to the new client row; the
// existing `session-from-auth` endpoint re-resolves the row by email on the
// next call, so the OOBE's success path just navigates the user back to
// `/` and lets the bridge mint a portal session.
const createClientFromAuthSchema = z.object({
name: z.string().min(1).max(200),
phone: z.string().max(50).nullish(),
address: z.string().max(500).nullish(),
notes: z.string().max(2000).nullish(),
});
portalRouter.post(
"/clients-from-auth",
zValidator("json", createClientFromAuthSchema),
async (c) => {
let auth;
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = c.req.valid("json");
const db = getDb();
// Pre-check: if a client already exists for this email, return 409 so
// the OOBE can render the "portal selection" message (the user needs
// to contact their groomer to link the new SSO identity to the
// pre-existing customer record). We don't return the existing row to
// avoid leaking PII about other accounts.
const [existing] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.email, session.user.email))
.limit(1);
if (existing) {
return c.json(
{ error: "A customer record with this email already exists" },
409,
);
}
let row;
try {
[row] = await db
.insert(clients)
.values({
name: body.name.trim(),
email: session.user.email,
phone: body.phone?.trim() || null,
address: body.address?.trim() || null,
notes: body.notes?.trim() || null,
})
.returning();
} catch (err) {
// Concurrent insert from a parallel OOBE submit — treat as 409.
if (
err instanceof Error &&
"code" in err &&
(err as { code?: string }).code === "23505"
) {
return c.json(
{ error: "A customer record with this email already exists" },
409,
);
}
throw err;
}
if (!row) {
return c.json({ error: "Failed to create client" }, 500);
}
return c.json(
{
id: row.id,
name: row.name,
email: row.email,
},
201,
);
},
);
// Apply middleware to all portal routes
portalRouter.use("/*", validatePortalSession, portalAudit);
@@ -303,46 +195,14 @@ portalRouter.get("/appointments", async (c) => {
.where(eq(appointments.clientId, clientId))
.orderBy(appointments.startTime);
// GRO-2319: surface the client's ACTIVE waitlist entries alongside their
// appointments so the portal can render them as `waitlisted` cards in the
// Upcoming list. The `appointment_status` enum cannot represent `waitlisted`,
// so these are synthetic entries (status hard-set to `waitlisted`, id prefixed
// `waitlist:`) derived from `waitlist_entries`.
const waitlistRows = await db
.select({
id: waitlistEntries.id,
petId: waitlistEntries.petId,
serviceId: waitlistEntries.serviceId,
preferredDate: waitlistEntries.preferredDate,
preferredTime: waitlistEntries.preferredTime,
})
.from(waitlistEntries)
.where(
and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")),
);
// Pet lookups must cover both appointment and waitlist pets.
const petIds = [
...allAppts.map(a => a.petId).filter((id): id is string => id !== null),
...waitlistRows.map(w => w.petId),
];
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
// GRO-2342: services must be looked up for both appointment and waitlist cards
// so the portal can render `service.name` in place of the fallback "Service"
// label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the
// service name; this follow-up closes the cosmetic gap).
const serviceIds = [
...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null),
...waitlistRows.map(w => w.serviceId).filter((id): id is string => id !== null),
];
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({
id: a.id,
@@ -353,35 +213,11 @@ portalRouter.get("/appointments", async (c) => {
customerNotes: a.customerNotes,
notes: a.notes,
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name } : null,
service: a.serviceId ? { id: a.serviceId } : null,
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
// Derive a display `startTime` from the entry's preferred date/time so the
// portal can sort/classify the synthetic card (an invalid combination simply
// yields a null startTime, which the portal tolerates). GRO-2342: also
// populate the synthetic card's `service` object with the full service
// record (id + name) — same shape the appointments join returns — so the
// portal renders the real service name in place of the fallback "Service"
// label.
const waitlistAppts = waitlistRows.map(w => {
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
return {
id: `waitlist:${w.id}`,
startTime,
endTime: null,
status: "waitlisted" as const,
confirmationStatus: null,
customerNotes: null,
notes: null,
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null,
staff: null,
};
});
return c.json({ appointments: [...appts, ...waitlistAppts] });
return c.json({ appointments: appts });
});
portalRouter.get("/pets", async (c) => {
+3 -16
View File
@@ -7,17 +7,6 @@ import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
type BusinessSettingsRow = typeof businessSettings.$inferSelect;
// Strip the encrypted googleMapsApiKey ciphertext from settings responses
// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is
// only written via the dedicated provider-config endpoint.
function redactSettings(row: BusinessSettingsRow) {
const rest: Partial<BusinessSettingsRow> = { ...row };
delete rest.googleMapsApiKey;
return rest;
}
// GET /api/admin/settings — return current business settings
settingsRouter.get("/", async (c) => {
const db = getDb();
@@ -25,10 +14,9 @@ settingsRouter.get("/", async (c) => {
if (!row) {
// Auto-create default settings if none exist
const [created] = await db.insert(businessSettings).values({}).returning();
if (!created) throw new Error("Failed to create default settings");
return c.json(redactSettings(created));
return c.json(created);
}
return c.json(redactSettings(row));
return c.json(row);
});
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
@@ -65,8 +53,7 @@ settingsRouter.patch(
.where(eq(businessSettings.id, settingsId))
.returning();
if (!updated) throw new Error("Failed to update settings");
return c.json(redactSettings(updated));
return c.json(updated);
}
);
View File