Task 3's implementer independently hit and fixed this exact bug
(a vi.fn() given a zero-arg implementation, then invoked via a spread
of unknown[] args inside a vi.mock factory, fails tsc -b with TS2556).
Applied the same fix (explicit vi.fn<...> generic type) to the other
occurrences of this pattern still written the old way in this plan's
Task 3 and Task 4 sections, so those implementers don't have to
rediscover it from scratch. Task 5, 10, and 11's occurrences were
checked and found to already use safe patterns (single literal-arg
calls, or bare vi.fn() with no implementation) that don't trigger this.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Task 3's review surfaced that the plan's original vi.importActual-based
mocking for Shop.test.tsx/Home.test.tsx/Book.test.tsx would execute the
real data-layer modules, which import the real Supabase client and
construct it at module-load time via createClient(VITE_SUPABASE_URL,
...) -- throwing "supabaseUrl is required" in a test env with no .env
file, before the test even runs.
Switched all three to mock ../lib/supabase directly instead, matching
the pattern Task 3's own products.test.ts already uses successfully.
Also fixed 3 occurrences of `err instanceof Error ? err.message :
String(err)` in the planned Shop.tsx/Home.tsx/Book.tsx error handling
-- fetchProducts/fetchServices throw the raw Postgrest error object
(not necessarily an Error instance), so this would have rendered
"[object Object]" instead of the real message in the mocked test
scenario.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
The fromMock in the test uses vi.fn's explicit generic type parameter
instead of inferring from a zero-arg implementation, since the brief's
literal (...args) => fromMock(...args) wrapper otherwise fails to
typecheck (TS2556) against tsc -b.
Without this, Vercel serves a 404 for any client-side route accessed
directly (e.g. /admin/login, /shop) since it has no server-side route
matching those paths — only index.html exists as a static file. React
Router then never gets a chance to handle the path client-side.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Translates all user-visible text in the admin panel (login, nav,
products, services, schedule, bookings, inquiries, and the shared
friendlyError helper) to plain, direct French for Mira's internal
use. Database CHECK-constraint enum values (product category,
booking/inquiry status) and column names are left untouched in code;
French display labels are added via lookup maps instead. Corresponding
test assertions updated to match.
Translates all user-visible English text on the public-facing pages
(Home, Shop, Book, Rules, Nav, Footer, InquiryModal, StepProgress,
ServiceCard, ProductCard) plus product and service data to French,
matching the tone/register of the already-French rules.ts. Category
enum values, IDs, route paths, and admin pages are left untouched.
Updates matching test assertions to the new French copy.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Consolidated fix wave for 1 Critical + 6 Important findings from the final
review before merge:
- Critical: marking a booking completed no longer frees its booked_slots
mirror row or the unique-slot index, closing a double-booking hole
(new migration 20260812110508, with regression pgTAP tests added first
and confirmed failing against the old trigger/index).
- uploadSalonImage() now returns the storage-relative path instead of an
absolute public URL, so products/services.image stays one consistent
format.
- The product/service Id field is disabled while editing, preventing
accidental duplicate rows from an upsert keyed on a changed id.
- Cancel / Mark completed in AdminBookings now require window.confirm,
matching the existing delete-confirmation pattern.
- /admin and any unmatched /admin/* path now redirect to /admin/products
instead of rendering blank.
- Public signup is disabled in supabase/config.toml, and ProtectedRoute
adds a defense-in-depth check that the session belongs to the admin
user id, not just that a session exists.
- Product/service delete now surfaces a friendly message for FK-violation
errors (23503) via a new friendlyError() helper, instead of a raw
Postgres error string.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Adds AdminApp as a lazy-loaded route subtree in App.tsx, mounting
ProtectedRoute, AdminLogin, AdminLayout, and all five CRUD pages built
in Tasks 3-11. Mounted as a sibling of the public Layout route (not
nested under it), since the admin panel has its own AdminLayout
sidebar rather than the public nav/footer.
Manually verified end-to-end against the local Supabase stack: sign-in
redirect guard, all five pages rendering real seeded data, creating and
cancelling a real booking through the UI (confirming the booked_slots
cleanup trigger from the merged Phase 2A follow-up fixes correctly
frees the slot), and sign-out. No console errors across the flow.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Brings in the Phase 2A follow-up fixes (booking-slot uniqueness,
booked_slots cleanup on reschedule/cancel/delete, notification_failures
table, worktree-exclusion hardening for eslint/vitest) so Task 12's
local verification runs against the fully-integrated schema.
Resolved the rls.test.sql conflict by hand: both branches independently
appended tests at the end of the file. Combined both sets (bucket-1's
booking/slot/notification tests + this branch's salon-images/booking-
completed tests) and bumped select plan() from 17/37 to 42, matching
the true combined assertion count.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Replace variadic spread pattern with properly-typed single-argument mock.
storage.from() only takes bucketId string argument, so fromMock type
accurately reflects the actual API. No suppression needed - TypeScript
and ESLint both pass cleanly.
Use @ts-expect-error on vi.mock line only - vitest mock function calls
require spreading unknown[] which TypeScript can't verify as type-safe
without explicit suppression or use of 'any' type. This is a known
limitation of TypeScript with generic mock frameworks.
Renamed useAdminAuthMock to adminAuthMock in ProtectedRoute.test.tsx to prevent
ESLint from flagging it as an invalid hook call. The 'use' prefix naming heuristic
was causing a react-hooks/rules-of-hooks rule violation, though it was not actually
a hook call context. This change maintains all test behavior while passing lint.
Same class of issue as the earlier vitest fix: linting from the main
checkout also picked up whatever worktree happened to still exist
under .claude/worktrees/, surfacing as a tsconfigRootDir conflict once
a second worktree was present on disk.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Adds pgTAP tests and migration to create the salon-images storage bucket
with RLS policies allowing public read access and admin-only write access.
Tests verify anon users can read but not upload, and admin can upload.
Fixes 3 Important issues from re-review of the previous fast-follow-up commit:
1. booked_slots desync on reschedule: the cleanup trigger was `after update
of status`, so changing date/time on a still-confirmed booking never fired
it -- the OLD slot stayed blocked and the NEW slot stayed shown as free.
Changed to an unqualified `after update`, and the UPDATE branch now
compares the full (date, time, status) triple instead of just status, so
it frees the old slot on any change away from it and (re)mirrors the new
one when the row is still/now confirmed. Added pgTAP coverage: reschedule
removes the old slot's mirror row and creates the new one.
2. The Edge Functions' comments/commit message claimed a non-200 response
would make pg_net "retry" -- that's not how pg_net works, it's
fire-and-forget and never inspects the response status. Corrected the
comments to state the real reasoning (nothing consumes the status code,
so a 500 wouldn't help and would misrepresent an already-successful
booking/inquiry as failed). Closed the actual gap that created -- a failed
Resend send was only visible in Edge Function logs nobody reads -- by
adding a `notification_failures` table (new migration, admin-only select
via RLS, no anon/authenticated write access, only service_role writes)
and having both handlers insert a row (booking/inquiry id + error
message) whenever the Resend call fails, the API key is missing, or an
unexpected error occurs. Insert failures are swallowed (best-effort --
already in a failure path, must not throw a louder one). Added pgTAP
tests for the table's RLS/grant shape.
3. `20260812000002_bookings_slot_uniqueness.sql` could have failed outright
on `supabase db push` against live data if any existing confirmed booking
already shared a (date, time) with another. Added
`dedupe_confirmed_booking_slots()`, called before the index is created:
it collapses each duplicate group down to the earliest-created row
(kept 'confirmed', others cancelled) and logs what it did via
`raise notice`, so index creation is guaranteed to succeed either way.
Extracted as a standalone, service_role-only-executable function (rather
than inlining the SQL) specifically so the pgTAP test can exercise the
exact same logic the migration runs. Added a test that seeds two
duplicate confirmed bookings (having first dropped the index to simulate
the pre-migration state), runs the dedupe function, and asserts exactly
one confirmed booking remains -- the earliest one -- before recreating
the index.
pgTAP suite grew from 23 to 37 tests. Verified locally: supabase db reset
(all 7 migrations apply cleanly), supabase test db (37/37 pass), vitest run
(51/51 pass), npm run build, npm run lint, and npm run test:functions
(6/6 pass) all succeed. Still nothing here touches the live Supabase
project.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Fast follow-up to the Phase 2A backend review (4 deferred Important items):
1. Add a partial unique index (date, time) where status = 'confirmed' on
public.bookings so two concurrent bookings for the same slot can no longer
both succeed. Previously sync_booked_slot()'s `on conflict do nothing`
masked the second insert as a silent no-op on the mirror table instead of
rejecting it.
2. Extend sync_booked_slot() (new migration, the applied 20260808000002 is
left untouched) to also fire on `update of status` and `delete`, removing
the booked_slots mirror row once no confirmed booking remains for a slot.
Also tightened the insert path to only mirror bookings that are actually
'confirmed' -- the original trigger mirrored every insert regardless of
status, so a booking inserted directly with status='cancelled' (allowed by
the public insert policy) would incorrectly block a slot nobody confirmed.
3. supabase/functions/{on-booking-created,on-inquiry-created}: guard
req.json() and payload.record with try/catch (400 + console.error on
failure), check the Resend response's .ok and log failures instead of
swallowing them, and skip the Resend call entirely (with a log) instead of
sending "Authorization: Bearer undefined" when RESEND_API_KEY is unset.
Still return 200 when only the notification leg fails, since the booking/
inquiry row itself already committed successfully by the time this handler
runs -- a non-200 would make pg_net retry, but retrying re-sends against
the same already-inserted row and won't fix a Resend outage or bad
payload, so it isn't the right signal.
4. README: document the Supabase-side dev loop (supabase start/db reset/test
db), npm run seed and its SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY env vars,
and npm run test:functions (Deno tests for the Edge Functions' shared
email builders).
pgTAP suite grew from 12 to 23 tests (11 new: 4 for the uniqueness
constraint, 7 for booked_slots cleanup on cancel/delete). Verified locally:
supabase db reset, supabase test db (23/23 pass), vitest run (51/51 pass),
npm run build, npm run lint, and npm run test:functions (Deno installed for
this session; 6/6 pass) all succeed. Nothing here touches the live Supabase
project -- deploying is a separate follow-up step.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Running the suite from the main checkout was picking up each
worktree's full copy of src/ under .claude/worktrees/, silently
running every test twice. The existing supabase/functions/** exclude
only matched at repo root, so it also missed the nested worktree's
copy of that path.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Fixes issues raised by the whole-branch review before merge:
- Critical: the previous migration hardcoded the live production
functions URL and anon key as the committed default, so every local
`supabase db reset` silently pointed dev/test triggers at the real
project. Adding a new migration instead of editing the already-applied
one (checksum/drift safety), with local dev values as the committed
default; production values are applied out-of-band, never committed.
- Important: verify_jwt=true plus sending the anon key as a bearer
token didn't actually stop anyone, since the anon key is public
(ships in the frontend bundle) — confirmed empirically, a request
with a valid anon key but no shared secret now gets a real 401.
Added a random shared secret known only to the database and the
Edge Functions' secret store.
- Important: notification emails interpolated raw contact fields into
HTML with no escaping. Added escapeHtml() and tests proving injected
markup renders as literal text, not live markup.
- Important: the URL/key/secret functions moved from public to a
private schema with EXECUTE revoked from anon/authenticated, closing
an unintended PostgREST RPC endpoint (verified 404 after the fix).
Also adds a test:functions npm script now that Deno is confirmed
available and the shared email builders' tests pass (12 total incl.
the new escaping cases).
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Substitutes the real admin UID everywhere the placeholder appeared,
points the Edge Functions base URL at the deployed project, and adds
production JWT verification: trigger functions now send the anon key
as a bearer token so verify_jwt can stay enabled instead of leaving
the functions open to unauthenticated calls from the public internet.
Also adds scripts/create-admin.ts (one-off helper used to create
Mira's admin auth user), documents the seed script's non-VITE env
vars in .env.example, and closes a gitignore gap where plain .env
wasn't excluded alongside .env.local.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
The brief's literal script copies source fields verbatim, but src/data
uses camelCase (inStock, durationMinutes, priceFrom) while the DB
columns are snake_case (in_stock, duration_minutes, price_from), so
that version fails with PGRST204. Map fields explicitly instead of
spreading the rest.
notify_booking_created() and notify_inquiry_created() each held their own
literal copy of the local Edge Functions base URL. Introduce
public.edge_functions_base_url() as the single source of truth so Task 9's
production deploy only needs to update one function definition instead of
two independent literals.
Wires pg_net-based database triggers on bookings/inquiries to invoke the two
Edge Functions on insert. Two empirically-discovered fixes over the brief's
literal SQL were required to make this work locally:
- ALTER DATABASE ... SET for a custom GUC needs superuser, but the local
migration role (postgres) isn't superuser in this Supabase CLI stack (only
supabase_admin is) - inlined the base URL as a literal constant in each
trigger function instead.
- pg_net's http_post lives in schema `net`, not `extensions.net` - fixed the
function calls accordingly.
Also disabled verify_jwt for both functions in config.toml since they're
invoked by pg_net (no Authorization header), not by end-user clients.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Adds supabase/functions/ (deno.json, _shared/email.ts) with pure
buildBookingNotificationEmail/buildInquiryNotificationEmail functions
for Task 6's Edge Functions, tested via Deno's test runner. Excludes
supabase/functions/** from the root Vitest config, which otherwise
tried to resolve the jsr: import and failed.
Replace the blanket select/insert/update/delete grant to anon/authenticated/
service_role with privilege-scoped grants: anon/authenticated get select-only
on the public-read tables plus insert-only on bookings/inquiries; full CRUD
is reserved for authenticated (narrowed to the admin UID by RLS) and
service_role. Table grants are now a real second layer of defense instead of
mirroring RLS. Updates two pgTAP assertions whose blocking mechanism moved
from RLS-filtered-empty to grant-level permission-denied, and adds a new
assertion proving anon cannot UPDATE bookings.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
Includes explicit GRANTs for anon/authenticated/service_role on all
public tables: the Supabase CLI's current default no longer
auto-exposes new tables, so RLS policies from the initial schema were
unreachable without them (every request failed at the privilege check
before RLS was evaluated).
Initializes the local Supabase project (supabase/config.toml) and adds
a thin src/lib/supabase.ts wrapper around @supabase/supabase-js that
reads connection details from Vite env vars, tested with a mocked
createClient.