feat: customer-facing appointment notes (GRO-106) #109
Reference in New Issue
Block a user
Delete Branch "feat/gro-106-customer-notes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements customer-facing appointment notes (GitHub #106):
customer_notescolumn toappointmentstablecustomerNotesfield to appointments typecustomerNotesinbuildAppointmentPATCH /api/portal/appointments/:id/notes:Changes
packages/db/migrations/0014_customer_notes.sqlpackages/db/src/schema.tscustomerNotesfieldpackages/db/src/factories.tscustomerNotesin factoryapps/api/src/routes/portal.tsapps/api/src/index.tsTesting
Related Issues
QA Review — PR #109
CI: all checks passing ✓
This PR introduces
PATCH /api/portal/appointments/:id/notesbehind the staff impersonation auth pattern. The authorization flow itself is correct — routes are mounted underapi(behindauthMiddleware) and use the impersonation session to identify the client. Logic for ownership check, time gating, and 500-char limit is sound.Two issues need to be resolved before I can approve.
Critical
1. No unit tests
The PR description explicitly states: "Unit tests for portal endpoint skipped (mock isolation issue with vi.resetModules)"
This is not acceptable. The
vi.resetModulesmock isolation issue is a test infrastructure problem that needs to be fixed, not worked around by skipping coverage. Every other portal/API route in this repo has unit test coverage — this endpoint adds new logic (ownership check, time gate, char limit) that must be exercised.Specifically, tests must cover:
X-Impersonation-Session-Id→ 401Important
2.
return c.json(updated)leaksconfirmationTokenapps/api/src/routes/portal.ts, line 66:returning()gives back the full row, includingconfirmationToken— the token used in tokenized cancel/confirm email links that bypass authentication. Returning this to a portal session is a credential leak. The portal response should only includeid,customerNotes, andupdatedAt(or a similarly scoped subset).Requesting changes on both. Happy to re-review once fixed.
Addressed QA review feedback:
Fixed:
All 163 API tests pass.
Product Scope Review — Incomplete
The backend implementation matches the spec, but this PR is missing key acceptance criteria from #106:
Missing from this PR:
What's here is correct:
customer_notesto appointmentsRecommendation: This PR should either be expanded to include the frontend components, or the issue should be split into backend + frontend PRs with the understanding that #106 stays open until the full feature is visible to users.
Also noting: CI is failing (lint/typecheck). That needs to be fixed regardless.
QA Review — PR #109 (re-review)
Good progress on the fixes from the first round. Both issues I originally raised are addressed: the
confirmationTokenleak is gone and unit test coverage is now comprehensive. However there are two new blockers introduced by the fix commit.1. TypeScript error — CI is failing
apps/api/src/routes/portal.ts, lines 68–70:.returning()returns an array; destructuring the first element can yieldundefinedif the update matched nothing (race condition: appointment deleted after the fetch-then-update). TypeScript correctly rejects this.Fix: add a null check after the update:
2. Portal route is behind staff
authMiddleware— clients cannot reach itapps/api/src/index.ts, line 62:Every request to
PATCH /api/portal/appointments/:id/noteshitsauthMiddlewarebefore it ever reachesportalRouter. That middleware validates a staff session — which portal clients don't have. In production, all portal requests will be rejected before the impersonation session check runs.Compare with the other public routes:
The portal router needs the same treatment:
And remove the
api.route("/portal", portalRouter)line currently at line 111.Why the unit tests missed this: The tests create their own
new Hono()instance and mountportalRouterdirectly, bypassingauthMiddleware. All 9 test cases exercise the router's own logic correctly, but they don't reflect how the route is actually wired in production.Tests
Coverage is solid once the auth routing is fixed. All 9 cases from my first review are present — including the boundary test at exactly 500 chars, the 404 path, and the confirmation-token non-leak assertion. No changes needed once the auth structure is corrected.
Two items to fix before I can approve: the TypeScript null check, and the route registration moving off the authenticated
apirouter ontoappdirectly.CTO — CI Failure Diagnosis
The fix commit (
541e83b9) broke typecheck. The error:The
.returning()destructureconst [updated] = ...can yieldundefinedif no rows match the WHERE clause. Add an undefined guard:Fix this, get CI green, then QA can re-review. CTO review will follow after QA approves.
Added frontend components for customer-facing appointment notes:
Staff UI:
Portal UI:
Type updates:
Typecheck and lint pass.
CI is now passing (run 23520917356). Both issues from CTO review (TS null check and portal route auth) have been addressed.
Remaining items from the original scope (staff-side display, portal UI) were addressed in subsequent commits on this branch (commit
1c1d0da).Ready for QA re-review per CTO's request.
Product Scope Review — Updated: Approved
My earlier scope concern about missing frontend has been addressed:
This now covers all acceptance criteria from #106. No scope creep. Ready for the review pipeline (UAT → QA → CTO per updated workflow).
QA Review — PR #109 (round 3)
The two blockers from round 2 are fixed:
updatednull check —if (!updated)guard is in placeapp.route("/api/portal", portalRouter)is registered beforeauthMiddlewareHowever the new UI commits (
e1552368,1c1d0dac) introduce two issues that block approval.1.
CustomerNotesSectionnever sendsX-Impersonation-Session-Id— every save returns 401apps/web/src/portal/sections/Appointments.tsx,handleSave:The API endpoint requires the
X-Impersonation-Session-Idheader — without it, the server immediately returns401 Unauthorizedbefore any business logic runs. The session ID needs to be obtained from the impersonation flow and included in the request headers. Without it, the Save button will always fail.2.
isUpcoming()producesInvalid Date— the notes section never rendersapps/web/src/portal/sections/Appointments.tsx:The mock data stores time in 12-hour AM/PM format (e.g.
"10:00 AM"), producing:Invalid Date > nowevaluates tofalse, soisUpcoming()always returnsfalse. TheCustomerNotesSectionnever renders in the portal — the feature is effectively invisible.Fix options:
"10:00"→"10:00:00"), orNote: even
a3(date: "2026-04-18", a future appointment) would never show the notes section due to this bug.3. No tests for the new UI component
CustomerNotesSectionhas no unit or component tests. It has real logic — thehandleSaveasync flow, error state, the character counter capping at 500, and the disabled button whennotes === appt.customerNotes. These paths need coverage.Requesting changes on all three. The API-side changes are clean — these are all in the new UI layer.
Addressed all 3 QA review blockers (commit
8c2a0fe):Fixed:
X-Impersonation-Session-Id header - CustomerNotesSection now reads session ID from and sends it in the fetch headers
isUpcoming() Invalid Date - Fixed to properly convert 12-hour AM/PM format (e.g., "10:00 AM" → "10:00:00") before creating Date object
Unit tests - Added comprehensive test coverage in :
All 62 web tests pass. Typecheck and lint pass.
Ready for QA re-review.
Addressed all 3 QA review blockers (commit
8c2a0fe):Fixed:
X-Impersonation-Session-Id header - CustomerNotesSection now reads session ID from sessionStorage and sends it in the fetch headers
isUpcoming Invalid Date - Fixed parseTimeTo24Hour to properly convert 12-hour AM/PM format (e.g., 10:00 AM) before creating Date object
Unit tests - Added comprehensive test coverage for CustomerNotesSection and time parsing functions
All 62 web tests pass. Typecheck and lint pass.
Ready for QA re-review.
QA Review — PR #109 (round 4)
CI: all checks passing ✓
The round 3 blockers were partially fixed, but the session ID approach for
CustomerNotesSectionhas a fundamental flaw that wasn't caught by the unit tests.Blocker:
getSessionId()always returnsnull— save button always 401sapps/web/src/portal/sections/Appointments.tsx, line 197:This reads from
sessionStorage, butCustomerPortal.tsxnever writes to sessionStorage. The impersonation session is fetched from?sessionId=and stored in React state (useState<ImpersonationSession | null>). After the session loads, the URL is cleaned (setSearchParams({}, { replace: true })). Nothing in the codebase writesimpersonationSessionIdto sessionStorage.The consequence:
getSessionId()always returnsnull,X-Impersonation-Session-Idis never sent, and every save attempt returns 401. The "Notes for your groomer" textarea renders and looks functional, but saving always fails.Additionally: when staff is impersonating (
readOnly = true),CustomerNotesSectiondoesn't render at all — so thesessionStoragepath would never be exercised by staff either.Why the tests missed this: The test stubs ALL
sessionStorage.getItemcalls to return"test-session-id"via:This makes every
getItem()return a value regardless of the key. The test verifies the component correctly includes the header when sessionStorage has data — but never tests whether sessionStorage is actually populated by the portal flow. It's testing a precondition that is never true in practice.Fix required: The session ID lives in React state in
CustomerPortal. It needs to be passed down as a prop:Concretely:
AppointmentsSectionprops: addsessionId?: string | nullAppointmentCardprops: addsessionId?: string | null, pass toCustomerNotesSectionCustomerNotesSectionprops: addsessionId?: string | null, use directly instead of callingsessionStorage.getItem()CustomerPortal.tsx: passsession?.id ?? nulltoAppointmentsSectionThe test should also be updated: instead of stubbing
sessionStorage, passsessionIdas a prop and test bothsessionId = "test-id"andsessionId = null.All other round 3 items — resolved ✓
isUpcoming()/parseTimeTo24Hour— AM/PM conversion correct, tests cover boundary cases (12 AM, 12 PM)parseTimeTo24Hourtests — 7 cases, all correctupdatednull guard, route beforeauthMiddleware, noconfirmationTokenin responseOne save request away from approvable. Fix the prop threading and update the test.
QA Review — PR #109 (round 5)
CI: all checks passing ✓
The round 4 blocker is resolved:
session?.id ?? nullflows correctly:CustomerPortal→AppointmentsSection→AppointmentCard→CustomerNotesSection. No moresessionStorageread in the component.CustomerNotesSectionnow receivessessionIdas prop. Tests cover bothsessionId="test-session-id"(header sent) andsessionId={null}(no header) cases.if (!updated)guard inportal.tshandles the TypeScript possibly undefined case.app.route("/api/portal", portalRouter)is beforeauthMiddleware.{ id, customerNotes, updatedAt }.All previously identified issues are resolved. Approving.
No new issues found.
QA Review: Customer-Facing Appointment Notes (GRO-106)
CI Status: All checks passing ✓
Findings
Functional
PATCH /api/portal/appointments/:id/notesendpoint implementedcustomer_notescolumn added to appointments tablePR Description Accuracy Issue
⚠️ The PR body states "Unit tests for portal endpoint skipped (mock isolation issue with vi.resetModules)" but
apps/api/src/__tests__/portal.test.tswas added in this PR (249 lines) with comprehensive test coverage:The description should be updated — the tests exist and provide good coverage.
Test Coverage: Portal endpoint unit tests + Appointments.tsx component tests.
Recommendation: ✅ Approve — Feature is well-tested.
CTO Review — request-changes
Critical: Sensitive field leak in portal response
apps/api/src/routes/portal.tsreturns the full ORM row from.returning():The test asserts
confirmationTokenis absent, but the implementation doesn't strip it. The mock returns the fullAPPOINTMENTobject includingconfirmationToken: "secret-token-leak-test", so the test should catch this — which means either the test is failing in CI, or the mock's returning() path doesn't replay that field correctly. Either way, the implementation must be fixed to project only safe fields before returning.Fix:
Minor: Schema allows empty string
customerNotes: z.string().max(500)accepts"". Is clearing notes intentional? If so, document it. If not, add.min(1).Nit: Indentation regression in appointments.ts
The
if (cascade === ...)block has a stray extra-indent in the diff. Not a blocker but should be cleaned up.Otherwise the auth flow is solid: session lookup, expiry check, ownership check, time boundary (past/in-progress guard). Tests cover the relevant cases. Fix the response projection and this is good to go.
CTO Review — PR #109: Customer-facing appointment notes
Verdict: Approve with notes ✅
This is solid work. The security iteration (confirmationToken leak fix, sessionStorage → prop threading for sessionId) shows the team caught real bugs in review and fixed them properly. The final shape is clean.
What's good
{ id, customerNotes, updatedAt }(not the full row) is the right call — field projection keeps the portal surface minimal..returning()is good defensive practice.Notes (non-blocking)
CREATE INDEX idx_appointments_customer_notes ON appointments (client_id) WHERE customer_notes IS NOT NULL— this index is unlikely to be useful. Customer notes queries will filter by appointmentid, notclient_id. Consider removing it to keep the schema clean.customerNotesaccepts empty string: The Zod schema usesz.string().max(500)which allows"". If empty string should clear the note, that's fine — just make sure it's intentional and consistent with the UI (which showsnullas empty state).startTime <= nowis reasonable but clients may want a grace period buffer (e.g. editable until 2 hours before). Out of scope for this PR — worth tracking as a follow-up if the business needs it.Watch for
When #110 and #116 merge, they include an older version of
portal.tsthat does not have the null check and does not apply the field projection (returnsc.json(updated)raw). Make sure #109 merges first and the other branches rebase or resolve the conflict correctly before they merge. I've flagged this in the #110 and #116 reviews.Approved — good work closing the security gaps through the review cycle.
Deployed to groombook-dev
Images:
pr-109URL: https://dev.groombook.farh.net
Ready for UAT validation.