Build progress
84 of 89 tracked features shipped · 94%
Foundation
Design token system
CSS vars + tokens.ts for color, type, spacing, radius, shadow.
Warm visual refresh (ad hoc, unnumbered)
Whole-app re-skin done purely at the token layer (no per-component hardcoding), so all 79 var()-consuming files re-themed at once. Palette moved off the drab olive primary (#517830) and alarm-crimson CTA (#c92c4e — read like an error state) to an emerald-forest primary (#2c7a54) and a friendly warm coral-terracotta accent (#c9432f), plus cozier cream neutrals (bg #FBF7F1, warmer surface-raised/border/text) and warm-tinted, softer, larger shadows for gentler depth. Every reused color re-verified ≥4.5:1 WCAG (PRD-045 floor held: primary 5.23:1 on white, white-on-accent 4.85:1, both text-muted/subtle ≥5:1). Mirrored across tokens.ts (drives themeColor meta + OG images), both hand-kept @react-pdf contract color maps (were still on the pre-PRD-045 #8abf5f green — user-facing PDFs now match the brand), and the OG/sample-asset fallbacks. Also fixed three visual-system bugs the handoff flagged: added the real --color-primary-100/200/700 tokens the /theme header referenced but that were never defined; wired Card's long-declared hoverShadow to an actual :hover lift on clickable cards (motion-safe); re-synced the stale legacy tokens.ts `colors` export and the hardcoded Select/WeeklySchedule chevron hex to the live muted value.
Theme showcase (/theme)
Living reference for all tokens and primitives — a "Components" section (PRD-051) renders every src/components/ui primitive via its real import (Avatar, AvatarUpload, Badge, Button, Card, Chip, FormField, Input, Lightbox, PriceInput, RadioCard, Select, Stepper, Textarea, Toggle, WeeklySchedule) in its meaningful states, with plausible PetNanny sample data; interactive ones (Chip, Lightbox, RadioCard, Stepper, Toggle, WeeklySchedule) are small client islands.
Component primitives
~15 reusable UI components in src/components/ui.
App shell
Nav + Footer + responsive layout.
Product vision & PRD backlog
docs/PRODUCT_VISION.md + 50 ordered PRDs in docs/prds/ with STATUS ledger.
Owner journey
Home & marketing
Hero (wired to filtered browse), how-it-works, live services catalog, "top nannies" row (verified providers only, hides below 3), honest stats strip (hides below 10 nannies) — ISR, 1h revalidate.
Browse & discovery
Live nanny listing on Supabase with URL-driven filters (city, live service catalog, pet type, max price), sort, removable chips and a hero-search deep link into browse.
Nanny profile
Bio, services, reviews, and a read-only 3-month availability calendar (tri-state: available/unavailable/unspecified, accepted bookings folded in as unavailable).
Request care flow
Multi-step booking request. Dates step is availability-aware: blocked dates are unselectable in the calendar picker, an overnight span crossing a blocked night names the conflicting date(s), unspecified dates show a "not confirmed" note, and the server action re-validates against live availability at submit time.
Pet profiles
Owners manage pets at /dashboard/pets (create/edit/delete on Supabase; delete blocked while a booking is active).
Care contract v2 (PRD-061)
The contract is now a real bilingual (Lithuanian primary, English secondary) care agreement, not a thin booking summary. New sections: one per-pet care sheet per booking (PRD-059/060 `booking_pets`/`booking_pet_care_profiles` snapshots — feeding, medication, health, vet, behavior, emergency contact, only for sections with content), an emergency veterinary authorization clause naming the owner's chosen € cap (new nullable `bookings.emergency_vet_cap numeric(8,2)`, captured at request time in `createBooking`, immutable afterwards — folded into `guard_booking_transition()`'s always-immutable set alongside `contract_terms_version`), keys/access notes (`owner_notes` reused under its own heading — no duplicate field needed), agreed price with explicit "settled directly between the parties, PetNanny does not process payments" wording, and both parties' phone/email revealed only once the booking is `accepted`/`completed` (`canViewContactInfo`, mirroring PRD-060's exact privacy pattern — email resolved via the service-role admin client the same way `email/booking.ts` already does, since `profiles` carries no email column). A static standard-terms section (liability, cancellation, no in-app payments) is versioned: new `bookings.contract_terms_version integer default 1`, set once at creation, looked up against a `CONTRACT_TERMS[version]` table in `src/lib/contractStrings.ts` so a year-old booking's contract always re-renders the terms actually in force then, never today's — **not lawyer-reviewed**, a plainly-flagged follow-up, not something claimed here. The bilingual copy is its own small string dictionary (`src/lib/contractStrings.ts`, `Bi = {lt, en}` pairs), deliberately independent of the `next-intl` app-locale system, since the PDF must show both languages at once regardless of viewed locale. **Lithuanian diacritics** (ą č ę ė į š ų ū ž): the v1 PDF used only the built-in Helvetica core font (no `Font.register` call existed), which has no glyphs outside WinAnsi/Latin-1 — a real, previously-unaddressed gap. Fixed by embedding Noto Sans (Regular + Bold, `public/fonts/`, SIL OFL license, static non-variable weight instances from the upstream Noto repo) — verified programmatically with `fontTools` that its cmap contains every LT diacritic codepoint (upper + lower) plus €, not just asserted by reputation. **F4 fix (PRD-053 audit, Medium)**: the whole reason this PRD also closes that ticket — `/contract` used to be a Client Component that `atob`/`JSON.parse`d an entire fabricatable contract from a base64 `?booking=` query param, no server fetch, no RLS check, no binding to a real booking at all. Routing is now `/contract/[bookingId]`, fetched server-side via a new `getContractDetail` (`src/lib/contractDetail.ts`) that wraps the same RLS-scoped `getBookingDetail` read the booking-detail hub already uses — a non-participant's query simply returns no row, which the page turns into a plain 404, verified by the existing RLS policies (`bookings_read`: owner or assigned provider only) rather than any new access-control code. The client no longer supplies booking data in any form. Every call site that built the old blob (`dashboard/bookings/[id]/page.tsx`'s `contractHref`, `RequestForm.tsx`'s post-submit redirect) now just links/routes by id; `createBooking` returns the new booking's id for that redirect. **Verified live** via Supabase MCP inside rolled-back transactions: the new columns/constraints apply cleanly, a negative `emergency_vet_cap` is rejected, and a direct post-creation update to either new column is rejected by the extended trigger. Lint/`tsc`/`next build` all pass. **Not verified here**: actual visual PDF rendering (no browser/`.env.local` in this environment) and a native Lithuanian speaker's review of the copy — both explicitly flagged as open follow-ups, not done.
Favorites
Heart toggle on NannyCard + profile header (optimistic, favorited state fetched in one query alongside browse/profile — no N+1); /dashboard/favorites lists the hearted set (inner join, so removed providers drop out); guests are sent to sign in and land back with the favorite applied.
Nanny journey
Become-a-nanny onboarding
Multi-step profile creation.
Nanny dashboard
Requests, bookings, stats, reviews (mock).
Availability management
Providers set date-level availability at /dashboard/availability — 3-month grid, click/drag/shift-click range toggle, "mark weekends available" + "clear month" bulk actions, past dates read-only, accepted bookings overlaid and protected. Persists to availability_slots (upsert on provider_id+date); unspecified days still allow requests.
Provider service & profile self-management (PRD-062)
Onboarding (PRD-003) used to be one-shot — no UI to change a price, add/drop a service, or fix a bio/city typo afterward. New `/dashboard/nanny/profile` ("My services & profile", registered in `DashboardSidebar.tsx` alongside PRD-024's `/dashboard/photos`, same "provider self-service area outside onboarding" convention) edits everything real that onboarding persists: bio, city, pet types accepted, and "member since" year (`provider_profiles.member_since` — the only column that actually models "experience"; onboarding's `HomeStep` fields — `experienceYears`/`certifications`/`homeType`/`fencedGarden`/etc. — were captured in wizard state but never written to any column by `createProvider`, confirmed by reading that action; nothing to make editable there). Shared field components extracted from onboarding (PRD-006 `PetFields` precedent) so both flows render identical markup: `src/components/provider/BioLocationFields.tsx` (bio+city, used by `PersonalStep` and the edit page), `PetTypeFields.tsx` (dog/cat toggle, used by `ServicesStep` and the edit page), `ServicePricingFields.tsx` (catalog toggle + price + additional-pet-price, used by `ServicesStep` and the edit page's services manager) — onboarding itself now also collects `additional_pet_price` (PRD-059's column, this is its first real UI, shared by both flows from day one). Services management: add any active catalog service, remove one currently offered, edit price + additional-pet price; a removal is blocked (service stays offered, everything else in the same save still applies) if a `pending`/`accepted` booking still references it — the response names each blocking booking with a direct link to `/dashboard/bookings/[id]` ("finish those first") rather than a dead-end error. New `updateProviderServices` re-validates every id server-side against the live active catalog ∪ the provider's own current rows (an already-offered-but-since-deactivated service stays manageable, PRD-052 requirement 5), never trusts a client price. **Price-change scoping (requirement 3) needed no new mechanism** — verified by reading `resolveBookingPricing`/`guard_booking_transition()`: a booking's `unit_price`/`total_price` are snapshotted at creation and immutable afterward, and `bookingDetail.ts`'s only *live* `provider_services` read (`liveUnitPrice`) feeds exclusively the owner-edit/provider-proposal date-change pickers' preview, never the displayed booking/contract total — so this UPDATE structurally cannot reach an existing booking. **Rebook fallback (requirement 5) verified against real edits, not just suspensions**: `getRebookCta` already keys off `nanny.services.some(s => s.id === serviceId && s.active)`, which is naturally false the instant a provider removes that `provider_services` row — no fix needed, confirmed by tracing `getProvider`→`mapProvider`. **Immediate consistency (requirement 4)**: `/nanny/[id]` and `/browse` have no ISR (`export const dynamic = 'force-dynamic'` on browse; no `revalidate` on the profile page, and both read through a cookie-bound client that forces dynamic rendering) — no staleness gap existed; `revalidatePath` calls added anyway to match the existing convention. Lint/`tsc`/`next build` all pass. **Verified live** via Supabase MCP against the real project (rolled back): the exact blocking query correctly identified both of a seeded provider's services as blocked (each referencing a `pending`/`accepted` booking) and zero blocked for a different seeded provider whose bookings were `declined`/`cancelled` only; a simulated clean removal (that second provider, both services) left exactly 0 `provider_services` rows behind before the rollback restored them — no orphans. **Not verified here**: a real click-through of add/remove-service and price-edit flows in a browser.
Backend & data (Supabase)
Local development environment (PRD-152)
Offline-capable local backend via the Supabase CLI's Docker stack (Postgres, GoTrue, Storage, Realtime, Studio) — before this, every feature in the whole backlog hard-depended on the single live remote project, with no way to iterate offline. New `supabase/config.toml` (deliberately no `[remotes]` block, researched against current Branching docs: config-sync-on-merge to a persistent branch only fires when a `[remotes.<branch>]` block names that branch's project ref — with none present, merging to `main` never pushes this file's local-tuned settings, e.g. `site_url=http://localhost:3000`, to the live project; only ephemeral per-PR previews pick it up, which is expected/low-risk since those DBs are recreated per PR anyway). Reconciles AGENTS.md's old "no config.toml by choice" note — updated to explain the actual current tradeoff and to explicitly warn against ever running `supabase config push` (which, unlike `db push`, does push this file straight to whatever remote the CLI is linked to). **Migration-replay audit**: statically reviewed all ~30 files in `supabase/migrations/` for anything that would only work against an already-migrated live DB — confirmed the PRD-060 ordering fix (`pet_care_profile` after `booking_pets`) is genuinely in place on disk, every FK's migration is preceded by its target table's, and no migration inserts/reads live-specific data. One real (non-error) local-vs-remote behavior difference found and documented in `docs/LOCAL_DEV.md`: `seed_demo.sql`'s admin-impersonation step (used to pre-verify 3 of 4 demo providers) finds no admin account on a brand-new local DB and degrades to its own documented fallback — providers seed unverified locally instead, everything else identical. New `docs/LOCAL_DEV.md`: prerequisites (Docker, Supabase CLI — `brew install supabase/tap/supabase` or `npm install --save-dev supabase`; confirmed the CLI rejects `npm install -g`), `supabase start`, `supabase db reset` (the from-scratch replay-verification command, vs. `migration up`'s incremental-only semantics — explained the actual difference), running `seed_demo.sql` locally via `psql`/Studio's SQL Editor, `.env.local` wiring from `supabase status`'s output (`NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`/`SUPABASE_SECRET_KEY`, cross-checked against the actual var names `src/lib/supabase/{client,server,admin}.ts` read), local port table, `supabase stop`/`db reset` cleanup, and how this relates to Supabase Branching (additive, doesn't replace preview/production). `.env.example` and `docs/SUPABASE_SETUP.md` cross-reference it. **Not verified here** (no Docker/Supabase CLI in this sandbox): literally running `supabase start`, the migration replay, `seed_demo.sql`, or a real browser click-through with zero remote network calls — config.toml and every documented step are written from current Supabase CLI/Branching documentation and static file review, not guessed from memory or executed.
Database schema design
Multi-service data model in docs/DATA_MODEL.md (service catalog, generic bookings).
Supabase project + schema
Schema live in production (10 tables, RLS, triggers); catalog seeded.
Auth
Email + password sign-up/in/out, self-serve password reset (forgot → email link → set new password), session middleware, gated dashboard.
Guest gating & post-auth redirects
One middleware gate for /dashboard, /request, /become-nanny, /messages — guests bounce to /signin?next=<path> and land back exactly where they were (incl. through email confirmation); next is same-origin-validated; signed-in users skip /signin & /signup.
Account profile management
Editable name/phone/address at /dashboard/profile, completeness nudge, name in nav.
Account settings: credentials & deletion
At /dashboard/settings: change password (current-password re-auth), change email (confirm from new inbox), delete account (type-DELETE, blocked while an accepted future booking exists, hard-deletes via auth.admin.deleteUser with the service-role key). Authored reviews on other profiles survive anonymized as "Deleted user"; provider rating aggregates untouched.
Role model: owner ↔ nanny in one account
getViewer() drives one dashboard shell: Owner nav always on, Nanny nav only with a provider_profiles row, "Become a nanny" upsell otherwise, sign-up "what brings you here?" routes to onboarding post-confirm, last-used side remembered in a cookie.
Replace mock data
Onboarding, browse, profile, request flow & nanny dashboard all on Supabase.
Catalog-driven onboarding & booking (PRD-052)
Kills the `SERVICE_SLUG`/`slugToType()` `walking|overnight` union duplicated across become-nanny/actions.ts, providers.ts, bookings.ts, ownerBookings.ts, bookingDetail.ts — a service is now the live `services` catalog row (id/slug/name/pricing_unit/icon), read through one shared client-safe module (`src/lib/serviceCatalog.ts`, extended: `ServiceCatalogItem`, `pricingUnitDateMode()`, `computeUnits()`/`computeTotalPrice()`) plus server-only catalog reads in `src/lib/providers.ts` (now locale-aware — `mapProvider(row, locale)` threaded through `getProvider`/`getProviders`/`getFeaturedProviders` and their call sites). Onboarding's services step (`ServicesStep.tsx`) renders the live active catalog as a checklist with a per-service price input labeled by that service's real `pricing_unit` (`become-nanny/page.tsx` fetches the catalog server-side, `OnboardingForm` state is `serviceIds: string[]` + `prices: Record<id, string>`); `become-nanny/actions.ts` re-validates submitted ids against the active catalog server-side rather than trusting the client. The request flow's service step lists only the specific provider's active `provider_services` rows; the date-picker mode (single date vs. check-in/check-out range) is derived from `pricingUnitDateMode(pricing_unit)` (`per_night`/`per_day` → range, everything else → single instance) instead of checking a slug — same derivation reused by `request/actions.ts` server-side validation, the request summary, `/contract`, `ContractPDF`, and the booking-detail price breakdown, so price math is one `unitPrice × units` computation everywhere (`computeUnits`/`computeTotalPrice`). Schema: bookings already had a real `service_id uuid references services(id)` (not the free-text column the PRD anticipated) — new idempotent migration `20260707140000_bookings_service_snapshot.sql` adds nullable `service_name_snapshot`/`service_pricing_unit_snapshot`, backfilled from the current catalog, and `request/actions.ts` populates both (locale-aware name) on every new booking so a later catalog rename/deactivation can't change what a historical booking displays; every reader (`bookings.ts`, `ownerBookings.ts`, `bookingDetail.ts`) prefers the snapshot and falls back to the live join only for pre-migration rows — deactivation itself was already safe (the FK join was never filtered on `active`) but is now additionally rename-proof. Final sweep: `grep -r "overnight\|walking" src/` turns up only seed data, mock/demo fixtures and copy/comments — zero logic branches on a hardcoded slug. **Not verified here** (no `.env.local`/live Supabase in this sandbox): actually applying the migration, and a live "admin adds a service → provider onboards → owner books → contract prices it" pass — confirmed via lint/`tsc`/`next build` and code review only.
Retroactive security audit of pre-PRD features (PRD-053)
Audited the ~13 features that shipped before their PRDs were written (PRD-003/004/005/007/008/009/010/011/012/020/021/025/026) against RLS correctness and server-side validation, using live Supabase MCP access (real SQL as specific roles/JWTs, every write-simulating statement inside a rolled-back transaction — nothing committed). Full checklist + evidence in `docs/audits/2026-07-pre-prd-audit.md`. **Two critical findings, both fixed**: (1) `request/actions.ts` (`createBooking`) inserted the client-submitted `unitPrice`/`totalPrice` verbatim — never read the provider's actual `provider_services.price` — now fetches it server-side and computes the total via the existing `computeTotalPrice()` helper, ignoring the client's price fields entirely. (2) `bookings_update` RLS let the owner *or* provider rewrite any column (status, price, dates) on a row they're party to, with zero transition rules, and `acceptBooking`/`declineBooking` (`dashboard/actions.ts`) had no server-side role check at all (relied on "only providers see the button" in the UI) — verified live that an owner could self-accept their own pending request and jump straight to `completed` with an inflated price via a direct API call; new migration `20260707150000_bookings_transition_guard.sql` adds a `before update` trigger making price/schedule/parties immutable post-creation and enforcing the real accept/decline/complete/cancel transition matrix (provider-only for accept/decline/complete, either participant for cancel), with a full `is_admin()` bypass verified not to break the existing suspend/report auto-decline admin flows. Three more findings sized beyond "small, in-place" were ticketed rather than fixed: `profiles_read`'s `using (true)` policy exposes `phone`/`address` to a direct `anon` API call even though no app code selects them that way (High); `/contract` has no server-side binding to a real booking, so a crafted URL param can produce a plausible contract PDF for a fictitious booking (Medium); `/messages/new?with=&as=provider`'s role param is trusted without checking the caller actually has a `provider_profiles` row (Low). Everything else audited (reviews, messages/conversations RLS, browse/profile/dashboard selects, service-role usage) passed — evidenced with live query results in the audit doc. Lint/`tsc`/`next build` all pass.
Booking state machine integrity (PRD-054)
One canonical graph, `src/lib/bookingStateMachine.ts` (client-safe, no Supabase import — states/transitions/actors/preconditions as plain data, `canTransition()`/`isCompleteDue()` for pre-flight UI checks) plus a server-only executor, `src/lib/bookingTransitions.ts` (mirrors the availability.ts/availabilityState.ts split): `transitionBooking(id, to, actor)` does the compare-and-swap update, returns a typed `TransitionResult` (`{ ok: true }` or `{ error, code: 'stale'|'not-authorized'|'precondition-not-met' }`), and is the single dispatch point for which `notifyBooking*` (if any) a transition fires via `after()` — no server action hand-picks a notify call anymore. `acceptBooking`/`declineBooking`/`completeBooking` (`dashboard/actions.ts`), `cancelBooking` (`dashboard/bookings/actions.ts`), and the PRD-038 suspend-user auto-decline (`admin/users/actions.ts`, actor `'system'`) all route through it — five call sites, one helper, no behavior change on the happy path. New `TransitionForm` client wrapper (`src/components/bookings/TransitionForm.tsx`, `useActionState`) replaces the plain `<form action=…>` on every accept/decline/cancel/complete button (RequestCard, CompleteBookingButton, OwnerBookingCard, the booking detail hub) so a stale-state failure now shows a toast ("This booking has changed — refresh.") instead of silently no-oping. New migration `20260709120000_bookings_complete_due_guard.sql` closes a real gap PRD-053 didn't: `guard_booking_transition()` now also rejects `accepted→completed` before `scheduled_end` (or `scheduled_start` for point-in-time services) — previously only UI-disabled (`completeDue`), never enforced server-side; verified live via Supabase MCP inside rolled-back transactions (early-complete correctly rejected, due-complete correctly allowed) and confirmed safe against the 2 real rows (neither `completed`, so nothing retroactively breaks). Backfill/anomaly check on the live table (2 rows): no status outside the defined graph, no reachability issue. **Not verified here**: the actual two-tabs-race UI scenario (accept vs. cancel in two real browser sessions) — the compare-and-swap logic is verified by direct SQL/code inspection, not by driving two live sessions.
Double-booking & capacity guard (PRD-055)
PRD-019 only checked availability at *request* time — nothing stopped a provider from then *accepting* two overlapping exclusive bookings (e.g. two different overnight stays covering the same dates) from different owners. New migration `20260710120000_bookings_double_booking_guard.sql`: `services.capacity smallint default 1` (requirement 1 — a future N>1 "I can board 2 dogs" is schema-ready, not implemented); `bookings.is_exclusive`/`booking_range` (a plain trigger-populated pair, not generated columns — an EXCLUDE predicate can't join `services` to read capacity, and timestamptz→date isn't IMMUTABLE, so a generated column was never an option), set once at insert by `set_booking_exclusivity()` and never touched again (mirrors the service snapshot convention); and the actual guard — a `btree_gist` `EXCLUDE USING gist (provider_id WITH =, booking_range WITH &&) WHERE (status = 'accepted' AND is_exclusive)` constraint, a real cross-row guarantee independent of PRD-054's single-row CAS (which can't stop two *different* booking ids from both committing an accept). `get_booked_dates` (PRD-018/019's availability grid) now reads `bookings.is_exclusive` directly instead of re-deriving the same "per_night/per_day" rule a second time (requirement 6) — one source of truth, not two rules that could drift. Verified live via Supabase MCP: the full migration DDL plus four test scenarios ran inside one rolled-back transaction (nothing committed) — an overlapping accepted per_night booking correctly rejected with `23P01 exclusion_violation`; a non-overlapping one succeeded; two overlapping accepted per_walk bookings (non-exclusive) both succeeded; and the real accept-flow code path (`UPDATE ... SET status = 'accepted'` on two overlapping *pending* bookings, exactly what `transitionBooking()` does) correctly accepted the first and rejected the second, which stayed `pending` rather than being corrupted — confirmed after via a fresh query showing zero leftover columns/constraint/extension on the live project. `transitionBooking()` (`src/lib/bookingTransitions.ts`) gained a `'conflict'` `TransitionResult` code: on `23P01` it looks up which accepted booking collided and returns dates + owner first name, surfaced through the existing `TransitionForm` toast (requirement 3/4) with no new UI plumbing. Provider dashboard requirement 5 — `getProviderBoard()` (`src/lib/bookings.ts`) flags any pending request that already overlaps an accepted exclusive booking (one read-time comparison over data it already fetches, no extra query); `RequestCard` renders that as an inline warning (dates + owner first name) with a one-click "Decline this request" button, same warning-banner style as `VerificationNoticeBanner`. **Not verified here**: the actual two-real-browser-tabs concurrent-accept race (no way to hold two genuinely concurrent transactions open across sequential MCP tool calls) — the exclusion constraint's atomicity under real concurrency is the same standard Postgres guarantee a unique index gives, which is the entire reason this is a DB constraint and not another app-level CAS; also not independently re-verified: the actual UI conflict banner/toast rendering in a real browser session.
Storage
Avatar uploads live: public avatars bucket + owner-only storage RLS, client-side square resize (≤512px), shared Avatar primitive with initials fallback in nav + profile. Provider photo gallery (PRD-024) live: public provider-photos bucket keyed by provider_profiles.id, up to 8 photos, client-side downscale (≤1600px), reorder/delete at /dashboard/photos, lightbox gallery on the public profile. Upload/resize/remove logic shared via src/lib/storage.ts. Pet photos still pending.
Booking lifecycle
Request → accept/decline → complete/cancel; owner "My bookings" view. Booking detail hub at /dashboard/bookings/[id] (PRD-041): one shared-layout page for either party — counterpart card, pet details incl. special notes, schedule breakdown, status timeline, and a single state-dependent action bar (accept/decline, cancel, mark complete, download contract, leave a review). Non-participants get a 404 (RLS-scoped read). Dashboard rows and every PRD-031/032/033 email/notification deep link here now instead of the bookings list pages.
Reviews persistence
Owners review completed bookings; rating cached on provider.
Rebook & repeat
One-click "Book again" (PRD-043) from a completed booking's detail page, the bookings list rows, and right after leaving a review — pre-fills the request flow via plain query params (service + pet, no new state machinery), landing straight on the Dates step. Price is always freshly fetched from the nanny's current services (never the old booking's snapshot), with a subtle note if it changed since. "Your recent nannies" strip on /dashboard/bookings surfaces the 3 most-recently-completed distinct providers. A discontinued service or a suspended/removed nanny (already excluded from provider_profiles reads by PRD-038's RLS) falls back to a "view profile" link or a friendly notice instead of a dead link.
Messaging
Lean 1:1 owner ↔ nanny chat on Supabase Realtime. Contextual entry points everywhere a counterpart appears — profile, booking rows on both dashboards, request confirmation — all routed through one `/messages/new?with=` resolver so guests land back in the thread after signing in, and booking-context links pre-fill (not send) a template message (PRD-028). Unread counts + read receipts (PRD-027): bold unread inbox rows, nav Messages badge (refetches on route change), opening a thread bulk-marks the other party read via a `mark_conversation_read` security-definer RPC (replaces the old client-writable UPDATE policy), and a single "Read" marker under your last message once seen.
Block & report
Block/unblock + report (spam/harassment/scam/other, optional details) from the nanny profile menu, the thread header, and review cards (PRD-029). A block stops sends both ways — `messages_insert` gains a security-definer `is_blocked()` check — and hides the blocked provider from the blocker's browse/featured results; existing bookings are untouched. Reports land `status='open'`, insert-only by the reporter, readable only by admins — the queue PRD-039 will read from.
Notifications
Email infra live (Resend via src/lib/email/, dev console fallback). Welcome email on signup confirmation, all four booking-lifecycle emails (new request → provider, accepted/declined/cancelled → the other party), plus scheduled reminder + review-nudge emails (PRD-032): an hourly Vercel cron route (`/api/cron/booking-notifications`, `vercel.json`, secret-header protected) sends a 24h-before reminder to both parties on accepted bookings and a 24-48h-after-completion review nudge to the owner when no review exists yet, each idempotent via a `notification_log(booking_id, kind)` ledger (deny-all RLS, service-role only). In-app notification center live (PRD-033): a `notifications` table (owner-only read RLS, `read_at` only ever changed via security-definer RPCs) is written by a shared `notify()` dispatcher alongside every booking-lifecycle email plus a review-received moment email never covered; nav bell (desktop) + mobile link show unread count and a latest-10 dropdown, `/notifications` has full paginated history, and the PRD-032 cron now also prunes read notifications older than 90 days. Notification preferences live (PRD-034): `profiles.email_prefs` jsonb (booking_updates/reminders/review_nudges default on, product_news default off), toggles at `/dashboard/settings`, and `sendEmail()` in src/lib/email/index.ts is the single choke point that checks a category against them before sending — no per-call-site checks. New-request-to-provider emails are exempt (not opt-outable) and so is the one-time post-signup welcome email; booking-accepted/declined/cancelled are `booking_updates`, the PRD-032 cron reminder/review-nudge sends now actually check `reminders`/`review_nudges` (resolving that PRD's TODO) and no longer count a skipped send in its stats. Every email footer carries an HMAC-signed one-click `/unsubscribe?token=` link (requires the new `EMAIL_UNSUBSCRIBE_SECRET` env var — human setup item) alongside a "manage all" link to settings; without that secret set, sends still go out but the footer only shows the manage-all link.
Care updates journal
Photo/text journal during a booking (PRD-042), on the booking detail hub. New `booking_updates(booking_id, author_id, body, photo_urls, created_at)` table — RLS: insert by the assigned provider only while `accepted`, read by either participant; no update/delete (part of the permanent booking record once posted). First private-bucket feature: `booking-photos` (`public: false`), path `{booking_id}/…`, storage RLS mirrors the same accepted-provider insert / either-participant read rule. Signed URLs (~1h expiry) are resolved server-side at render via a new `createSignedUrls` helper in src/lib/storage.ts (takes the caller's own server client rather than constructing one, so it never imports `@/lib/supabase/server` itself and stays safe to share with client-component uploaders) — only paths are ever persisted. Provider composer (text and/or up to 4 client-downscaled photos, reusing `downscaleImage`) shows only from acceptance until completion; owner sees the same reverse-chron feed with the PRD-024 `Lightbox` reused for photo viewing. Owner is notified per update: in-app (`care_update_posted`, PRD-033) + a new opt-outable `care_updates` email category (PRD-034, default on).
Admin
Admin shell & RBAC
Protected /admin area (PRD-035): `requireAdmin()` server helper 404s guests and non-admins at the layout (not 403, so the route doesn't advertise itself) — reads the existing `profiles.is_admin` flag through the normal RLS-respecting client, no service-role key. Sidebar + admin badge use a new token-based accent (`--color-admin-accent`), not a new theme. Landing page shows placeholder counts (real metrics in PRD-040); nav items for Catalog/Verification/Users/Reports stay hidden until PRDs 036–039 ship each one. Admins are appointed manually via SQL — no grant UI in v1.
Admin: service catalog management
/admin/catalog (PRD-036): table of every service (icon, name, slug, pricing unit, sort order, active, provider count) with create/edit/deactivate/delete. Slug auto-derives from name and is immutable after creation (DB unique constraint is the collision backstop, surfaced as a friendly error). Deactivate flips `services.active` — already the only gate the home page and browse page read (`getServiceCatalog()`); hard delete is blocked (button disabled, counts shown) whenever any `provider_services` or `bookings` row references the service, on top of the existing FK `on delete restrict`. No new migration: the init migration already ships `services_admin` (admin-only write RLS) alongside public read. Former known gap now resolved by PRD-052: the onboarding and request-flow forms used to be hardcoded to two service types (`walking`/`overnight`) independent of this table — they now read the live catalog, so an admin-added or -deactivated row propagates with zero code changes.
Admin: nanny verification queue
/admin/verification (PRD-037): unverified-providers queue (oldest first — profile summary, services+prices, photos, "view public profile") plus a verified-providers list for revoke. New migration adds `verified_at`/`verified_by`/`verification_checklist jsonb`/`verification_note text` to `provider_profiles`; extends the existing `guard_provider_verified` trigger (init migration) to guard the new columns the same way it already guards `verified`, and adds a new admin-only UPDATE RLS policy (multiple permissive policies OR together, so the owning provider can still update their own bio/etc. while only an admin can touch any row's verification fields). Verify requires every checklist box ticked (defines "verified" operationally) and clears any prior note; Request changes/Revoke store a note reused for both (shown as a `VerificationNoticeBanner` on `/dashboard/nanny`, non-dismissible). New email templates (`provider-verified`, `provider-verification-note`, both non-opt-outable) + two new in-app notification kinds (`verification_approved`, `verification_note`) via `src/lib/email/verification.ts`, following the PRD-031/033 notify pattern. Badge rendering itself untouched — `NannyCard`/profile page already read `provider_profiles.verified` (PRD-023), so the badge goes live as soon as an admin verifies.
Admin: user management & suspension
/admin/users (PRD-038): searchable list (name or email — email comes from `auth.users` via the service-role client, never mirrored into `profiles`) with roles, joined date, bookings/reports-against counts and status; detail view adds profile data, provider profile, recent bookings, reviews written/received and reports both directions. New `profiles.suspended_at` (guarded admin-only by a `guard_profile_suspension` trigger, same pattern as `guard_provider_verified`) drives enforcement everywhere: `provider_profiles_read` RLS now excludes a suspended provider from public browse/profile reads (both already treat "no row" as not-found, so one policy change covers both), and `src/lib/supabase/middleware.ts` signs a suspended user out on every request and `src/middleware.ts` redirects to a new public `/suspended` notice page (`getViewer()` mirrors the check as defense in depth). Suspending auto-declines the account's pending bookings on either hat it wears, with a `bookings.system_note` recording why; new admin-bypass `bookings_admin_read`/`bookings_admin_update` RLS policies back this and the detail view's booking history. Unsuspend only clears `suspended_at` — auto-declined bookings stay declined by design. Delete reuses PRD-016's exact deletion path, now factored into `src/lib/accountDeletion.ts` and shared by both the self-serve and admin-initiated flows; the admin flow adds a double confirm (type the account's exact email, then a separate final click) on top. Every suspend/unsuspend/delete writes a `moderation_actions(admin_id, subject_type, subject_id, action, reason, created_at)` row — a free-form `action` column and no FK on `subject_id` (same polymorphic convention as `reports`) so PRD-039's report/content moderation actions can log to the same table without a migration.
Admin: reports & content moderation
/admin/reports (PRD-039): the PRD-029 report queue finally has a consumer — open reports oldest-first with an inline preview (reported user/review/message + context link), filterable by subject type and status, closable via Dismiss (unfounded) or Resolve with a recorded action (remove review, suspend user, or warned/other) and a required note; every closure writes `reports.status/resolution_note/resolved_by/resolved_at` plus a `moderation_actions` row (suspend reuses PRD-038's exact `suspendUser` action so it logs identically and shows up on that user's own admin page). Reviews gain a soft-delete (`removed_at`/`removed_by`/`removal_reason`, guarded admin-only the same way `suspended_at` is) — `reviews_read` RLS now hides removed reviews from everyone but admins, and `refresh_provider_rating()` excludes them from the `rating_avg`/`review_count` recompute, so removing a 5★ review immediately corrects the provider's cached aggregate. **Ad hoc fix (2026-07-24)**: this trigger was written invoker-rights, not `security definer` — `provider_profiles` RLS only lets the provider (or an admin) UPDATE their own row, so the trigger's internal recompute silently no-op'd for every review authored by someone other than the provider (i.e. almost every real review), found live during PRD-082. Fixed in `20260724120000_fix_provider_rating_trigger.sql` (adds `security definer set search_path = public`, matching `refresh_place_rating()`'s already-correct pattern) plus a one-time backfill recomputing every `provider_profiles` row from live `reviews` data. Message reports get a narrow, deliberate RLS exception (`messages_admin_report_context`, scoped to conversations with an *open* message report via a security-definer helper — the same `is_blocked()`-style technique, needed to avoid Postgres' "infinite recursion in policy" error from a table referencing itself) so the queue can show the reported message ± 10 surrounding messages; access closes the moment the report is no longer open. Resolution notifies the reporter with a new content-free `report_reviewed` notification kind (PRD-033) — no details disclosed, per the PRD.
Admin: platform metrics dashboard
/admin (PRD-040) replaces the PRD-035 placeholder with real numbers: headline stat cards (total users, verified/total providers, completed bookings 30d with a vs-prior-30d delta on `StatCard`'s new optional `delta` prop, open reports), a bookings-by-status (30d) breakdown, and a "Vision metrics" section tracking all five of the product vision's success metrics — a 6-month completed-bookings bar chart (zero-filled months, not gaps/NaN), request→accept rate + median time-to-response (`updated_at − created_at` on accepted/declined bookings, 30d), provider activation % (≥1 `provider_services` row AND `profiles.avatar_url` AND (an `availability_slots` row OR ≥1 booking)), repeat owner rate (owners with ≥2 completed bookings / owners with ≥1), and review coverage (live reviews / completed bookings, 30d). Everything comes from one new `admin_metrics()` SQL RPC — `security definer`, checks `is_admin()` itself and raises otherwise (no RLS policy exists for a jsonb-returning function to attach to), so the page costs a single round-trip; every sub-metric's equivalent hand-run SQL query is documented directly in the migration's comments for spot-verification. New `MonthlyBarChart`/`MetricProgressBar` components render everything from `--color-admin-accent` + spacing/radius tokens — no charting library.
ID verification upgrade (PRD-067)
PRD-037's badge used to attest a profile-plausibility checklist only, never identity — this PRD adds a real government-ID + selfie step, admin-reviewed, with strict data minimization: the actual document images are deleted right after the decision, and only a retention note survives ("Identity document reviewed (approved) on {date} by {admin}."). New PRIVATE `id-documents` storage bucket (PRD-042 pattern): the owning provider may INSERT/UPDATE under their own `{provider_profile_id}/` prefix but has NO select policy at all — a non-admin, including the document's own provider, can never mint a signed URL or read the object directly; only `public.is_admin()` gates SELECT/DELETE. Two-tier column split on `provider_profiles`: `id_document_path`/`id_selfie_path`/`id_submitted_at` stay provider-writable (like bio/location always have been); `id_verified_at`/`id_verified_by`/`id_verification_note` join `verified_at`/etc. in the existing `guard_provider_verified()` admin-only guard trigger. New checklist item "identity matches profile" (`VerificationChecklist.identityMatchesProfile`) — genuinely required to verify a NEW provider (same `allChecked` gate PRD-037 already had), disabled in the UI and re-validated server-side (`revalidateChecklist` in the verification actions) whenever the provider hasn't actually submitted docs, so "no ID, no badge" holds for every provider verified from here on without any new DB constraint. **Data minimization mechanism** (requirement 3, "enforced by the PRD-032 cron, audited"): `consumeIdDocuments` (`src/lib/idVerification.ts`) is the synchronous fast path — called from every admin decision (verify/request-changes/revoke/the new straggler review) while docs are on file, best-effort deletes the storage objects immediately and writes the retention note; `sweepLeftoverIdDocuments`, wired into the existing PRD-032 hourly cron, is the actual *enforcement* backstop the requirement asks for — finds any provider whose decision was recorded (`id_verified_at` set) but whose paths are still non-null and deletes them via the service-role client, independent of any admin session. Both paths log to a new admin-only-readable `id_verification_deletions` audit table (provider id + timestamp + which path triggered it — never document content) plus a structured console line, satisfying "audited" without retaining anything sensitive. **Grandfathering** (requirement 6): a provider already `verified = true` before this shipped gets 60 days computed from `greatest(their existing verified_at, this feature's 2026-07-17 launch date) + 60d` (`idGrandfatherDeadline`, `src/lib/idVerificationState.ts`) — nobody is retroactively "overdue" the day this ships, and everybody gets the full 60 days regardless of how long ago they were originally verified; surfaced as a non-blocking nudge banner (`IdVerificationCard`, same dashboard area as `VerificationNoticeBanner`) and an admin-only awareness list ("ID verification not started", soonest-due first) on `/admin/verification` — never a hard cutoff or auto-revoke. Since grandfathered/stragglers don't appear in the normal unverified queue (`verified = true` already), a dedicated `reviewIdentityDocuments` action + `IdReviewCard`/"ID review needed" section handles their catch-up submission without touching `verified`/the general checklist at all. **Badge copy** (requirement 5): a new `Nanny.identityVerified` flag (true only when `id_verified_at` is set with no outstanding note — deliberately NOT just `verified`, so a grandfathered provider's badge doesn't falsely claim an ID check that hasn't happened yet) drives a new `title="Identity verified by PetNanny"` tooltip on the badge (`Badge` primitive gained a `title` prop) across `NannyCard` and the profile page; both `messages/en.json`/`messages/lt.json` gained real `verifiedTooltip` copy (`"Tapatybę patvirtino „PetNanny“"`). **eIDV evaluation** (requirement 4): `docs/decisions/eidv-provider-evaluation.md` — iDenfy (Vilnius) vs Veriff (Estonia) compared on cost/integration shape/jurisdiction fit; recommendation is explicitly NOT to integrate either for v1 (manual admin review is the right mechanism at current volume/cost), with iDenfy named as the natural first choice if/when a concrete trigger (backlog, volume, or a missed-forgery incident) is hit. **Verified live** via Supabase MCP inside a rolled-back transaction against synthetic admin/provider-A/provider-B identities (namespaced `f067f067-…` UUIDs, pre-checked against collision with real seeded accounts): a non-admin provider could not SELECT their own uploaded document (0 rows) nor any other provider's; an admin SELECT correctly saw it; the owning provider could INSERT under their own prefix, a different non-admin could not insert under someone else's (rejected, `insufficient_privilege`); the guard trigger correctly reverted a non-admin's attempt to forge `id_verified_at`/`id_verified_by`/`id_verification_note` on their own row while leaving the self-editable `id_document_path`/`id_selfie_path`/`id_submitted_at` tier genuinely provider-writable; an admin's write to the admin-only tier succeeded; the new `id_verification_deletions` audit table was correctly unreadable by a non-admin and readable by an admin. One real platform behavior discovered mid-verification and worked around: Supabase's `storage.protect_delete()` trigger blocks ALL direct SQL `DELETE` on `storage.objects` regardless of role ("Use the Storage API instead") — the DELETE RLS policy's predicate was confirmed correct by introspection (`pg_policies`) and by the identical-shaped SELECT policy's live pass/fail behavior, since the app's real deletes always go through the Storage API, never raw SQL. Also discovered mid-verification: `auth.users` INSERT fires the project's existing `on_auth_user_created` trigger, auto-creating a `profiles` row with `is_admin` defaulted false — the first test run silently produced a non-admin "admin" until the synthetic-data upsert was fixed to overwrite on conflict rather than `do nothing`. Lint/`tsc`/`next build` all pass. **Not verified here** (no `.env.local`/live storage upload possible in this sandbox): a real browser upload of an actual ID photo/selfie, the full admin approve→cron-sweep-confirms-deletion click-through, and the acceptance criterion's own explicit ask for a lawyer's sign-off on retention/privacy-policy wording — flagged plainly as still needed before this ships, same honesty bar as PRD-061's unreviewed contract terms.
Places directory foundation (PRD-076)
First PRD of Phase 11 (Vilnius directory & local content) — schema + admin tooling only, no public route yet (that's PRD-077 vet clinics/PRD-079 breeders/PRD-083 pet-friendly spots, all building on this). New `place_categories` catalog (slug/name/icon/sort_order — shaped exactly like `services`, no `active` flag since an unwanted category is simply never assigned) and `places` (category_id, name, slug, description, address, city, phone, website, email, photo_urls, `attributes jsonb`, `status` draft/published/archived, created_by, timestamps) — mirrors `services`' conventions closely per the PRD's own technical note: slug immutable after creation (app-layer, same as PRD-036), `status` is this table's equivalent of `active`. RLS: public read of `published` only (`status = 'published' or public.is_admin()`, same shape `reviews_read`'s `removed_at is null or public.is_admin()` established) + a `places_admin`/`place_categories_admin` `for all using (is_admin()) with check (is_admin())` pair, exact shape of `services_read`/`services_admin`. Per-category `attributes` validated in TypeScript only, never a DB CHECK — PRD-060's `careProfile.ts` precedent, followed closely: `src/lib/placeAttributes.ts` has a zod schema + declarative field list per seeded category (vet_clinic, breeder, park, cafe, groomer, pet_store, hotel), every leaf optional. **The extensibility acceptance criterion is the literal mechanism, not just a claim**: `PLACE_ATTRIBUTE_SCHEMAS` is keyed by category slug — a category with no entry (proven live: a synthetic `training_school_test` category inserted by plain SQL, zero code touched) still gets full core-field CRUD, publish/unpublish and photos from the admin UI; `parsePlaceAttributes`/`placeAttributeFields` both degrade to a passthrough/empty-section rather than blocking anything for an unrecognized slug. `/admin/places` (list: category/status filters + search via GET params, same convention `/admin/users`' search box established) + create/edit form (`PlaceForm.tsx`, shared core fields + a dynamic attribute section switched by the selected category's live slug) + publish/unpublish/archive (`PlacesTable.tsx` row actions and a duplicate `PlaceStatusBar.tsx` control on the edit page itself) + photo upload reusing `src/lib/storage.ts` exactly the way PRD-024's `PhotoGalleryManager` does, against a new public `place-photos` bucket — simplified versus `provider-photos`' owner-path-join RLS since every writer here is an admin regardless of *which* place, so storage policies are a plain `is_admin()` check with no path-to-row join needed. **10 real, phone/address-verified Vilnius vet clinics** as the required acceptance dataset (`supabase/seed.sql`, matching the `services` catalog's own seeding precedent exactly — not auto-run against the remote project, same as `services`): every name/address/phone/website was read directly off the clinic's OWN website (individually fetched, not a third-party directory) — Vilnius Vet/Žvėryno veterinarijos gydykla (Filaretų g. 35), Veterinarinės diagnostikos ir chirurgijos klinika (Pašilaičiai), Naujosios Vilnios veterinarijos gydykla/Vetpagalba, TOTO Zooveterinarijos centras, Vilniaus veterinarijos gydykla, 8 Drambliai's referral hospital (Tolimoji g. 2B, confirmed 24/7 directly from its own site — its phone, +370 612 88788, differs from a third-party directory's listed +370 612 88288, a live example of exactly the directory-error risk PRD-072's research already flagged), DR.VET Lazdynų gyvūnų ligoninė, Antakalnio veterinarijos gydykla, Begemotas, and AD Letenėlė. A field (hours/district/email) was only entered if the clinic's own page actually stated it — several plausible-looking facts that only appeared in search-engine summaries (not the primary fetch itself) were deliberately left out rather than presented as verified. **Verified live** via Supabase MCP, `apply_migration` never called — the full migration DDL + RLS was instead run inline inside one rolled-back transaction per check (this session's hard rule: schema changes are files under `supabase/migrations/` only, applied by CI/CD on merge): an anonymous/public role read exactly the published row and zero draft rows; a real non-admin authenticated profile's INSERT was rejected outright by RLS (`42501`) and its UPDATE attempt silently affected 0 rows (and the row stayed invisible to them afterward, still `draft`); a real admin profile could read both statuses, update, and insert; the extensibility proof (new category + a place under it, zero schema/code change) succeeded. `seed.sql`'s exact new blocks (categories + all 10 clinics) were also dry-run inside a separate rolled-back transaction and confirmed to insert cleanly (7 categories, 10 published places, 10 distinct slugs) with zero residue after rollback — since the `places`/`place_categories` tables don't exist on the live project yet (the migration hasn't merged), the 10 clinics could NOT be landed as a real live insert this session (unlike some prior PRDs' seed data) — they're staged in `seed.sql` for a human to run once this migration deploys to production, per the standard `services`-catalog precedent. Lint/`tsc`/`next build` all pass. Not verified here: a real two-browser-tab click-through of the admin list/form/photo-upload UI, and an actual phone call to each clinic (address/phone were cross-checked against the clinic's own site, not by calling — same honesty bar PRD-072's vet-clinic research already set).
Public vet clinics directory (PRD-077)
The first PUBLIC page on PRD-076's `places` schema — `/vilnius/vet-clinics` (listing, GET-param filters for district + "open 24/7") and `/vilnius/vet-clinics/[slug]` (detail). Reads live in a new `src/lib/vetClinics.ts`, deliberately NOT added to `src/lib/placeCatalog.ts` — that module is imported by 'use client' admin components (`PlaceForm.tsx` etc.), so a server-only `@/lib/supabase/server` import there would break their bundling; `vetClinics.ts` is this PRD's own server-only sibling, same split `providers.ts` already models. Every read is `.eq('status', 'published')` explicitly, on top of (not instead of) the `places_read` RLS policy already gating anonymous reads to published-only — two independent layers, per the PRD's own "confirm via RLS + a not-found check, don't just trust it" instruction; the `[slug]` page 404s (`notFound()`) whenever the query returns nothing, so a draft/archived slug is unreachable either way (RLS hides the row entirely for an anon visitor; the explicit status filter is defense-in-depth if RLS were ever misconfigured). District/24-7 filtering is a plain JS `.filter()` over the small (dozens-of-rows) published set rather than a second DB round trip per filter combination — filter state lives in the URL (`src/lib/vetClinicFilters.ts`, same parse/serialize split as `browseFilters.ts`) so results stay shareable/bookmarkable, and the actual controls (`VetClinicsClient.tsx`) navigate via `router.push` exactly the way `BrowseClient.tsx` already does. Detail page renders description, address, a real `tel:` tap-to-call link, website, hours and services (plain text — PRD-078 structures these later, explicitly not built here), and the "last verified by PetNanny on {date}" stamp (`attributes.verifiedOn`, formatted via `formatDate`) — falls back to an honest "not yet verified" line rather than hiding the block when a clinic has no stamp yet. Marketplace bridge (requirement 4, light-touch v1 per the PRD): a sidebar "Need a pet sitter in Vilnius?" card linking to `/browse`, nothing more — full cross-linking is PRD-084's job. Navigation (requirement 3): "Explore Vilnius" added to both `Nav.tsx` and `Footer.tsx`, pointing straight at `/vilnius/vet-clinics` (the only live category so far); a modest home-page teaser `Card` (outline-button CTA, muted background, placed after the Top Nannies section — deliberately last, so it never competes with the hero/services/top-nannies CTAs above it for attention). Bilingual page chrome via new `next-intl` namespaces (`vetClinics`/`vetClinicDetail` + `metadata.vetClinics`/`vetClinicDetail`, plus new `nav.exploreVilnius`/`footer.exploreVilnius`/`home.vilniusTeaser` keys) in both `messages/en.json` and `messages/lt.json` — hand-written Lithuanian, not machine-translated, not yet native-speaker-proofread (same disclosed gap as PRD-047/061/071/072); the clinic data itself (names/addresses) is never translated, per the PRD's own note. `export const dynamic = 'force-dynamic'` on the listing page (same convention `/browse` uses) so `next build` never attempts to prerender against `places` — relevant while PRD-076's migration still hasn't deployed; `vetClinics.ts`'s `hasEnv`-guard-plus-try/catch (mirroring every public read helper in `providers.ts`) additionally means a missing-relation DB error degrades to an empty listing/404 rather than a 500, both at request time and if anything ever did try to touch it at build time. **Content grown from PRD-076's 10 clinics to 26** (`supabase/seed.sql`, new block, `on conflict (slug) do nothing`) — the required 15+ additional Vilnius vet clinics, each researched the same way as PRD-076/072's precedent: fetched from the clinic's OWN website, never a third-party directory. New clinics include 4 more DR.VET branches (Žalgirio/Šeškinės/Vytenio/Jeruzalės — beyond the Lazdynų hospital PRD-076 already seeded), Vilnius Vet's other two branches (Pašilaičiai, Kęstučio/Žvėrynas — beyond its Filaretų branch), Ritos Žilinskienės veterinarijos klinika (Fabijoniškės), Rekso gydykla, Vilniaus veterinarijos klinikinė ligoninė (Naujoji Vilnia), 8 Drambliai's separate (non-24/7) Pilaitė day clinic, Buivydiškių veterinarijos klinika (Justiniškės), Vetpulsas' two branches (Pašilaičiai, Balsiai), and Vilniaus Veterinarija/CanisFelis's three branches (Erfurto/Lazdynai, Viršuliškių, S. Žukausko). **Candidates deliberately dropped, not fabricated**: AH Fiona and Žirmūnų veterinarija (no independently reachable primary site — only third-party directory listings existed), VetBalsas and the original vetgyd.lt-branded Fabijoniškių clinic (both returned TLS/certificate errors on fetch), and VETA/Kalvarijų g. 62 (its own site's content didn't cleanly match the clinic being researched, so it was dropped rather than guessed at). `attributes.district` is only set when a street name unambiguously names a real Vilnius district (e.g. "Šeškinės g." → Šeškinė) or the clinic's own page explicitly labels the branch (Vilnius Vet's own "Kęstučio (Žvėryne)" heading) — left blank otherwise rather than guessed. Same explicit, disclosed limit as PRD-072/076: **not phone-verified by an actual call** — this environment cannot place phone calls, stated plainly (`vetClinicDetail.verifiedDisclaimer`, both languages) rather than silently skipped or falsely claimed. **Verified** via Supabase MCP (`apply_migration` never called): since `places`/`place_categories` still aren't live (confirmed via `list_tables`/`list_migrations` — the PRD-076 migration hasn't merged), the full migration DDL + both seed.sql blocks (26 vet-clinic rows total) were run inline inside a rolled-back transaction, confirming clean execution with no SQL errors and the expected shape (26 rows, 1 with `is24Hour: true` — the original 8 Drambliai referral hospital — 25 without, including the new non-24/7 Pilaitė branch); a second rolled-back transaction confirmed zero residue (`to_regclass` returns null for both tables outside any transaction) and that status filtering (`published`/`draft`/`archived`) behaves as `getPublishedVetClinics`/`getVetClinicBySlug` expect. Lint/`tsc`/`next build` all pass. Needs human: run `seed.sql` against production once PRD-076's migration deploys, an actual phone-verification pass, and a real mobile click-through of the tap-to-call link and filter controls.
Clinic services, hours & emergency flags (PRD-078)
Structures vet_clinic attributes so "open now" and a real services filter are possible, replacing PRD-077's free-text `hours`/`services` strings. `src/lib/placeAttributes.ts`'s `vetClinicSchema` gained: `hours` (per-day `{enabled, from, to}`, keyed `Mon`..`Sun` — deliberately reuses `src/components/ui/WeeklySchedule.tsx`'s own storage shape verbatim rather than inventing a second one, so the admin form renders that exact battle-tested grid component instead of a rival widget), `holidayNote` (free text — the PRD's own non-goal says a note is honest enough, no calendar automation), `services` (a closed `z.enum` array from the fixed 9-item PRD list: consultations/vaccination/surgery/dentistry/imaging/lab/exotic_animals/house_calls/grooming), `servicesExtra` (free text for anything outside that list), plus the pre-existing `is24Hour`/`emergency` booleans (now the unconditional "skip the hours grid entirely" case — a 24/7 clinic's `hours` are left unset, not filled with a synthetic all-day range, since `WeeklySchedule`'s own time-picker only covers 06:00–23:30 anyway). New `src/lib/vetClinicHours.ts` (pure, no Supabase/Next import): `isVetClinicOpenAt(hours, is24Hour, at)` computes open/closed at a given instant using `Intl.DateTimeFormat` with `timeZone: 'Europe/Vilnius'` (DST-correct, never a manual UTC-offset), half-open `[from, to)` per day (a clinic closing "at 22:00" is NOT open at 22:00:00 sharp) plus a same-function check of the previous day's range in case it wraps past midnight; `summarizeWeeklyHours()` renders a grouped display string ("Mon–Fri 09:00–20:00, Sat 09:00–18:00") for the safety page. **Admin form** (`PlaceForm.tsx`): two new `AttributeFieldType`s, `weeklyHours` (renders `WeeklySchedule` directly — hidden with an explanatory note whenever `is24Hour` is on, since the grid is moot then) and `serviceChecklist` (a `Toggle` grid over the fixed 9-slug vocabulary) — real structured inputs, no raw JSON textarea. **Listing page**: new "Open now" toggle (alongside the existing "Open 24/7 only") and a `Chip`-based multi-select services filter (AND semantics — a clinic must match every selected chip), both URL-driven (`src/lib/vetClinicFilters.ts` gained `openNow`/`services` params) and computed against `openNow`/`services` already attached to each `VetClinicSummary` server-side in `getPublishedVetClinics()`; cards gained an Open now/Closed now badge next to the existing 24/7/emergency badges. **Caching tradeoff** (documented inline in the listing page): the PRD suggests short ISR over full `force-dynamic` where possible, but this page was already `force-dynamic` for two independent PRD-077 reasons (protects `next build` from touching `places` before its migration is live; `searchParams`-driven filtering) — and `createClient()`'s cookie access forces it dynamic regardless of the export anyway, so "open now" is recomputed fresh every request for free; no ISR reconciliation was needed today, flagged as a future PRD's tradeoff to revisit once `places` is permanently live and cost/latency actually motivates caching. **Detail page**: a real per-day hours table (today's row highlighted using the Vilnius-local day, not the server's own day) with a live "Open now"/"Closed now" state line above it, the holiday note, and the services checklist rendered as `Badge`s (fixed vocabulary) plus `servicesExtra` free text — no more raw attribute dump. **`/safety` swap** (requirement 4): `getEmergencyVetClinics()` (new, in `vetClinics.ts`) queries published vet_clinic places with `attributes.emergency === true`; the safety page uses this live result and falls back to the old hardcoded `EMERGENCY_VET_CLINICS` array (kept, not deleted, comment updated to explain why) only while the live query returns `[]` — which it will until a human runs `seed.sql` post-deploy, since `places` isn't live in production yet; a safety page silently going from "2 real clinics" to "none listed" during that gap would be a worse regression than serving the same hardcoded data slightly longer. **Backfill — all 26 seed clinics re-researched from their own primary sites** (not reused from PRD-076/077's free text), cross-checking real service pages this time, not just contact pages. Two real corrections this pass caught: DR.VET Lazdynų gyvūnų ligoninė's own site states "I–VII 00:00–23:59" (functionally 24/7) — it had NO `is24Hour`/`emergency` flag at all before this PRD, even though `/safety`'s hardcoded list (PRD-072) already independently named it 24/7; now correctly flagged so the live safety query actually surfaces it. Begemotas advertises "emergency care every day" but never states literal 24/7 coverage, so it is deliberately NOT flagged `emergency` — advertising urgent care within stated hours isn't the same claim as never closing. 24 of 26 clinics got real structured hours; 2 (Rekso gydykla, Vilniaus veterinarijos klinikinė ligoninė) have hours left honestly unset — their own primary sites (veterinarija.eu, veterinarijavilniuje.lt) don't publish an hours page and their `/kontaktai` paths 404, disclosed in `seed.sql`'s own comment and each row's `servicesExtra`, not guessed at. All 26 got a real services checklist from what IS confirmed on their sites; all 9 checklist slugs appear at least once across the dataset. **Verified**: all 26 `attributes` jsonb blobs parse as valid JSON; the full migration DDL + both seed.sql insert blocks were run inline inside a rolled-back Supabase MCP transaction (`apply_migration` never called) — 26 places inserted, 26 distinct slugs, `emergency_count`/`is24h_count` both 2 (the two hospitals), the 2 documented hours-gap clinics correctly identified by query, zero residue confirmed after rollback. **The PRD's own Tuesday-22:00 acceptance example**, run against the real backfilled data (not a synthetic fixture) via a standalone script exercising the exact `isVetClinicOpenAt` algorithm: at a real Tuesday 22:00 Europe/Vilnius instant, exactly the 2 24/7 clinics are open and all 24 others are closed; a same-script cross-check one minute earlier (21:59) confirms three late-closing non-24/7 clinics (Begemotas, DR.VET Žalgirio, Vetpulsas Pašilaičiai — all close at 22:00) are correctly open at 21:59 and closed at 22:00 sharp, proving the half-open-interval hours-grid path works correctly, not just the `is24Hour` shortcut. Lint/`tsc`/`next build` all pass. Deviations: `is24Hour`/`emergency` kept as two separate booleans (matching PRD-076/077's existing shape) rather than collapsed into one flag, since the listing page's 24/7 badge and the safety page's emergency filter are genuinely different questions (a clinic could in principle be 24/7 without accepting emergencies, or vice versa, though none in this dataset are). Needs human: run `seed.sql` against production once PRD-076's migration deploys (this now also delivers the PRD-078 structured backfill in the same seed run), an actual phone-verification pass, and a real mobile click-through of the new filters/hours table.
Breeders directory (PRD-079)
The second PUBLIC category on PRD-076's `places` schema, and the most reputationally sensitive one built so far — the PRD's own Problem statement warns "a naive breeder list becomes a puppy-mill catalog." Extended `src/lib/placeAttributes.ts`'s `breederSchema` (PRD-076's original stub had a comma-separated `breeds` string and no species/checklist) to a real `species` enum (dog/cat), a real `breeds` string array (so a multi-breed kennel's filter membership is a proper `.includes()` test, not string parsing), `kennelName`/`registry`/`yearsActive`/`healthTesting`/`social`, the `checkedOn` stamp (kept that key name from PRD-076's stub — PRD-077's `verifiedOn` pattern, just under the name already established), and a new `checklist` object (`BREEDER_CHECKLIST_ITEMS`: registeredKennel/noRedFlags/contactableVerifiable/healthTestingDisclosed) — the admin-side mirror of PRD-037's provider verification checklist the PRD explicitly asked for, stored in this category's own `attributes` jsonb rather than a new column since that jsonb bag already IS this entity's "verification_checklist" equivalent. Three new `AttributeFieldDef` types added to support it in `PlaceForm.tsx`: `select` (species dropdown), `stringList` (comma-separated text that reads/writes a real array — used for breeds), `breederChecklist` (a `Toggle` grid over the four fixed checklist items, same shape as PRD-078's `serviceChecklist` widget but keyed-object rather than array-valued). New server-only `src/lib/breeders.ts` (sibling of `vetClinics.ts`, same reason: kept out of the client-imported `placeCatalog.ts`) + `src/lib/breederFilters.ts` (species/breed URL params, same parse/serialize split as `vetClinicFilters.ts`). **Public pages**: `/vilnius/breeders` (species + breed filters, `BreedersClient.tsx` — same URL-driven-navigation pattern as `VetClinicsClient.tsx` — plus a static buyer-education block: 5 questions to ask a breeder, 5 red flags, and an adoption-first alternative box linking two real, independently verified Lithuanian shelters, Vilniaus gyvūnų globos namai and Lesė) and `/vilnius/breeders/[slug]` (detail page: breeds as badges, kennel/registry info, health-testing free text, contact/website/social, a "registration checked on {date}" stamp mirroring PRD-077's "last verified" card, and a standards-context sidebar box — "Listed, not endorsed"). New `/vilnius/breeders/standards` page ("How we list breeders" — same static-Card-of-rules shape as `/how-reviews-work`, PRD-071's precedent): states the three listing criteria (registered kennel required, no visible red flags at entry time, contactable/verifiable, health-testing disclosed) plus a delisting policy, and an explicit "listed, not endorsed" framing repeated on every touchpoint (listing page, both sidebar box and standards page itself) — never implying a platform guarantee. Linked from the listing page header and every breeder detail page, per the PRD's own requirement. **Breed filter correctness (the PRD's named acceptance bar)**: verified live via Supabase MCP inside a rolled-back transaction — a multi-breed kennel (e.g. "Ir Gojus", bred as Labrador Retriever + Flat-Coated Retriever) appears under a query for EITHER breed, not just its first one; a `jsonb ? text` containment query for "Flat-Coated Retriever" correctly returned both multi-breed kennels that carry it ("Ir Gojus", "Basteta"), and "Labrador Retriever" correctly returned all 5 kennels that carry it. **10 kennels researched and verified — the required floor, not padded past it**: every kennel was checked against ONE of (a) the Lithuanian Kennel Club's (LKD) own archived public announcements (archyvas.kinologija.lt), or (b) the kennel's own site explicitly stating a real FCI/LKD number, VMVT registration number, or named club membership — never a classifieds listing or a bare Facebook page selling puppies. 8 dog kennels (Ir Gojus, Okeanas Elite, Medeinė, Basteta, Iš Rojaus Slėnio — LKD's own record names this "the first officially registered LKD kennel in Lithuania" — Mažasis Aristokratas, Gerulis, Alto Paradas) and 2 cat catteries (Lordcoon*LT — Maine Coon, FIFe via the Bubastė feline society — and Katės Akis — British Shorthair, FIFe via the Selena club). Several other real candidates surfaced in research (older ~2006-2009 LKD archive announcements whose current 2026 active status couldn't be confirmed from any reachable site; breed-club member lists — taksuklubas.lt's dachshund kennels, prancuzai-mopsai.lt's French Bulldog kennels — naming a breeder and city but no specific registration number or independently reachable primary site) were deliberately DROPPED rather than padded in, exactly per this task's own instruction to under-ship honestly. `attributes.checklist.healthTestingDisclosed` is honestly `false` for 7 of the 10 where the source only offered general "healthy dogs" marketing language rather than a named testing procedure (elbow/hip/eye screening, a hereditary-disease panel) — true only for the 3 (Medeinė, Mažasis Aristokratas, Gerulis) whose own sites named an actual testing practice; `attributes.healthTesting` is left entirely UNSET (not filled with invented text) for the 2 catteries whose own sites disclosed no health information at all, same "disclose the gap, don't guess" rule PRD-077/078 used for missing clinic hours. **Shelters verified the same way**: Vilniaus gyvūnų globos namai (the official Vilnius city shelter, vggn.grinda.lt — already the source `safetyContent.ts`/PRD-072 links for lost/found animals, so this reuses a source this repo already trusted once) and Lesė (a real Vilnius/Kaunas rescue founded 2007, lese.lt, with a live adoption page) — both confirmed active via their own current sites, not a directory. Full bilingual LT/EN copy (`breeders`/`breederDetail`/`breederStandardsPage` + `metadata.breeders`/`breederDetail`/`breederStandards` namespaces, `nav.vilniusBreeders`/`footer.vilniusBreeders`), hand-written not machine-translated, not yet native-proofread — same disclosed gap as every prior Vilnius-guide PRD's LT copy. Nav/footer gained a second "Breeders directory" entry alongside PRD-077's "Explore Vilnius" (that link's own code comment already anticipated a second `/vilnius` category needing this). `export const dynamic = 'force-dynamic'` on both the listing and detail pages, same reasoning as PRD-077/078: `places` isn't live on production yet, and `searchParams`-driven filtering is inherently per-request anyway. Lint/`tsc`/`next build` all pass; all three new routes (`/vilnius/breeders`, `/vilnius/breeders/[slug]`, `/vilnius/breeders/standards`) render in the build output with no attempt to touch `places` at build time. Deviations: no litter-listing/availability/"contact to buy" functionality of any kind, per the PRD's own explicit non-goal and this task's reputational bright line — the directory shows kennel info only, nothing transactional. Needs human: run `seed.sql` against production once PRD-076's migration deploys; a second, independent LKD-familiar human cross-check of the 10 kennels' registration status before this goes live (this session's own verification, while sourced to LKD's own records/each kennel's own site, is still a single AI research pass on a reputationally sensitive category); real mobile click-through of the species/breed filters.
Directory SEO & structured data (PRD-080)
Makes PRD-077/079's vet-clinic/breeder directory pages crawlable and rich-result-eligible, reusing PRD-046's metadata layer rather than inventing a parallel one. New `src/lib/placeSeo.ts` (sibling to `site.ts`/`hreflang.ts`, per the PRD's own "schema stays in one module" note — PRD-046's own `buildNannyJsonLd` lives inline in the nanny page since it only ever needs one shape; the directory needs `VeterinaryCare` AND `LocalBusiness` plus a `BreadcrumbList` reused across four route files, so a shared module avoids writing that breadcrumb logic four times): `vetClinicJsonLd()`/`breederJsonLd()` (checked field-by-field against schema.org's own spec — `VeterinaryCare`, a `LocalBusiness` subtype, `PostalAddress`, `OpeningHoursSpecification` with `dayOfWeek`/`opens`/`closes`; breeders deliberately carry no `aggregateRating` — they don't have PetNanny's review system, and inventing one would contradict PRD-079's own "listed, not endorsed" framing), a `placeJsonLd()` dispatcher named per the PRD's literal instruction, and `breadcrumbJsonLd()`. New `Breadcrumbs.tsx` (`src/components/directory/`, tokens only, no ui primitive existed) renders the same hierarchy visibly next to the JSON-LD on all five directory pages (both listings, both details, the standards page) — Google's BreadcrumbList guidance expects the markup to reflect something actually on the page. **Requirement 4, the real architectural work**: both listing pages were `force-dynamic` (searchParams-driven filtering + protecting `next build` from touching `places` pre-migration) and both `[slug]` pages were implicitly dynamic (their reads used a cookie-backed `createClient()`, and `cookies()` is a Dynamic API that bails a route out of static rendering regardless of any `revalidate` export). Fixed at the root instead of papering over it: `src/lib/vetClinics.ts`/`breeders.ts` switched from that cookie-backed client to a plain anon-key client (same technique `sitemap.ts`'s `getPublicProviderIds()` already used) — every row read is already `status = 'published'`, i.e. public per RLS, so no auth context is lost; filtering moved client-side (`VetClinicsClient`/`BreedersClient` now read `useSearchParams()` themselves against the full fetched list, wrapped in `<Suspense>`) so the listing pages stop reading `searchParams` server-side. Both `[slug]` pages gained `generateStaticParams` wrapped in try/catch returning `[]` on any failure (missing table, missing env vars, anything) — deliberately defensive since this runs at `next build` time against whatever Supabase project the build targets, and PRD-076's migration still hasn't merged to production; an empty list means zero pages pre-rendered today, everything served on-demand via ISR fallback (`dynamicParams = true`, kept explicit though it's Next's default) — once the migration deploys and clinics/breeders are published, this same code starts pre-rendering real pages automatically, no further changes needed anywhere. **A second, non-obvious blocker found and fixed**: even after all that, the listing pages still built as fully dynamic (`ƒ`) — next-intl's static-rendering recipe requires `setRequestLocale(locale)` in *every* page calling `getTranslations()`, not just the root layout, or it silently resolves locale from request headers (a Dynamic API) and drags the whole route back to dynamic. Added `generateStaticParams` (locale-only) + `setRequestLocale` to all five directory pages; confirmed in the actual build output afterward — both listing pages and the standards page now show `●` with real `Revalidate` windows (60s vet-clinics, 1h breeders/standards) for both `/lt/...` and `/en/...`, and both `[slug]` routes show `●` (SSG via `generateStaticParams`, 0 pages pre-rendered today since `places` has no data in this sandbox, exactly the intended degrade). Revalidate windows: 60s for vet-clinics (bounds staleness of PRD-078's "open now" badge, now computed once per revalidation instead of per request — the exact tradeoff that PRD's own comment anticipated: "would need to move to a short revalidate window... acceptable, use your judgment"), 3600s for breeders (no time-sensitive data). **Sitemap**: new dedicated directory section in `sitemap.ts` (own `getPublishedPlacesByCategory()`, same plain-anon-client/try-catch-degrades-to-`[]` shape as the existing provider section) — both listing URLs plus every published vet-clinic/breeder detail URL, `lastModified` set to the place's real `updated_at` (the existing `places_set_updated_at` trigger), never "now"; archived/draft places are absent with zero extra filtering since RLS already scopes an anon client to `published` only. **Metadata**: LT-primary titles tightened to the PRD's own query-language example ("Veterinarijos klinikos Vilniuje — darbo laikas, kontaktai") and matched for breeders/detail templates, both languages, canonical + hreflang via the existing `localeAlternates()`. **OG images**: category-level branded defaults (`vilnius/vet-clinics/opengraph-image.tsx`, `vilnius/breeders/opengraph-image.tsx`, same literal-hex-from-token-source technique PRD-046 established since Satori can't resolve CSS vars) plus per-detail dynamic cards (`[slug]/opengraph-image.tsx` ×2) using the place's own `photo_urls[0]` when present, falling back to the category default when a place has no photo or the slug doesn't resolve — same missing-data-falls-back-to-branded-default shape PRD-046's nanny profile OG image established. **Verified**: JSON-LD shape confirmed against a real seeded clinic (with full weekly hours, emergency=true) and a real seeded breeder, inserted inline via a rolled-back Supabase MCP transaction against the connected project (`place_categories`/`places` already exist there, empty, matching PRD-079's own note) — hand-traced the exact `VeterinaryCare`/`LocalBusiness`/`PostalAddress`/`OpeningHoursSpecification` output against the returned row and against schema.org's real property names, zero residue confirmed after rollback (`apply_migration` never called, only `execute_sql` inside `begin`/`rollback`). Lint/`tsc`/`next build` all pass; build output confirms the `[slug]` routes never attempt a real DB call that could break other PRDs' builds pre-deploy. Not verifiable in this sandbox (same disclosed-gap convention as PRD-046/050): Google's Rich Results Test, a real Lighthouse run (needs a live deployed preview + real browser), and Search Console property verification/indexed-page tracking at +2/+6 weeks — the last of these has no code component at all, it's pure ops once the sitemap (already correct) is submitted. Needs human: the three items above, plus running `seed.sql` against production once PRD-076's migration deploys so the static params/sitemap/OG images start reflecting real data.
User-suggested places (PRD-081)
Coverage scales past admin research hours: signed-in users can suggest a place (or "report outdated info" on an existing one), admins verify and publish. **Schema**: `places.status` gained a fourth value, `suggested` — added in its OWN migration (`20260723100000_place_status_suggested.sql`, `alter type place_status add value`), committed before a second migration (`20260723101000_place_suggestions.sql`) references it in RLS/a trigger — this repo's hard, repeatedly-enforced two-transaction split (PRD-070/073's exact precedent; `ALTER TYPE ... ADD VALUE` can't be used in the same transaction as a statement that USES the new value, SQLSTATE 55P04). Second migration also adds `suggested_reason`/`suggestion_source_place_id` (self-referencing FK, set only for a "report outdated info" correction)/`dismissed_at`/`dismissed_by`/`dismissed_reason` columns — dismissal deliberately reuses the existing `archived` status rather than a second new enum value, since "dismissed" and "archived" both mean "not live, not actionable" to every reader of the column. **RLS**: two new additive policies alongside PRD-076's existing `places_read`/`places_admin` — `places_creator_suggest_insert` (`status = 'suggested' and created_by = auth.uid()`, so a non-admin can never insert any other status or attribute a suggestion to someone else) and `places_creator_read_own_suggestions` (same predicate, SELECT) — a creator sees exactly their own open suggestions, never another user's, and a published one is already covered by the pre-existing public branch. **Rate limit (max 5 open suggestions/user)**: deliberately a DB-layer trigger (`guard_place_suggestion_rate_limit`, security definer, admin-exempt — same admin-bypass convention as `guard_booking_transition`), not just an app-layer check — every write here goes through the user's own RLS-scoped session (never the service-role key), so a direct `supabase-js`/REST insert call bypassing the Next.js server action entirely would satisfy the INSERT policy's `with check` just as easily as going through the app; only a real Postgres-side guard can't be bypassed that way. The server action (`src/app/[locale]/suggest-place/actions.ts`) keeps its own count-before-insert pre-check purely for a fast, friendly error message — the trigger is the actual enforcement, and its own exception is what a race between two concurrent submissions ultimately hits. **Duplicate guard**: explicitly a heuristic, not a guarantee, per the PRD's own wording — `src/lib/placeSimilarity.ts`, a pure normalized-substring-plus-word-overlap (Dice coefficient) score, diacritic-insensitive, zero dependencies. Surfaced twice from the identical function/threshold: the suggestion form's live "Is it one of these?" panel (client-side, against every already-fetched published place in the selected category) and the admin queue's "possible duplicate of X" flag — never blocks submission, only prompts a human to check. **Public flow**: `/suggest-place` (new PROTECTED_PREFIXES entry, so the existing PRD-015 guest-gate middleware transparently bounces a signed-out visitor through `/signin` and back — no bespoke auth-check UI needed) — category dropdown (from live `place_categories`), name, district/address, a "why it's worth listing" reason, optional website/phone. The exact same mechanism, pre-filled from an existing published place and framed as a correction (`?correctionFor=<id>`), backs the "report outdated info" link PRD-081's own non-goals section explicitly carves back in-scope, now on both vet-clinic and breeder detail pages. **Admin queue**: a new "Suggestions" tab on `/admin/places` (`?tab=suggestions`, badge count) — a dedicated `SuggestionsQueueTable` (suggester name, why-text, possible-duplicate/correction-for badges, Publish/Dismiss-with-reason actions) rather than reusing `PlacesTable`, since a suggestion needs review context that table has no room for; `PLACE_STATUSES` (that generic table's filter/status-setter values) deliberately still excludes `suggested`. Reviewing a suggestion lands on the exact same `/admin/places/[id]/edit` page and `PlaceForm.tsx` every other place uses (a suggestion IS a `places` row) — a new provenance `Card` shows who suggested it, why, and (for a correction) which place, permanently, not just while still queued; `SuggestionReviewActions` replaces the normal `PlaceStatusBar` only while `status === 'suggested'`. Publish is literally the same `setPlaceStatus(id, 'published')` every "Publish" button in the admin UI already calls — the notify-on-publish branch lives inside that one shared function (fires only on a genuine `suggested -> published` transition, never an admin's ordinary publish of their own draft), so no call site needed duplicated logic. **Notification**: reuses the exact PRD-033 in-app mechanism (`createNotification` + service-role `createAdminClient`, `src/lib/notifications.ts`/`notificationKinds.ts`, same shape `src/lib/email/booking.ts`'s `notify()` established) — a new `place_suggestion_published` kind ("Your suggestion '{name}' is live 🎉"), deep-linking into the live vet-clinic/breeder detail page when that category has a public route, `/` otherwise. **Verified live** via Supabase MCP, `apply_migration` never called: the new columns + self-referencing FK + the `places_created_by_fkey` name every admin query assumes were confirmed structurally inside a rolled-back transaction; the RLS/rate-limit-trigger mechanism was verified using `draft` as a structural proxy for `suggested` (this session's disclosed, established workaround for verifying a not-yet-committed enum value's USE — PRD-070/073/075's precedent — since `ALTER TYPE ADD VALUE` requires a committed transaction before the new value is usable, and this repo's hard rule forbids `apply_migration`): a non-admin inserted and read back their own (proxy) suggestion, could not spoof `created_by` as another user (rejected, `insufficient_privilege`), a second non-admin could not read the first's row, a 6th open suggestion was rejected by the trigger while a 5th succeeded, and an admin was correctly exempt. Two checks needed no proxy at all, since they only exercise mechanisms already live in production today: an anonymous/public read of the (proxy-suggested) row via the real, unmodified `places_read` policy returned nothing, and the literal `sitemap.ts` query shape (`status = 'published'`) excluded it too. Zero residue confirmed after rollback. Lint/`tsc`/`next build` all pass; `/suggest-place` renders dynamic, the five static/ISR directory routes PRD-080 established are unaffected. Deviations: dismissal reuses `archived` rather than a fifth enum value (see above); no "my suggestions" history page (not asked for — the in-app notification on publish is the only status signal the PRD specifies); breeder suggestions still funnel through the identical PRD-079 admin-review/standards bar before publish, so the reputational floor that category set is unchanged. Needs human: a real two-browser-tab click-through of the suggest/report-outdated/admin-queue flow, and — once `apply_migration` is authorized outside this session's hard rule — deploying the two migrations and re-confirming the enum-dependent RLS/trigger checks against the real committed `suggested` value rather than the `draft` proxy.
Place ratings & reviews (PRD-082)
The community-review layer on top of PRD-076's directory — genuinely separate from the marketplace's own `reviews` table per this PRD's own technical note (a place review has no booking to verify against, a different integrity model), not a reuse/generalization of it. New `place_reviews(place_id, author_id, rating 1–5, body, created_at, updated_at, unique(place_id, author_id))` — one review per user per place, editable/deletable by the author anytime (no PRD-071-style edit window, this PRD doesn't ask for one), plus PRD-039's exact soft-delete triad (`removed_at`/`removed_by`/`removal_reason`, admin-only via a `guard_place_review_removal` trigger mirroring `guard_review_removal` verbatim). RLS mirrors the marketplace shape closely: public read excluding removed rows, author-owned insert/update/delete, an additive admin-only update policy. `places` gained denormalized `rating_avg`/`review_count` (same `numeric(2,1)`/`int` shape as `provider_profiles`), refreshed by a new `refresh_place_rating()` trigger mirroring `refresh_provider_rating()`'s exact structure — full recompute from non-removed rows, fired after insert/update/delete. **One real, load-bearing deviation from the mirrored precedent, found by testing rather than assumed**: `refresh_place_rating()` had to be made `security definer` (the marketplace's `refresh_provider_rating()` isn't) — live-verified that `places` has no RLS grant letting a regular review author UPDATE it at all (only the admin-only `places_admin` policy), so a plain invoker-rights trigger would silently no-op (0 rows affected, no error) for every real, non-admin author's review; the identical gap was then confirmed to reproduce on the LIVE marketplace `reviews`/`provider_profiles` trigger too via a rolled-back-transaction test against real seeded data (a genuine non-owner authenticated review insert left `provider_profiles.review_count` unchanged) — flagged here and left for a future fix, since touching the marketplace table is explicitly out of this PRD's scope. Moderation: a new `report_subject_type` value `place_review` (this repo's mandatory two-transaction `ALTER TYPE ADD VALUE` split, its own isolated migration first, PRD-070/073/081's exact precedent), wired through the full existing PRD-029/039 pipeline — `ReportForm`/`ReportPlaceReviewButton` (a small `place_review`-specific sibling of `ReportReviewButton`, kept separate to avoid touching that already-shipped component), `/admin/reports`' queue (new subject-type filter, preview text, and a `remove_place_review` resolution action that soft-removes the row — the aggregate recomputes immediately since the same trigger fires on that update), and the place's own `/admin/places/[id]/edit` page now lists all its reviews (including removed ones, since admin reads see past `place_reviews_read`'s `or is_admin()`) for at-a-glance visibility, linking to the reports queue for actual removal. UI: `PlaceRating` (compact stars+avg+count+a colored `Badge`, not a dim caption — the PRD's own "genuinely visible, not a tiny gray footnote" bar) on both listing cards and detail headers; `PlaceReviewsSection` (a bigger callout box + the reviews list + write/edit/delete form) on both `/vilnius/vet-clinics/[slug]` and `/vilnius/breeders/[slug]`. Sign-in gating deliberately does NOT reuse PRD-081's whole-page `PROTECTED_PREFIXES` pattern (the review form lives embedded on an otherwise-public, statically generated detail page, not a dedicated route) — instead it's the click-triggered "redirect through `/signin?next=...`" pattern `ReportReviewButton` already established for the identical situation, with "am I signed in / is one of these mine" resolved entirely client-side via the browser Supabase client (`src/lib/supabase/client.ts`) inside `PlaceReviewsSection` — chosen specifically so the vet-clinic/breeder `[slug]` pages, statically generated with ISR since PRD-080, never need a cookie-backed read in their render path (confirmed, not assumed: both routes still build static/ISR after this change, verified in the real build output). Profile-completeness gate (requirement 3): no exact precedent existed for "must have a name set before doing X", so per the PRD's own instruction this is a simple, un-over-built check against `profiles.full_name` being non-empty, enforced server-side in `src/app/actions/placeReviews.ts` with a friendly inline error. **Verified live** via Supabase MCP inside rolled-back transactions against real fixtures (real profile ids, synthetic place/category rows): insert succeeds; a second insert by the same author at the same place correctly hits the `unique(place_id, author_id)` violation; a second author's insert correctly recomputes the average; the author's own edit recomputes it again and bumps `updated_at`; a different author's attempt to edit someone else's review is correctly a 0-row no-op under RLS; an author attempting to self-set `removed_at` is correctly reverted by the guard trigger; a real admin's removal correctly recomputes the average excluding that row and stays visible to admin reads while disappearing from an anon read; an anonymous insert attempt is correctly rejected (`42501`). The `place_review` enum value itself could not be exercised in the same rolled-back session per this repo's established, disclosed limitation (`ALTER TYPE ADD VALUE` requires a committed transaction before use) — the generic `reports` insert/admin-read mechanism was instead verified using the existing `review` value as a structural proxy, per the PRD-070/073/075/081 precedent. Zero residue confirmed after every rollback; `apply_migration` never called. Lint/`tsc`/`next build` all pass, and both directory `[slug]` routes remain static/ISR after adding review reads (server reads use the same public anon client `vetClinics.ts`/`breeders.ts` already established; nothing new touches `cookies()`). Deviations: place-review deletion was added (mirroring the marketplace `reviews_delete` policy) even though the PRD's own requirement list only mentions editing — judged low-cost and consistent with "copy the machinery." Needs human: a real two-browser-tab click-through of the review form/report/admin-removal flow, native-speaker LT proofread. The flagged backport (`security definer` on the live marketplace `refresh_provider_rating()`) has since shipped as its own ad hoc fix, `20260724120000_fix_provider_rating_trigger.sql`.
Directory ↔ marketplace cross-linking (PRD-084)
The bridge PRD tying Phase 11's directory back to the actual marketplace, both directions. **Requirement 1 (directory → marketplace)**: contextual CTAs per category, all sharing one new `DirectoryBridgeCard` component and one copy/href module (`src/lib/directoryBridge.ts`) — the PRD's own "keep bridge copy in one module" instruction taken literally rather than scattering three near-identical Cards. Vet-clinic detail: "Nannies near {district}" (falls back to a generic heading when a clinic has no district on file) → `/browse?city=Vilnius`; breeder detail (previously had no bridge at all — PRD-077/079's own comments explicitly deferred this to PRD-084): "New puppy? Book a walker for the settling-in weeks" → `/browse?service=dog-walking`; pet-friendly hotel-category detail: "Or book an overnight nanny instead — your pet stays home" → `/browse?service=overnight`; every other pet-friendly category keeps the pre-existing generic "need a pet sitter" bridge, now consistently tagged. **`/browse` has no district filter** (`src/lib/browseFilters.ts` only understands `city`/`service`/`pet`/`maxPrice`/`sort`) — rather than inventing one for a single directory category (explicitly out of scope: this PRD's own non-goals defer real "nearby" logic to PRD-135's geodata), the vet-clinic link stays city-level even though its copy names the district. `dog-walking`/`overnight` are the real catalog slugs, confirmed against `src/lib/catalogIcons.ts`'s `SERVICE_TYPE_ICONS` keys. Every bridge link carries a plain `?src=directory_vetclinic`/`directory_breeder`/`directory_hotel`/`directory_petfriendly` param — PRD-089 (UTM)/PRD-049 (analytics)/PRD-120 (admin reporting) are all confirmed unbuilt in this codebase, so per the PRD's own fallback language this is deliberately just a grep-able param, not real tracking infra. **Requirement 2 (marketplace → directory)**: `careProfile.ts`'s `vetSchema` gains two optional fields — `placeId` (uuid) and `is24Hour` (boolean) — alongside the existing free-text `clinicName`/`phone`; free text with no `placeId` remains exactly as valid, satisfying the "fallback still works" acceptance criterion by construction rather than a special case. New `src/components/pets/VetClinicPicker.tsx` (built directly on the `Input` primitive — no existing combobox primitive to extend, per AGENTS.md rule 2's own "build a minimal new one" fallback): fetches the published vet-clinic list once from a new client-safe `src/app/api/vet-clinics/route.ts` (re-exposing `getPublishedVetClinics()`, which `CareProfileFields.tsx` can't call directly since it's a `'use client'` tree and that read is server-only — same split PRD-077 established between `vetClinics.ts` and `placeCatalog.ts`) and filters locally as the owner types; picking a clinic writes `clinicName`/`phone`/`placeId`/`is24Hour` into the care profile immediately, all captured at pick time from the already-fetched result — nothing ever live-joins back to `places` later. `VetClinicSummary` (src/lib/vetClinics.ts) gained a `phone` field (was previously `VetClinicDetail`-only despite already being selected/mapped) so the picker's search route didn't need a second per-clinic query. Booking detail (`CareSheetView.tsx`) and the contract PDF (`ContractPDF.tsx`, new bilingual `vetAvailability`/`vetOpen247` strings in `contractStrings.ts`) both render a "24/7" badge/row when `is24Hour` is set, alongside the existing clinic-name/phone rows. **No new migration** — extending a versioned jsonb schema is additive by design (same precedent as PRD-079's breeder-attributes extension needing zero migration), and since `booking_pet_care_profiles` already snapshots `pets.care_profile` verbatim at booking-creation time (`createBooking`, unchanged), `placeId`/`is24Hour`/`clinicName`/`phone` all flow through that existing copy mechanism for free — **live-verified via a rolled-back Supabase MCP transaction** (never `apply_migration`): inserted a synthetic published vet-clinic place, set a real pet's `care_profile.vet` to reference it (placeId + is24Hour: true), snapshotted that into a real `booking_pet_care_profiles` row (mimicking `createBooking`'s own insert), then renamed/re-phoned/flipped `is24Hour` to false/unpublished the source place — the booking's already-created snapshot came back byte-for-byte unchanged (`snapshot_unchanged: true`), proving the "survives a later clinic edit/unpublish" requirement holds with zero new columns; rolled back with zero residue confirmed. **Requirement 3**: `Footer.tsx`'s vet-clinics/breeders/pet-friendly links (added incrementally by PRD-077/079/083) plus the pre-existing standalone `/safety` link are now grouped into one "Explore Vilnius" block — a small uppercase section heading (the same label formula `CareSheetView.tsx`'s `SubSection` already established) over a four-link sub-list, separated from the flat top-level row by a top border; confirmed sitewide since `Footer` is mounted once in the root `[locale]/layout.tsx`, not per-page. Full hand-written bilingual LT/EN copy for every new string (`directoryBridge` namespace, footer/careProfile additions), not yet native-proofread — same disclosed gap as every prior directory PRD's copy. Lint/`tsc`/`next build` all pass; PRD-080's static/ISR directory routes (vet-clinics/breeders/pet-friendly listing + detail) remain static/ISR — the new bridge cards/hrefs are pure string construction, no new data dependency. Needs human: native-speaker LT proofread, real click-through of the typeahead (search-as-you-type, pick, clear, free-text fallback) and the three bridge CTAs, and — once PRD-089/049/120 exist — wiring the already-present `?src=` params into real analytics.
Vet-clinic detail page visual redesign
User-requested visual overhaul of the public `/vilnius/vet-clinics/[slug]` page (this category only — breeders/pet-friendly untouched), moving it from the old single-column "main + sticky sidebar" layout to a warmer, editorial full-width flow per a supplied mockup. **Hero** is now two-column: breadcrumb + a pill-styled "← All vet clinics" back link, a large clinic-name `<h1>`, a district line (`MapPin`), the live open/closed state as a semantic-tinted pill, and the rating line (`PlaceRating`) + a "Read reviews ↗" anchor to `#reviews` — beside a decorative dog+cat Unsplash photo in an organic soft-blob shape (`--radius-blob`, a new token) over a tinted blob backdrop. **Body** (stacked sections): a lead paragraph; side-by-side **Contact** + **Opening hours** `Card`s (address/`tel:`/website with icons; the existing today-highlighted 7-day hours table with an open/closed chip top-right); a **Services** `Card` rendering each `VET_CLINIC_SERVICES` slug as an icon-in-circle chip (new `VET_CLINIC_SERVICE_ICONS` lookup added to `catalogIcons.ts` following PRD-153's inline-lookup convention) plus the free-text `servicesExtra` line; a prominent sage-tinted **Community reviews** banner that *relocates* PRD-082's existing honesty disclosure copy (same i18n keys, not new prose) into a bigger treatment with a person-with-dog Unsplash photo; the existing reviews list + "last verified" card; the map; and the marketplace bridge card now carrying a dog-walker Unsplash photo. **Shared components touched additively/backward-compatibly** (verified breeders + pet-friendly render unchanged via Playwright): `DirectoryBridgeCard` gained optional `photoUrl`/`photoAlt` (text-only path byte-identical for the no-photo callers); `PlaceReviewsSection` gained `hideCommunityDisclosure` (default `false`, so the inline callout stays for breeders/pet-friendly) to avoid double-showing the disclosure now that it's a banner; `PlaceDetailMap`'s "Open in Google Maps" link became a floating pill overlaid on the map's bottom-right corner instead of a row below (applies to all three categories). **Photography**: three free Unsplash-License CDN photos, hotlinked by fixed `photo-<id>` path via `next/image` (each fetched + eyeballed on-topic before wiring — dog+cat cuddling, two owners with their dog, a dog walker holding leashes in a park); `images.unsplash.com` added to `next.config.ts` `images.remotePatterns` + the CSP `img-src` allow-list. **Branches line deliberately NOT built**: the mockup's "one of {clinic}'s N Vilnius branches" line needs a real chain/branch relationship the schema doesn't model (brand names live only inside free-text `name`/`description`, e.g. "(Vilnius Vet)", not a structured field) — deriving a count by name-prefix matching would be unreliable/hacky, so it was skipped rather than faked; needs a real data-model addition first. New bilingual EN/LT i18n keys (`vetClinicDetail.contactHeading`/`readReviewsLink`/`heroPhotoAlt`/`reviewsPhotoAlt`/`bridgePhotoAlt`), hand-written LT not native-proofread (same disclosed gap as prior directory PRDs). Verified with a real local Supabase stack + Playwright screenshots (desktop + mobile + map-overlay), iterated on the hero rating/link redundancy. Lint/`tsc`/`next build` all pass. Needs human: assign a real PRD number in STATUS.md, native-speaker LT proofread, and a live click-through on a geocoded clinic to see the map with tiles loaded.
Guides & content hub (PRD-085)
The platform's editorial home — admin-authored markdown articles, listed and cross-linked, SEO-complete. New `articles` table, mirroring PRD-076's `places` template (`status` draft/published gates the one public-read RLS policy, admin-only writes via a second `for all` policy, public `article-covers` storage bucket `is_admin()`-gated exactly like `place-photos`). **Deviation from the PRD's literal column list, deliberate and explained in the migration's own header comment**: added a `locale` column (unique on `(locale, slug)`, not just `slug`) — the PRD's schema has no locale field, but the orchestrator's brief required genuine hand-written EN + LT launch content at this repo's established bilingual bar, and unlike every other bilingual page in this app (next-intl message-driven UI chrome around identical single-language DB content), an article's `body_md` is real long-form prose that can't share one row across two languages without becoming unreadable — one row per locale, exactly how `[locale]` routing already assumes everywhere else. **Markdown renderer**: `react-markdown` 10.1.0 + `remark-gfm` 4.0.1, both exact-pinned (not caret ranges) — chosen because it renders straight to React elements via its own `Components` map, never `dangerouslySetInnerHTML`, and critically never enables `rehype-raw` (or any raw-HTML plugin) anywhere in this codebase, so embedded HTML in article source renders as inert literal text rather than executing — satisfies the PRD's "no raw HTML passthrough" requirement structurally, no separate DOMPurify-style sanitizer needed. One shared `ArticleBody.tsx` renders both the public article body and the admin editor's live preview pane (same component, can't visually drift) — requirement 3's typographic-care callout is handled entirely with the *existing* `--text-*`/`--space-*` scale (this repo has no `--font-serif`/dedicated reading type scale, confirmed by checking `globals.css` first) via a larger base size, 1.75 line-height, and a heading-demotion map (`h1`→`h2`, `h2`→`h3`...) so a body-authored `#` never collides with the page's own real `<h1>` (the article title) or skips a DOM heading level. **Admin `/admin/articles`**: list + status filters (mirrors `/admin/places`), `ArticleForm.tsx` — a plain `Textarea` for `body_md` plus the live `ArticleBody` preview side-by-side (`.editor-grid`, collapses to stacked below 768px, same convention `.profile-grid` already established), deliberately no WYSIWYG at all per the PRD's own explicit instruction ("a textarea with preview beats a broken WYSIWYG"). Cover upload reuses `src/lib/storage.ts` exactly as PRD-024/076's photo upload does, but skips PlacePhotoManager's two-step "create row, then manage photo" split — a cover is a single image in a flat, non-owner-scoped bucket, so a client-generated random object name uploads inline before the article itself is ever saved. **CTA directive (requirement 3)**: a single `cta_target` text column (nullable, admin types a relative path like `/browse` or `/vilnius/vet-clinics`) — no second "kind" field; `ArticleCtaBlock.tsx` infers marketplace-vs-directory copy tone from the path's own prefix and reuses PRD-084's `DirectoryBridgeCard` component directly, plus a `guideBridgeHref()` addition to `directoryBridge.ts` that tags the link `?src=guide_<slug>`, matching that module's existing `?src=` tracking convention. **Public**: `/guides` index (cards, tag-filter chips reading `useSearchParams()` client-side exactly like `VetClinicsClient.tsx`, so the page itself stays static/ISR) and `/guides/[slug]` (JSON-LD `Article` via new sibling module `src/lib/articleSeo.ts` — kept separate from `placeSeo.ts` since an `Article` is a structurally different schema.org type over a structurally different table, not a fourth case bolted onto that dispatcher; `breadcrumbJsonLd()` still reused directly from `placeSeo.ts`), related-articles-by-shared-tag block, and the CTA block. `generateStaticParams` on `/guides/[slug]` is a real, documented departure from the vet-clinic/breeder precedent: because `articles` rows are locale-partitioned (unlike `places`), it returns `{ locale, slug }[]` pairs directly (computed across every `routing.locales` entry) rather than `{ slug }[]` alone — returning both ancestor keys explicitly is what stops Next.js from cross-multiplying an LT-only slug against the EN locale and generating a route that can only ever 404. OG images generated FROM each article's `cover_url` (new `BOOK_OPEN_NODE` raw icon-path data added to `src/lib/ogIcons.tsx`, same Satori-can't-render-hooks constraint/workaround PRD-153 already solved and documented there). `sitemap.ts` gained a guides section (LT-locale only, matching this file's own pre-existing convention of never listing `/en/...`-prefixed alternates for any section). Nav (`discoverItems`) and Footer (flat link row, not the Vilnius-scoped group) both gained a "Guides" entry. **5 launch articles, hand-written not lorem-ipsum'd, 10 rows total (5 topics × 2 locales)** in `supabase/seed.sql` (same "stage real content, don't insert live" convention every PRD in this phase has followed — `status = 'published'` in the row itself, but the whole file is never auto-run against the linked remote): new-puppy checklist (Vilnius edition), choosing a sitter, leaving your cat for a week, a dog-friendly Vilnius weekend, and what a care contract covers. The Vilnius-weekend article cross-links real, already-seeded `pet-friendly` places from earlier in this phase (Vingis Park's dog area, 12 Istorijų, Rotonda, Pavilniai Regional Park, the informal Neris dog beach, Vilnius Grand Resort) rather than fabricating venues; the care-contract article was checked against `src/lib/contractStrings.ts`'s real section list (Parties/Service & Schedule/Pet Care Sheet/Emergency Veterinary Authorization/Keys & Access/Agreed Price/Standard Terms/Signatures) before writing, not guessed. **Live-verified** via Supabase MCP (`apply_migration` never called): the full migration DDL plus all 10 seed rows were run inside a rolled-back transaction — a draft row was invisible to an anonymous/public-role read, visible to a real admin profile, a real non-admin profile's insert was rejected (`42501`), and a published row was visible to both anon and admin; zero residue confirmed after rollback. Lint/`tsc`/`next build` all pass; `/guides` and `/guides/[slug]` render static/ISR in the real build output with the same "0 pre-rendered pages today, degrades safely" shape every prior directory `[slug]` route established, since `articles` isn't live on production yet. Needs human: run `seed.sql` against production once this migration deploys and a publishing decision is made, native-speaker LT proofread, a real Google Rich Results Test + Lighthouse pass on a live article page, and real click-through of the admin markdown editor/preview and cover upload.
Pet-friendly places (PRD-083)
Was expected to be the last content category of Phase 11 when written, superseded by PRD-086's later addition of the `/vilnius` hub page itself — see that entry for the actual phase close. Lifestyle categories (parks, cafés/restaurants, hotels, trails & beaches), the "habit" moment the Problem statement names, versus vet clinics/breeders' "need" moment. **Requirement 1 turned out mostly already done**: `park`/`cafe`/`hotel` zod schemas in `src/lib/placeAttributes.ts` were fully specced back in PRD-076's original commit (`offLeashZone`/`fenced`/`size`; `indoorAllowed`/`waterBowls`/`menuNotes`; `petFee`/`sizeLimits` — all already matching this PRD's literal requirement text), so the only real schema work was a new `trailBeachSchema` (`leashRules`/`seasonNotes` free text, plus `district` for parity with every other category — needed for the shared district filter to mean anything for this category too) and a genuinely new `trail_beach` category row. **`PlaceForm.tsx` needed ZERO lines changed** — its category dropdown already reads live `place_categories`, and its field-type switch already had a generic fallback (`text` input) for any field type not explicitly handled, so adding `trail_beach`'s two textarea fields to `PLACE_ATTRIBUTE_SCHEMAS` was the complete admin-side change; concrete proof of PRD-076's own "extensible by insert, no code change" claim, not just an assertion of it. New optional `AttributeFieldDef.chip` boolean (only `true` on park's `offLeashZone`/`fenced` and cafe's `indoorAllowed`/`waterBowls`) drives requirement 3's "good to know" chips — deliberately a flag, not a label string, since the label needs to be bilingual (public page) while `AttributeFieldDef.label` is admin-only English copy by existing convention. **Requirement 2, the PRD's own explicit warning heeded**: rather than a third/fourth near-identical fork of `vetClinics.ts`+`VetClinicsClient.tsx`+`[slug]`/page.tsx (the pattern PRD-077 and PRD-079 each already used once), one new `src/lib/petFriendlyPlaces.ts` covers all four new categories at once — `getPublishedPetFriendlyPlaces()`/`getPetFriendlyPlaceBySlug()`/`getPetFriendlyDistricts()` read across park/cafe/hotel/trail_beach in a single `.in('category_id', ids)` query (places.slug is globally unique, so one flat `/vilnius/pet-friendly/[slug]` route serves every category, no `[category]` segment needed), plus two generic helpers — `goodToKnowChips()` and `detailTextFields()` — that both iterate `placeAttributeFields(categorySlug)` rather than switching on category, so a park's chips/text-fields and a trail_beach's render off the exact same code path with zero per-category branches anywhere in the rendering layer. One new `PetFriendlyClient.tsx` (URL-driven category+district filters, sectioned-by-category card grid) and one new `[slug]/page.tsx` cover all four categories — no vet_clinic-style hours table/services checklist exists here since none of these categories have that shape, so the detail page is deliberately simpler than the vet-clinics one, not a stripped copy of it. `src/lib/placeSeo.ts` gained one `petFriendlyJsonLd()` (schema.org `LocalBusiness`, WITH `aggregateRating` when reviews exist — unlike breeders, this category does carry PRD-082 ratings) and one new `placeJsonLd()` dispatcher case, not four. **Requirement 3**: the district filter is the identical mechanism `getVetClinicDistricts()` already established (a live, data-derived distinct-values list — this repo has never had a hardcoded Vilnius district master list, so "shared with clinics" means the same derivation approach, not a shared literal array) — verified directly: a rolled-back-transaction query for `category=park AND district=Šnipiškės` returned exactly the one matching place. Chips verified the same way: a park with `offLeashZone`+`fenced` both `true` shows 2 chips, a park with only `offLeashZone` shows 1 (the un-set `fenced` key is correctly absent, not defaulted to false-shown-as-a-chip), and hotel/trail_beach — which have zero `chip`-flagged fields — correctly render 0 chips rather than a generic attribute dump. **A real category-generic bug found and fixed, not just avoided**: `src/lib/placeSuggestions.ts`'s `directoryPathForCategorySlug()` (used by both the suggestion form's "Is it one of these?" links and PRD-082's admin report-context links) and `src/lib/notificationKinds.ts`'s `place_suggestion_published` href BOTH hardcoded only `vet_clinic`/`breeder` with a comment naming park/cafe/hotel as "no public route yet" — exactly the kind of hardcoding requirement 5 warned about, now stale the moment this PRD shipped a fifth public category. Fixed by testing category membership against a new tiny zero-dependency `src/lib/petFriendlyCategories.ts` (`PET_FRIENDLY_CATEGORY_SLUGS`/`isPetFriendlyCategorySlug`, split out of `petFriendlyPlaces.ts` specifically so `placeSuggestions.ts` — imported by the 'use client' `SuggestPlaceForm.tsx` — never pulls `petFriendlyPlaces.ts`'s Supabase-querying code into a client bundle, the same bundling hazard `vetClinics.ts`/`breeders.ts`'s own header comments warn about for `placeCatalog.ts`) instead of a second hardcoded branch; `notificationKinds.ts` now imports and calls the shared function rather than keeping its own copy. **Requirement 5 verified directly, not assumed**: inside a rolled-back transaction, a real non-admin authenticated profile successfully suggested a new place under the `park` category (`status = 'suggested'`, the exact `places_creator_suggest_insert` RLS shape PRD-081 built), and a real non-admin authenticated profile successfully posted a `place_reviews` row against a `cafe` place with `refresh_place_rating()` correctly recomputing that place's `rating_avg`/`review_count` (5.0/1) — both mechanisms worked unmodified against the new categories, confirming neither PRD-081 nor PRD-082 needed a single line of category-special-casing beyond the `directoryPathForCategorySlug` fix above. **Content — 31 places**, honestly split per the PRD's own differentiated guidance rather than an even split: 12 parks (near-exhaustive per the PRD's "there aren't that many" framing — Vilnius' own municipal address list for its ~38 small fenced dog-walking areas, compiled from https://voljeras.eu's transcription of the city's own published addresses, covers one per major residential district here rather than all ~38 near-duplicate entries; two flagship areas — Vingio parko šunų vedžiojimo aikštelė and the Purina-donated Šnipiškės area — carry real, multiply-sourced detail (exact m², dual small/large-dog zones, agility obstacles) from madeinvilnius.lt/vilnius.lt and 15min.lt/sputniknews.lt respectively), 9 cafés/restaurants and 6 hotels (both deliberately short of exhaustive, per the PRD's own "best-known-first" instruction — sourced primarily from draugiskigyvunams.lt, Lithuania's own dedicated opt-in pet-friendly venue registry, each venue's own registry page fetched individually rather than bulk-scraped; `waterBowls`/`menuNotes` set only where a venue's own page stated it explicitly, not assumed from category membership; two hotels — Vilnius Grand Resort, Hotel PACAI — verified against the hotel's own domain with exact fee/weight-limit detail, the other four against the registry's own per-venue pages, which state "a fee may apply" without a number, recorded honestly as "amount not disclosed" rather than invented), 4 trails & beaches (genuinely small in Vilnius, not padded — the city's one informal river-shore dog beach inside Vingis Park plus the three regional parks bordering the city, each sourced from the parks' own joint directorate site or independent coverage of their own published visitor rules). **Honestly dropped, not padded in**: several well-known Vilnius hotels aggregators (Booking.com/BringFido/Tripadvisor) describe as pet-friendly — Narutis, Stikliai, Kempinski Cathedral Square, Best Western Vilnius, Artis Centrum, Amberton — could not be confirmed from the hotel's own domain within this research pass (fetches 403'd/timed out/came back empty) and were left out rather than sourced only from a third-party aggregator, this repo's established PRD-072/077/079 bar; similarly "Kavos reikalai" (turned out to be a coffee-equipment shop, not a café), "Openini" (a flooring showroom), "Jurgis ir Drakonas" (registry listing itself marked expired), and "The House" gastropub (its domain resolved to a Druskininkai location, no Vilnius address confirmable) were all dropped as wrong-fit or unconfirmable rather than kept to inflate the count. Per this PRD's own live-insert exception (the orchestrator's own instruction, since `place_categories` is a plain extensible catalog table and 7 of its 8 rows were already seeded live earlier in this phase): the single new `trail_beach` category row was inserted live via `execute_sql` (never `apply_migration`) — the one live write this PRD made; all 31 real places stay in `supabase/seed.sql` only, same "staged, not yet published" convention every PRD in this phase has followed since `places` itself is still empty on production pending a human publishing decision. **A pre-existing production anomaly found, not caused, and deliberately left alone**: live `places` already had exactly one row — a `status = 'published'` `breeder`-category row named "test1" (`created_at` today, `created_by` the real admin account) — meaning something outside this PRD's own writes is already live and publicly visible on `/vilnius/breeders` today, contradicting this phase's "nothing published until a human decides" convention; flagged prominently for human attention below rather than silently deleted (deleting an existing live row was never part of this PRD's authorization). Nav/footer gained a third `/vilnius` entry ("Pet-friendly places", one hub link for all four categories, not four separate nav items). Full hand-written bilingual LT/EN copy under new `petFriendly`/`petFriendlyDetail` + `metadata.petFriendly`/`petFriendlyDetail` namespaces, not yet native-proofread — same disclosed gap as every prior directory PRD's copy. `sitemap.ts` gained a `/vilnius/pet-friendly` listing entry plus every published detail URL across all four categories, reusing the already-category-generic `getPublishedPlacesByCategory()` unchanged. Lint/`tsc`/`next build` all pass with zero warnings; both new routes render exactly like PRD-080's established pattern in the real build output — `/vilnius/pet-friendly` static/ISR (60s revalidate) for both locales, `/vilnius/pet-friendly/[slug]` SSG with 0 pre-rendered pages today (no live pet-friendly data yet) via the same try/catch-to-`[]` `generateStaticParams` guard every prior directory `[slug]` route uses. Verified live via Supabase MCP inside rolled-back transactions throughout (`apply_migration` never called except for the one permitted category-row insert): the full 31-row seed block inserts cleanly (12/9/6/4 split, 31 distinct slugs, all 31 `attributes` jsonb blobs independently parsed valid before the DB round-trip too), zero residue confirmed after every rollback. Needs human: run `seed.sql` against production once a human decides to publish this phase's directory content (still gated, per this phase's own established convention); investigate and resolve the pre-existing live "test1" breeder row flagged above — it predates and is unrelated to this PRD's own work; real mobile click-through of the category/district filters and chips; native-speaker LT proofread.
Vilnius city hub page (PRD-086)
Last item in Phase 11 — Vilnius directory & local content is now fully complete. `/vilnius` composes everything this phase built into the one confident "pet care in Vilnius" page: a hero reusing `HeroSearch` (new optional `defaultCity` prop, pre-fills but stays editable — no second search box built), the PRD-023 "top nannies" row (`getFeaturedProviders` gained an optional third `city` param — substring `ilike` match, not `/browse`'s own exact-match city filter, since `provider_profiles.location` is free text like "Vilnius, Žvėrynas" and an exact match would silently under-count every districted row), the live services catalog, three self-hiding directory highlight tiles (24/7-aware vet clinics, parks, breeders — each hides independently on zero count, whole section vanishes only if all three are empty), a latest-guides teaser (PRD-085, up to 3, hides on zero — deliberately no `FEATURED_MIN_PROVIDERS`-style minimum floor, argued in `cityHub.ts`'s own comment: unlike a thin "top nannies" showcase, one genuine published guide isn't a fabricated/thin section), and an honest city stats strip reusing `getPlatformStats`'s exact computation shape via a new sibling `getCityPlatformStats(city)` (same `STATS_MIN_NANNIES` honesty threshold, city-scoped nannies + completed-bookings via a `provider_profiles!inner(location)` embedded-join filter) plus a `placesListed` count that costs no extra query — summed from the vet-clinic/breeder/pet-friendly lists `getCityHubData` already fetches for the highlights tiles. **The architectural requirement (explicitly acceptance-criteria-checked)**: one `getCityHubData(city, opts)` aggregator (`src/lib/cityHub.ts`) — the page component and every component it renders take fully-composed data from this one call and contain zero Supabase reads and zero city-string branching of their own; `grep -ri vilnius src/app/[locale]/vilnius/page.tsx src/lib/cityHub.ts` turns up exactly one hit outside translated copy/comments — the page's own `const CITY = 'Vilnius'` constant feeding the single `getCityHubData(CITY, ...)` call, which is the one hardcode the PRD's own acceptance criterion explicitly permits. `CityHubData` returns a `citySlug`/`hubPath` pair (`/vilnius` today) so even the page's own breadcrumb/canonical/OG-url plumbing derives from the aggregator's output instead of re-typing the route literal a second time — set up so PRD-147 (multi-city) can turn `CITY` into a real `[citySlug]` route param without touching a single component this PRD built. SEO: `metadata.cityHub` title/description (LT-first, "Augintinių priežiūra Vilniuje" — the natural head term, not a generic default), a new generic `webPageJsonLd()` added to `placeSeo.ts` (a `WebPage` type, not a `LocalBusiness` subtype — this is a composition page, not one business record) paired with the existing `breadcrumbJsonLd()`, and a bespoke OG image (`vilnius/opengraph-image.tsx`) combining the brand paw mark with the vet-clinic/park/guide category icons in one row — the "everything in one place" composition the PRD asked for, distinct from every per-category default card this phase already shipped. Footer: PRD-084's "Explore Vilnius" section heading itself now links to `/vilnius` (previously inert label text over the four sibling links, which are unchanged) — the PRD's own wording ("becomes... the footer Vilnius anchor") pointed at the heading, not a fifth sibling link. ISR `revalidate = 3600`, matching the home page's own cadence exactly, same static-composition-of-cached-queries category as every PRD-080 directory page. **Verified against live data via Supabase MCP (read-only, `apply_migration` never called)**: today's real counts are 4 total nannies (2 verified, both in Vilnius) — below both the featured-row's min-3 and the stats strip's min-10 threshold, so both correctly self-hide; 0 published vet clinics, 0 published parks, 1 published breeder, 0 published articles — directory highlights render breeders-only (vet-clinics/parks tiles hidden), guides section hidden entirely; this is expected given most Phase 11 content is still staged in `seed.sql`, not published to production, not a bug in this PRD's logic. Lint/`tsc`/`next build` all pass; the route renders static/ISR (not dynamic) in the build output, same as the home page. **Deviation, disclosed**: `getFeaturedProviders`/`getPlatformStats` (both called here) still read via the cookie-backed Supabase client (`@/lib/supabase/server`, which calls the Dynamic API `cookies()`) — exactly what the home page's own call sites already do, and exactly the pattern PRD-080 deliberately moved the vet-clinics/breeders/pet-friendly/guides readers away from for true static safety. This PRD didn't touch that pre-existing tension (fixing it is a `providers.ts`-wide concern spanning the home page too, out of this PRD's scope) — `/vilnius`'s static-ness claim rests on the exact same assumption the home page's already does, no new risk introduced. Not verified here: a real Lighthouse/CWV run, a Slack/FB OG share preview, and a native-speaker LT proofread of the new copy.
Quality & growth
Loading, empty & error state standardization
Three shared primitives in src/components/ui — `Skeleton` (shimmer, card/row/profile presets), `EmptyState` (icon/title/body/CTA), `ErrorState` (apology + retry, no stack traces) — replace the ad-hoc empty-state/spinner/error blocks that had accreted across dashboard, favorites, pets, messages, notifications, browse and admin (PRD-044). App Router coverage: skeleton `loading.tsx` for dashboard and its bookings/pets/favorites/settings/availability/photos/nanny/profile subroutes, browse, the nanny profile, messages (+ thread), notifications and admin (+ catalog/users/verification/reports); `error.tsx` boundaries at the app root, `/dashboard` and `/admin`; a branded token-based `not-found.tsx` replacing Next's default 404. New `Toast` primitive (`src/components/ui/Toast.tsx`, `useToast()`) mounted once at the root layout for transient success/failure feedback — none existed before. Server actions consolidate onto one shared `ActionResult<T>`/`ActionError` type (`src/lib/actionResult.ts`); adopted as a type-only swap (no behavior change) across effectively every actions.ts in the app — pets, settings, favorites, availability, admin catalog/users/reports, request flow, auth, become-nanny, bookings, photos, profile, messages, plus the original block/report actions. `admin/verification/actions.ts` (throws rather than returning a result) is the one action file left as-is.
Accessibility pass (PRD-045)
WCAG 2.1 AA pass across `src/components/ui/*` and the five core journeys. Primitives: `Toggle` is a real `role="switch"` button, `RadioCard` takes `role="radio"`/`"checkbox"` + `aria-checked` (group wrappers at each call site get `radiogroup`/`group` + `aria-label(ledby)`), `Stepper` is a `<nav>`/`<ol>` with `aria-current="step"` + a visually-hidden live announcement, `Lightbox` gets a Tab focus trap and returns focus to its trigger on close, `Chip`'s remove control is split into its own real button (was an unfocusable nested `<span role="button">`), `FormField` now actually wires `htmlFor`/`id`/`aria-describedby` (previously a label with no association), `Input`/`Select`/`Textarea`/`PriceInput` all get `error`→`aria-invalid`+`aria-describedby`+`role="alert"`. New `--focus-ring` token (`--color-focus-ring`, `#2563eb`) plus a global `:focus-visible` rule gives every interactive element a visible keyboard ring (most primitives are inline-styled, so this rule intentionally uses `!important` to win over inline styles — documented in globals.css). Calendars (`MonthAvailabilityGrid`, `ProviderAvailabilityCalendar`): `role="grid"`, roving-tabindex arrow-key navigation across month boundaries, `aria-label`s with full spoken date + availability state (`lib/dates.formatFullDateKey`/`addDaysToKey`). Structure: skip-to-content link in the root layout, one `<h1>` per page (added a missing one to the request flow and the messages thread), `role="log"` + labelled composer on the chat thread. Contrast: darkened `--color-primary`/`--color-success`/`--color-warning`/`--color-error`/`--color-info`/`--color-text-subtle` tokens (all previously 2.1–3.8:1 on white, now ≥4.5:1) plus two components (`Badge` primary variant, calendar "available" cell) switched to `--color-text` where even the darkened brand green still fails against its own light tint. **Not verified here** (no live browser/Supabase in this sandbox): a real axe-core/Lighthouse scan and a VoiceOver spot-check — static/manual review only.
SEO & sharing metadata (PRD-046)
Root `layout.tsx` gets a `%s · PetNanny` title template, default description, `metadataBase` from a new `NEXT_PUBLIC_SITE_URL` env var (`src/lib/site.ts`, falls back to `https://petnanny.lt`), and a `viewport` export with `themeColor` read out of `--color-primary` in `src/styles/tokens.ts` rather than retyped. Per-page `generateMetadata`/`metadata`: home, browse (filtered variants build readable titles like "Dog walking in Vilnius" from the live service catalog + active filters, not a hardcoded slug map), nanny profile (name + location + a word-boundary-safe bio excerpt), become-a-nanny. OG images via `next/og`: a static branded default at `src/app/opengraph-image.tsx` and a dynamic per-profile card at `src/app/nanny/[id]/opengraph-image.tsx` (photo, name, star rating, services, verified badge) reusing `getProvider()` — both pull their color values out of `src/styles/tokens.ts` programmatically and hardcode only the resolved hex *inside the image-generation function*, the one place CSS vars can't resolve (Satori renders outside any CSS context). `src/app/sitemap.ts` lists static marketing pages plus every provider profile (RLS already excludes suspended providers from this anonymous read per PRD-038, and includes unverified ones); `src/app/robots.ts` blocks `/dashboard`, `/admin`, `/messages`, `/api`. Nanny profile page emits `LocalBusiness` JSON-LD (chosen over `Person` — Google's review-rich-result eligibility documents LocalBusiness/subtypes, not a bare Person, and `location`/`services` map onto `address`/`priceRange`) with `aggregateRating` only when `reviewCount > 0`. **Not verified here** (no `.env.local`/live Supabase/browser in this sandbox): actual OG image rendering, the live sitemap DB query, a real social-debugger unfurl, and Google's Rich Results Test — confirmed via lint/`tsc`/`next build` only (all three routes compile and appear in the build's route list).
Lithuanian localization (PRD-047)
`next-intl` wired the way its own docs recommend for "default locale without a URL prefix": `localePrefix: 'as-needed'` (`src/i18n/routing.ts`) with LT as `defaultLocale` (unprefixed — `/browse`) and EN under `/en` (`/en/browse`); the entire `src/app` tree moved under `src/app/[locale]/` (root `layout.tsx` now does the `hasLocale`/`notFound()`/`generateStaticParams` dance and mounts `NextIntlClientProvider`). `src/middleware.ts` composes next-intl's `createMiddleware` with the pre-existing PRD-014/015/038 guest-gate/suspended/signup-intent middleware per next-intl's "composing other middlewares" guidance — custom route-matching runs against the locale-stripped path, then hands off to next-intl's middleware for the actual `/[locale]/...` rewrite, merging the Supabase session-refresh cookies onto that response rather than replacing it. `src/i18n/request.ts` throws on a missing/invalid message key in development (loud-fail, the PRD's explicit acceptance criterion) and falls back to the English string (never the raw key) in production. Message catalogs `messages/lt.json`/`messages/en.json`. All five named core journeys are fully extracted with real Lithuanian (native-speaker-quality effort, not machine-translated filler) and zero known English leakage: sign-up/sign-in/forgot-reset-password + become-a-nanny onboarding (all 5 steps + success page); browse + nanny profile (incl. `MonthAvailabilityGrid`/`WeeklySchedule` shared calendar primitives); the request flow through to the on-screen `/contract` preview page; accept/decline (`RequestCard`, `/dashboard/nanny`, the booking-detail hub at `/dashboard/bookings/[id]` which has its own accept/decline entry point); and chat (`Thread`, `/messages` list, block/report menu + report form). The home page and shared primitives (Nav, Footer, `Chip`, `FavoriteButton`, `ReviewCard`, `ProfileGallery`) are done too. LT's 3 plural forms (one/few/other) via next-intl's ICU `{count, plural, ...}` — ` nightsCount`/`resultsCount`/`reviewsCount`/`petCount`/`starsCount` — not hand-rolled if/else (verified: 1→naktis, 2→naktys, 10→naktų, 21→naktis). Dates via `Intl.DateTimeFormat` (`src/lib/format.ts`, `src/lib/dates.ts` now take an optional `locale` param instead of a hardcoded `en-GB`); currency via `Intl.NumberFormat(locale==='lt'?'lt':'en-IE', {style:'currency',currency:'EUR'})` (verified: `12,00 €` for lt, `€12.00` for en) — no manual string formatting. New forward-only migration `supabase/migrations/20260706180000_service_catalog_i18n.sql` adds nullable `services.name_lt`/`description_lt` and backfills the 5 seed rows with real Lithuanian names (`supabase/seed.sql` updated to match); `getServiceCatalog()` picks the LT variant when present and falls back to English rather than showing blank; the admin catalog UI (`ServiceForm.tsx` + `admin/catalog/actions.ts` + the new/edit pages) exposes both fields. Locale switcher (`LocaleSwitcher.tsx`, a plain `<select>` on design tokens) in the footer and `/dashboard/settings`, persisted via next-intl's own `NEXT_LOCALE` cookie (no separate app cookie) — guests default to LT. `hreflang` alternates (`src/lib/hreflang.ts`) wired into root layout, home, browse and nanny-profile metadata. **Follow-up pass — the dashboard/email/contract gap closed**: the rest of `/dashboard` is now translated for both owner and nanny roles — pets (list/add/edit, `PetCard`, `DeletePetButton`, `PetForm`), photos (`PhotoGalleryManager`), availability (`ProviderAvailabilityCalendar`, `TimeScopedAvailabilityEditor`, reusing the existing `availabilityCalendar`/`timeWindows` message namespaces for day/window labels rather than duplicating them), owner profile (`ProfileForm`, `AvatarField`), favorites, the recurring-series detail hub and its accept/decline/cancel/review forms (reusing `bookingCancellation`/`reviewForm` keys for the shared reason-codes/star-rating), the dashboard home page, and `DashboardSidebar`'s nav — all new keys real Lithuanian in both `messages/en.json`/`messages/lt.json`. Transactional email templates (`src/lib/email/templates/*.ts`, 18 of 19 — see below) are now bilingual LT-primary/EN-secondary in both HTML and plain-text bodies (`bi()`/`biLine()`/`biHeadingHtml()`/`biParagraphHtml()`/`biCtaHtml()`/`biText()` helpers added to `layout.ts`) rather than translated into a single guessed locale: a transactional email is triggered by someone else's action, so there's no request/cookie context and no stored per-recipient locale (`profiles` has no `locale` column, deliberately not added) to pick one language from server-side — same reasoning PRD-061 already used for the bilingual contract PDF, applied consistently here. `incident-report-alert.ts` (internal ADMIN_ALERT_EMAIL only, never a customer) stays English-only, the same "staff-only tooling" cut as `/admin`. Along the way, fixed real pre-existing bugs the bilingual pass surfaced: booking/reminder emails' dates and the data-export-ready email used hardcoded `en-GB`/hand-rolled `€${n}` formatting — switched to the shared `formatDate`/`formatEUR` (`src/lib/format.ts`); `CANCEL_REASON_LABELS_EN` (`src/lib/bookingCancellation.ts`) became a bilingual `CANCEL_REASON_LABELS` map, forked from the in-app notification feed's own English fallback strings (`src/lib/notificationKinds.ts`, itself still English-only — a separate, out-of-scope surface, left untouched) so the email copy went bilingual without regressing that unrelated feed. `ContractPDF.tsx` turned out already wired to `contractStrings.ts` from PRD-061; `SeriesContractPDF.tsx` was only *partially* wired — its schedule row pulled weekday abbreviations from an English-only `WEEKDAY_LABELS_EN` constant with no LT counterpart, the one genuine "still renders English" gap found in either PDF. Fixed by adding bilingual `WEEKDAY_NOUNS` (LT abbreviations copied verbatim from the existing `availabilityCalendar.days` keys) plus a handful of other inline-hardcoded labels (`repeatsLabel`/`periodLabel`/`petsCountLabel`/`pricePerOccurrenceLabel` etc.) into `contractStrings.ts`. **Also discovered, NOT fixed (real, pre-existing, outside this pass's scope)**: `Intl.DateTimeFormat('lt', {month: 'short'})` renders as a broken raw '2026-08-01'-style fallback rather than an abbreviated month in this session's Node/ICU build — `month: 'long'` (ContractPDF's own already-proven style) works correctly and is what the email module now uses throughout, but several already-shipped call sites (e.g. `/contract/[bookingId]`'s `formatDate(..., locale, {month: 'short'})`) still use the short form and may carry the same bug for LT viewers; worth a human checking on the actual deploy target's Node runtime. **Deliberately still out of scope**: all of `/admin` (staff-only tooling, single operator, English is fine — same reasoning as `incident-report-alert.ts` above); most `Link`/`router` call sites outside touched files still import from `next/link`/`next/navigation` rather than `@/i18n/navigation` (pre-existing, functionally fine); the in-app notification feed (`notificationKinds.ts`) is English-only and shares payload fields with the now-bilingual emails, forked rather than fixed (see above); and PRD-047's own requirement 7 (native-speaker review of the full catalog, including this pass's new LT copy) is explicitly a human task per the PRD's own text, not attempted here. Verified: `tsc --noEmit` and `eslint` clean project-wide; every new/changed dashboard message key resolves in both `en.json`/`lt.json` (scripted check, no missing keys); every changed email template rendered to real HTML/text via a throwaway Vitest smoke test (deleted after) and eyeballed — bilingual body, correct escaping, no double-escaping regression; both `ContractPDF`/`SeriesContractPDF` rendered via a throwaway element-tree smoke test (deleted after; full PDF-byte generation blocked in this sandbox by no filesystem access to the absolute `/fonts/...` paths `Font.register` uses — a sandbox limitation, not a code issue, and unchanged pre-existing font-loading infrastructure) confirming LT+EN document title, diacritics, dynamic values, and the newly-bilingual weekday abbreviations all reach the rendered tree. No live dev server/Supabase in this sandbox, so this session did not click through `/lt`/`/en` dashboard pages in a real browser.
Performance & CWV pass (PRD-050)
Full writeup in `docs/PERFORMANCE.md`. Measured via `@next/bundle-analyzer` (new dev dep, `npm run analyze`) rather than guessed — real gzip sizes confirm the whole-app JS bundle is ~940KB gzip before and after (essentially flat: the biggest possible win, keeping `@react-pdf/renderer` out of every non-contract chunk, was **already correctly done** pre-PRD via `next/dynamic(…, {ssr:false})` on `ContractDownloadButton` — this pass added hard proof of that isolation via `.next/react-loadable-manifest.json`, not just a source read). Real fixes made: (1) **auth resolved redundantly up to 3–4x per request on every page** — root layout's `getUser()` plus `getUnreadCount()`/`getUnreadNotificationCount()` each re-called `supabase.auth.getUser()` independently (a real Supabase Auth network round trip, not a free cookie read), `/dashboard` stacking a 3rd/4th via `getViewer()`×2 + `getOwnerBookings()`; `getUser()` (`src/lib/supabase/server.ts`) now wrapped in `React.cache()`, `src/lib/viewer.ts`/`ownerBookings.ts`/`messages.ts`/`notifications.ts` updated to share it — one auth round trip per request instead of several. (2) `/browse?service=` fetched the service catalog 3x per request (`generateMetadata` + page body + `getProviders()`'s internal slug→id lookup); `getServiceCatalog()` wrapped in `React.cache()`, `getProviders()` now takes the caller's already-resolved `locale` so its lookup dedupes against the other two. (3) `getConversations()` ran two independent reads sequentially instead of `Promise.all`. (4) the favorites/blocks joins the PRD named as suspects were already parallelized in PRD-022 — verified, not re-fixed. New migration `20260707120000_perf_messages_unread_index.sql`: partial index on `messages (conversation_id) where read_at is null` (the `getUnreadCount()` query run on every signed-in page load had no supporting index — `favorites`/`blocks` were checked too and already covered by existing unique constraints, no index needed there). Images: `next.config.ts` had **no `images.remotePatterns` at all**, so all 15 `<Image>` usages shipped `unoptimized` (skips resize/format/caching) as a workaround; added `*.supabase.co`/`storage/v1/object/**` + `i.pravatar.cc` patterns, removed `unoptimized` from all 14 fixed-size avatars/photos, added `sizes` to the 3 `fill`-layout photo grids (none had one), converted the shared `Avatar.tsx` (rendered on every page via Nav) from a raw `<img>` to `next/image`. Caching: home's `revalidate = 3600` (PRD-004/023) confirmed intact post-PRD-047 move; `/nanny/[id]` has no ISR and never has (checked via `git log`, not a regression) — flagged as a real tradeoff (needs per-viewer favorite/block state) rather than blindly "fixed". RSC sweep: only `/contract`'s page.tsx is a client component (justified — multi-step form); `BrowseClient.tsx` renders the whole results grid client-side (only `FavoriteButton` truly needs it) but pulling `NannyCard` server-side is a data-flow redesign (non-goal), flagged as a candidate not fixed. **Not verified here** (no `.env.local`/live Supabase/browser in this sandbox, and Next.js 16.2.9's `next build` no longer prints a per-route size table at all — confirmed against Turbopack, `--webpack`, and `--webpack --debug`): real Lighthouse mobile score, real LCP/CLS, real-device browse→profile→request feel. Lint/`tsc`/`next build` all pass.
Booking modification before acceptance (PRD-057)
While `pending`, an owner can edit dates/pet/notes instead of cancel-and-redo, and a provider can counter-propose new dates instead of only accept/decline — both replace the old "the only edit is decline and start over" workflow. Modeled exactly as the PRD asked: a provider proposal is a sub-state on `pending` (new nullable `bookings.proposed_scheduled_start/end/units/unit_price/total_price/note/by` columns, `proposed_by` only ever `'provider'` today), not a new `booking_status` — PRD-054's graph stays six states. **Design call**: "accepting the proposal" directly moves the booking to `accepted` in the same statement that applies the proposed dates (rather than a second provider re-confirm step) — the provider already committed to those exact dates by proposing them, so a second round-trip would be pure friction; documented inline in the migration's header. New shared `src/lib/bookingPricing.ts` (`resolveBookingPricing`) extracts createBooking's PRD-053 price-authority + PRD-019 availability-re-check logic so owner-edit, provider-proposal, and the original request flow can never independently drift — createBooking itself now calls it instead of duplicating the logic inline. `guard_booking_transition()` (the PRD-053 F2 trigger) is relaxed *only* for the requesting owner editing schedule/units/price on their own still-`pending` row (plain edit, or applying an active provider proposal by accepting it) — every other actor/status combination stays exactly as immutable as before, and even the relaxed path re-verifies `unit_price` against `provider_services`' live price and `total_price = unit_price * units` at the DB layer too (belt-and-suspenders on top of the app-layer computation, which stays authoritative). `proposed_*` writes are separately guarded: only the assigned provider may open/update one (same live-price re-check), only a participant may clear one. Verified live via Supabase MCP inside one rolled-back transaction against synthetic owner/provider/outsider identities (the real DB only has one, admin, test account) — 9 scenarios: owner edit with correct price succeeds; forged price rejected; provider attempting the owner-only edit rejected; a non-participant rejected; provider proposing with correct price succeeds; forged proposed price rejected; owner attempting to fabricate their own proposal rejected; owner accepting an active proposal succeeds and correctly lands `accepted`; and — the critical regression check — once `accepted`, the same owner can no longer edit schedule/price at all, confirming PRD-053's hole stays closed. New `booking_events(booking_id, actor_id, kind, payload, created_at)` table (RLS: participants + admin read, participant-scoped insert only) is the one real change-log the PRD asked for — written by this PRD's four new actions (`updateBookingRequest`/`proposeBookingDates`/`acceptBookingProposal`/`declineBookingProposal`, all in `dashboard/bookings/actions.ts`) *and* retrofitted into `transitionBooking()` and `createBooking` so every PRD-054 core transition and the initial request land in the same log, not a second competing history. The PRD-041 booking-detail timeline now renders real `booking_events` rows (with a short "was → now" subtext for edits/proposals) when present, falling back to the original two-step requested/terminal-status rendering for older bookings with no event rows yet. UI: a shared `BookingDatesPicker` (same `MonthAvailabilityGrid` + PRD-019 availability the request flow uses, so a provider literally cannot propose a date they've blocked out) renders inline in both a new `BookingEditForm` (owner) and `ProposeDatesForm` (provider), each showing the "was → now" dates/price comparison the requirement asked for; a `ProposalCard` gives the owner accept/decline on an active proposal, reusing the existing `TransitionForm` client wrapper since both new actions return a `TransitionResult`-compatible shape. Contract PDF requirement verified rather than assumed: `contractHref()` builds the contract payload fresh from `getBookingDetail()` on every render, so an edited/re-accepted booking's contract reflects the final agreed values with no code change needed. **Not verified here**: a real two-browser-tab click-through of the edit/propose/accept UI flow (no live browser in this sandbox) and the four new transactional emails' actual rendering/delivery.
Structured cancellations (PRD-058)
Cancel is no longer a bare status flip — every cancel now requires a structured reason and records who cancelled and whether it was late. Four new immutable-once-set `bookings` columns (`cancelled_by uuid`, `cancel_reason text`, `cancel_note text`, `late boolean`). Reason codes: owner (`plans_changed`/`pet_unwell`/`found_other_care`/`other`) and provider (`unavailable`/`illness`/`conflict`/`other`) as a TS const (`src/lib/bookingCancellation.ts`) — the DB check constraint validates the combined, deduplicated 7-string set (`other` is intentionally shared, not `owner_other`/`provider_other` — which side cancelled is separately, immutably recoverable from `cancelled_by`); side-correctness (an owner can only submit an owner code) is enforced app-side only in the new `cancelBookingWithReason` action, a documented, deliberate tradeoff (see the migration's header). `late` is `now() >= scheduled_start - 48h`, computed in `transitionBooking` and independently re-verified at the DB layer (same belt-and-suspenders pattern as this trigger's existing live-price check). **State machine**: `transitionBooking` (`src/lib/bookingTransitions.ts`) gained a pair of TypeScript overloads rather than a forked executor — calling it with `to: 'cancelled'` only type-checks when `opts.cancellation` (`{ reason, note? }`) is present; every other `to` is unaffected. `cancelBooking` was replaced by `cancelBookingWithReason(bookingId, { reason, note? })`, which resolves the caller's role first and validates the reason against the correct side's 4-code set before ever reaching the transition helper. **UI**: no dialog/modal primitive exists in this app and none was added — a new `CancelBookingForm` inline-expand component (same `open`-state pattern as `ProposeDatesForm`/`ReviewForm`) renders a `RadioCard` reason list scoped to the acting role plus an optional note, used from both `OwnerBookingCard` and the booking-detail hub (`role={detail.role}` there, always `"owner"` on the owner's own list). **Surfacing**: the cancellation `booking_events` row carries the same reason/note/late/cancelledBy data as the booking columns (cancelled is terminal, so the two never drift), and the PRD-041 timeline's `eventDetailText` renders "Cancelled by the owner/nanny: {reason}" (+ note, + a late tag) straight from that one event row; `notifyBookingCancelled`'s email template gained the same reason line (plain-English label map, `CANCEL_REASON_LABELS_EN` — every email template in this app is hardcoded English, no next-intl). **Admin**: `getAdminUserDetail` gained a `cancellations: { total, late }` count (queries `bookings` by `cancelled_by`, admin-bypass RLS already covers it) and the admin user detail page shows it as a small two-row section — a data-existence hook for the future PRD-074 reliability scoring, no scoring/suspension logic added here. Verified live via Supabase MCP inside one rolled-back transaction against synthetic non-admin owner/provider auth users (the real DB's one seed profile is flagged `is_admin`, which would have silently bypassed every check via the trigger's own admin early-return — caught and corrected mid-verification): cancelling with no reason rejected; a valid reason succeeds; a provider-only code submitted through what the DB sees as a valid caller still succeeds (confirms the documented app-layer-only side check); a bogus reason code rejected by the check constraint; the 48h boundary computed correctly on both sides (exactly-48h-out required `late=true`, 48h01m-out required `late=false`, 47h59m-out required `late=true`, each tested against both the correct and an incorrect submitted value); a forged `cancelled_by` rejected; all four cancellation columns confirmed immutable once set. **Not verified here**: a real two-browser-tab click-through of the reason-picker UI and actual email delivery/rendering.
Pending request auto-expiry (PRD-056)
A `pending` request whose start date has passed unanswered no longer sits pending forever. New terminal `booking_status` value `expired` (own migration, `alter type ... add value if not exists`, per this repo's established safe-add precedent) wired into PRD-054's state graph as `pending -> expired`, actor `'system'` only. The hard part was the caller: the hourly PRD-032 cron route has no signed-in user at all, but `transitionBooking()` (`src/lib/bookingTransitions.ts`) previously always called `createClient()` (cookie-derived) and `redirect('/signin')` on no user — both wrong for a cron context. Solved by threading an optional `opts.systemClient` (a service-role client) through `transitionBooking`, honored only when `actor === 'system'`, which skips the cookie/session/redirect path and the `getTranslations` call (no request-scoped locale in a bare route handler) in favor of plain English fallback strings — every other actor is unaffected. `guard_booking_transition()` (the DB trigger, authoritative) gained a matching branch: `pending -> expired` is allowed only when `auth.uid()` is null, which is safe specifically because `bookings_update` RLS already blocks every non-participant, non-service-role UPDATE from ever reaching the trigger at all (verified live via Supabase MCP, rolled back: the service-role path succeeds, a random authenticated non-participant is rejected). Vilnius-timezone semantics ("expire at start-of-day local, not UTC midnight") are computed in Postgres, not JS — a new `find_expiring_pending_bookings()` SQL function compares `(scheduled_start AT TIME ZONE 'Europe/Vilnius')::date` against today's Vilnius date, correctly following EET/EEST DST via Postgres's own IANA timezone database. The cron route calls that RPC, then routes each id through `transitionBooking(id, 'expired', 'system', { systemClient: admin })` — compare-and-swap guarantees at-most-once per booking even across overlapping runs. Owner notification (in-app + `booking_updates` email, new `notifyBookingExpired`) reuses PRD-032's exact reserve-then-send `notification_log` idempotency pattern (`unique(booking_id, kind)`, one new kind `booking_expired`) — belt-and-braces on top of the transition's own CAS. `admin_metrics()`'s request→accept rate already excluded `expired` bookings by construction (its `decided` CTE is an allow-list of `accepted`/`declined`, never a deny-list) — redeclared with a documentation-only "Hand-check" comment confirming this, no logic change. Provider dashboard: expired requests already drop out of the pending queue (no code change needed — that filter was always `status === 'pending'`); a new private `missedRequestsCount` is computed and shown as a small counter (only when non-zero) on `/dashboard/nanny`, deliberately not a full stat widget ("feeds PRD-074"). Booking detail page renders `expired` honestly: no accept/decline/cancel/complete action bar, just a "book someone else" `/browse` link pre-filtered to the requested service + city (same `browseFiltersToParams` convention as the booking-declined email's CTA).
Multi-pet bookings (PRD-059)
A booking used to assume exactly one pet — `bookings.pet_id` existed from the init migration but no app code ever wrote it; the real pet data lived entirely in a `details.pet` jsonb snapshot (confirmed by reading the 2 real booking rows live before writing the migration). New `booking_pets` join table (name/type/breed/age/weight/vaccinated snapshot per pet, nullable `pet_id` FK `on delete set null` — a later-deleted pet profile can't orphan history, same rationale as PRD-043's rebook snapshot) replaces it: 1-3 rows per booking, capped app-side. Backfilled existing bookings from `details.pet` (idempotent `where not exists`); `pets_provider_read` RLS re-pointed from the always-null `bookings.pet_id` to `booking_pets.pet_id` (no regression — both were null for every existing row). New nullable `provider_services.additional_pet_price` drives the pricing: null = "same price each pet" (the safe default until PRD-062 ships a UI to set it), 0 = no extra charge, positive = a per-unit surcharge for each pet beyond the first — one shared `computeMultiPetTotalPrice`/`computePetPriceBreakdown` (`src/lib/serviceCatalog.ts`) every call site uses, so the request summary, contract, and booking detail can never drift apart; `resolveBookingPricing` (PRD-057) now takes/returns `petCount` and re-verifies the live surcharge server-side, never trusting the client, exactly like it already did for the base price. Request flow: the pet step is now a multi-select over the owner's saved pets (`src/lib/pets.ts`'s new `getOwnerPets()`, PRD-006) instead of free-typing one pet inline — reuses `RadioCard`'s existing `multi` checkbox mode. `createBooking` takes `petIds: string[]` (1-3), re-fetches those rows server-side (never trusts client-supplied pet details), and snapshots them into `booking_pets` right after inserting the booking; `details.pet` keeps carrying just the *first* pet (backward-compat for the email templates, which only ever needed one representative pet — never a stated PRD-059 surface). Every booking-facing read model (`bookings.ts`, `ownerBookings.ts`, `bookingDetail.ts`) now embeds `booking_pets` and exposes a `pets[]` array; a shared `petsSummaryParts()` (`src/lib/bookingPets.ts`) produces the "Riko + 1 more" label everywhere it's needed (dashboards, request card, care journal header) from one place. Rendering: booking detail page shows one block per pet with its own price line; contract PDF renders one "Pet Details" section per pet plus a per-pet payment breakdown; care journal gets a "For Riko + 1 more" header when there's more than one pet; provider's pending-request card shows a prominent pet-count badge before they accept (requirement 5). Rebook (PRD-043): `rebook.ts`'s query-param payload changed from one pet object to a pet *array* (`encodeRebookPets`/`decodeRebookPets`, capped at 3, with a legacy single-object fallback for old links), so "book again" on a multi-pet booking carries every pet, matched best-effort by name+type against the owner's current saved pets (a renamed/deleted one simply isn't pre-selected). **Deviation**: pet composition is intentionally NOT editable via the owner-edit form (`BookingEditForm` dropped its `PetFields` entirely, dates/notes only) — not a stated requirement, and re-snapshotting mid-negotiation would blur the "snapshot never changes after creation" guarantee; `BookingDatesPicker`'s before/after price preview was extended with `petCount`/`additionalPetUnitPrice` so it doesn't undercount a multi-pet booking's real total while editing dates. **Verified live** via Supabase MCP inside a rolled-back transaction against this project's real 2 booking rows: backfill correctly produced one `booking_pets` row per booking matching the source `details.pet` snapshot exactly (name/type/age/vaccinated all confirmed byte-for-byte, `pet_id` correctly carried through as null). Lint/tsc/build all pass. **Not verified here**: a real two-browser-tab multi-select-and-submit click-through and PDF visual rendering of the multi-pet contract.
Pet care profile v2 (PRD-060)
Pet profiles (PRD-006) gain an optional structured "care sheet" — feeding, medication, health/vaccination, vet contact, behavior, emergency contact — entered once and reused on every booking. One `pets.care_profile jsonb` column, versioned and zod-parsed (new dep) via `src/lib/careProfile.ts` (client-safe, no Supabase import): every field optional, a flat even-split completeness calc ("Care sheet 40% complete"), and per-section "has content" helpers so nothing ever renders an empty box. Pet form (`PetFields.tsx`) gets a new reusable `CareProfileFields` component rendering six independently collapsible sections via a new `Disclosure` UI primitive (progressive disclosure, requirement 2) — shared verbatim with the booking-detail nudge form so the two surfaces can never define the fields differently. **Privacy (requirement 3, the hard part)**: `booking_pets` (PRD-059) grants either booking participant unconditional read regardless of status — RLS is row-level, not column-level, so a `care_profile` column bolted onto that table could not be selectively hidden from a provider on a still-`pending` request. Solved with a new, separate 1:1 table, `booking_pet_care_profiles(booking_pet_id unique, care_profile, created_at, updated_at)`, whose own `select` RLS conditions the assigned provider's read on the parent booking's status (`accepted`/`completed` only) while the owner can always read their own — the actual privacy boundary lives in this policy, not in any UI check. `createBooking` (`src/app/[locale]/request/actions.ts`) snapshots each selected pet's *current* `care_profile` into this table right after inserting its `booking_pets` row (same "snapshot, not live join" rationale as PRD-043/059 — a later pet-profile edit must not silently rewrite an already-agreed booking's terms). `bookingDetail.ts` embeds the nested `booking_pet_care_profiles` in its one query and re-applies the same role/status gate defensively (`canViewCareProfileSnapshot`, `src/lib/bookingCareProfile.ts`) before ever exposing a `careProfile` value — belt-and-suspenders on top of the RLS, which is what actually blocks the row at the DB layer. Booking-detail page (`/dashboard/bookings/[id]`) renders a read-only `CareSheetView` per pet, only for sections with real content, entirely absent when the whole sheet is empty. **Design decision on requirement 5's nudge** ("Add feeding instructions before the stay"): rather than only linking to "edit your pet" — which would have zero effect on an *already-snapshotted* booking, silently contradicting the nudge's own "before the stay" copy — `booking_pet_care_profiles` uniquely (among this app's snapshot tables) also gets an owner-only `update` RLS policy, and a new `updateBookingPetCareProfile` action lets the owner patch *this booking's* snapshot directly from a `CareProfileNudge` banner (shown only to the owner, only while `accepted`, only for pets missing feeding or a complete emergency contact — the two fields judged "key"). The reusable `pets.care_profile` is deliberately left untouched by this action, so a future booking still starts from the owner's general saved profile. This is the one snapshot table in the app that isn't write-once by convention — documented at length in the migration's header. **Verified live** via Supabase MCP against the real project (migration `20260712110000_pet_care_profile.sql` actually applied, not just simulated) inside rolled-back transactions using synthetic seed identities: a provider on a `pending` booking got 0 rows back for the care-profile snapshot; the same provider on an `accepted` booking got the row; the booking owner could read *and update* their own pending booking's snapshot; the assigned provider's attempted update silently affected 0 rows (no provider update policy exists); a non-participant outsider got 0 rows on an accepted booking; the table was confirmed empty (0 leftover rows) after every rollback. Lint/`tsc`/`next build` all pass. **Not verified here**: a real browser click-through of the collapsible pet form and the nudge's save flow, and PDF contract rendering of the care sheet (PRD-061, explicitly out of scope for this PRD per its own requirement 4 — "until then, detail page rendering is enough").
Meet & greet before first booking (PRD-063)
A lightweight, always-optional meet & greet on the very first booking between an owner/provider pair — never a new `booking_status` (PRD-054's six-state graph is untouched), just a new `meet_greets` table hanging off the booking (`unique(booking_id)`, states proposed/confirmed/done/skipped), exactly as the PRD asked. **First-booking detection**: "no prior booking rows for the pair in a terminal-positive state" is defined precisely and computed server-side (`src/lib/meetGreets.ts`'s `isFirstBookingBetweenPair`) — a prior `accepted`/`completed` booking between the exact same owner+provider disqualifies "first"; `pending`/`declined`/`cancelled`/`expired` never do — and independently re-verified a second time at the DB layer inside `guard_meet_greet_transition()`, never trusted from the client. **Provider setting** (requirement 1): new `provider_services.offers_meet_greet boolean default false` — off by default for every service, since defaulting it true for overnight/day-care would be consent nobody gave; "recommended for overnight/day-care" is instead a UI hint next to a new `Toggle` in the shared `ServicePricingFields.tsx` (wired into `ProviderServicesManager.tsx`/`/dashboard/nanny/profile` now; **deliberately not wired into onboarding's `ServicesStep`** — a scoped-down call to avoid touching the onboarding reducer, so a service created at signup starts with meet & greets off until the provider visits the edit page). **State machinery** (`guard_meet_greet_transition()`, mirrors `guard_booking_transition()`'s "broad RLS gate, fine-grained trigger rules" split): either participant may propose/re-propose a time; only the *other* participant may confirm it (the proposer can never confirm their own suggestion — enforced at the DB layer, not just the UI); a re-proposed time always resets to unconfirmed; either participant may skip at any point (even before ever proposing a time) or mark a confirmed meet done. **Reminders** (requirement 4): the existing PRD-032 hourly cron gained a new sweep (`findDueMeetGreetReminders`/`sendMeetGreetReminder` in `src/lib/email/reminders.ts`, a new `meet-greet-reminder` email template) using the identical reserve-then-send `notification_log` idempotency pattern as the booking reminder — two new `notification_kind` enum values (`meet_greet_reminder_owner`/`_provider`) added in their own isolated migration, per this repo's established "never `ALTER TYPE ... ADD VALUE` in the same transaction as anything that uses it" rule (PRD-056's precedent). Chat entry point reuses PRD-028's exact `/messages/new?prefill=` mechanism (`meetGreetPrefill` in `src/lib/messages.ts`), no new mechanism. **Post-meet prompt** (requirement 5): "Great, keep the booking" flips the meet_greet to `done` with no other write at all; "Cancel the booking" routes through the same `transitionBooking()` cancellation path every other cancel uses, with a new reason code `after_meet` — added to the DB check constraint (a plain checked `text` column, not an enum, so this needed a simple drop/re-add constraint migration, not the enum-split treatment) but deliberately *not* added to either side's 4-code `cancelReasonsFor()` list in `bookingCancellation.ts`, since it must never appear as a manually-pickable option in the ordinary `CancelBookingForm` reason picker — reached only through this one dedicated action, which calls `transitionBooking` directly rather than through `cancelBookingWithReason` (whose role-scoped reason validation would otherwise reject it). "No review eligibility side effects" holds structurally, verified rather than assumed: `submitReview` already gates on `booking.status === 'completed'`, which a meet-and-greet cancellation (terminal at `cancelled`) can never reach — no new logic added. UI: one new `MeetGreetCard` (server component, reuses the existing `TransitionForm` client wrapper for every zero-argument action) plus one small `MeetGreetProposeForm` client component for the date+time input, rendered on the booking detail page for `pending`/`accepted` bookings only, and only while there's something to show (an active proposal/confirmation, or first-booking eligibility) — once resolved (`done`/`skipped`) the card disappears entirely rather than rendering an empty husk. **Verified live** via Supabase MCP inside a rolled-back transaction against synthetic owner/provider/outsider profiles (careful to pick a UUID namespace that didn't collide with this project's real seeded demo accounts, which share the same generic-looking UUID prefix a first attempt collided with): propose→(self-confirm rejected)→other-party-confirms→done all behave correctly; re-proposing after a confirm correctly resets `confirmed_by` to null (a bug caught and fixed mid-verification — the trigger originally required `confirmed_by` stay unchanged on re-propose, which is backwards); a second booking between an already-`accepted` pair is correctly rejected as "not the first booking"; an outsider can neither read nor insert into another pair's `meet_greets` row; skip-from-scratch (no time ever proposed) succeeds; the widened `bookings.cancel_reason` check constraint accepts `after_meet` and still rejects a bogus code. Lint/`tsc`/`next build` all pass. **Not verified here**: a real two-browser-tab click-through and actual reminder-email delivery/rendering.
Recurring bookings (PRD-065)
The highest-LTV request shape — "walk Riko every Mon/Wed/Fri at lunch" — used to need one request per walk. New `booking_series` table (own small `booking_series_status` enum — pending/accepted/declined/cancelled, deliberately NOT reusing/polluting `booking_status`) plus a nullable `bookings.series_id`: occurrences are ordinary `bookings` rows, tagged, so PRD-054's transition graph, PRD-055/064's conflict guards and PRD-032's cron all apply completely unmodified — the PRD's own explicit "materialize, don't virtually expand" instruction. Time-scoped services only (`TIME_SCOPED_PRICING_UNITS`, PRD-064), weekly patterns only, capped at 8 weeks (`booking_series_max_8_weeks` — a plain `end_date <= start_date + 56` check, re-verified server-side in `createBookingSeries` too, never just a UI `max` attribute). Every "agreed term" (unit price, per-pet surcharge, pet snapshots incl. care profile, owner notes, emergency vet cap, contract terms version) is captured ONCE at series creation into `booking_series.pet_snapshots`/etc — occurrences never re-derive pricing or re-read `pets` live, same "snapshot the agreement, not a live join" posture every other booking-time snapshot in this app already has. **Generation** (`src/lib/bookingSeries.ts`'s `materializeSeriesOccurrences`, shared by series-creation and the cron): for each weekday in range, checks live PRD-064 window availability (`getProviderWindowAvailability`/`bookableWindowsFor` — the exact same call `resolveBookingPricing` makes for a one-off request) and either inserts a normal `bookings` row (+ `booking_pets`/`booking_pet_care_profiles` from the frozen snapshot) or logs a gap. A date that already has ANY occurrence row (any status, including cancelled) is treated as handled — this is also how "cancel this occurrence only, never regenerate that date" (requirement 4) holds for free, no extra column needed. New unique index `bookings_series_occurrence_date_key` on `(series_id, lower(booking_range))` is the DB-layer backstop against a double-generation race (reuses PRD-055's trigger-populated `booking_range`, not a raw `scheduled_start::date` cast — Postgres rejects timezone-dependent casts in index expressions, caught live while writing the migration). **Series accept/decline** (requirement 3, `seriesActions.ts`): one action CAS-flips `booking_series.status`, then loops the real `transitionBooking()` per currently-pending occurrence — reusing PRD-054's guards, never bypassing them, per the PRD's own instruction; a rare per-occurrence accept-time conflict just leaves that one row `pending`, left to the pre-existing PRD-056 expiry sweep rather than a bespoke resolution path. **The conflict-flagging mechanism** (requirement 3's explicit acceptance criterion — "flag it, don't silently create or silently skip"): new `booking_series_gaps` table, `unique(series_id, gap_date)` doubling as the idempotency guard (a repeat sweep on a still-blocked date is a silent no-op, not a second notification), surfaced both on the series detail page (a visible "N occurrences couldn't be scheduled" card) and via a new in-app notification kind to both parties. **Cancel semantics** (requirement 4): "this occurrence only" is unchanged — the existing single-booking structured cancel (PRD-058) already works, no new code needed. "Whole series from here on" (`cancelBookingSeriesFromHere`) sets the series to `cancelled` and cancels every future `pending`/`accepted` occurrence via that exact same per-booking cancel path — never a parallel cancellation concept; past `completed` occurrences and their reviews are untouched by construction (the occurrence query only ever selects `pending`/`accepted` rows). **Reviews — the trickiest schema change**: `reviews.booking_id` was already nullable (PRD-016, for an unrelated reason); this PRD adds a nullable `series_id`, a `reviews_booking_xor_series_check` CHECK (exactly one of the two set), and a partial unique index on `series_id`. The "updateable while active" requirement (5) could NOT be expressed as a second, additive permissive RLS policy — verified live (rolled-back transaction) that the pre-existing `reviews_update` (author-owned, no status check at all) silently defeats a narrower additive policy, since Postgres ORs every permissive policy for the same command together. Fixed by replacing `reviews_update` itself with one policy whose condition branches on `series_id` (null → pre-existing unrestricted behavior, unchanged; not null → editable only while the series is still `accepted`) — a real bug caught by live verification, not just theorized. `refresh_provider_rating()` needed no change (it aggregates by `provider_id` alone, confirmed by re-reading it, not assumed). **Verified live** via Supabase MCP inside one rolled-back transaction (temporarily layering in PRD-064's `time_window` enum too, since the linked project's branch lags behind this repo's own migration history — `MIGRATIONS_FAILED` on `main`, a pre-existing condition unrelated to this PRD): series-accept bulk-transition touched exactly the 2 occurrences of the target series and left a same-provider, different-series pending occurrence untouched; cancel-from-here correctly cancelled a future `accepted` occurrence while a past `completed` one and its review stayed byte-for-byte unchanged; the reviews CHECK constraint rejected both "both set" and "neither set"; the fixed reviews UPDATE policy allowed the author to edit while `accepted` and correctly rejected the same edit once `cancelled` (rating stayed at its last-good value, the blocked update did not silently partial-apply); the 8-week and weekday-range constraints both rejected out-of-bounds inserts. **UI**: additive per the PRD's brief — a new `SeriesCard` (schedule in words, status, occurrence progress) sits in its own "Recurring bookings" section on both dashboards, series-linked occurrences excluded from the flat per-booking lists (each occurrence is still individually a normal booking, reachable from the new series detail page — `/dashboard/bookings/series/[id]` — link-per-occurrence, never re-rendered specially); a new `/contract/series/[id]` route + `SeriesContractPDF` (schedule described in words + per-occurrence price, deliberately a leaner sibling of `ContractPDF.tsx`, not a full reuse — a series has no single `scheduledStart`/`scheduledEnd` to render). **Deviations/scope cuts, disclosed**: no new email templates for series lifecycle events (in-app notifications only, via the existing `createNotification`); every new series-specific UI string is hardcoded English, not threaded through next-intl (given this PRD's size); the series contract omits the full per-pet care-sheet reproduction (still viewable on each occurrence's own `/contract/[bookingId]`) — the series document's job is the agreement (parties/schedule/price/terms), not an operational day-of-care sheet. Lint/`tsc`/`next build` all pass. **Not verified here**: a real two-browser-tab click-through of the full request→accept→cancel-from-here flow, and the cron's rolling-generation sweep actually firing in a live deployed environment (Vercel Cron, hourly) rather than just its underlying function verified live.
Provider vacation mode & listing pause (PRD-066)
An honest, reversible "away" switch for a provider going quiet for a while — deliberately NOT the same mechanism as PRD-038's admin suspension, even though the technical note points at reusing that pattern. New `provider_profiles.paused_at`/`paused_until`/`pause_reason` (self-service, provider-writable — no admin-only guard trigger like `guard_provider_verified()` is needed, since pausing your own listing needs no protecting from the provider themselves). **The key design split**: suspension tightens `provider_profiles_read` RLS to exclude the row from every read, including the single-profile page (a real 404); pause must NOT do that (requirement 2: profile stays reachable via direct link) — so `provider_profiles_read` is left completely untouched, and exclusion happens at the QUERY level instead (`getProviders`/`getFeaturedProviders`/`getPlatformStats` in `src/lib/providers.ts` add `.is('paused_at', null)`), the one place RLS genuinely can't distinguish "browse" from "the profile page" on the same table the way two separate call sites can. `nanny/[id]/page.tsx` checks `nanny.paused` (only ever populated by `getProvider`'s single-profile select, never the listing queries) and swaps the request CTA (hero, per-service card, mobile sticky bar) for a "not accepting requests" notice — favorites/reviews render completely normally, explicitly unlike suspension. Defense-in-depth: `/request/[nannyId]` redirects back to the profile if already paused at page-load, and both `createBooking`/`createBookingSeries` re-check live server-side before ever inserting a row. **Pause-time cleanup** (requirement 3): pausing with pending requests returns a `{ confirmationNeeded, pendingCount }` result instead of writing anything — `PauseListingCard` shows the count and asks for an explicit second click before `pauseListing` loops `transitionBooking(id, 'cancelled', 'provider', { cancellation: { reason: 'paused' } })` per pending row, the exact same executor every other cancellation goes through (which is what fires the existing `notifyBookingCancelled` email/notification to each owner for free — no new notification plumbing needed for this step). `paused` is a new hidden, dedicated-action-only `cancel_reason` code (drop/re-add the check constraint, same treatment as PRD-063's `after_meet`) — never added to either side's pickable `cancelReasonsFor()` list. Only `status = 'pending'` rows are ever looked up or transitioned; `accepted` bookings are never touched by any code path here. **Auto-unpause + reminders** (requirement 4), both new behaviors on the existing PRD-032 hourly cron: (a) `findDueUnpauseProviders`/`autoUnpauseProvider` (`src/lib/providerPause.ts`) — a compare-and-swap on the exact `paused_until` value read (mirrors `transitionBooking`'s own CAS), which is what guarantees the "welcome back" notification (`notifyProviderUnpaused`, new `provider_unpaused` in-app kind + `provider-welcome-back` email) fires at most once per pause episode with NO separate `notification_log` reservation needed — a permanent `unique(provider_id, kind)` row would have wrongly blocked every subsequent pause/unpause cycle a provider ever goes through. (b) for providers paused with no return date: a monthly "still paused?" email (`provider-still-paused` template), "monthly on an hourly cron" solved by a new `find_providers_due_for_still_paused_reminder()` SQL function checking `notification_log` for the most recent row of a new `provider_still_paused_reminder` kind per provider (>30 days or none at all) — `notification_log.booking_id` had to become nullable with a new sibling `provider_id` column (`notification_log_subject_check`: exactly one of the two set) since this reminder isn't tied to any booking. Both new enum values isolated in their own migration file per this repo's established `ALTER TYPE ... ADD VALUE` convention. **Admin** (requirement 5): `admin_metrics()`'s `provider_activation` CTE now filters `pp.paused_at is null` (excluded from both numerator and denominator — a paused provider isn't "failing to activate", it's temporarily out of the pool), documented with a "Hand-check" comment per this migration file's convention; the admin user detail page shows a "Listing status" row (Active/Paused badge) plus the pause reason/return-date line alongside the existing suspension badge. **Verified live** via Supabase MCP inside rolled-back transactions: a paused provider correctly excluded from the browse-shaped listing query while the single-profile-shaped read still returned the row; pausing with a mix of pending and accepted bookings for the same provider bulk-cancelled only the pending ones (each landing `cancelled`/`cancel_reason='paused'`) and left every accepted row completely byte-for-byte unchanged (status, all columns); the widened `bookings_cancel_reason_check` accepted `paused` and still rejected a bogus code; the auto-unpause CAS only succeeded once for a given `paused_until` value and returned false on a repeat attempt against the same (already-cleared) row. **Non-goals, explicitly out of scope per the PRD**: auto-pausing inactive providers (needs PRD-074's data first) and owner-side "your favorite paused" notifications (PRD-098). Lint/`tsc`/`next build` all pass. **Not verified here**: a real two-browser-tab click-through of the pause/confirm/resume UI flow, and the cron's auto-unpause + monthly-reminder sweeps actually firing in a live deployed environment.
Time-of-day availability & slots (PRD-064)
Whole-day tri-state availability (PRD-018/019) fit overnight/day-care but not the services that drive weekday frequency — a walker doing 3 walks/day and an owner wanting "Tuesday around lunch" had no way to express either, and per_walk/per_visit bookings got zero conflict detection at all (PRD-055's `is_exclusive` only ever applies to per_night/per_day). New catalog flag (`isTimeScopedUnit`/`TIME_SCOPED_PRICING_UNITS` in `src/lib/serviceCatalog.ts`) is deliberately narrower than the existing "single vs range" date-mode split — per_walk/per_visit only, NOT per_hour/per_session (grooming), which stay an in-person-coordinated appointment, not a provider-declared-window system. **Schema**, exactly per the PRD's own technical note: `availability_slots` gains a nullable `window` enum (morning/midday/evening) — whole-day rows keep `window null`, completely untouched; the old `unique(provider_id, date)` became a single `NULLS NOT DISTINCT` unique index on `(provider_id, date, window)` (Postgres 15+; verified live this project runs 17.6) so a null-window row still collides with another null-window row exactly as before, while two different-window rows on the same date now correctly coexist — chosen specifically because a *partial* index would've broken the existing `setAvailabilityDates` upsert's `onConflict` inference (Postgres can't infer a partial unique index from a bare column list), so a tiny new `upsert_availability_window()` SQL function (security invoker — RLS still applies) exists purely to express the one ON CONFLICT clause PostgREST's upsert helper can't. **The conflict guard** (requirement 4) is a brand-new, independent mechanism, NOT an extension of PRD-055's GiST exclusion constraint — adding a nullable `window` column to that constraint's key list would have made every whole-day-exclusive (window-null) booking compare as "always distinct" (NULL semantics in exclusion/unique constraints), silently disabling PRD-055's overlap guarantee for every overnight/day-care booking. Instead: `bookings.time_window` (nullable enum) plus a plain partial UNIQUE index `bookings_no_overlapping_time_window` on `(provider_id, lower(booking_range), time_window)` scoped to `status = 'accepted' AND time_window IS NOT NULL` — reuses PRD-055's own trigger-populated `booking_range` column rather than re-deriving a date from `scheduled_start` (avoiding the same non-immutable-cast problem that column was built to solve), and its `WHERE time_window IS NOT NULL` predicate means it can never even see, let alone interact with, an `is_exclusive` row. **Verified live** via Supabase MCP inside rolled-back transactions (see this PRD's full report for the complete transcript): a whole-day row and a windowed row now coexist on the same (provider, date); a second whole-day row for the same (provider, date) is still rejected exactly as before; two accepted per_walk bookings same provider/day/SAME window are rejected (23505); same provider/day/DIFFERENT window both succeed; the real accept-flow CAS shape (two pending requests, `UPDATE ... WHERE status='pending'`) leaves the loser `pending`, not corrupted; a baseline overlapping-per_night-booking-pair is STILL rejected by the pre-existing `bookings_no_overlapping_exclusive` (23P01), completely unaffected; every per_walk row inserted has `is_exclusive = false`, confirming zero interaction between the two mechanisms. `transitionBooking()` (`src/lib/bookingTransitions.ts`) now also catches this constraint's 23505 (matched on constraint name, not just SQLSTATE) and reuses the exact same `code: 'conflict'`/`TransitionConflict` shape PRD-055 established — one conflict UI path, not two; `src/lib/bookings.ts`'s pre-emptive `findConflict` (RequestCard's warning banner) extended the same way. **Provider UI**: new `TimeScopedAvailabilityEditor` (only rendered on `/dashboard/availability` when the provider offers a time-scoped service) — a weekly pattern (Mon–Sun × morning/midday/evening toggles, "Apply to next 3 months" bulk-writes concrete `availability_slots` rows, additive-only like the existing "mark weekends available" convention) plus a per-day override grid below it, kept deliberately to this one screen/two-piece shape per the PRD's own explicit "don't over-build before a real walker tests it" caution — no fancy calendar-per-window view. **Request flow**: for a time-scoped service, `DatesStep` swaps the exact-time input for a chip picker showing only the windows the provider actually offers, greying out ones already booked by someone else (`getProviderWindowAvailability`/`get_booked_windows` RPC — the security-definer counterpart to `get_booked_dates`); `resolveBookingPricing` (`src/lib/bookingPricing.ts`) re-validates the chosen window against live data server-side exactly like it already does for whole-day ranges — never trusts the client's window choice any more than its date. A nominal per-window anchor time (09:00/13:00/18:00) populates the otherwise-required `scheduled_start` timestamp for sorting/reminders/`completeDue`, but is never shown to a user — every human-facing surface renders the window label instead. **Rendering** ("tomorrow, midday"): dashboards/booking detail/emails share the window suffix via a new `src/lib/timeWindowLabel.ts` (English-only, matching this codebase's pre-existing convention that every `describeDates`-style formatter is English regardless of locale); the 24h-ahead reminder email specifically renders "today"/"tomorrow, {window}" since that email's own fixed send window makes it accurate there; the contract PDF shows a bilingual window noun row instead of a clock time (`WINDOW_NOUNS` in `contractStrings.ts`) — printing a specific `HH:mm` on a legal document for a service that was only ever agreed to a coarse window would overstate the agreement. **Deviation/scope cut**: the pre-existing owner-edit/provider-counter-proposal date pickers (PRD-057) were NOT extended to collect a window — editing a time-scoped booking's date through those flows leaves `time_window` untouched (the shared `resolveBookingPricing` only validates a window when the caller passes one, which only the new request-flow caller does), same residual-risk posture as `service_name_snapshot`/`emergency_vet_cap` (neither trigger-guarded either). Lint/`tsc`/`next build` all pass. **Not verified here**: real browser click-through, and the PRD's own "usability-test with one real walker before building the override UI" caveat, which can't happen in this sandbox.
Phone verification (PRD-068)
SMS OTP phone verification, deliberately built as a fully self-managed pipeline — NOT Supabase Auth's phone-identity/`signInWithOtp({phone})` system, which the PRD's own non-goals list explicitly rules out (no `auth.users.phone` touched anywhere). New `src/lib/sms/` mirrors `src/lib/email/`'s exact shape: one `sendSms()` choke point (`src/lib/sms/index.ts`) over a `deliverSms()` transport (`src/lib/sms/transport.ts`) that calls Messente's Omnimessage API when `MESSENTE_API_USERNAME`/`MESSENTE_API_PASSWORD` are set, and falls back to a `[sms:dev] would send` console log otherwise — same dev-console posture Resend had pre-launch. Messente chosen over Twilio for better Baltic-corridor (+370) pricing/delivery as a direct-carrier EU gateway rather than a US aggregator reselling through third parties; documented in `docs/SMS_SETUP.md` alongside the human setup checklist (create account, get API creds, optional dedicated sender ID, set `PHONE_OTP_PEPPER`). This transport is wired into exactly one call site (`src/lib/phoneOtp.ts`) and nowhere else — not the notification system, per the PRD's explicit non-goal. **Schema** (`20260717120000_phone_verification.sql`): reuses the existing `profiles.phone` column (already the one phone field ProfileForm/become-nanny/admin/contracts all read) rather than adding a parallel one — new `profiles.phone_verified_at` just answers "has the CURRENT `phone` value been OTP-proven". A `guard_phone_verification()` trigger (mirrors PRD-037's `guard_provider_verified()` shape, but keyed on `auth.role() = 'service_role'` rather than `is_admin()` — the trusted writer here is the app's own OTP-verify action via `createAdminClient()`, not a signed-in admin) reverts any non-service-role attempt to set `phone_verified_at` directly, and unconditionally nulls it whenever `phone` itself changes through any non-service-role path — requirement 4's "number changes re-verify" enforced at the DB layer, not just app discipline. New `phone_otp_codes` table (RLS enabled, ZERO policies — service-role only by omission, stricter even than PRD-067's admin-readable `id_verification_deletions`, since a raw OTP hash has no legitimate reason to ever reach a browser session) stores one row per send: `user_id`, `phone`, `code_hash` (`HMAC-SHA256(pepper, phone:code)`, never the raw code), `attempt_count`, `expires_at` (10 min), `consumed_at`, `created_at`. **Rate limiting** (requirement 4, the PRD's literal acceptance criterion — "6th request in a day is refused"): `src/lib/phoneOtp.ts`'s `requestPhoneOtp` counts rows in the last 24h via the admin client on BOTH dimensions before writing anything — `eq('user_id', ...)` and, separately, `eq('phone', ...)` — the phone dimension specifically requires bypassing RLS since it must see every account's attempts against that number, not just the caller's own (a user-scoped `user_id = auth.uid()` policy could never catch a different account cycling through someone else's number). **Provider publish gate** (requirement 2): reuses PRD-066's exact "query-level exclusion" mechanism, not a new one — `getProviders`/`getFeaturedProviders` (`src/lib/providers.ts`) now also drop any row failing `isProviderPhoneGateOk` (`src/lib/phoneVerificationState.ts`), applied as a JS filter on the raw rows (not a SQL `WHERE`, unlike PRD-066's plain `.is('paused_at', null)`) because the grace-period math depends on comparing this provider_profiles row's own `created_at` against a fixed launch-date constant — not a plain boolean column, and not reliably expressible as a single PostgREST filter across the embedded `profiles.phone_verified_at` join without `!inner`-join fragility; same functional guarantee ("excluded from browse at the query surface"), same two functions, just JS rather than SQL. Grandfathering: any provider_profiles row that already existed before this feature's launch date gets a single global 30-day grace deadline (shorter than PRD-067's 60 — this is actively enforced, not just a nudge, so the window is tighter) via `PhoneGateBanner` on `/dashboard/nanny`; a genuinely new (post-launch) or grace-expired provider sees a "your listing isn't live" variant of the same banner. `getProvider(id)` (the single-profile read) is deliberately untouched — same "profile page stays reachable via direct link" carve-out PRD-066 established. **Contract/badge surfacing** (requirement 3): `src/lib/bookingContactInfo.ts` gained `revealedPhone(status, phone, phoneVerifiedAt)`, layering "and it's actually verified" on top of the existing PRD-061 `canViewContactInfo` accepted-only gate — `bookingDetail.ts`/`seriesContractDetail.ts` both now show a contract phone number only when BOTH conditions hold; an accepted booking with an unverified counterpart phone renders "Not provided", the same honest fallback as no number at all, rather than printing something nobody validated (this PRD's own Problem statement). A "✓ Phone verified" `Badge` chip renders in the PRD-037 admin queue (`VerificationQueueCard`, next to the existing phone-on-file line) and on the public nanny profile (`/nanny/[id]`, alongside the identity-verified badge) — boolean-only, never the raw number, and provider-only by construction (owners have no public profile for a chip to sit on, addressed in the PRD's own "on profiles" framing). **Settings UI**: new `PhoneVerificationCard` on `/dashboard/settings` alongside `ChangePasswordForm`/`ChangeEmailForm` — enter a number, send code, enter code, verify; two `useActionState` forms backed by new `sendPhoneVerificationCode`/`confirmPhoneVerificationCode` server actions (`dashboard/settings/actions.ts`) that just authenticate the caller and delegate to `phoneOtp.ts`. Owners get a dismissible, non-blocking nudge (`RequestForm`, gated on `showPhoneNudge` — true only for a genuinely first-ever, still-unverified owner request, computed server-side in `request/[nannyId]/page.tsx` via a `bookings` count) rather than any enforcement. **Verified live** via Supabase MCP inside a rolled-back transaction: the 5th OTP send in a rolling 24h window for a synthetic user+number succeeded and the 6th was correctly refused, independently confirmed on both the per-user and per-number dimension (a different synthetic user against the same already-5x-sent number was also refused, proving the cross-account phone dimension actually works, not just the per-user one); a non-participant could not see a pending booking's counterpart phone at all, and a genuine participant only saw it once the booking's status flipped to accepted AND the counterpart's `phone_verified_at` was set (an accepted booking with an unverified counterpart still correctly showed nothing); an unverified, post-launch-created provider row was excluded from the browse-shaped listing query while a pre-launch, still-within-grace-period unverified row correctly remained. **Deviations, disclosed**: `getPlatformStats()`'s home-page "nannies" count was NOT updated to exclude phone-gated providers (unlike PRD-066's equivalent treatment of pause) — it's a `count`-only head query and the grace-period math isn't cheaply expressible there; left as a minor over-count until a real need justifies the extra complexity. The older ProfileForm/become-nanny phone inputs still accept any free-typed string (unnormalized) — E.164 normalization (requirement 1) only happens at the OTP-verify boundary (`src/lib/phone.ts`'s `normalizeToE164`), not retrofitted onto every existing phone-writing path. Every new UI string is hardcoded English, not threaded through next-intl, consistent with this dashboard/admin area's existing convention (ChangeEmailForm, IdVerificationCard, etc. are the same). Lint/`tsc`/`next build` all pass. **Not verified here**: no real Messente account exists in this sandbox, so only the dev-console SMS fallback (logs the code instead of texting it) could be exercised — the actual send-a-real-text path is unverified until a human completes `docs/SMS_SETUP.md`'s setup steps; also unverified: a real two-browser-tab click-through of the settings verify flow and the owner-nudge dismissal UX.
Owner trust signals for providers (PRD-069)
Trust used to be asymmetric — owners see badges/reviews/galleries, but a provider deciding whether to accept a stranger's request only saw a first name and a chat message. New `getOwnerContextCard(ownerId, viewerId)` (`src/lib/ownerContextCard.ts`) is the ONE server helper every surface calls, per the PRD's own technical note ("don't scatter the join logic") — member-since (`profiles.created_at`), a phone-verified chip (PRD-068's `phone_verified_at`), a platform-wide completed-bookings count, whether any of the owner's saved pets has a non-empty care sheet (PRD-060's `pets.care_profile`/`careProfileIsEmpty`), and a past-cancellation count — shown only past a >2 threshold (symmetric honesty with PRD-058), and filtered to `cancelled_by = ownerId` specifically, never a cancellation a provider or the system caused on the owner's behalf. **The relationship check is the real access-control mechanism, not incidental**: `bookings`/`pets` RLS alone can't give the right answer here — a provider's own session can only ever see the SPECIFIC booking(s) they share with an owner, never that owner's full platform history with other providers or their other pets, which is exactly what "completed-bookings count" and "pet profile completeness" need to read. So the helper first re-verifies (via the caller's own RLS-scoped session — belt and suspenders, since RLS itself guarantees a false positive is structurally impossible here) that `viewerId` actually shares a booking (any status) or a `conversations` row with `ownerId`, and ONLY once that holds does it switch to the service-role admin client to read the cross-table aggregate — the same "verify with the normal session, read the rest with admin" split `contractDetail.ts` (PRD-061) already established for contract emails. No relationship at all (a stranger supplying any `ownerId`) returns `null` before the admin client is ever touched — there is no public owner directory. **"Has photo" — a disclosed gap, not fabricated**: the PRD's own requirement asks for pet-profile completeness to include "has photo", but `pets` has no photo column at all (confirmed by reading the init migration before writing any code) — no PRD to date has shipped pet-photo upload. Rendering "false" for literally every owner would describe a missing feature, not owner behavior, so that half is scoped out entirely (no `hasPetPhoto` field), documented in `ownerContextCard.ts`'s header comment rather than invented against a column that doesn't exist. **Neutral framing** (acceptance criterion 2): `OwnerContextCard.isNew` (zero completed bookings) drives a "🌱 New to PetNanny" badge in place of a literal "0 completed bookings" line — every other chip (`OwnerTrustSignals.tsx`, hardcoded English like every other dashboard trust card — IdVerificationCard/PhoneVerificationCard) is opt-in/positive-only by construction: phone-verified and care-sheet-on-file render ONLY when true, the cancellation count only past its threshold — nothing ever spells out an explicit zero/false. **Surfaces**: the pending-request card (`RequestCard.tsx`, via a new `PendingRequest.ownerContext` populated in `getProviderBoard`'s existing per-request loop) and the booking-detail hub for the provider role only (`bookingDetail.ts`'s new `BookingDetail.ownerContext`, null for the owner's own view of their booking — this is a provider-facing screening aid, not a profile summary shown back to the owner). Scoped to these two surfaces, not also the compact upcoming-bookings row (`BookingRow`) — a deliberate cut given this PRD's Small effort budget; the two chosen surfaces are where a provider actually makes an accept/decline judgment call. **Requirement 4 nudge**: "nannies respond faster to complete profiles" — mirrors PRD-068's exact `showPhoneNudge` shape (dismissible, first-request-only, `request/[nannyId]/page.tsx` computes `showProfileNudge` from `profiles.avatar_url`). The PRD also asks for a pet-photo half of this nudge; since pet-photo upload doesn't exist as a feature (see above), only the profile-photo half was built — nudging toward a field the product has no way to fill would be dishonest, not just incomplete. No migration needed — every signal is derivable from existing tables (`profiles`, `bookings`, `pets`), confirmed by reading the schema before starting, exactly as the PRD's own constraints anticipated. **Verified live** via Supabase MCP inside a rolled-back transaction against this project's real seeded data: a provider with a genuine completed booking with an owner saw the relationship confirmed, and RLS itself returned zero rows (not just an app-layer false) for the identical query run as an unrelated provider's session — proving "stranger gets nothing" isn't merely this helper's own discipline; a synthetic conversation-only pair (zero bookings between them) was also correctly recognized as a valid relationship; the cancellation count correctly stayed at 2 (and the >2 threshold correctly did NOT fire) when 2 owner-caused plus 2 provider-caused cancellations existed for the same owner — proving the `cancelled_by` filter genuinely excludes cancellations the owner didn't cause — then correctly flipped to 3/threshold-fires once a 3rd owner-caused row was added; the pet care-sheet aggregate correctly read `false` before and `true` immediately after inserting a pet with actual care-sheet content. Every synthetic row was rolled back; a follow-up count query confirmed the database returned to its exact pre-verification state. Lint/`tsc`/`next build` all pass. **Not verified here**: a real two-browser-tab click-through of the request/booking-detail pages actually rendering the signal row and the nudge's dismiss behavior in a browser.
Provider responses to reviews (PRD-070)
A provider can publicly respond once to each review on their profile — "Response from {name}", indented under the review, everywhere reviews render (public profile, dashboard reviews list, admin user detail). Per the PRD's own technical note, "columns on `reviews` beat a join table for a strict 1:1": new `response_body`/`response_at`, plus a genuinely SEPARATE removal triad `response_removed_at`/`response_removed_by`/`response_removal_reason` (an exact mirror of PRD-039's review-removal columns, on the same table) — deliberately independent of the review's own `removed_at`, since requirement 4 needs a response removable without touching the review it's attached to, and requirement 5 needs the reverse (a removed review hides its response) without the removal columns being conflated. **Enforcement is one new trigger** (`guard_review_response`, 20260718091000_review_responses.sql), extending PRD-038's "column-scoped via trigger guard" pattern: a new additive `reviews_provider_response` RLS policy grants the reviewed provider row-level UPDATE (orthogonal to the existing author-owned and admin policies — unlike PRD-065's series case, this is a genuinely different actor, so it doesn't silently broaden either existing policy), and the trigger is what actually confines that grant to just the response columns: not-the-reviewed-provider is reverted; the first post stamps `response_at` from the server clock, never the client's; a body-null attempt (self-clearing) is reverted; an edit past 48h-from-first-post is reverted (the window anchors to the ORIGINAL post, never resets on later edits); an admin-removed response is locked even within window; and — the part that makes the RLS grant safe at all — any attempt to smuggle a change to rating/comment/booking_id/series_id/author_id/provider_id/created_at into the same UPDATE statement is reverted for a provider-only actor, never for the author (this PRD doesn't touch review-edit rules, that's PRD-071's territory). A 1000-char check constraint backs the composer's client-side cap server-side. **Reportable-content design decision**: a genuinely NEW `report_subject_type` enum value, `review_response` (isolated single-purpose migration per this repo's established `ALTER TYPE ADD VALUE` convention), rather than reusing `subject_type='review'` with the same id — both are mechanically identical (subject_id is still the review's id either way, a response has no id of its own), but only a distinct subject type lets `/admin/reports` structurally tell "flagging the review" apart from "flagging the provider's reply", which is what drives the right default resolution action (`remove_review` vs. the new `remove_response`) and lets an admin filter the queue by which one. `getReportsQueue` extends its existing review-context lookup (same query, same `/nanny/{id}` context link) to also cover `review_response` reports, with a preview showing both the review AND the response text. **Prompt to respond** (requirement 3): the existing `review_received` notification's href now carries `?respond={bookingId|seriesId}` (both booking- and series-linked reviews handled, since PRD-065 reviews can be either), matched on the dashboard nanny page against the fetched board's own reviews to auto-open that one row's composer; also reachable any time from the provider's own "Recent reviews" list. Composer (`ReviewResponseForm`, new) has gentle guidance copy (thank the reviewer, clarify gently, stay professional) and a 1000-char cap enforced both client-side (maxLength + counter) and server-side (`submitReviewResponse`, `dashboard/nanny/actions.ts`, re-checking reviewed-provider-only/removed-review/response-removed/48h-window before ever attempting the write, so the common path gets a specific friendly error rather than a silent DB-level no-op). **Requirement 5 cascade** needed no new mechanism, only verification: a removed review (`removed_at is not null`) is already invisible to non-admins under the existing `reviews_read` RLS policy (PRD-039), which hides the whole row — response columns included — for free; confirmed live rather than assumed. Public `ReviewCard` gained a second `ReportReviewButton` (now accepting a `target: 'review' | 'response'` prop) under the response block, localized (`nannyProfile.responseFrom`/`reportForm.subjects.review_response`, LT+EN) — unlike the dashboard composer, which is hardcoded English per that area's existing convention. **Verified live** via Supabase MCP inside one rolled-back transaction (20 checks, all passed): a different provider's hijack attempt rejected at RLS itself (0 rows matched, never reaching the trigger); the review's own author's update DOES match at RLS (they still own the row for rating/comment) but the response columns are reverted by the trigger regardless; a random unrelated user rejected at RLS; an edit within the 48h window succeeds with `response_at` genuinely pinned to the original post time (not reset); an edit attempt on a response admin-seeded 49h in the past is correctly locked; a client-forged `response_at` 10 years in the future is overwritten by the server clock on first post; a provider cannot self-clear their own response back to null; a provider attempting to smuggle a `rating` change into the same UPDATE as a legitimate response edit has the rating reverted while the response still saves; a non-admin cannot set `response_removed_at`, an admin can, and the provider is then locked out of further edits; a removed review (whole-row `removed_at`) is invisible to a non-admin (0 rows) while an admin still sees both the review and its response intact; the 1000-char check constraint rejects a 1001-char insert; and the reports mechanism (insert-by-reporter, admin-only read) proxy-verified against the existing `review` subject type, since the new `review_response` enum value can't be both added and used in the same transaction it's added in (this repo's established `ALTER TYPE ADD VALUE` restriction) — documented as a structural proxy, not a gap, since RLS never branches on the subject_type value at all. `apply_migration` never called; the connection's own auto-rollback (no COMMIT issued) confirmed by a zero-row follow-up check for every synthetic id used. Lint/`tsc`/`next build` all pass (build required switching this sandbox to Node 22 via nvm — Next.js 16 requires ≥20.9, the sandbox's default `node` is 18.14). **Not verified here**: a real two-browser-tab click-through of the notification-to-composer flow and the admin resolve-response UI.
Review integrity rules (PRD-071)
Reviews used to have implicit rules — no submission window, unclear edit rights, nothing structurally stopping a manufactured 5★ or a review-bombing pile-on. Four rules made explicit and server-enforced, one new guard trigger (`guard_review_integrity`, 20260720150436_review_integrity.sql) added ALONGSIDE — never replacing — PRD-039's `guard_review_removal` and PRD-070's `guard_review_response` (three separate `before insert or update` triggers on `reviews`, genuinely disjoint column sets, same "column-scoped via trigger guard" convention). **Submission window**: 30 days from completion (`bookings.updated_at`, the established completed_at stand-in — same convention PRD-032/admin_metrics already set). Enforced twice: `submitReview` (dashboard/bookings/actions.ts) checks first and returns the friendly refusal; `guard_review_integrity` re-checks the identical window on INSERT as the DB-layer backstop, raising a hard exception for a direct API call bypass. The booking detail page and the compact "My bookings" list both hide the "Leave a review" action once the window has closed (`reviewWindowOpen`, computed server-side in `getBookingDetail`/`getOwnerBookings` — impure `Date.now()` stays out of component render, same rule `completeDue` already followed) rather than letting a user hit a dead end. **Edit window**: 7 days from the review's own `created_at`, for booking-linked reviews (series-linked reviews keep PRD-065's own "editable while active" rule, untouched — different mechanism, different requirement). New `reviews.edited_at` timestamp — null until the author genuinely changes rating/comment within the window, stamped by the trigger, never client-settable; renders as an "(edited)" badge everywhere a review does (`ReviewCard`, `ReviewRow`, `ReviewForm`'s own post-submit view). Past the window, the trigger silently reverts rating/comment (mirrors `guard_review_response`'s established revert-not-raise style for its own 48h window on this exact table) — the new `editReview` action checks the window first so the normal path gets a specific friendly error, not a silent no-op. **Delete anytime**: an explicit author right ("keeps GDPR simple," per the PRD) — needed zero schema change, since the init migration's `reviews_delete` policy (`author_id = auth.uid()`) already has no time condition; a new `deleteReview` action is a thin friendly wrapper around a permission that already existed, confirmed by reading the policy directly rather than assumed. **One review per booking**: `reviews_booking_id_key` (a plain `unique` on `booking_id` from the init migration) — confirmed still present by reading `pg_constraint` directly; PRD-016 dropped `not null` on that column (for anonymize-on-account-deletion) but never touched the separate unique constraint, and a unique constraint never restricts multiple NULLs, so it holds for every booking-linked review unaffected by NULL rows (series reviews). **Plausibility signals** (requirement 3, a plain SQL VIEW per the PRD's own technical note — no new event pipeline): `review_integrity_signals` joins reviews against `booking_events`' real accept/complete timestamps (PRD-057, not `bookings.updated_at` alone, which only ever reflects the LATEST flip) and each author/provider pair's live review count, pre-filtered to only rows tripping at least one of three signals — booking marked complete before its own scheduled end (structurally impossible today per PRD-054's `guard_booking_transition`, built anyway as defense-in-depth per this PRD's own instruction), accept→complete under 24h for a multi-day service (`per_night`/`per_day` only — `per_walk`/`per_visit` legitimately complete same-day), and the same owner reviewing the same provider more than 3 times (flags ALL of that pair's reviews once the threshold is crossed, not just the newest — confirmed live, see below). **A bare view's RLS evaluates as the view OWNER's privileges, not the querying role's** — so the view itself has zero grants (`revoke all ... from public, anon, authenticated`); a `security definer` wrapper RPC (`admin_review_integrity_signals()`, checks `is_admin()` in its body, exact same pattern as PRD-040's `admin_metrics()`) is the only way to read it. New `/admin/reports?view=integrity` tab (plain query-param tab, same shareable/bookmarkable convention as the existing status/subjectType filters), read live with no materialization — a read-only card per flagged review (`IntegritySignalRow`) listing which signal(s) tripped; deliberately NO action buttons, since this tab "flags suspicious patterns, never auto-blocks" per the PRD's own framing — an admin acting on what they see here still goes through the existing report/remove/suspend flows. **"How reviews work"** (requirement 5): a short static page (`/how-reviews-work`, six plain-language rules, LT+EN) linked from every review composer (`ReviewForm`, `SeriesReviewForm`) and the public profile's reviews section heading. **Verified live** via Supabase MCP inside one rolled-back transaction against this project's real seeded data (14 real checks): an insert 5 days post-completion succeeded, a 31-days-post-completion insert was rejected with the exact friendly-refusal-shaped message, a duplicate `booking_id` insert was rejected by the unique constraint (23505); an edit at day 6 succeeded and correctly stamped `edited_at`, an edit attempt at day 8 was silently reverted (rating/comment unchanged, `edited_at` stayed null) and the same day-8 review could still be deleted with zero restriction; the manufactured-early-completion booking's review was correctly flagged `signal_completed_before_due`, a synthetic 2-hour accept→complete on a `per_night` booking was correctly flagged `signal_fast_multiday_complete` while a normal 3-day accept→complete was NOT flagged; a real pre-existing seeded review surfaced an instructive true positive — an author's 4th review of the same provider (1 real + 3 synthetic) correctly flagged ALL FOUR as `signal_repeat_reviewer`, including one that looked "normal" in isolation, proving the view aggregates against real production data, not just synthetic fixtures, exactly as designed; a plain `authenticated` role was rejected with a permission-denied error querying the view directly (42501), the wrapper RPC correctly rejected a non-admin caller and correctly returned rows (including the manufactured booking) for an admin caller. `apply_migration` never called; the transaction's own `rollback` confirmed by a follow-up count query showing `bookings`/`reviews`/`booking_events` back at their exact pre-verification row counts and none of the new schema objects (`edited_at` column, `guard_review_integrity`, `admin_review_integrity_signals`) persisting. **Non-goals confirmed, not just assumed**: a cancelled booking cannot be reviewed today — `submitReview`'s `status === 'completed'` gate plus `reviews_insert` RLS's identical condition both structurally exclude it, and `guard_booking_transition` has no `cancelled -> completed` path for a review to even exist against. **Cron-nudge check**: `findDueReviewNudges` (src/lib/email/reminders.ts) already only fires once, in a fixed 24-48h-post-completion window — always inside the 30-day submission window by construction (the PRD-032 migration comment already said so) — so it needed no fix, confirmed by re-reading rather than assumed. Lint/`tsc`/`next build` all pass (build required switching this sandbox to Node 22 via nvm, same as PRD-070). **Not verified here**: a real two-browser-tab click-through of the edit/delete UI and the admin integrity tab in a browser.
Safety center (PRD-072)
A `/safety` hub — no schema change, pure content, confirmed before writing anything. All copy lives in one typed content object per locale (`src/data/safetyContent.ts`), deliberately NOT `messages/en.json`/`lt.json` (long-form named sections, not short UI labels) and NOT hardcoded in JSX, per the PRD's own instruction that PRD-085's future content hub should be able to absorb it directly; `src/app/[locale]/safety/page.tsx` is layout only. Three sections: an emergency playbook (pet medical emergency → real 24/7 Vilnius vet clinics, lost-pet steps, unreachable-counterpart guidance), an honestly-scoped "how verification works" (what PRD-037's admin checklist + PRD-067's ID/selfie review actually check, and — equally prominent — what they don't: no criminal-background check, a one-time point-in-time review not ongoing monitoring, no professional-qualification check, no AI forgery detection), and a reviews/moderation summary (PRD-029/039/071's real mechanisms in plain language) linking to the existing `/how-reviews-work` page rather than duplicating it. **Requirement 2 — the vet clinics**: researched by fetching each clinic's OWN website, not a directory listing, after directory listings twice turned out wrong for this exact question (one claimed a Kaunas clinic — Tilžės g., LSMU — was in Vilnius; another claimed a 4-branch chain was 24/7 when only 1 of its Vilnius branches actually is). Two clinics, each independently cross-checked across multiple pages of their own site: 8 Drambliai's Pavilnys branch (Tolimoji g. 2B, +370 612 88788, "Dirbame visą parą") and DR.VET Lazdynų gyvūnų ligoninė (Bukčių g. 8, +370 677 05503, explicit "24/7"/"00:00–23:59"). Hardcoded in `EMERGENCY_VET_CLINICS` with a code comment flagging the swap to a live PRD-077/078 directory query once that ships. **Explicitly NOT done, and said so in the page copy itself (both languages), not just in this note**: an actual verification phone call to either clinic — a human still needs to do that before this goes live, exactly as the PRD's own acceptance criterion names ("verified by calling, honestly"). Lost-pet guidance points to the real Vilnius municipal line (1355) and Vilniaus gyvūnų globos namai (+370 652 57707), also confirmed via their own site. **Requirement 3 — three link placements**: the footer (new `footer.safety` key, both locales); the booking detail page's action area, gated to `detail.status === 'accepted'` ONLY (a new `showSafetyLink` — deliberately excludes `pending`, nothing to be an emergency about yet, and `completed`/`cancelled`/`declined`/`expired`, the stay is over); and both contract PDFs (`ContractPDF.tsx` and `SeriesContractPDF.tsx`) as a short bilingual pointer line with a real clickable `@react-pdf/renderer` `Link` to `{SITE_URL}/safety` — a pointer, not a duplicate, so the emergency content changes in one place. **Requirement 4**: real, hand-written Lithuanian throughout (not machine-translated) — not yet proofread by a native speaker, flagged here the same way PRD-047/061/071's LT copy was before its own review pass. Lint/`tsc`/`next build` all pass (build required switching this sandbox to Node 22 via nvm, same as PRD-070/071). Not verified here: a real browser click-through of all three link placements, and the phone-verification call named above.
No-show & incident reporting (PRD-073)
PRD-029 reports covered content ("this message is abusive"); nothing captured what happens *offline* — a no-show, a pet returned injured, an unsafe home. Reuses PRD-039's `reports` table entirely, per the PRD's own technical note — no new table: a new `booking` `report_subject_type` value (isolated migration, this repo's established `ALTER TYPE ADD VALUE` convention — 6th time this session), a new `incident_kind` enum (no_show/safety_concern/pet_harmed/property_issue/misrepresentation/other, a brand-new type so it could be created and used in the SAME transaction, unlike an added value on an existing enum), a server-derived `severity` column (`normal`/`high`, computed by a new `guard_report_severity` trigger from `incident_kind` on every insert — never trusted from the client), and a `photo_paths text[]`. **New terminal `booking_status`**: `did_not_happen`, added in its own isolated migration, then a SEPARATE migration extending `guard_booking_transition()` with an explicit `accepted -> did_not_happen` branch. **The admin-bypass question, reasoned through explicitly in that migration's header**: `guard_booking_transition()`'s `is_admin()` check already bypasses everything below it, so an admin could already reach any status including this one with zero trigger change — the explicit branch is reached ONLY by a non-admin attempting the transition directly, and unconditionally rejects them. Added anyway for two reasons: documentation (the trigger should read as a complete picture of the status graph, not force a reader to infer this edge is blocked only by the generic fallback) and defense-in-depth (if a future refactor ever narrows the admin bypass, this branch still independently rejects a non-admin, rather than silently relying on whatever the bypass removal left behind) — verified live, not just reasoned about. **Reporting window** (requirement 1 — "accepted until 14 days post-terminal, computed server-side"): a shared pure predicate (`isIncidentReportWindowOpen`, `src/lib/incidentReportsState.ts`) used by `getBookingDetail` (gates the "Report a problem" button), `fileIncidentReport` (the app-level refusal), AND a new DB trigger `guard_incident_report_window` (the belt-and-suspenders backstop for a direct API call, same posture as PRD-071's submission-window trigger) — all three read `bookings.updated_at` as the terminal-transition timestamp, the same established stand-in every prior PRD needing one has used. **Photos** — a genuinely NEW private bucket, `incident-photos`, NOT a reuse of PRD-042's `booking-photos`: read access is admin + the report's own reporter ONLY, deliberately narrower than booking-photos' "either participant" rule, since an incident photo is evidence attached to one specific report, not shared care-updates content the implicated other party should be able to browse. Path convention `{bookingId}/{reportId}/{file}` — the client generates the report's own id (`crypto.randomUUID()`) BEFORE uploading, then passes that same id to the insert, so read RLS can scope by report id even though the `reports` row doesn't exist yet at upload time. **Admin queue** (`getReportsQueue`, `src/lib/reports.ts`): severity-first stable re-sort (high-severity booking incidents above every plain content report, without disturbing the existing oldest-first/newest-first order within a band) computed in application code rather than leaning on the two literal strings' coincidental lexicographic DB order; full booking context (both parties' names, dates, booking status, a prior-incident count for either party — deliberately excludes other reports on the SAME booking, since those are adjacent context already visible in the row, not "history"); a chat-context toggle reusing PRD-039's exact scoped-read mechanism, extended with a sibling security-definer function (`conversation_has_open_booking_report`) OR'd into the existing `messages_admin_report_context` policy. **No-show resolution**: `resolveReport` gained a `mark_did_not_happen` action (booking reports only), routing through the one `transitionBooking()` executor with actor `'admin'` — no new authorization code needed, per the discussion above. `suspend_user` is deliberately NOT offered for a booking report (unlike every other subject type) — a booking has two parties and nothing here can safely guess which one is at fault; an admin suspends the specific implicated party from their own `/admin/users/[id]` page instead, after reading the context this report already surfaces. **Structural verification, not new logic**: review-eligibility revocation holds for free — `reviews_insert` RLS hard-requires `status = 'completed'`, which `did_not_happen` can never be, confirmed by reading the policy directly; every `admin_metrics()` query is an explicit `status = 'completed'` allow-list (never a deny-list), so a `did_not_happen` booking simply never matches any of them, confirmed by re-reading every query rather than assumed. **Acknowledgment** (requirement 4): a NEW `report_received` in-app notification kind fires immediately on filing (distinct from PRD-039's existing `report_reviewed`, which fires on closure and is reused completely unmodified — reusing that same kind for both moments would have made its own "we've reviewed your report" copy describe a report that hadn't been looked at yet). Serious types (`pet_harmed`/`safety_concern`) also email a new `ADMIN_ALERT_EMAIL` env var (documented in `.env.example`, same "unset = skipped + logged, safe default" posture as every other optional integration this session added) — this codebase has no admin mailing list or per-admin email column, so a single configured destination is the concrete answer to "what does the admin inbox mean here". **Admin user detail** (requirement 5): a new "Incident reports" section alongside the existing PRD-058 cancellation counts — filed (this user as reporter) and involving (this user as the other, non-reporting party), computed the same "resolve provider_profiles.id, then `.or(owner_id/provider_id)`" shape the existing booking-list query already uses. **Verified live** via Supabase MCP inside rolled-back transactions: a non-admin directly attempting `accepted -> did_not_happen` was rejected with the specific new error message (not the generic fallback), confirming the explicit branch actually fires; a mix of high-severity booking incidents, normal-severity booking incidents, and non-booking content reports in the same queue query sorted with every high-severity row first while preserving each band's original order; a third party (neither the reporter nor an admin) could neither read nor sign a URL for an incident photo, while the reporter and an admin both could; a review-submission attempt against a `did_not_happen` booking was rejected by RLS itself (0 rows), not merely the app layer. `apply_migration` never called — every check ran inside a transaction with no `COMMIT`, confirmed rolled back via a zero-row follow-up. Lint/`tsc`/`next build` all pass. **Not verified here**: a real two-browser-tab click-through of the report-a-problem form and the admin resolve/mark-did-not-happen UI, and actual email delivery of the serious-incident alert (no real `ADMIN_ALERT_EMAIL`/Resend account configured in this sandbox — only the dev-console fallback could be exercised).
Response & reliability signals (PRD-074)
Closes the loop two earlier PRDs explicitly deferred: PRD-056's private "missed requests" counter and PRD-058's `cancelled_by`/`late` columns both existed purely as data-existence hooks ("feeds PRD-074", "no scoring logic added yet") — this is that scoring logic. New nightly-recomputed (never live) `provider_profiles` columns — `reliability_requests_count`, `reliability_response_rate`, `reliability_median_response_seconds`, `reliability_terminal_count`, `reliability_clean_cancel_rate`, `reliability_computed_at` — written by one set-based `recompute_reliability_signals()` UPDATE over every provider in a single statement, called from the existing PRD-032 hourly cron. **Formulas, reusing PRD-056/058 data directly rather than re-deriving it**: response rate = (accepted+declined) / (accepted+declined+expired) over a rolling 90-day `created_at` window — `expired` is PRD-056's own "missed request" signal, used as-is; median response time = median(`updated_at`-`created_at`) for the answered (accepted/declined) subset only; completed-without-late-cancel rate = 1 − (this provider's own `cancelled_by = profile_id AND late = true` count ÷ every completed-or-cancelled booking), scoped by `updated_at` — an owner cancellation or a non-late provider cancellation is never disqualifying, only PRD-058's exact late+provider combination is. Every block carries a "Hand-check" SQL comment per the PRD-040 convention this PRD's own acceptance criterion names. **Nightly cadence on an hourly cron, without new infrastructure**: the route now self-guards via `shouldRecomputeReliabilitySignals()` — reads the most recent `reliability_computed_at` across the table and only calls the recompute RPC when it's null or >20h old, bounding the actual recompute to roughly once/day (a documented judgment call, not a real cron-schedule change) rather than standing up a second Vercel cron entry or a scheduling table. **Display thresholds are enforced entirely app-layer, not in SQL** (`src/lib/reliability.ts`) — the DB always returns every provider's exact raw numbers; `publicResponseTier`/`publicResponseRate`/`publicCleanCancelRate` are pure TypeScript gates called only by the PUBLIC read path (`mapProvider`, src/lib/providers.ts) before a value ever reaches the public `Nanny.reliability` shape: nothing shown below 5 requests received (small-N honesty — a 3-request provider shows nothing even with a 100% rate and a 30-minute median, verified live), response rate shown only ≥80%, response-time tiers "a few hours" (≤4h, a documented cutoff) / "within a day" (≤24h) / nothing below that — never a bad-response-time badge. The two gates (tier, rate) are independently evaluated, by design: a provider can show a "within a day" tier while their rate stays hidden below 80%, or vice versa. The provider's OWN dashboard (`getProviderBoard` → new `ReliabilityStatsCard`, `/dashboard/nanny`) reads the same raw columns directly and never calls the public gates at all — exact numbers even below every public threshold, plus honest improvement copy (next-intl, `dashboardNanny.reliability.*`, both locales) — "private honesty, public kindness" per the PRD's own framing; sits alongside (not replacing) PRD-056's existing lifetime "missed requests" counter, which is a different, unbounded population. **Surfacing**: a "⚡ Fast responder" `NannyCard` chip for the best tier only (never the "day" tier); a fuller threshold-gated stats block on the profile page (tier line + response-rate line + clean-cancel-rate line, each independently rendered only if non-null, the whole section omitted if nothing qualifies); a new `fastest_response` option in PRD-017's sort dropdown — deliberately NOT the DB-level `.order()` (PostgREST can't express "qualifying providers first, by raw value, non-qualifying providers after, stably" as a single column order) but a client-side-in-the-server-fetch stable sort over the RAW `reliability_median_response_seconds`/`reliability_requests_count` columns, applied to the fetched rows before `mapProvider` runs (after which the coarse fast/day/null tier would no longer be precise enough to order two "day"-tier providers against each other) — opt-in only, per the PRD's own non-goal ("don't silently demote slow responders in the DEFAULT sort", deferred to PRD-138). `admin_metrics()` gains a `reliability` block (measurable-provider count via the same ≥5 gate, fast/within-day/slow-or-unmeasured tier counts, high-response-rate count, average clean-cancel-rate among providers with ≥1 terminal booking), same Hand-check convention, tier-boundary literals documented as needing to stay in sync with `src/lib/reliability.ts`'s. **Verified live** via Supabase MCP inside one rolled-back transaction against synthetic fixtures layered onto the real seeded data (8 providers touched: 5 real + 3 synthetic): a 6-answered-fast-request provider computed `requests_count=6, response_rate=1.0000, median=7200s` (≤14400 → correctly the "fast" tier); a 3-request provider computed `requests_count=3` despite a 100% rate and a 1800s median — confirmed the small-N gate is purely count-driven, exactly the acceptance criterion's wording ("even if their raw stats would otherwise qualify"); a mixed 8-considered/6-answered/4-terminal provider's recomputed numbers (`requests_count=8, response_rate=0.7500, median=25200s, terminal_count=4, clean_cancel_rate=0.7500`) matched an INDEPENDENTLY hand-written verification query digit-for-digit (the acceptance criterion's own literal requirement); `admin_metrics()`'s new block correctly counted 2 measurable providers (the 3-request one excluded), 1 fast, 1 within-day, 0 slow/unmeasured, 1 high-response-rate, and an average clean-cancel-rate of exactly the one measurable provider with terminal bookings. `apply_migration` never called — the transaction's own `ROLLBACK` confirmed by a zero-row follow-up (new column absent, 0 leftover synthetic profiles/bookings, provider count back to 5). Lint/`tsc`/`next build` all pass. **Non-goals confirmed, not built**: no ranking penalty in the DEFAULT sort (PRD-138's territory); no owner-side reliability display (PRD-069's, already shipped, untouched). **Not verified here**: a real two-browser-tab click-through of the browse chip/profile block/dashboard card in an actual browser, and the cron's ~20h staleness guard actually firing correctly across real deployed hourly invocations over multiple days.
Chat scam & abuse pattern warnings (PRD-075)
Server-side pattern detection (plain regex, explicitly NOT ML) for scam/abuse signals in chat messages, evaluated in the existing `sendMessage` server action (`src/app/[locale]/messages/actions.ts`, already the PRD-027 choke point) before insert. **Rules** live in one versioned, greppable module (`src/lib/chatSafety/rules.ts`, `CHAT_SAFETY_RULES_VERSION`) — five flag categories: `payment_link` (paypal.me/revolut.me/venmo/cash.app/wise/stripe/klarna payment-link domains — matched by domain+path, not the bare brand word, so "I use PayPal sometimes" doesn't trip it), `crypto` (exchange links, or a currency name paired with a transfer/wallet verb), `deposit_request` (LT+EN — "send a deposit"/"pay in advance"/"wire transfer" and Lithuanian `avans-`/`užstat-`/`depozit-` word-stems plus "sumokėkite iš anksto"/"bankinį pavedimą"), `url_shortener` (bit.ly/tinyurl/is.gd/goo.gl/etc, ~20 domains), and `off_platform_pressure` (LT+EN phrasing that PRESSURES leaving the platform — "let's talk outside this app", "don't tell PetNanny", "delete this chat" — deliberately NOT a bare phone/email match, since exchanging contact info is explicitly legitimate here per the PRD's own non-goal: PetNanny facilitates no payment and takes no cut, so there's no disintermediation to police). **Schema**: `messages.flags text[] default '{}'` (store only the flags, never a content copy elsewhere — the body is already right there on the same row) plus a partial index on `(sender_id, created_at) where flags <> '{}'` for the requirement-4 scan. `reports.reason` gained a new `pattern_flags` enum value in its own isolated migration (this repo's established `ALTER TYPE ADD VALUE` convention, 7th time this session) rather than reusing `'scam'` — an admin needs to tell "a person reported this as a scam" apart from "our own rules flagged this sender repeatedly" at a glance. `reports.reporter_id` became nullable (a genuinely new case: this PRD's auto-report has no human reporter at all) — `reports_insert`'s existing `reporter_id = auth.uid()` check already makes a NULL reporter_id unforgeable through any authenticated client, so the auto-report can only ever be written by the service-role client. **Requirement 4's idempotent auto-report** (`src/lib/chatSafety/autoReport.ts`, called only when a send's own regex pass actually flagged something — clean sends, the overwhelming majority, never touch this file): counts the sender's distinct flagged-conversation ids over a rolling 30 days; at ≥3, an app-level existence check (no open `pattern_flags` report for this subject already) gates the insert, backstopped by a genuinely DB-layer partial unique index (`idx_reports_pattern_flags_open_unique` on `subject_id where subject_type='user' and reason='pattern_flags' and status='open'`) against the race where two flagged sends both pass the app check before either commits — the loser's 23505 is caught and swallowed, not surfaced, since the other request already achieved the same idempotent goal. Deliberately scoped to `status='open'` only (not "ever") — a documented judgment call: once an admin resolves/dismisses it, a sender who keeps accumulating new flagged conversations can generate a fresh one, consistent with how every other report on this table treats "closed" as done, not a permanent dedup key. **UI**: a recipient-only inline warning (`FlaggedMessageBanner.tsx`, `--color-warning`/`--color-warning-bg` tokens, no new tokens needed) with a genuinely one-tap report — NOT PRD-029's full reason-picker `ReportForm`, a deliberate UX choice since the reason is already implied by the banner itself; reuses `submitReport('message', id, 'scam', '')` underneath. Enforced purely by `!mine` in `Thread.tsx`'s render — the sender's own copy of the identical flagged message never mounts the banner at all, so "sender sees nothing different" holds structurally, not by suppressing a value. First-message safety note (requirement 3) needed no new column/flag: it's appended to the existing empty-conversation ("Say hello 👋") view, which by construction can only ever be true once per conversation. Both locales' copy is real hand-written LT/EN (not machine-translated, not yet native-speaker-proofread — same disclosed gap as PRD-047/061/071/072). **Tests**: this repo has zero test runner installed (confirmed — no vitest/jest/mocha/ts-node/tsx anywhere in package.json or node_modules/.bin) — rather than add a framework dependency for one PRD's module, `rules.test.mjs` uses only what's already here: Node's built-in `node:test`/`node:assert` plus the already-installed `typescript` package's `transpileModule` to strip rules.ts's types in memory (this Node version has no working `--experimental-strip-types`); a new `npm test` script runs it — 17 cases, all passing (clean scheduling chat, a bare phone/email exchange, and passing mentions of PayPal/bitcoin all correctly stay clean; each flag category has a true-positive case, LT and EN separately for deposit_request/off_platform_pressure; a multi-rule message returns all matching flags in stable order). **Verified live** via Supabase MCP inside rolled-back transactions (`apply_migration` never called): a synthetic payment-link message flagged `{payment_link}`, a clean control message flagged `{}`; three synthetic messages by one sender across three distinct conversations (all flagged) correctly left exactly one open `pattern_flags` report after simulating the app-level existence-check-then-insert sequence twice — the second attempt correctly found the existing open row and did not insert a duplicate, confirmed by a `count(*) = 1` follow-up; a direct second insert attempt bypassing the app-level check (simulating the concurrent-race case) was rejected by the partial unique index itself (23505), confirming the DB-layer backstop actually fires; a plain authenticated client's insert attempt with `reporter_id: null` was rejected by `reports_insert` RLS, confirming a system report cannot be forged without the service-role client. Rollback confirmed zero residue on `messages`/`reports` afterward. Lint/`tsc`/`next build` all pass. **Non-goals confirmed, not built**: no LLM/ML moderation; no blocking/holding of flagged messages (still delivered, always); no retroactive scan of message history (the new column defaults `'{}'` on every pre-existing row and is only ever computed going forward, at insert time); no phone/email detection. **Not verified here**: a real two-browser-tab click-through of the banner/one-tap-report/safety-note UI, and native-speaker proofreading of the LT copy.
Lucide icon migration — passes 1–3 of 3, complete (PRD-153)
First of 3 planned passes replacing every raw emoji in the product with Lucide SVG icons (`docs/EMOJI_INVENTORY.md`'s ~70-emoji/~350-400-site audit). This pass: added `lucide-react` (v1.26, React 19-compatible) and established the repo convention — direct `lucide-react` imports styled via `size`/`color`/`style` referencing design tokens, no wrapper `Icon` component. Collapsed the data-driven catalog-icon duplication (audit §2): new `src/lib/catalogIcons.ts` (`SERVICE_TYPE_ICONS`/`PLACE_CATEGORY_ICONS`, keyed by the real `services.slug`/`place_categories.slug` values, each with a `DEFAULT_CATALOG_ICON` fallback) replaces the hardcoded emoji fallback maps in `src/data/mockDashboard.ts`, `src/data/nannies.ts`, and the 4 separate park/cafe/hotel/trail_beach copies under the pet-friendly directory (`PetFriendlyClient.tsx` + 3 `opengraph-image.tsx` files) — all deleted, not left dead. Architecture: `services.icon`/`place_categories.icon` DB columns are no longer read for rendering at all (every UI looks up by `slug` instead); `supabase/seed.sql`'s `icon` values are now seeded `null` rather than emoji (verified inserting cleanly via Supabase MCP in a rolled-back transaction; `apply_migration` never called). Every render site that used to read `service.icon || '🐾'` (or a place-category equivalent) across booking cards, the provider profile, the request flow, browse, and admin catalog/places tables now renders the looked-up Lucide icon instead — confirmed via a repo-wide `.icon` grep that no remaining render site expects an emoji string off a service/place row. Admin: `ServiceForm.tsx`'s dead "Icon (emoji)" input removed; `CatalogTable.tsx`/`PlacesTable.tsx`/`SuggestionsQueueTable.tsx` now show the real looked-up icon instead of the stale raw column. **Satori/OG finding** (blocks passes 2/3 too): a `lucide-react` icon *component* cannot render inside `next/og`'s `ImageResponse` — every icon is `forwardRef` + calls `useContext` internally, and Satori invokes function components with no real hook dispatcher installed, throwing `Cannot read properties of null (reading 'useContext')` (verified directly against this repo's installed versions via an isolated script hitting the actual bundled `@vercel/og`/Satori engine). Fix: render each icon's raw shape data (every lucide-react icon module exports `__iconNode`, an array of `[svgTag, props]` tuples) as plain `<svg>/<path>/<circle>` host elements instead — new `src/lib/ogIcons.tsx` (`OgIcon` + `OG_PLACE_CATEGORY_NODES`) does this for the pet-friendly OG routes; confirmed producing a correct PNG the same way. **Also discovered mid-pass**: `eslint-plugin-react-hooks`'s React-Compiler-derived `static-components` rule flags `<Icon/>` whenever the picked component comes back from a function call (even a pure, stable lookup) — only a *direct* `SOME_MAP[slug] ?? Default` object-index expression proves stable to it, so `catalogIcons.ts` intentionally exports the plain maps (not a `serviceTypeIcon(slug)`-style helper) and documents the inline-lookup pattern every call site uses. Lint/`tsc`/`next build` all pass. **Not in this pass** (passes 2/3): star ratings, pet-type icons, booking-status badges, both nav sidebars, `notificationKinds.ts`, one-off/decorative emoji, arrow/symbol glyphs, the brand-mark paw (incl. the other 4 OG routes + 404 page), and the `/theme` showcase page. **PASS 2 (systematic patterns — audit §2c/§3/§4a/§5/§6)**: reconciled the two star-rating glyph patterns (`'★'.repeat(n)` and the lone ⭐) into one shared `src/components/ui/StarRating.tsx` (Lucide `Star`, filled via `fill`/outline via `stroke`; an optional `onRate` turns each star into a real `<button>` for the interactive pickers in `ReviewForm`/`SeriesReviewForm`/`PlaceReviewsSection`) — swapped into all ~18 render sites the audit listed (admin place/user detail, `nanny/[id]/page.tsx`, `IntegritySignalRow`, `ReviewRow`, `PlaceRating`/`PlaceReviewsSection`, `NannyCard`, `ReviewCard`, `dashboard/nanny/page.tsx`'s StatCard, `ReviewStep`). The `nanny/[id]/opengraph-image.tsx` star row uses pass 1's `OgIcon` technique with a new `STAR_NODE` (added to `ogIcons.tsx`, which also gained an optional `fill` prop for solid-filled stars vs. pass 1's outline-only icons) — verified by driving the real `next/og`/Satori `ImageResponse` engine directly against a standalone script (no live Supabase needed to exercise the risky part), not just trusting `next build`'s compile success. Two plain-text contexts deliberately keep an ASCII/text fallback, no SVG: `src/lib/reports.ts`'s admin report-preview strings (`previewText: string`, no JSX rendering context) and `notificationKinds.ts`'s `text()` template strings (e.g. "left you a 5★ review" — copy, not an icon prop). New `src/components/ui/PetTypeIcon.tsx` (Lucide `Dog`/`Cat`, `PawPrint` fallback) collapses the `type === 'dog' ? '🐶' : '🐱'` ternary duplicated across all 11 audit-listed sites (`dashboard/bookings/[id]/page.tsx`, `nanny/[id]/page.tsx`, `BrowseClient.tsx` ×2 incl. its filter chips, `BookingRow.tsx`, `RequestCard.tsx`, `NannyCard.tsx`, `PetCard.tsx`, `PetFields.tsx`, `PetTypeFields.tsx`, `PetDetailsStep.tsx`, `ReviewStep.tsx`) — `RadioCard`'s icon prop (already `ReactNode` since pass 1) and a new optional `icon` prop on `Chip` (browse's pet filter/active-filter chips) carry it in; both branch with a direct `<Dog/>`/`<Cat/>` JSX return rather than picking a component into a variable, sidestepping the static-components question entirely. New `src/lib/bookingStatusIcons.ts` (`BOOKING_STATUS_ICONS`, plain `Record<string, LucideIcon>` — same convention as `catalogIcons.ts`, `Hourglass`/`CheckCircle`/`XCircle`/`Timer`/`Flag`) replaces the ⏳✓✕⌛🚩 label-map pattern across `dashboard/bookings/[id]/page.tsx`, `dashboard/bookings/series/[id]/page.tsx` (both the series-level and per-occurrence badges), `OwnerBookingCard.tsx`, `SeriesCard.tsx`, and `BookingRow.tsx`'s compact version — every call site does the `BOOKING_STATUS_ICONS[status] ?? DEFAULT_BOOKING_STATUS_ICON` lookup inline right next to that file's existing `ServiceIcon` lookup, never through a helper function. `completed`'s own `'●'` marker is untouched everywhere — that glyph was never in the audit's ⏳✓✕⌛🚩 scope for this requirement. Both nav sidebars (`DashboardSidebar.tsx`, `AdminSidebar.tsx`) changed their item-array shape from `{label: '📊 Overview', href}` to `{icon: <LayoutDashboard size={16}/>, label: 'Overview', href}` — pre-built element instances at module scope (not a lookup keyed at render time), all 19 icons from the PRD's own mapping (`LayoutDashboard`/`Calendar`/`Heart`/`PawPrint`/`ClipboardList`/`CalendarDays`/`Image`/`Wrench`/`User`/`Settings`/`ArrowLeft` for dashboard; `LayoutDashboard`/`FolderOpen`/`MapPin`/`ShieldCheck`/`Users`/`Flag`/`ArrowLeft` for admin). `src/lib/notificationKinds.ts`'s `NotificationKindDef.icon` field changed type from `string` to `LucideIcon`, one direct component reference per kind (`Mail`/`CheckCircle`/`XCircle`/`Star`/`ShieldCheck`/`ClipboardList`/`Flag`/`Camera`/`Timer`/`Pencil`/`Calendar`/`Repeat`/`AlertTriangle`/`Hand`/`PartyPopper`, matching the PRD's mapping exactly) — the one render consumer, `NotificationRow.tsx` (nav bell dropdown + `/notifications` page), reads `notificationKinds[kind].icon` into a local const and renders it directly; confirmed via grep this is the ONLY render site (no email template reads this table's `icon` field — every email template under `src/lib/email/templates/*` has its own independent copy, untouched). `notificationKinds.ts`'s `text()` strings keep their literal ★/🎉 characters — plain notification/email copy, not an icon prop. `StatCard.tsx`'s `icon` prop widened from `string` to `React.ReactNode` (backward-compatible — every other still-emoji `<StatCard icon="📅"/>` call site keeps working unchanged; only the rating card's `icon="⭐"` moved to a real `<Star fill=".." stroke=".."/>` element). Lint/`tsc`/`next build` all pass — specifically re-checked that `npm run lint` reports zero `eslint-plugin-react-hooks` static-components violations across every new lookup/component. **PASS 3 (final pass — brand mark, one-offs, arrows/symbols, §4b verification badges, theme page, full-repo sweep)**: brand-mark 🐾 replaced with Lucide `PawPrint` everywhere it appeared — `Nav.tsx`/`Footer.tsx` (real JSX import), the `/become-nanny` heading, the 404 page, and all 6 audited `opengraph-image.tsx` routes via pass 1's `OgIcon` raw-node technique (new `PAW_PRINT_NODE`/`MAP_PIN_NODE`/`CHECK_NODE`/`CLOCK_NODE`/`SIREN_NODE` added to `ogIcons.tsx`). Final-sweep OG gap found and fixed beyond the 6 audited routes: `vilnius/breeders/opengraph-image.tsx`+`[slug]/opengraph-image.tsx` and `vilnius/vet-clinics/[slug]/opengraph-image.tsx` were still rendering raw 🐕/🏥/📍/✓/🕐/🚑 emoji pass 1 never reached — now use the same `OgIcon` technique (`OG_PLACE_CATEGORY_NODES.vet_clinic` added, reusing `catalogIcons.ts`'s `vet_clinic → Stethoscope`/`breeder → PawPrint` choices for consistency). Email brand mark (`src/lib/email/layout.ts`'s `logoText: 'PetNanny 🐾'`) deliberately LEFT as plain text/HTML — checked `src/lib/email/` first per the PRD's own instruction not to guess: every template is `escapeHtml()`'d strings only, zero SVG/icon rendering precedent anywhere in that directory (matches provider-verified.ts's 🛡️ and provider-welcome-back.ts's 👋, both already left alone by passes 1–2 for the identical reason), and email clients' unreliable SVG support makes a real icon strictly riskier than the emoji it would replace. One-off/decorative emoji (audit §7): 👋→`Hand` (welcome banners, empty-thread hello), 💡→`Lightbulb` (admin empty state), 💶→`Euro` (earnings stat), 🇱🇹/🇬🇧→dropped entirely rather than forced into a mismatched icon (Lucide has no flag glyphs, and a shared `Languages` icon on both badges wouldn't distinguish LT from EN — the section already has its own "Languages" heading, so plain badge text reads just as clearly), 📝/🐕/📄→`ClipboardList`/`PawPrint`/`FileText` (homepage 3-step icons), 📞→`Phone`, 📵→`PhoneOff`, 📷→`Camera`, 📭→`Inbox` (new `EmptyState` default, plus every call site's own emoji `icon=` string prop converted to a real element — `MapPin`/`Lightbulb`/`FolderOpen`/`CheckCircle2`/`ShieldCheck`/`Users`/`Flag`/`Search`/`PawPrint`/`Stethoscope`/`CalendarDays`/`Heart`/`Bell`/`MessageCircle` across ~20 pages), 🔍→`Search`, 🏠→`Home`, 🌙→`Moon` (`DatesStep.tsx` + the theme showcase), 🚨→`AlertTriangle` (safety link), 🤍/❤️→`Heart` with the exact `fill`/`stroke` filled-vs-outline technique `StarRating.tsx` established, 🔔→`Bell`. `Badge.tsx` gained a small `icon?: ReactNode` prop (leading icon + text, `var(--space-1)` gap) — the natural seam for the ~30-site "✓ Verified"/"✓ Phone verified"/"🛡️ …"-style leading-glyph pattern (audit §4b) that pass 2 didn't reach; used to swap `✓`→`Check`, `🛡️`-style badges→`ShieldCheck`/`CheckCircle2`, `⏸`→`PauseCircle`, `🌱`→`Sprout`, `⚡`→`Zap`, `🚩`→`Flag`, `🚑`→`Siren`, `🕐`→`Clock`, `🟢`/`⚪`→a small filled/outline `Circle` (same fill-prop technique again) across `NannyCard`/`OwnerTrustSignals`/`PhoneVerificationCard`/`IdReviewCard`/`VerifiedProviderRow`/`VerificationQueueCard`/`AdminUserRow`/`PauseListingCard`/vet-clinic listing+detail pages. `🪪`→`IdCard` (`IdVerificationCard.tsx`); generic ✓-close-file-name pattern→`Check` inline. Five ✕-as-close-button sites (`PlacePhotoManager`/`CareUpdatesJournal`/`ReportProblemForm`/`PhotoGalleryManager`/`Lightbox`)→`X`, kept deliberately distinct from the ✕-as-declined-status glyph `bookingStatusIcons.ts` already owns (audit's own flagged distinction, respected). Two shared components fixed once, fixing every call site for free: `Stepper.tsx`'s done-step `'✓'`→`Check`, `ErrorState.tsx`'s `'⚠️'`→`AlertTriangle`. Arrow/symbol glyphs (audit §8, ~19+19+7+3 sites): `→`→`ChevronRight` inline after text for "Continue"/"View X"/"back to X" links (`ArrowRight` instead where it's a date-range or before/after separator, e.g. `BookingDatesPicker`/`ProposalCard`/admin `IntegritySignalRow`), `←`→`ChevronLeft` before text for every "back to X" link and the calendar prev/next `aria-hidden` spans (`MonthAvailabilityGrid`/`ProviderAvailabilityCalendar`/`TimeScopedAvailabilityEditor`, paired with their `→`→`ChevronRight` next-month siblings), `↗`→`ExternalLink` (7 "visit website/social" sites), `↔`→`ArrowLeftRight` (`ReportRow.tsx`'s owner/provider pairing, the availability-calendar instructions copy — `reports.ts`'s own admin-preview-text `↔` left alone, same plain-text precedent as its `★`/`☆`), `⬇`→`Download` (contract download button — icon placed as a sibling next to the now-emoji-free `messages/*.json` label, per the PRD's own (a)/(b) either-is-fine instruction). `messages/en.json`/`lt.json` updated in lockstep for every string that carried a baked-in emoji/arrow (`downloadContract`, `safetyLink`, `becomeNanny.back`/`.continue`, `request.back`/`.continue`, `bookingDetail.backToMyBookings`/`.backToDashboard`, `providerProfileEdit.backToDashboard`/`.viewBooking`, `contractPage.backToBooking`, `dashboardNanny.profileNudge.cta`) plus `src/data/safetyContent.ts`'s two `howReviewsWorkLinkLabel` strings — same pattern, data stripped of the glyph, icon rendered as a JSX sibling at the call site. Theme showcase (`/theme`, lowest priority per the audit, done last): `sampleAssets.ts`'s sample gallery/avatar images used to bake a raw emoji into an SVG `data:` URI's own `<text>` node — genuinely different from every other swap (it's static markup assembly, not live JSX, so no component/hook exists to import) — fixed with the same raw-`__iconNode`-as-host-elements technique `ogIcons.tsx` already established for Satori, now reused a third time for a third "not real React" rendering context (`User`/`Dog`/`Cat`/`House` path data rendered as literal `<path>`/`<circle>` inside the hand-built SVG string). `ComponentsShowcaseClient.tsx`'s service-icon showcase (🐕/🌙/✂️) now mirrors `catalogIcons.ts`'s real `Footprints`/`Scissors` choices (Moon kept for "Overnight stay" specifically, matching the audit's own call-out of this exact site rather than the catalog's `Home`, which represents a different service-icon decision made in pass 1). **Final full-repo sweep** (the acceptance-criterion proof): a Python regex scan across every emoji Unicode block plus the audited typographic-symbol set (→←↗↔⬇✓✕★☆), re-run after each batch of fixes, catching real gaps the original audit missed entirely — a pervasive "📍 {location}" pattern never itemized in `EMOJI_INVENTORY.md` at all (now `MapPin` at ~20 sites incl. 3 more OG routes), an `ℹ️` info icon in `PlaceReviewsSection.tsx`, `▾` dropdown-caret glyphs in `NavDropdown.tsx`/`Disclosure.tsx` (→ `ChevronDown`, matching every other chevron swap this pass) — and confirmed what should legitimately stay: `completed`'s plain `'●'` bullet (pass 2's own explicit non-goal), `reports.ts`'s/`notificationKinds.ts`'s plain-text-only `★`/`☆`/`↔`/`🎉` strings (established precedent, no rendering context exists there), and the two email templates' emoji (documented exception above). Lint/`tsc`/`next build` all pass. **Not verified here**: real-browser spot-checks of the OG image routes (only checkable via a live deployed URL fetch, not this sandbox) and the theme page's rendered SVG data-URI output.
Programmatic SEO service pages (PRD-087)
One indexable `/vilnius/[service]` landing page per active catalog service — the commercial-intent queries ("šuns priežiūra Vilnius", "dog boarding Vilnius") that `/browse?service=x` alone (a filter param Google treats as one page) never won. Each page: LT-first intent-matched title/copy (a per-slug content map, `src/lib/serviceLandingContent.ts` — hand-written real copy + 3-4 FAQ questions for the 5 real launch services (dog-walking, overnight, daycare, drop-in, grooming), a generic `{service} Vilniuje` template for any other/future active service, e.g. the live `test-walking` test row), real provider cards (reusing `getProviders()` verbatim — PRD-017, requirement 1: never a parallel/lookalike query), a "from €X" price range computed from those exact same returned providers' prices (never hardcoded — `Math.min` over the already-service-filtered `provider_services` rows), and cross-links to every sibling service plus the `/vilnius` hub. One new composition module, `src/lib/serviceLanding.ts`'s `getServiceLandingData()`, mirrors `cityHub.ts`'s "one aggregator, page only renders" split. **Requirement 2, the anti-doorway-spam quality gate — implemented as a hard floor, not a decoration**: `MIN_PROVIDERS_FOR_INDEX = 3` (same honesty threshold `getFeaturedProviders`' `FEATURED_MIN_PROVIDERS` already established for PRD-023) gates BOTH "render the growth variant instead of the full page" and "the page is `noindex`" off the exact same `providers.length` the full page's own grid would render — the two can never drift apart. A sub-threshold service renders an honest `EmptyState` ("this service is growing in Vilnius", get-notified-by-email mailto + become-a-nanny CTAs, primitives-only, no new backend/table for the notify path since none exists yet in this codebase) instead of a fabricated grid, and carries `robots: {index:false, follow:true}` (still `follow`, so crawlers keep discovering the sibling-service cross-links every variant renders). Sitemap inclusion follows the identical gate (`sitemap.ts`'s new `getIndexableServiceLandingSlugs()` — deliberately reimplements the same paused/phone-verification eligibility math as a plain anon-client query rather than importing `getServiceLandingData` itself, matching this file's own established "no cookies() dependency" convention for sitemap generation), so "listed in the sitemap" and "actually renders indexable" can never disagree. New `Service` + `FAQPage` JSON-LD builders in `placeSeo.ts` (`serviceJsonLd`/`faqPageJsonLd`), rendered only on the qualifying variant — there's nothing real to back either schema on the growth variant. **Requirement 3 — localized slugs**: new nullable `services.seo_slug_lt` column (`supabase/migrations/20260727120000_service_seo_slug_lt.sql`, partial unique index so multiple `null`s coexist, backfilled with real hand-written LT slugs for the 5 real services — e.g. `dog-walking` → `suns-vedziojimas`), editable from a new "SEO slug (Lithuanian)" field on `/admin/catalog`'s `ServiceForm` (own collision error message, separate from the existing immutable-slug collision check) — adding a service in admin (with or without a custom LT slug) makes its landing page live with no code change, satisfying the acceptance criterion directly rather than by assertion. `src/lib/serviceCatalog.ts` gained `serviceLandingSlug(service, locale)` (LT uses `seoSlugLt` when set, every other case falls back to the plain catalog `slug` — never a broken link) as the one function every caller (the route, the `/vilnius` hub's now-clickable service cards, the sitemap) resolves a link through, so they can never disagree on the URL. `src/lib/hreflang.ts` gained `serviceLandingAlternates()`, a `localeAlternates()` counterpart for a route whose path SEGMENT (not just its locale prefix) differs across locales. **A deliberate, disclosed deviation from the PRD's literal "statically generated" wording**: this route ships with NO `generateStaticParams`/`dynamicParams`/`revalidate` — confirmed `ƒ Dynamic` in the real build output, rendering per-request like `nanny/[id]`/`vilnius/vet-clinics/[slug]` already do. This is a direct application of a bug this exact codebase already shipped to production twice this session (`src/app/[locale]/vilnius/vet-clinics/[slug]/page.tsx`'s own header comment has the full forensics): `[locale]/layout.tsx` calls `cookies()` on every request via `getViewer()`, and a `generateStaticParams`-bearing route only safely discovers that during `next build` if the build actually renders at least one REAL param instance — otherwise the route stays flagged-SSG-eligible-but-never-validated until the first real visitor 500s. This route's real params depend on the live `services` catalog being reachable at build time; on this machine `next build` runs with no `.env.local` at all, so `getServiceCatalog()` would've returned `[]` and produced ZERO real params — exactly the dangerous case. Rather than trust that Vercel's real production build definitely has Supabase env vars available (very likely true, per the sibling directory pages' own working ISR, but unprovable from here, and `getServiceCatalog`'s own deliberate try/catch-degrades-to-`[]` resilience means even a transient build-time DB hiccup could reproduce the bug a third time), this follows the PRD's own explicit fallback guidance: "a working dynamic page beats a broken static one." **Live-verified against real production Supabase data (read-only `execute_sql`, `apply_migration` never called) — not just trusted from the code**: the catalog has 6 active services (5 real + one pre-existing `test-walking` test row with no `name_lt`, included because catalog-driven means no per-slug special-casing). Real, currently-eligible (non-paused, phone-verified-or-still-grandfathered-under-PRD-068's-30-day-window) providers per service: dog-walking 2, overnight 2, daycare 2, drop-in 1, grooming 1, test-walking 0 — every real service today has FEWER than 3 eligible providers (there are only 4 real seeded provider profiles total, each offering 2 services), so **every service page currently renders the honest growth variant and is `noindex`** — a genuine, correctly-computed finding from an early-stage marketplace's real data, not a bug: the full/indexed variant activates automatically the instant any service crosses 3 real eligible providers, with zero further code change, which is exactly what this gate is for. Lint/`tsc`/`next build` all pass (confirmed zero errors, the new route builds and lists as `ƒ Dynamic`). **Not verified here**: Google Rich Results Test for the new `Service`/`FAQPage` schema, a real Search Console baseline (PRD-080's own disclosed-gap pattern), and native-speaker LT proofread of the new hand-written FAQ copy.
Testing foundation — unit + e2e + CI (PRD-048)
Zero automated tests before this, on a codebase auto-deploying every push to production. **Unit (Vitest, new dependency — pinned to 3.x, not the just-released 4.x, whose default `vite`/`rolldown` toolchain needs a native binding this session's Node 22.1.0 couldn't satisfy)**: `src/lib/serviceCatalog.test.ts` (the nights/units/total-price math `resolveBookingPricing` leans on), `src/lib/browseFilters.test.ts` (PRD-017 URL↔filter parsing), `src/lib/availabilityState.test.ts` (PRD-019 tri-state resolver's pure half), `src/lib/contractDetail.test.ts` (`contractNumberFor`) — 81 tests total. PRD-075's old hand-rolled `node:test` + in-memory `ts.transpileModule` shim (`rules.test.mjs`, written before this repo had a real runner) migrated onto Vitest too (`rules.test.ts`), one runner for everything now (`npm test`). **Acceptance criterion proven, not assumed**: deliberately mutated the nights calculation, watched 4 tests fail with the exact expected diff, reverted. **E2E (Playwright, new dependency)**: 6 specs against `next start` + a real local Supabase stack (`supabase start`, PRD-152) — signup→confirm(admin API, no real inbox)→profile, `/become-nanny` wizard→listing appears on `/browse`, the full owner-request→provider-accept→complete→review loop, the cancel path, guest-gating redirect, and contract download (asserts the downloaded file's PDF magic bytes + filename, never pixels). Each spec creates its own fixture users via the admin API with unique emails and deletes them afterward. Booking-lifecycle transitions (accept/decline/complete/cancel) are driven through the real signed-in UI in every spec, never a direct DB write — `guard_booking_transition` authorizes purely off `auth.uid()`, which is null on a service-role connection, so this is the only way to actually exercise RLS + the transition guard rather than assume it (satisfies this PRD's other acceptance criterion, "breaking accept-flow RLS fails e2e"). **Two real, previously-undiscovered bugs found and fixed while building this suite** (both blocked it from proving anything real, so fixing them was in scope, not a drive-by): (1) a completely fresh local Supabase DB had zero base SELECT/INSERT/UPDATE/DELETE grants for `anon`/`authenticated`/`service_role` on any table this project's own migrations create (Postgres' GRANT check fails before RLS is ever evaluated; `supabase-js` swallows it as a silent empty read, not an error) — `docs/LOCAL_DEV.md` had explicitly flagged this whole local stack as never verified end-to-end, and this was exactly that gap; fixed in `supabase/migrations/20260727150000_public_schema_grants.sql`, a no-op against the remote project (Supabase Cloud already provisions these outside of migrations). (2) every contract PDF failed to generate — `ContractPDF.tsx`/`SeriesContractPDF.tsx` only ever registered Regular + Bold for the embedded Noto Sans font, but several styles (every contract's bilingual clause captions) set `fontStyle: 'italic'`, which `@react-pdf/renderer` can't fake without a matching registered font; PRD-061's original font-embedding comment had explicitly flagged actual visual PDF rendering as unverified (no browser available then) — fixed by embedding real `NotoSans-Italic.ttf`/`NotoSans-BoldItalic.ttf` (same upstream source/license/cmap-verification as the existing files). **One real bug found and deliberately NOT fixed here** (a schema/product decision, not a testing-PRD's call): deleting an account that has left a review can crash entirely (500) — the booking cascade-deletes, `reviews.booking_id` follows with `SET NULL`, landing a booking-linked review at both `booking_id`/`series_id` null, violating `reviews_booking_xor_series_check`; GoTrue wraps the whole account-deletion cascade in one transaction, so this fails PRD-016's real "delete account" feature outright for any owner who ever reviewed. Worked around only in this suite's own fixture cleanup (`e2e/helpers/db.ts`); flagged prominently in `docs/TESTING.md` as a genuine follow-up. **CI** (`.github/workflows/ci.yml`): a `test` job (lint + typecheck + unit, every PR/push, no external services) and an `e2e` job gated on the PR actually touching `src/`/migrations/e2e config (via `dorny/paths-filter`) that provisions the same local Supabase stack inside the runner via the official `supabase/setup-cli` action — no cloud project, no new secrets. Real measured runtime (not guessed): build ~30-60s, `supabase start` ~1-3 min cold, the 6 specs themselves ~15-20s — comfortably under the PRD's own "~10 min, else nightly" threshold, so every relevant PR runs it rather than nightly-only. Full suite run repeatedly from a clean `supabase db reset` + fresh build: unit 81/81 and e2e 8/8 green every time (3 consecutive full runs, 24 total e2e executions, zero leftover fixture rows after fixing a real cleanup-ordering race — deleting two fixture users concurrently could deadlock on a shared review row; now sequential). **Outstanding human step, explicitly not claimed as done**: making either CI check "required" before merge is a GitHub repo setting (Settings → Branches → protect `main` → require status checks) — documented exactly in `docs/TESTING.md`, not achievable by pushing code.
Geo & discovery (Phase 18)
Geolocation data foundation (PRD-135)
Prioritized ahead of its normal phase position (unblocks PRD-136's map). Every provider and place can now carry real coordinates + a canonical district — location has been city/district free text only until now. **Schema**: new migration installs `postgis` (confirmed available, not yet installed, its own `create extension if not exists` step first), a `districts` table (21 rows — Vilnius' real seniūnijos, cross-verified against LT Wikipedia's own "Sąrašas:Vilniaus seniūnijos" list page and EN Wikipedia's "Vilnius"/"Neighborhoods of Vilnius" articles after vilnius.lt itself 403'd automated fetches; `name` short display form / `name_lt` official "X seniūnija" form, `centroid_lat`/`lng` for the requirement-2 fallback point), and on both `provider_profiles`/`places`: `lat`/`lng double precision`, `location_geog geography(Point,4326) generated always as (ST_SetSRID(ST_MakePoint(lng,lat),4326)::geography) stored` + a GiST index, `district_id`. **Geocoding provider: Nominatim** (OpenStreetMap) — evaluated against Google/HERE and chosen for needing zero new paid API key/billing setup, matching this project's established pattern of avoiding paid integrations without one already justified (Messente/PRD-068 is the sole exception, chosen for the same self-serve simplicity); self-rate-limited to 1req/sec via a module-level throttle (`src/lib/geo/nominatim.ts`) that correctly paces both a single write-time geocode and the admin backfill batch's sequential loop. **Provider privacy — the safety-critical half**: exact coordinates are stored (needed for correct radius-search math later) but enforced NEVER to reach a non-owner through TWO layered DB mechanisms — `revoke select (lat,lng,location_geog) on provider_profiles from anon, authenticated` (a genuine column-privilege, so even a raw PostgREST call for those columns 403s, not just an app-layer convention like `profiles.phone`'s pre-existing gap) plus `provider_profiles_public`, a view exposing only a deterministically fuzzed point (`fuzzed_point()`, `ST_Project` by 300-500m at an angle/distance both hash-seeded from the provider's own id — same id always fuzzes to the same point, defeating triangulation-by-averaging, unlike a per-render `Math.random()` offset) + district. `places` gets none of this fuzzing (requirement 3: a business address is meant to be found). **Write-time geocoding + confirm step**: a shared `LocationConfirmField` (`src/components/geo/`) used identically by onboarding's `PersonalStep`, the PRD-062 profile-edit page, and the admin `PlaceForm` — geocodes the caller's free text, shows the matched address + a nearest-district suggestion (`nearest_district()`, real PostGIS KNN, never a JS haversine) for explicit confirmation, with an always-available (not fallback-only) manual district picker as the second path, which uses that district's own centroid as the stored point. Provider onboarding requires a confirmed location before submitting (server-revalidated); the profile-edit page tracks a `locationDirty` flag so an unrelated bio edit can never silently null out already-saved coordinates the client can't read back (since `authenticated` is revoked too — the owner re-geocodes fresh rather than round-tripping exact numbers). **Backfill**: a dismissible `GeoNudge` dashboard card (mirrors `ProfileNudge`) for existing providers with no `district_id`; `/admin/places` gained a "Backfill coordinates" button running `backfillPlaceCoordinates()` (20 places/click, respects the 1req/sec pacer, shows geocoded/skipped/remaining counts) over every place missing coordinates. A row that fails to geocode is simply left null everywhere — no crash, no missing pin, no distance shown — the explicit design brief for PRD-136's map to build on next. **First consumer — `/browse`**: district chips (`Badge`, public — a district is not privacy-sensitive) + "X km" on every `NannyCard`, from either an explicit opt-in "Use my location" button (`navigator.geolocation`, never an auto-prompt) or the always-available district `Select` (both filters can be active together). Distance is computed server-side via a new `providers_near()` security-definer RPC running real `ST_Distance` against each provider's FUZZED point (never the exact one, never a JS haversine) — the viewer's own lat/lng travels via non-canonical `?vlat=&vlng=` URL params kept deliberately OUT of `BrowseFilters`/canonical metadata (visitor-specific, not a real search state), while `?district=` is a first-class canonical filter. New opt-in `distance_asc` sort, hidden from the dropdown until a viewer point exists. **Verified live** (Supabase MCP, rolled-back transactions only, `apply_migration` never called): the full migration applies cleanly; a real seeded provider's `lat`/`location_geog` genuinely inaccessible to the `anon` role (`SET LOCAL ROLE anon` + caught `insufficient_privilege`) while `bio` and the `provider_profiles_public` view/`providers_near()` RPC remain fully readable to that same role; `fuzzed_point()` confirmed deterministic (two calls agree byte-for-byte) and its offset lands in the 300-500m band; two different provider ids fuzz an identical input point differently (proving the id, not just the coordinate, seeds it); `providers_near()` returns 300-500m — never ~0m — for a provider's distance to their OWN exact point, proving the RPC genuinely uses the fuzzed point end-to-end; a hand-computed distance check (two synthetic points 0.0090° of latitude apart at 54.69°N, expected ~1002m via the standard ~111.3km/degree-latitude constant) landed at 1001.86m; `nearest_district()` resolved a point exactly at a seeded centroid back to that same district; a row with null lat/lng correctly produced a null `location_geog`, was excluded from `providers_near()`, and still returned a (fuzzed-null) row from the public view rather than erroring. Lint/`tsc`/`next build` all pass. Full bilingual (LT/EN) copy for every new string, not yet native-speaker-proofread — same disclosed gap as every prior PRD's LT copy. **Deviations**: `getFeaturedProviders`/`getProvider` don't thread the district lookup through (only `/browse`'s `getProviders` does, this PRD's one required consumer) — a featured/profile-page district chip is a natural small follow-up, not done here to keep scope to the acceptance criteria; no admin UI to view a provider's own exact coordinates (not required by the PRD, service-role SQL access covers any real support need). Needs human: a real two-browser-tab click-through of the confirm step / "Use my location" prompt / district picker, native-speaker LT review, and running the admin backfill against the live `places` directory once this migration is live.
Map view for browse & directory (PRD-136)
User-requested ("Airbnb-style map view"), prioritized ahead of its normal phase position, built entirely on PRD-135's geo layer — **no new migration**, pure consumption. **Map library: MapLibre GL JS + OpenFreeMap** (`tiles.openfreemap.org/styles/liberty`) — evaluated against Google Maps and chosen for this project's standing "no paid API without an already-provisioned key" rule (the same reasoning PRD-135 used for Nominatim over Google/HERE geocoding); confirmed current (not assumed from stale memory) that OpenFreeMap is maplibre's own recommended free, no-key, no-signup, no-request-cap hosted tile instance. One shared `src/components/map/AppMap.tsx`, used by every map surface in this PRD: tokens-styled DOM markers (`maplibregl.Marker({element})` holding a real React tree via `createRoot`, not a canvas/GL sprite layer, so every marker/popup uses this app's normal `var(--token)` inline-style convention with zero new plumbing) driven by `supercluster` for clustering past ~50 pins (the established MapLibre-GL-JS + Supercluster pairing the PRD's own technical note names — chosen over MapLibre's native `cluster:true` GeoJSON-layer clustering specifically because that path only clusters canvas sprite layers, which would've meant hand-building an SVG-icon sprite pipeline just to keep using React/token-styled markers). A `kind: 'fuzzy'` marker renders as a soft blurred circle (never a pointer/pin shape) and a `kind: 'pin'` marker renders a real pin with a Lucide icon inside — the CALLER decides which kind per marker by which query it read the point from, so a fuzzed point can never accidentally render as a false-precision pin. **`/browse` map mode**: a List/Map `Chip` toggle, state in the URL (`?view=map`) via two new non-canonical parse helpers in `browseFilters.ts` (`parseViewMode`/`parseMapBounds`, same "view-specific, not canonical metadata" treatment `parseViewerPoint`'s `?vlat=&vlng=` already established for PRD-135 — kept OUT of `BrowseFilters`/the canonical OG URL). Pins come from a new `attachMapPins()` in `src/lib/providers.ts` — deliberately NOT a second, separately-filtered query: it takes the exact `Nanny[]` result `getProviders()` already produced for list mode and does ONE extra `.from('provider_profiles_public').select('id, fuzzed_lat, fuzzed_lng')` lookup for those same rows, so map mode is provably running "the same underlying getProviders() call" the PRD asks for, and a provider with no geocoded point yet is silently dropped from the pin list (graceful degradation, matching PRD-135's own posture). Pin tap opens a mini card (avatar, name, rating, price-from, "View profile" link — reusing `Nanny` fields already on hand, no new fetch) inside a new `BottomSheet` primitive. Viewport search: panning fires a "Search this area" button (never an auto-query on pan); clicking it writes the new bounds into the URL (`neLat`/`neLng`/`swLat`/`swLng`, same non-canonical treatment) as a normal `router.push`, so the existing server round-trip re-filters `mapPins` to the bounded rectangle and the results line honestly switches from "N nannies on the map" to "N nannies in this area" — no separate API route needed, and because `AppMap`'s own maplibregl.Map instance persists across that re-render (only its data updates, never its camera, when `refitOnMarkersChange={false}`), the visitor's pan position survives the refresh. **Directory maps** (`/vilnius/vet-clinics`, `/vilnius/breeders`, `/vilnius/pet-friendly`): the same List/Map toggle added to each existing client-side-filtered listing (PRD-080's pattern — no server round-trip needed, `view` just joined each page's own filter-param type), rendering through one new generic `src/components/directory/PlaceListMap.tsx` + `PlaceMapPinCard.tsx` (shared across all three categories, exact pins — `places` addresses were never fuzzed by PRD-135, reading `lat`/`lng` directly is correct here) with category icons reused verbatim from `PLACE_CATEGORY_ICONS`/`SERVICE_TYPE_ICONS` (no new icon choices). 24/7 vet clinics render with a distinct marker color (`--color-success`, mirroring the existing `openNow` card badge treatment) plus the same `badge24h` label on their mini card. **Detail-page embeds** (`[slug]` pages for all three categories): a small, `interactive={false}` `<AppMap>` (180px, single exact pin, no pan/zoom/rotate at all) via a new `PlaceDetailMap.tsx`, paired with a plain "Open in Google Maps" outbound link (`src/lib/googleMapsLink.ts`'s `googleMapsSearchUrl`, the documented `?api=1&query=lat,lng` scheme — no API key, navigation itself is explicitly not this app's job) — omitted entirely for a place with no coordinates yet. Deliberately did NOT reintroduce `generateStaticParams`/ISR on these detail pages (the real bug `bbb8bb9` already fixed for exactly this reason). **Mobile**: the new `src/components/ui/BottomSheet.tsx` primitive — a bottom-pinned, scrim-backed sheet on mobile (≤640px, CSS media query in `globals.css`, this codebase's own "inline by default, a class only for what inline literally can't do" convention) vs. a small non-blocking floating card bottom-left on desktop (`pointer-events:none` on the wrapper, `auto` on the card itself, so the map stays pannable/tappable underneath). Gesture conflict: resolved differently per surface rather than one blanket rule — full map MODE (`/browse`, directory listings) replaces the entire results area, so there's no competing page-scroll region under the visitor's finger and full touch capture is correct; the small inline detail-page embed sits inside an otherwise normally-scrolling page, so it's simply non-interactive (`interactive={false}`) rather than a two-step tap-to-activate affordance — a visitor who wants to actually explore taps through to Google Maps instead. **Performance**: `AppMap`/`PlaceListMap`/`BrowseMap` are all `next/dynamic(..., { ssr:false })`-imported from their client parents (`BrowseClient.tsx`, `VetClinicsClient.tsx`, `BreedersClient.tsx`, `PetFriendlyClient.tsx`) so `maplibre-gl` (528KB, confirmed the only chunk containing it — `ca4dcb09-*.js`) never ships to a list-mode page load; `PlaceDetailMap` is imported directly (not dynamic) into the three server-component `[slug]` pages, which is safe/correct because a Server Component -> Client Component boundary is ALREADY its own automatic lazy chunk (Next only fetches it when that specific instance actually renders in the RSC payload — confirmed a place with no `lat`/`lng` never triggers the reference at all). **Verified in the built output, not just trusted from the dynamic-import pattern**: `.next/static/chunks/ca4dcb09-*.js` is maplibre-gl's ONLY chunk; none of the six prerendered `/vilnius/{vet-clinics,breeders,pet-friendly}` listing HTML files (en+lt) reference it (`grep`'d every `chunks/*.js` reference in each file's prerendered HTML); `/browse`'s RSC client-reference-manifest doesn't reference it either. Lint/`tsc`/`next build` (both Turbopack and `--webpack`) all pass. **Critical finding, NOT introduced by this PRD, flagged for a human/follow-up migration**: live-verified via Supabase MCP (rolled-back transactions, `apply_migration` never called) that `attachMapPins()` itself only ever queries `provider_profiles_public` (confirmed: `SET LOCAL ROLE anon; select id,fuzzed_lat,fuzzed_lng from provider_profiles_public` succeeds, returns fuzzed-null rows since 0 of 5 seeded providers are geocoded yet) — but ALSO discovered, while re-testing PRD-135's own privacy guarantee as this PRD's verification bar requires, that `anon`/`authenticated` currently DO have live SELECT (and INSERT/UPDATE) on `provider_profiles.lat`/`lng`/`location_geog` on the connected database (`information_schema.column_privileges` lists them; `SET LOCAL ROLE anon; select count(lat) from provider_profiles` succeeds with no permission error) — the opposite of PRD-135's own `revoke select (lat,lng,location_geog) ... from anon, authenticated`, and the opposite of what PRD-135's own STATUS entry recorded verifying. The migration file's SQL is correct and `list_migrations` shows it applied; nothing in this session (or any migration after it — checked every `provider_profiles`-touching migration file, none contains a blanket re-grant) explains the drift. No app code (this PRD's or otherwise) queries these raw columns — the view/RPC-only discipline holds end-to-end regardless — but the column-level ACL "backstop" a direct/rogue PostgREST call would hit is NOT currently enforced live, and needs a human to investigate (possible Supabase-platform grant reconciliation clobbering column-level REVOKEs on deploy) and a follow-up migration to re-assert it. Full bilingual LT/EN copy for every new string, not yet native-speaker-proofread. **Needs human**: real browser/mobile-device verification (marker rendering, clustering visuals, touch-gesture feel, bottom-sheet swipe/tap behavior, "Search this area" pan interaction) — none of this is meaningfully testable without a real browser, only build/type/lint-level correctness was verified here; the column-privilege regression above; native-speaker LT review; running the admin places backfill so real pins actually appear. **Follow-up (this session)**: the map-tiles bug reported after this shipped was separate (worker-file 404 under Turbopack + a middleware matcher issue) and already fixed elsewhere — but investigating "only nanny pins show, nothing else does" surfaced the real remaining gap this PRD's own STATUS flagged as still needed: zero providers/places had `lat`/`lng` populated in production. Ran the equivalent of `backfillPlaceCoordinates()` by hand against the live prod Supabase project (Nominatim geocoding via sequential rate-limited calls, `nearest_district()` RPC, direct SQL writes — this project has no preview/local DB, only `main`/production): 49 of 55 addressed places geocoded (6 failed on genuinely bad/ambiguous addresses — a street-intersection description, out-of-Vilnius rural k./sen. addresses Nominatim can't resolve — left null, not fabricated; the other 13 places have no address on file at all, unchanged/expected). Also bulk-geocoded the 4 demo/seed `provider_profiles` rows (`*@demo.petnanny.lt`) from their existing district-level `location` text, deliberately excluding the one real dev/test account, which correctly stays consent-gated behind the normal profile-edit `LocationConfirmField` flow (still shows no pin — intended, not a bug). Separately closed a genuine requirement-3 gap this PRD's own text called for but never shipped: the public nanny detail page (`/nanny/[id]`) had no map at all — only the three `/vilnius/*` directory detail pages did. Added one by calling the existing `attachMapPins()` for the single provider and rendering the existing `PlaceDetailMap` embed, reading ONLY `provider_profiles_public`'s fuzzed point (never the RLS-locked-down raw `provider_profiles.lat`/`lng`), with a new `nannyProfile.location.fuzzedNote` disclaimer mirroring `browse.map.fuzzedNote`'s existing approximate-area copy — omitted entirely when a provider has no fuzzed point yet, same graceful-degradation posture as everywhere else. Lint and `next build` both re-verified passing after this change. **P0 post-launch fix (follow-up session)**: the previous session's backfill made this PRD's own latent bug fire in production — `PlaceDetailMap` accepted `icon: LucideIcon`, a component reference, passed from every `[slug]/page.tsx` Server Component; passing a function across the RSC server->client boundary is invalid and 500'd every geocoded vet-clinic/breeder/pet-friendly detail page ("Functions cannot be passed directly to Client Components..."). Fixed by having `PlaceDetailMap` accept a plain `categorySlug: string` instead and resolve the icon internally via `PLACE_CATEGORY_ICONS`/`DEFAULT_CATALOG_ICON` (falling back to a generic pin icon when omitted, e.g. the nanny profile's fuzzed-location marker); all four callers (`vet-clinics/[slug]`, `breeders/[slug]`, `pet-friendly/[slug]`, `nanny/[id]`) updated. Verified by reproducing the exact crash locally against the real prod DB pre-fix, then confirming 200s post-fix, for one page per category. Also fixed `backfillPlaceCoordinates()` (`admin/places/actions.ts`): it required a non-null `address` (so a `city`-only row, common for breeders, was never even attempted) and unconditionally appended a hardcoded `'Vilnius'` to every geocoding query, which produced self-contradictory queries for breeders outside Vilnius (PRD-079 — breeders are the one multi-region category) and caused otherwise-fine addresses to fail. Loosened the eligibility filter to `address OR city`, and `'Vilnius'` is now only appended when `city` is null. Re-ran the backfill by hand for every previously-null row: breeders went from 0/11 to 7/11 geocoded, hotel 3/6 to 6/6, park 10/12 to 12/12, trail_beach 1/4 to 4/4 (vet_clinic/cafe were already complete); the remaining 4 breeders (`test1`, `Katės Akis`, `Mažasis Aristokratas`, `Alto Paradas`) have neither `address` nor `city` on file — genuinely nothing to geocode from, left null rather than fabricated. Lint/`tsc`/`next build` re-verified passing.
Compliance & data protection (Phase 16)
Terms, privacy & legal foundation (PRD-121)
Real, versioned Terms of Service and Privacy Policy, LT + EN, both live at `/terms`/`/privacy` (archived at stable `/terms/v1`/`/privacy/v1`-style URLs, so a future v2 never rewrites what an earlier user actually agreed to). **Written from a real data inventory, not a generic template**: `docs/DATA_INVENTORY.md` was compiled by reading the actual live schema (`supabase/migrations/*.sql`) and the actual code paths that write personal data — profiles, pets, the sensitive `care_profile` jsonb (feeding/medication/health/vet/behavior/emergency contact), private messages, PRD-067 ID-verification documents, PRD-068 phone OTP, PRD-135 location/fuzzing, reviews, bookings/contracts, reports/moderation — each with what/why/legal-basis/processor/retention, INCLUDING the honest gaps (bookings/reports/OTP-code rows are kept indefinitely today, no automated purge — real PRD-125 territory, not papered over) and a live-data forensic note about the PRD-136 STATUS entry that found the location column-privilege revoke had drifted on production at one point. Both documents (`src/data/legalContent.ts`, versioned `Record<version, Record<locale, doc>>`, same "never edit a past entry" discipline `CONTRACT_TERMS` (PRD-061) already established) are genuine, carefully-drafted first-pass legal copy — Lithuanian written natively, not machine-translated — covering the marketplace-venue liability framing `AGENTS.md` requires ("PetNanny is a venue... payments settled directly, never in-app," cross-checked and consistent), eligibility, verification's real scope (linked to `/safety`), reviews, cancellations, an explicit liability section, and versioned change mechanics. **Explicitly, prominently NOT lawyer-reviewed** — flagged in the file's own header comment, in both documents' framing, and here — exactly the disclosed-gap pattern PRD-061's contract terms already set, not a claim of completeness. Rendered through the existing PRD-085 `ArticleBody` markdown pipeline (no second renderer built). **Consent capture**: new idempotent migration adds `profiles.tos_accepted_version`/`tos_accepted_at` (int/timestamptz, self-writable, no guard trigger needed — this isn't an adversarial column). Signup (`AuthForm.tsx`) gains a required, unchecked-by-default checkbox linking to both real documents; `auth/actions.ts`'s `signUp` re-validates server-side (a JS-disabled client can't bypass the `required` attribute) and records acceptance via the service-role client specifically because the email-confirmation-required flow has no session yet at that point — best-effort/non-fatal to account creation, same posture `consumeIdDocuments` (PRD-067) already established for a secondary write, so a transient failure here just leaves the column null, caught by the fallback below. **Existing accounts**: a new dashboard-level `TosConsentBanner` (rendered from `dashboard/layout.tsx` whenever `tos_accepted_version` isn't the current bundle version) — a banner, never a redirect/hard block, so an existing signed-in session is never locked out; accepting calls a new `acceptCurrentTos()` server action (self-scoped RLS write, no admin client needed) and revalidates so the banner clears on the same navigation. Footer (`Footer.tsx`) gained a real "Terms of Service"/"Privacy Policy" link pair next to the copyright line — verified no dangling placeholder link existed first. **Change-notification mechanics (requirement 4), honestly split three ways, documented in `legalContent.ts`'s own header comment**: re-acceptance-on-material-change already works today (bumping `CURRENT_TOS_VERSION` makes the dashboard banner reappear for every stale account — no further code needed); email-on-material-change is achievable now via the existing PRD-030 email infra but is deliberately left as a named TODO (a manually-triggered admin action once a real v2 ships, not a speculative template for a change that doesn't exist yet); the in-app notice genuinely blocks on PRD-101 (in-app announcements — confirmed unbuilt, no `announcements` table or broadcast mechanism exists anywhere in this codebase) and is disclosed as blocked, not faked with a lookalike mechanism. **Explicitly NOT built, per the PRD's own non-goals**: a cookie-consent banner (PRD-122's job) and OAuth consent capture (PRD-088 hasn't shipped — no OAuth path exists to hook into yet). **Spot-checked reality claims** (the PRD's own acceptance criterion 3): (1) "payments settled directly, never in-app" — verified against `AGENTS.md`'s own out-of-scope line and the absence of any payment-processing code/dependency anywhere in `src/`; (2) "no IP-address logging for analytics" — verified by grepping the whole `src/` tree for IP capture/analytics code, finding none, and confirming PRD-089/PRD-049 (the PRDs that would add it) are both still unbuilt per every prior PRD that references them; (3) the ID-document retention claim ("deleted right after an admin's decision, cron backstop, only a note survives") — verified against `src/lib/idVerification.ts`'s actual `consumeIdDocuments`/`sweepLeftoverIdDocuments` implementations and the PRD-067 migration's own column comments, matched word-for-word rather than assumed. Lint/`tsc`/`next build` all pass. **Needs human**: the actual Lithuanian-lawyer review (PRD-121's own requirement 1 — a budget line, not an engineering task), a native-speaker proofread of the new LT legal copy, and a real click-through of the signup checkbox / dashboard banner in a browser.
GDPR data export (PRD-123)
Settings → "Download my data": a real, self-service export against the PRD-121 data inventory, not a hand-run SQL script. **Deliberate deviation from the PRD's own technical note, disclosed**: the PRD says "request queues an export job (PRD-032 cron drains)" — built SYNCHRONOUS instead. Checked this project's actual live data volume via Supabase MCP first (11 users, 14 bookings, 3 messages, 0 booking_updates, 3 reviews) — genuinely tiny, so a queue+cron round trip is pure overhead; the PRD-032 hourly cron also has a known, separately-flagged production gap (`CRON_SECRET` never set on Vercel, so it has likely never fired in prod — see the "provider reliability signals" STATUS entry), and building this PRD on top of that dependency would make it unverifiable end-to-end for no benefit at this scale. Instead the settings action itself runs the whole cycle: query, zip, upload, log, email — no queue table, no new cron dependency for the core flow. **`src/lib/dataExport.ts`** — one module (per the PRD's own technical note), split into a fetch layer (`fetchExportRows`, the ONLY function touching Supabase — every cross-user boundary is an explicit `.eq()`/`.in()` filter here, scoped to the requester's own id/own `provider_profiles` id, since this runs on the service-role client rather than RLS — needed to read the requester's OWN `provider_profiles.lat`/`lng`, which PRD-135's column-privilege revoke blocks even a provider's own session from selecting) and a PURE assembly layer (`buildExportBundle`), which is where the one privacy-critical rule in the whole PRD lives: a message the counterpart sent never carries its `body` (or content-moderation `flags`) past that function — metadata (timing/read state) only, own words only. Bundle matches the PRD-121 inventory: profile + consent record + notification prefs, pets + care profiles (owner-side only — a provider's own export deliberately excludes another owner's pet-medical snapshot even though they can view it in-app once accepted, a "my data, not everything I can see" distinction PRD-124/erasure will need too), bookings on either side (counterpart phone/address gated by the exact same `canViewContactInfo`/`revealedPhone` the care contract already uses — a still-pending request reveals nothing), reviews written + (as a deliberate small addition beyond the PRD's literal bullet, disclosed in the README) review responses authored, care-update journal posts authored + received, conversations with the counterpart-redaction rule above, avatar/gallery/journal photo files actually bundled as real media inside the zip (not links), notification history. Zipped with `fflate` (added as a direct dependency — was already an indirect one via `@react-pdf/renderer`'s `png-js`, so this added zero new install-time surface), manifest is a bilingual (EN/LT) `README.txt` explaining every file and the counterpart-privacy rule in plain language. New `data_export_requests` table (own migration, not `moderation_actions` — that table's insert policy is admin-only and PRD-113's own framing is "admin-attributable events," not a user acting on their own account) is requirement 5's audit trail — real and queryable now even though PRD-113 (the admin UI that would read it) hasn't been built yet, confirmed by checking. New private `data-exports` Supabase Storage bucket, signed URLs resolved fresh per read (never persisted), 7-day expiry. **Cleanup, two backstops, not one**: the settings page's own next read does an age-check-on-read lazy delete (no cron dependency, catches the common case), and a `sweepExpiredDataExports` step was added to the existing PRD-032 cron route as the backstop for a request nobody revisits — same synchronous-primary/cron-backstop split PRD-067's `consumeIdDocuments`/`sweepLeftoverIdDocuments` already established, with the same disclosed CRON_SECRET caveat (not new, already flagged) rather than a second broken dependency. Rate limit (one export per 7 days) is a pure timestamp check, no new infra. **Adversarial testing (Vitest, PRD-048)**: a two-user shared-booking-and-conversation fixture (`src/lib/dataExport.test.ts`) proves Alice's export contains her shared data but nothing from Bob's unrelated booking/conversation with a third party, and that the message-redaction rule holds in both directions. **Actually broke a real WHERE clause to prove the test catches it**: temporarily removed the `.eq('owner_id', userId)` filter on the owner-bookings query — the "no leaked private data" assertions failed immediately (a stranger's booking, with their private `owner_notes`, appeared in the export), confirming the test is load-bearing, then reverted. **Verified against real production data** (Supabase MCP, read-only): the real account with actual booking history (`Egidijus Lenksas`) turned out to have booked *itself* (owner_id and provider_id both resolve to the same profile) — a genuine edge case that exercises the dedup logic's `role: 'owner_and_provider'` branch, confirmed to collapse to one row per booking rather than duplicating. Lint/`tsc`/`next build`/unit tests all pass. **Not verified here**: an actual two-browser click-through of the settings download flow, and (as already disclosed elsewhere) `CRON_SECRET` being set on Vercel — the cleanup backstop cron step is unverifiable in production until that's fixed, same pre-existing gap, not new.
Account erasure hardening (PRD-124)
Audited `src/lib/accountDeletion.ts` (PRD-016/038) table-by-table against the full `docs/DATA_INVENTORY.md` (PRD-121) inventory and fixed real gaps, not just re-confirmed it worked. **The central finding**: `bookings.owner_id`/`provider_id`, `reviews.provider_id`, `conversations.owner_id`/`provider_id`, and `messages.sender_id` were ALL `on delete cascade` — meaning either party deleting their account destroyed the WHOLE shared record out from under the other, still-existing party (a booking's dates/price/pet snapshot, an entire conversation and every message in it including the counterpart's own, a review about a since-deleted provider). Only `reviews.author_id`/`booking_id` had ever gotten the correct `on delete set null` treatment (PRD-016) — this PRD extends that exact pattern to the four FKs above (new migration `20260727170000_erasure_hardening.sql`), plus patches `guard_booking_transition()` (which makes `owner_id`/`provider_id` immutable post-creation as a fraud guard) to allow the one-way transition to NULL the FK's own referential action needs, without weakening the "can never be reassigned to a different party" guarantee at all. **New `src/lib/accountErasureManifest.ts`** — the sibling PRD-123's `dataExport.ts` asked for: an in-code `ACCOUNT_TABLE_MANIFEST`/`STORAGE_BUCKET_MANIFEST` (table/bucket → delete/anonymize/retain + a real reason, per requirement 1's "manifest, not prose") covering every table in the inventory, each documented against the actual live FK behavior (verified via `psql`/`\d` against a real local Supabase stack, not assumed). **Storage — the other real gap**: `accountDeletion.ts` never called `.storage.*` at all before this PRD; a cascade-deleted DB row never touches the Storage OBJECT its column only held a path to, so avatar/gallery/journal/ID-document/incident-evidence/data-export objects were leaking forever. Fixed: every bucket-owning row is queried and its objects removed BEFORE `auth.admin.deleteUser` runs (after which the rows/paths are already gone or nulled). **The two content-anonymization asks**: message bodies (`DELETED_MESSAGE_BODY`, a fixed placeholder — the `messages_body_check` CHECK constraint forbids blanking to `''`/null — sender_id set null by the schema fix, counterpart's own messages completely untouched) and the `emergencyContact.name`/`phone` embedded inside `booking_pet_care_profiles.care_profile` jsonb snapshots (`anonymizeCareProfileSnapshot`, pure function — leaves `vet.clinicName`/`phone` alone, a third-party business contact, not the deleted user's own data). **Decided and documented** (the PRD's own explicit "decide the line" ask): journal (`booking_updates`) ownership follows the UPLOADER, not the booking counterpart — a journal entry is single-authored content, not a two-sided thread like messages, so it's deleted entirely with the author's account even though the booking itself survives; disclosed inconsistency left alone (not silently missed): `place_reviews.author_id` is still `on delete cascade`, unlike marketplace `reviews` — not named in the PRD's own requirement text, flagged as real follow-up work. **Registry-consistency tests** (acceptance criterion 1, literally): `src/lib/accountErasureManifest.test.ts` asserts every manifest `handledBy: 'app-code'` entry has a matching `accountDeletion.ts` handler in `APP_HANDLED_TABLES`/`APP_HANDLED_BUCKETS` AND vice versa — proved both directions actually fail by temporarily removing a manifest entry (the vice-versa check failed) and separately removing a handler-set entry (the forward check failed), reverted after confirming both. **Completeness test, run for real, not assumed**: `e2e/specs/erasure-completeness.spec.ts` seeds a full-footprint dual-role account (owner on one booking, provider on another, pet, gallery photo, ID docs, journal entry+photo, review written + review received, conversation+messages both directions, notification, incident report+evidence photo, PRD-123 data-export archive) via the PRD-048 admin-API fixture pattern, drives the REAL "Delete account" UI (not a direct DB write — same "exercise the real path" convention this repo's other e2e specs already follow), then sweeps every manifest table/bucket for the deleted user and asserts zero remain, while confirming the counterpart's booking/conversation/review survive coherently as "Deleted user." Passed against a real local `supabase start` stack. **Regression proof, also run for real**: commented out `await deleteAvatar(admin, userId)` in `accountDeletion.ts`, rebuilt, re-ran the e2e spec — failed immediately (`avatarList.data` had length 1, not 0); reverted, rebuilt, re-ran — passed again. **`/privacy` updated to v2`** (§5): a new, honest backup-retention disclosure (Supabase PITR keeps deleted data in older backups until they age out — the exact day-count for this project's actual plan could NOT be independently verified/discovered from this session's toolset, flagged as a concrete TODO rather than guessed) plus a factual correction to the account-deletion bullet, which previously described bookings/messages as fully REMOVED — no longer accurate now that they correctly survive, anonymized. Judged additive/corrective, so `CURRENT_TOS_VERSION` (the actual re-consent trigger) was deliberately NOT bumped alongside it. **New `docs/RUNBOOKS.md`** — step-by-step for handling a GDPR erasure request that arrives via plain email (PRD-119's support inbox hasn't shipped): confirming the account, checking the same active-booking guard the self-serve path already enforces, using the existing PRD-038 admin `/admin/users/[id]` "Delete account" double-confirm action (same underlying `deleteUserAccount` call, not a separate mechanism), and what to tell the requester about what survives and why. Lint/`tsc`/`next build`/`npm test` all pass. **Not done here**: the PRD-121 lawyer review this whole legal-content area already disclosed as outstanding; the real Supabase PITR day-count (flagged, not guessed); `place_reviews.author_id`'s cascade inconsistency (disclosed, not fixed); `id_verification_deletions.provider_id` staying cascade-delete was a deliberate, documented triage call, not an oversight.
Security headers & session hardening (PRD-127)
The HTTP-layer security pass this codebase never had: real security headers, a CSP, verified cookie hygiene, and a written checklist. **Headers** (`next.config.ts`'s `headers()`, deliberately NOT `src/middleware.ts` — that file's matcher has already caused two real map-breaking incidents, and headers() needs no per-request state to add this bug class to): HSTS (2yr, preload-ready — not yet submitted to hstspreload.org, needs human), `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` (camera/mic off, `geolocation=(self)` — NOT fully off, since PRD-135's real "Use my location" browser prompt on `/browse` needs it), `X-Frame-Options: DENY`, and `Content-Security-Policy-Report-Only` (shipped report-only per the PRD's own explicit instruction — never blocks anything yet). **CSP directives researched and empirically verified, not guessed**: the real `liberty` style JSON was fetched from OpenFreeMap to confirm every tile/sprite/glyph URL resolves to one host (`tiles.openfreemap.org`, `connect-src` not `img-src` — MapLibre fetches via `fetch()`), the MapLibre worker file was curled directly to confirm it's same-origin (`worker-src 'self'` covers it), fonts were confirmed genuinely self-hosted by `next/font` (curled the rendered page's font URLs — same-origin `/_next/static/media/*.woff2`, no Google Fonts CDN call), and the app was actually run (`next build && next start`) against real production Supabase data (temp `.env.local`, deleted before finishing, confirmed via `git status` never staged) with headers inspected via `curl -I` against `/browse`, a `/vilnius/vet-clinics` page, and the worker file itself. **Deliberately NOT nonce-based** (`script-src 'self' 'unsafe-inline'`) — a real, considered tradeoff, not an oversight: Next's own docs state nonce-based CSP forces every page onto dynamic rendering, and this codebase has the opposite architecture by deliberate, repeated prior-PRD design (PRD-080/087 specifically moved several readers OFF the cookie-backed Supabase client so pages could stay static/ISR) — adopting nonces now would undo that work app-wide; `style-src 'unsafe-inline'` is structural, not optional, since `AGENTS.md` rule 3's inline `style={{ var(--token) }}` convention runs through every component and inline style attributes can't carry a nonce at all. **A real XSS gap found and fixed, not just re-confirmed as safe**: all 20 real `dangerouslySetInnerHTML` sites (12 files, all JSON-LD `<script type="application/ld+json">` tags) used bare `JSON.stringify()`, which doesn't escape `<`/`/` — `buildNannyJsonLd()` embeds a provider's own editable `name`/`bio`, so a bio containing a literal `</script><script>...</script>` would have closed the tag early and executed as a real script on that provider's own public profile for every visitor, a genuine stored-XSS path distinct from (and missed by) the safe plain-JSX `{nanny.bio}` render spot-checked elsewhere on the same page. New `src/lib/safeJsonLd.ts` (`safeJsonLdStringify`) escapes `<`/`>`/`&` to `\uXXXX` JSON-escapes before injection (verified: output contains no literal `</script>`, and round-trips through `JSON.parse` back to the exact original value) — applied to all 20 sites. The other two spot-checked user-content surfaces (PRD-070 review/response body, chat message body) confirmed already-safe plain JSX interpolation. Markdown rendering (PRD-085's `ArticleBody`) re-confirmed `rehype-raw` is never enabled — no raw HTML passthrough exists to sanitize. **Cookie hygiene**: `@supabase/ssr`'s own auth-session cookie shipped with no `secure` flag at all (verified against the library's own `DEFAULT_COOKIE_OPTIONS` source) — new `src/lib/supabase/cookieOptions.ts` (`withSecureCookieOption`) forces `secure: true` in production (gated on `NODE_ENV`, so `next dev`'s plain-http localhost still works) across every `cookies.set()` call in `server.ts`/`middleware.ts`; `httpOnly: false` is kept deliberately (the browser Supabase client needs JS-readable cookies to refresh sessions — the real authority boundary is server-side JWT re-verification + RLS, not cookie opacity). The two custom cookies (`pn_signup_intent`, `pn_dash_view`) gained `httpOnly: true` + the same `secure` treatment — neither is ever read by client JS. Full inventory (including `next-intl`'s own `NEXT_LOCALE` cookie, and a note that PRD-089/114/122 haven't shipped so no cookies exist for them yet) in `docs/SECURITY.md`. **`npm audit`**: bumped `next` 16.2.9→16.2.12 (same stable minor line, verified via `npm view` — fixes 8 high-severity Next-specific CVEs: proxy/middleware bypass, Server Action DoS/SSRF, cache confusion, unauthenticated Server Function disclosure), `eslint-config-next` alongside it, `js-yaml` auto-fixed via plain `npm audit fix`, and removed the unused `@types/react-pdf` devDependency entirely (grep-confirmed nothing imports the `react-pdf` viewer library it was typing — this app only uses the unrelated `@react-pdf/renderer` for contract PDFs — so this removed the CVE at its root). `postcss`/`sharp` (need an unstable Next 16.3.0-preview to fix — documented, deferred to a stable release) and a new `brace-expansion` finding (dev-tooling-only, `eslint@10` compat pending) triaged with reasoning in `SECURITY.md`, not silently ignored. **Supabase advisors**: read live via MCP (73 findings) — 58 are `SECURITY DEFINER` RPC warnings, spot-checked (`admin_metrics()`, `providers_near()`) and confirmed each has its own internal `is_admin()`/narrow-output guard, matching this codebase's established pattern, documented rather than blind-migrated. New migration `20260727140000_security_hardening_advisors.sql` fixes the two genuinely fixable findings: `search_path` set on 4 functions (signatures verified read-only against live schema first) and 4 public storage buckets' listing-enabling SELECT policies dropped (grep-confirmed `.list()` is never called anywhere in `src/`, and public-URL object reads bypass RLS entirely so nothing breaks). `security_definer_view` (`provider_profiles_public`), the two RLS-enabled-no-policy service-only tables, PostGIS's own `spatial_ref_sys`, and the `postgis`/`btree_gist` public-schema extensions are all documented as deliberate/out-of-scope-to-relocate, not fixed. Auth's "leaked password protection" is a dashboard toggle, not a migration — needs human. **New `docs/SECURITY.md`** covers all of the above as a real checklist (headers/CSP table, cookie inventory table, renderer rules, RLS review gate) and is now referenced from `AGENTS.md`'s working rules (a short addition, rule 5). `public/.well-known/security.txt` added with a disclosed placeholder contact (`security@petnanny.lt` — needs human to confirm the mailbox is real). Lint/`tsc`/`next build` all pass. **Not done here, honestly**: flipping CSP to enforcing (this PRD's own instruction was report-only only), a real headless-browser DevTools console check (no such tool available in this session — verification was via `curl`/direct source inspection of every real external surface instead, cross-referenced against the declared policy), a Mozilla Observatory scan, HSTS preload submission, and CI wiring for `npm audit`/lint/build (no CI config exists in this repo at all yet).
Launch & scale readiness (Phase 20)
Vilnius public-launch runbook (PRD-150)
New `docs/ops/LAUNCH_RUNBOOK.md` — the single committed pre-launch gate checklist, day-of sequence, incident playbook and first-week cadence, per PRD-150. **Written honestly against real code/data, not aspirationally**: read every dependency PRD's actual current status from `docs/prds/STATUS.md` first rather than assuming from the PRD-150 text alone, and every gate that depends on an unbuilt PRD (alerting/PRD-148, feature flags/PRD-115, rate limiting/PRD-126, load testing/PRD-146, support inbox/PRD-119, in-app banner/PRD-101, admin broadcast/PRD-116, analytics/PRD-049/089, cookie consent/PRD-122) is marked "Blocked" with the fastest real workaround available today, never faked as done. **Supply-bar number resolved for real, not left blank**: checked this exact codebase's own already-shipped honesty thresholds (`STATS_MIN_NANNIES = 10` in `src/lib/providers.ts`, the bar the home page itself already uses to decide its stats strip isn't too thin to show) and queried the live Supabase project directly — chose N = 10 verified providers with availability set, reasoned against that precedent plus PRD-147's own draft Kaunas cold-start number; live count today is 5 verified providers but only 1 with any future availability, so the gate is currently NOT met, and getting the other 4 to set their calendars is called out as the single highest-leverage pre-launch task. **Kill-switch inventory checked, not assumed**: grepped the codebase and queried live Supabase tables for `admin_settings`/`feature_flags`/`maintenance_mode` — none exist. Found one real, accidental-but-functional switch instead: `/api/cron/booking-notifications` fails closed (401) whenever `CRON_SECRET` is unset/wrong, so rotating it is a genuine cron kill switch — and flagged that this may already be sitting OFF today, since `docs/prds/STATUS.md` (PRD-123's entry) discloses `CRON_SECRET` has likely never been set on Vercel in production at all. Also documented the real Supabase Auth dashboard toggle ("Allow new users to sign up") as today's actual signup-pause mechanism, since no app-level one exists. **Rollback**: the real Vercel dashboard "Promote to Production" procedure is documented precisely, but explicitly flagged as NOT executed here — this sandbox has no Vercel CLI/API token/`.vercel/` link (confirmed absent), so PRD-150's "rollback executed once, timed" acceptance criterion is left as a named human action rather than faked with an invented timing number. **Tabletop dry run — a genuine written walkthrough, not a placeholder**: worked the PRD's own "booking emails stopped" example step by step against real code (`sendEmail()`'s never-throw contract, `deliverEmail()`'s silent console-log fallback when `RESEND_API_KEY` is unset, PRD-033's `notify()` dispatcher writing the in-app notification row before attempting email). Found a genuinely new, previously-undocumented diagnostic technique (check the recipient's `/notifications` page first — it cheaply distinguishes "email transport broken" from "notification never fired at all," using a mechanism that already exists) and a real blind spot for PRD-148's future design (a naive "alert on `sendEmail` error" monitor would miss this exact failure mode, since a missing `RESEND_API_KEY` produces zero errors anywhere — it needs its own explicit config check). Severity levels in the incident playbook are grounded in this session's own real incident history, not invented examples: the 2026-07-27 provider-signup `42501` outage (Sev1), the provider-coordinate-privacy grant-drift near-miss found during PRD-136's own re-verification (Sev1), and the 2026-07-24 `PGRST201` silent-empty-data bug (Sev2, the closest real precedent to "emails silently stopped"). "Payment issue" deliberately excluded as a severity example — this app never facilitates in-app payments (`AGENTS.md`). Content-completeness and supply numbers in the checklist are live-queried (68/69 places published, 10/10 articles published, 21 districts, 6 active services), not asserted. **Needs human** (consolidated in the runbook's own §6, matching this session's established flagging pattern): confirm `CRON_SECRET`/`RESEND_API_KEY`/`SUPABASE_SECRET_KEY`/`EMAIL_UNSUBSCRIBE_SECRET`/`ADMIN_ALERT_EMAIL` are genuinely set in production (with a real end-to-end send/cron-run, not just presence-checking — the top-priority item this whole exercise surfaced); execute and time one real off-hours Vercel rollback; the already-flagged PRD-121 lawyer review; get the 4 availability-empty verified providers onto their calendars; hold a real live tabletop once there's a second team member.
Out of scope
In-app payments
Not facilitated — owners and nannies settle directly.