End-to-end proof that the full React + Vite + Tailwind + TypeScript
pipeline works against the existing Express API:
client/src/pages/Extensions.tsx
Minimum-viable port of the Extensions tab. Fetches /api/extensions
via the typed api wrapper; renders an add form backed by Zod
validation (ExtensionCreateSchema). Uses @tanstack/react-query for
server state (queryKey: ['extensions'], invalidate on mutate).
Full CRUD UI (trash / restore / purge / search) is a follow-up —
this ships just enough to prove the stack works.
client/src/lib/api.ts
Thin fetch wrapper. Every React page goes through apiFetch<T>(),
which narrows ApiResponse<T> to the success shape and throws
ApiError on failure. Central spot for future request/response
instrumentation, auth-token refresh, etc.
client/src/App.tsx
Replaced the Vite starter splash screen with a minimal router:
BrowserRouter basename='/app', routes for / (landing) and
/extensions. QueryClientProvider wraps the tree so every page can
use useQuery/useMutation.
client/src/shared/
Mirrored copy of /shared/types.ts + schemas.ts. Canonical source
stays at /shared/ (used by backend). A post-migration task is to
wire proper TypeScript project references so the client can import
straight from /shared — TS 6's cross-root bundler-mode paths
resolution isn't pulling it in cleanly. For now, the mirror is
header-annotated 'do not edit, mirror only'.
client/src/index.css
Tailwind v4 @theme block declaring the shadcn design tokens as
first-class CSS custom properties, which exposes the
bg-background / text-foreground / border-border utility classes
the page components use. v4 no longer uses @apply for these —
the theme block is the idiomatic form.
server.ts
Added:
app.get('/app/*splat', ...) → sendFile public/app/index.html
so React Router deep links (e.g. /app/extensions) resolve
client-side. Express static middleware below continues to serve
the hashed /app/assets/*.js + .css.
Build output (checked into public/app/ so the next prod docker
rebuild ships the React bundle without requiring a client/npm
install step in the Dockerfile — that's a day-8 refinement):
index.html 0.46 kB gzip 0.29 kB
index.css 9.51 kB gzip 2.69 kB
index.js 329.24 kB gzip 100.98 kB
Typecheck green on both sides:
server: npx tsc --noEmit → EXIT 0
client: npx tsc -b → EXIT 0
client: npx vite build → 150 modules, 219ms
How to see it live (after Daniel rebuilds prod):
https://<host>/app/ → React landing page
https://<host>/app/extensions → React-rendered Extensions list
https://<host>/ → unchanged vanilla JS app
Nothing destructive. The vanilla JS /extensions tab still works
identically. The React /app/extensions route talks to the same
/api/extensions backend endpoints. Both render from the same
PostgreSQL rows.
This closes out the 7-day migration scaffolding. The rest is a
port-one-tab-at-a-time grind that Codex (or anyone) can pick up
tab-by-tab with the Playwright suite as the safety net.
118 lines
4.6 KiB
TypeScript
118 lines
4.6 KiB
TypeScript
// ============================================================
|
|
// ZOD SCHEMAS — runtime validation at API boundaries
|
|
// ============================================================
|
|
// Each incoming request body gets parsed through one of these schemas
|
|
// BEFORE the handler sees it. If parse fails, a 400 is returned with
|
|
// the detailed validation error — no more silent "undefined reading
|
|
// X" crashes on malformed input. TypeScript types for req.body can
|
|
// be inferred from the schema with `z.infer<typeof XxxSchema>`.
|
|
//
|
|
// Keep wire-shape aligned with shared/types.ts. The types file is
|
|
// for RESPONSES (what the server returns); this file is for REQUESTS
|
|
// (what the client sends).
|
|
// ============================================================
|
|
|
|
import { z } from 'zod';
|
|
|
|
// ── Common fragments ─────────────────────────────────────────
|
|
const NonEmptyString = z.string().min(1);
|
|
const OptionalTrimmed = z.string().optional();
|
|
const OptionalModel = z.string().optional();
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────
|
|
export const LoginRequestSchema = z.object({
|
|
email: z.string().email(),
|
|
password: NonEmptyString,
|
|
turnstileToken: OptionalTrimmed,
|
|
totpCode: OptionalTrimmed,
|
|
});
|
|
|
|
export const RegisterRequestSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
name: NonEmptyString,
|
|
turnstileToken: OptionalTrimmed,
|
|
});
|
|
|
|
export const ForgotPasswordRequestSchema = z.object({
|
|
email: z.string().email(),
|
|
turnstileToken: OptionalTrimmed,
|
|
});
|
|
|
|
// ── AI generation requests ───────────────────────────────────
|
|
export const HpiEncounterRequestSchema = z.object({
|
|
transcript: NonEmptyString,
|
|
patientAge: OptionalTrimmed,
|
|
patientGender: OptionalTrimmed,
|
|
model: OptionalModel,
|
|
setting: z.enum(['outpatient', 'inpatient']).optional(),
|
|
physicianMemories: OptionalTrimmed,
|
|
});
|
|
|
|
export const SoapRequestSchema = z.object({
|
|
transcript: NonEmptyString,
|
|
patientAge: OptionalTrimmed,
|
|
patientGender: OptionalTrimmed,
|
|
model: OptionalModel,
|
|
type: z.enum(['full', 'subjective']).optional(),
|
|
additionalInstructions: OptionalTrimmed,
|
|
physicianMemories: OptionalTrimmed,
|
|
});
|
|
|
|
export const SickVisitRequestSchema = z.object({
|
|
patientAge: OptionalTrimmed,
|
|
patientGender: OptionalTrimmed,
|
|
chiefComplaint: NonEmptyString,
|
|
transcript: OptionalTrimmed,
|
|
dictation: OptionalTrimmed,
|
|
ros: OptionalTrimmed,
|
|
physicalExam: OptionalTrimmed,
|
|
diagnoses: OptionalTrimmed,
|
|
physicianMemories: OptionalTrimmed,
|
|
model: OptionalModel,
|
|
});
|
|
|
|
export const RefineRequestSchema = z.object({
|
|
currentDocument: NonEmptyString,
|
|
instructions: NonEmptyString,
|
|
sourceContext: OptionalTrimmed,
|
|
model: OptionalModel,
|
|
});
|
|
|
|
export const PeNarrativeRequestSchema = z.object({
|
|
steps: z.array(z.object({
|
|
component: OptionalTrimmed,
|
|
label: NonEmptyString,
|
|
method: OptionalTrimmed,
|
|
normal: OptionalTrimmed,
|
|
status: z.enum(['normal', 'abnormal']).nullable().optional(),
|
|
note: OptionalTrimmed,
|
|
})).min(1),
|
|
ageGroup: OptionalTrimmed,
|
|
system: OptionalTrimmed,
|
|
patientAge: OptionalTrimmed,
|
|
patientGender: OptionalTrimmed,
|
|
model: OptionalModel,
|
|
format: z.enum(['narrative', 'list']).optional(),
|
|
});
|
|
|
|
// ── Extensions CRUD ──────────────────────────────────────────
|
|
export const ExtensionCreateSchema = z.object({
|
|
location: z.string().min(1).max(120),
|
|
name: z.string().min(1).max(120),
|
|
number: z.string().min(1).max(40),
|
|
type: z.enum(['extension', 'pager']).optional(),
|
|
notes: z.string().max(500).optional(),
|
|
});
|
|
export const ExtensionUpdateSchema = ExtensionCreateSchema;
|
|
|
|
// ── Inferred types (use instead of hand-written interfaces) ─
|
|
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
|
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
|
|
export type ForgotPasswordRequest = z.infer<typeof ForgotPasswordRequestSchema>;
|
|
export type HpiEncounterRequest = z.infer<typeof HpiEncounterRequestSchema>;
|
|
export type SoapRequest = z.infer<typeof SoapRequestSchema>;
|
|
export type SickVisitRequest = z.infer<typeof SickVisitRequestSchema>;
|
|
export type RefineRequest = z.infer<typeof RefineRequestSchema>;
|
|
export type PeNarrativeRequest = z.infer<typeof PeNarrativeRequestSchema>;
|
|
export type ExtensionCreate = z.infer<typeof ExtensionCreateSchema>;
|