From 23c0e05acd08fa22f0808cf30f34f3f63f25cf8f Mon Sep 17 00:00:00 2001 From: William Ford Date: Tue, 14 Apr 2026 21:02:17 -0600 Subject: [PATCH 1/2] feat: session notes on bookings (#17) - Add session_notes TEXT column to bookings table - Add PUT /api/v1/bookings/:id/notes endpoint with RBAC (PM project owner, admin, or assigned engineer) - Add updateSessionNotesSchema Zod validator (max 10000 chars) - Add session_notes to Booking interface and project detail query - Add auto-save notes textarea in Signed Up Clients table (debounced on input, immediate on blur, with save status) - Notes read-only display for cancelled bookings Closes #17 --- docker/init.sql | 3 + .../client/src/pages/ProjectDetailPage.tsx | 69 ++++++++++- packages/client/src/styles/global.css | 35 ++++++ packages/server/src/app.ts | 2 + packages/server/src/routes/bookings.ts | 115 ++++++++++++++++++ packages/server/src/routes/projects.ts | 3 +- packages/shared/src/contracts.ts | 2 + packages/shared/src/schemas.ts | 4 + packages/shared/src/types.ts | 1 + packages/shared/tsconfig.tsbuildinfo | 2 +- 10 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/routes/bookings.ts diff --git a/docker/init.sql b/docker/init.sql index b514d5e..814cb98 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -209,6 +209,9 @@ ADD COLUMN IF NOT EXISTS pii_redacted_at TIMESTAMPTZ; ALTER TABLE IF EXISTS bookings ALTER COLUMN booking_token SET DEFAULT app_random_token_64(); +ALTER TABLE IF EXISTS bookings +ADD COLUMN IF NOT EXISTS session_notes TEXT; + CREATE TABLE IF NOT EXISTS waitlist_entries ( id BIGSERIAL PRIMARY KEY, tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id), diff --git a/packages/client/src/pages/ProjectDetailPage.tsx b/packages/client/src/pages/ProjectDetailPage.tsx index 18c9e54..6223673 100644 --- a/packages/client/src/pages/ProjectDetailPage.tsx +++ b/packages/client/src/pages/ProjectDetailPage.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { CalendarX, ClipboardCopy, Inbox, Pencil, Plus, Save, Trash2 } from 'lucide-react'; import type { + Booking, ProjectDetail, ProjectDetailResponse, ProjectResponse, @@ -45,6 +46,7 @@ interface ProjectSignupRow { end_time: string; booked_at: string; cancelled_at: string | null; + session_notes: string | null; } interface EditTimeBlockState { @@ -77,6 +79,60 @@ function toFormState(project: ProjectDetail): ProjectFormState { }; } +function SessionNotesCell({ bookingId, initialNotes }: { bookingId: number; initialNotes: string | null }): JSX.Element { + const [notes, setNotes] = useState(initialNotes ?? ''); + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const debounceRef = useRef | null>(null); + + const saveNotes = useCallback(async (value: string) => { + setSaveStatus('saving'); + try { + await apiFetch(`/bookings/${bookingId}/notes`, { + method: 'PUT', + body: JSON.stringify({ session_notes: value }) + }); + setSaveStatus('saved'); + } catch { + setSaveStatus('idle'); + } + }, [bookingId]); + + const handleChange = useCallback((value: string) => { + setNotes(value); + setSaveStatus('idle'); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + void saveNotes(value); + }, 800); + }, [saveNotes]); + + const handleBlur = useCallback(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + void saveNotes(notes); + }, [notes, saveNotes]); + + return ( +
+