The final big piece of "everything in React + Tailwind". Login,
register, forgot-password, reset-password, and email-verification all
render from the React bundle now. The root path / serves the SPA,
vanilla index.html + public/js/* are no longer served by the server.
BACKEND — src/routes/auth.ts
New GET /api/auth/public-config (public — no auth required) returns
{ registrationEnabled, turnstileSiteKey, oidcEnabled,
disableLocalAuth, ssoButtonLabel }.
Single round-trip the React auth screen needs on mount. Reuses
existing DB settings; no new tables.
BACKEND — server.ts
• / and /index.html now send public/app/index.html (React SPA),
not public/index.html (vanilla).
• /auth, /reset-password, /verify-email explicitly route to the SPA
so the email links land on the React router.
• /app/*splat preserved as an alias so old bookmarks keep working.
• SPA fallback added after express.static so hard-refresh on
/encounter / /bedside / /settings etc. serves the React index
instead of 404ing. API paths and static-file extensions still
fall through to their existing handlers.
• The dead app.get('/') duplicate that also pointed at the vanilla
index is removed.
CLIENT — React auth flow
client/src/pages/Auth.tsx (new)
Login / register / forgot sub-forms with a single useQuery on
['public-config'] driving Turnstile + SSO button visibility.
Login flow handles all three vanilla-equivalent responses
(token / requires2FA / needsVerification). 2FA field reveals
inline when the server asks for it; resend-verification link
appears when needsVerification fires. SSO button renders
whenever oidcEnabled is true, even if local auth is disabled
(disableLocalAuth hides the login/register/forgot forms
entirely). HIPAA notice + APK download link preserved.
client/src/pages/ResetPassword.tsx (new)
Reads ?token=xxx from the URL, POSTs /api/auth/reset-password.
Confirm-password match, 8+ char validation, server
passwordWarning (pwned password) surfaces as an amber info box.
Redirects to /auth 2.5 s after success.
client/src/components/Turnstile.tsx (new)
Loads the challenges.cloudflare.com/turnstile script once,
renders a widget per form, calls onToken(token) on success and
onToken('') on error / expiry. If siteKey is null/empty (e2e
container with TURNSTILE_SITE_KEY="") renders nothing and
auto-reports empty — matches the vanilla no-key-no-widget
behaviour.
client/src/components/AuthGuard.tsx (new)
useQuery(['auth-me']) with retry: false. On 401/error redirects
to /auth?next=<current-url> so the deep link survives sign-in.
Used as a parent route in App.tsx wrapping every private page.
client/src/components/Layout.tsx
"← back to legacy app" link replaced with "Sign out" — calls
POST /api/auth/logout then window.location = /auth.
client/src/App.tsx
BrowserRouter no longer has basename (was "/app"). Public
routes: /auth, /reset-password. Everything else lives under
<AuthGuard> → <Layout>. Lazy-loaded Auth + ResetPassword join
the existing heavy-route code-split.
client/vite.config.ts
base stays "/app/" so hashed asset URLs resolve to
/app/assets/... (served unchanged by express.static).
shared/types.ts + client/src/shared/types.ts — additive:
PublicConfigOk { registrationEnabled, turnstileSiteKey,
oidcEnabled, disableLocalAuth, ssoButtonLabel }.
Bundle — Auth chunk splits out at 10.87 kB / 3.35 kB gz, lazy-loaded
only on the sign-in path; initial bundle unchanged at 343.97 kB /
106.59 kB gz.
Backend tsc + client tsc + vite build + 136/136 vitest all green.
572 lines
16 KiB
TypeScript
572 lines
16 KiB
TypeScript
// ============================================================
|
|
// SHARED TYPES — consumed by both server (src/routes/*.ts) and
|
|
// client (client/src/*). A response-shape change here breaks the
|
|
// build on both sides until they agree.
|
|
//
|
|
// This is the single most important file in the TypeScript
|
|
// migration. Three of the bugs we hit before this point were
|
|
// response-shape mismatches (refine returned `content` vs
|
|
// `refined`, sick-visit endpoint path mismatch, hospital-course
|
|
// key mismatch). Typing the wire protocol makes them compile-time
|
|
// errors.
|
|
//
|
|
// Keep this file strictly about wire protocol — no DB row shapes,
|
|
// no server-internal helpers. Anything that crosses the network.
|
|
// ============================================================
|
|
|
|
// ── Envelope ─────────────────────────────────────────────────
|
|
// Every route returns either `{success: true, ...extraFields}` or
|
|
// `{success: false, error: string}`. The generic `T` is the shape
|
|
// of the extra fields on success. Client code does:
|
|
// const r: ApiResponse<HpiOk> = await fetch(...).then(r => r.json());
|
|
// if (r.success) console.log(r.hpi);
|
|
// else showToast(r.error);
|
|
export interface ApiErr {
|
|
success: false;
|
|
error: string;
|
|
}
|
|
export type ApiResponse<T> = ({ success: true } & T) | ApiErr;
|
|
|
|
// Model tag accompanies every AI response — LiteLLM pass-through.
|
|
export interface WithModel {
|
|
model: string;
|
|
}
|
|
|
|
// ── AI generation responses ──────────────────────────────────
|
|
// Keys match exactly what each route returns today. Do not rename
|
|
// without updating the server-side res.json() call in the same commit.
|
|
|
|
// /api/generate-hpi-encounter
|
|
// /api/generate-hpi-dictation
|
|
export interface HpiOk extends WithModel {
|
|
hpi: string;
|
|
}
|
|
|
|
// /api/generate-soap
|
|
export interface SoapOk extends WithModel {
|
|
soap: string;
|
|
}
|
|
|
|
// /api/sick-visit/note (NOT /api/generate-sick-visit — that path does not exist)
|
|
// /api/well-visit/note
|
|
export interface VisitNoteOk extends WithModel {
|
|
note: string;
|
|
}
|
|
|
|
// /api/generate-hospital-course
|
|
export interface HospitalCourseOk extends WithModel {
|
|
hospitalCourse: string;
|
|
format?: string;
|
|
}
|
|
|
|
// /api/generate-chart-review
|
|
export interface ChartReviewOk extends WithModel {
|
|
review: string;
|
|
}
|
|
|
|
// /api/generate-pe-narrative
|
|
export interface PeNarrativeOk extends WithModel {
|
|
narrative: string;
|
|
summary: {
|
|
normal: number;
|
|
abnormal: number;
|
|
notAssessed: number;
|
|
};
|
|
}
|
|
|
|
// /api/generate-milestone-narrative
|
|
export interface MilestoneNarrativeOk extends WithModel {
|
|
narrative: string;
|
|
summary: {
|
|
achieved: number;
|
|
notAchieved: number;
|
|
notAssessed: number;
|
|
};
|
|
}
|
|
|
|
// /api/generate-milestone-summary
|
|
export interface MilestoneSummaryOk extends WithModel {
|
|
summary: string;
|
|
}
|
|
|
|
// /api/well-visit/shadess
|
|
export interface ShadessOk extends WithModel {
|
|
assessment: string;
|
|
}
|
|
|
|
// /api/refine
|
|
export interface RefineOk extends WithModel {
|
|
refined: string;
|
|
}
|
|
|
|
// /api/shorten (via refine.js router)
|
|
export interface ShortenOk extends WithModel {
|
|
shortened: string;
|
|
}
|
|
|
|
// /api/clarify and /hospital-course/clarify
|
|
export interface ClarifyOk extends WithModel {
|
|
questions: string;
|
|
}
|
|
|
|
// /api/suggest-billing-codes
|
|
export interface BillingCodesOk extends WithModel {
|
|
icd10: Array<{ code: string; description: string; reason?: string }>;
|
|
cpt: Array<{ code: string; description: string; reason?: string }>;
|
|
}
|
|
|
|
// /api/transcribe
|
|
export interface TranscribeOk {
|
|
text: string;
|
|
provider: string;
|
|
duration: number;
|
|
}
|
|
|
|
// /api/tts
|
|
export interface TtsOk {
|
|
audioBase64: string;
|
|
}
|
|
|
|
// /api/models
|
|
export interface ModelsOk {
|
|
models: Array<{ id: string; label?: string }>;
|
|
}
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────
|
|
export interface AuthUser {
|
|
id: number;
|
|
email: string;
|
|
name: string;
|
|
role?: string;
|
|
isVerified?: boolean;
|
|
has2FA?: boolean;
|
|
// canLocalAuth: false for SSO-auto-created accounts whose password is a
|
|
// random hex blob that can never verify. Settings hides password/2FA UI
|
|
// for those users. totp_enabled / email_verified / nextcloud_* mirror
|
|
// DB columns returned by /api/auth/me.
|
|
canLocalAuth?: boolean;
|
|
totp_enabled?: boolean;
|
|
email_verified?: boolean;
|
|
nextcloud_url?: string | null;
|
|
nextcloud_user?: string | null;
|
|
nextcloud_folder?: string | null;
|
|
webdav_learning_path?: string | null;
|
|
created_at?: string;
|
|
}
|
|
|
|
// /api/auth/login (two-phase: may also return requires2FA / needsVerification)
|
|
export interface LoginOk {
|
|
token: string;
|
|
user: AuthUser;
|
|
sessionId?: string;
|
|
}
|
|
|
|
export interface LoginRequires2FA {
|
|
success: true;
|
|
requires2FA: true;
|
|
}
|
|
|
|
export interface LoginNeedsVerification {
|
|
success: true;
|
|
needsVerification: true;
|
|
}
|
|
|
|
// /api/auth/me
|
|
export interface MeOk {
|
|
user: AuthUser;
|
|
}
|
|
|
|
// ── Sessions ─────────────────────────────────────────────────
|
|
// Keys match the wire shape returned by /api/sessions (snake_case from
|
|
// the DB columns, deliberately unchanged to keep the existing vanilla
|
|
// client working during migration).
|
|
export interface SessionRow {
|
|
id: string;
|
|
ip_address?: string | null;
|
|
device_label?: string | null;
|
|
created_at: string;
|
|
last_activity: string;
|
|
}
|
|
export interface SessionsOk {
|
|
sessions: SessionRow[];
|
|
currentSessionId: string | null;
|
|
}
|
|
export interface RevokeAllSessionsOk {
|
|
revoked?: number;
|
|
}
|
|
|
|
// ── 2FA + password change ────────────────────────────────────
|
|
// /api/auth/setup-2fa
|
|
export interface Setup2faOk {
|
|
secret: string;
|
|
qrCode: string;
|
|
}
|
|
// /api/auth/verify-2fa — backupCodes populated only on first enable
|
|
export interface Verify2faOk {
|
|
backupCodes: string[] | null;
|
|
}
|
|
// /api/auth/2fa/backup-codes/count
|
|
export interface BackupCodesCountOk {
|
|
remaining: number;
|
|
}
|
|
// /api/auth/2fa/backup-codes (regenerate)
|
|
export interface RegenBackupCodesOk {
|
|
codes: string[];
|
|
message?: string;
|
|
}
|
|
// /api/auth/change-password
|
|
export interface ChangePasswordOk {
|
|
message: string;
|
|
passwordWarning?: string;
|
|
}
|
|
|
|
// ── Integrations ─────────────────────────────────────────────
|
|
// /api/nextcloud/connect
|
|
export interface NextcloudConnectOk {
|
|
message: string;
|
|
}
|
|
// /api/nextcloud/disconnect — {success: true}
|
|
|
|
// /api/documents — shape returned to the client
|
|
export interface UserDocument {
|
|
id: number;
|
|
filename: string;
|
|
mime_type: string;
|
|
size_bytes: number;
|
|
description?: string | null;
|
|
created_at: string;
|
|
}
|
|
export interface DocumentsListOk {
|
|
documents: UserDocument[];
|
|
s3_configured: boolean;
|
|
}
|
|
// /api/documents/upload — multipart; response below
|
|
export interface DocumentUploadOk {
|
|
id: number;
|
|
filename: string;
|
|
}
|
|
// /api/documents/:id/download — returns a short-lived presigned URL
|
|
export interface DocumentDownloadOk {
|
|
url: string;
|
|
}
|
|
|
|
// ── Voice prefs + transcription settings ─────────────────────
|
|
// /api/user/preferences
|
|
export interface UserPreferencesOk {
|
|
stt_model: string | null;
|
|
tts_voice: string | null;
|
|
}
|
|
// /api/user/preferences/options — the provider-scoped lists of models/voices
|
|
export interface VoiceOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
export interface PreferencesOptionsOk {
|
|
sttProvider: string;
|
|
sttModels: VoiceOption[];
|
|
ttsProvider: string;
|
|
ttsVoices: VoiceOption[];
|
|
}
|
|
|
|
// ── Saved encounters list (Settings view) ────────────────────
|
|
// Note: /api/encounters/saved returns a richer row than the sidebar
|
|
// EncounterSummary. The Settings list only needs these fields.
|
|
export interface SavedEncounterRow {
|
|
id: number;
|
|
label: string;
|
|
enc_type: string;
|
|
status?: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
expires_at: string;
|
|
transcript_preview?: string;
|
|
note_preview?: string;
|
|
}
|
|
export interface SavedEncountersListOk {
|
|
encounters: SavedEncounterRow[];
|
|
}
|
|
|
|
// ── Audio backups (server-stored recordings, 24h TTL) ────────
|
|
export interface AudioBackupRow {
|
|
id: number;
|
|
module: string;
|
|
mime_type: string;
|
|
size_bytes: number;
|
|
compressed_bytes?: number;
|
|
created_at: string;
|
|
expires_at: string;
|
|
}
|
|
export interface AudioBackupsListOk {
|
|
backups: AudioBackupRow[];
|
|
}
|
|
|
|
// ── Memories (templates + corrections share this shape) ──────
|
|
// Extends the minimal Memory type with fields needed by the Settings
|
|
// list view (corrections need created_at to show dates).
|
|
export interface MemoryRow {
|
|
id: number;
|
|
category: string;
|
|
name: string;
|
|
content: string;
|
|
created_at?: string;
|
|
}
|
|
export interface MemoriesOk {
|
|
memories: MemoryRow[];
|
|
}
|
|
|
|
// ── Admin ───────────────────────────────────────────────────
|
|
// /api/admin/users (admin-gated)
|
|
export interface AdminUser {
|
|
id: number;
|
|
email: string;
|
|
name: string;
|
|
role: string | null;
|
|
email_verified: boolean;
|
|
totp_enabled: boolean;
|
|
disabled: boolean;
|
|
created_at: string;
|
|
updated_at?: string;
|
|
nextcloud_url?: string | null;
|
|
api_calls?: number;
|
|
last_login?: string | null;
|
|
}
|
|
export interface AdminUsersOk { users: AdminUser[] }
|
|
export interface AdminUserOk { user: AdminUser }
|
|
export interface AdminSettingsOk {
|
|
settings: { registrationEnabled: boolean };
|
|
stats: { totalUsers: number; totalApiCalls: number; todayApiCalls: number };
|
|
}
|
|
export interface AdminLogEntry {
|
|
id: number;
|
|
user_id: number | null;
|
|
action: string;
|
|
detail: string;
|
|
category: string;
|
|
ip_address?: string | null;
|
|
timestamp: string;
|
|
user_email?: string | null;
|
|
user_name?: string | null;
|
|
}
|
|
export interface AdminLogsOk { logs: AdminLogEntry[] }
|
|
|
|
// /api/admin/config / /api/admin/config/:key
|
|
export interface AdminConfigRow {
|
|
key: string;
|
|
value: string | null;
|
|
description?: string | null;
|
|
source?: 'env' | 'db' | 'openbao' | string;
|
|
}
|
|
export interface AdminConfigOk { config: AdminConfigRow[] }
|
|
export interface AdminAnnouncementOk {
|
|
enabled: boolean;
|
|
message: string;
|
|
kind?: string;
|
|
}
|
|
|
|
// /api/admin/config/prompts
|
|
export interface AdminPromptRow {
|
|
key: string;
|
|
value: string;
|
|
description?: string;
|
|
default?: string;
|
|
isDefault?: boolean;
|
|
}
|
|
export interface AdminPromptsOk { prompts: AdminPromptRow[] }
|
|
|
|
// /api/admin/config/smtp/status
|
|
export interface AdminSmtpStatusOk {
|
|
configured: boolean;
|
|
host?: string;
|
|
port?: number;
|
|
user?: string;
|
|
from?: string;
|
|
}
|
|
|
|
// /api/admin/config/models
|
|
export interface AdminModelRow {
|
|
id: string;
|
|
label?: string;
|
|
provider?: string;
|
|
enabled: boolean;
|
|
isDefault?: boolean;
|
|
isCustom?: boolean;
|
|
tags?: string[];
|
|
}
|
|
export interface AdminModelsOk {
|
|
models: AdminModelRow[];
|
|
provider?: string;
|
|
defaultModel?: string | null;
|
|
}
|
|
|
|
// /api/admin/config/tts and /stt
|
|
export interface AdminVoiceProviderOk {
|
|
provider: string;
|
|
enabled: boolean;
|
|
voice?: string | null;
|
|
model?: string | null;
|
|
endpoint?: string | null;
|
|
extra?: Record<string, unknown>;
|
|
}
|
|
|
|
// ── Learning Hub (user-facing) ──────────────────────────────
|
|
// Categories
|
|
export interface LearningCategory {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
description?: string | null;
|
|
}
|
|
export interface LearningCategoriesOk {
|
|
categories: LearningCategory[];
|
|
}
|
|
// Feed / category / search list rows — same shape across all list endpoints
|
|
export interface LearningFeedRow {
|
|
id: number;
|
|
title: string;
|
|
slug: string;
|
|
subject?: string | null;
|
|
content_type: 'article' | 'pearl' | 'presentation' | 'quiz' | string;
|
|
created_at: string;
|
|
updated_at?: string;
|
|
category_name?: string | null;
|
|
category_slug?: string | null;
|
|
author_name?: string | null;
|
|
question_count?: number;
|
|
score?: number;
|
|
match_type?: 'keyword' | 'semantic';
|
|
}
|
|
export interface LearningFeedListOk {
|
|
content: LearningFeedRow[];
|
|
total?: number;
|
|
method?: 'keyword' | 'semantic' | 'hybrid';
|
|
}
|
|
// Single content with questions + progress
|
|
export interface LearningOption {
|
|
id: number;
|
|
option_text: string;
|
|
sort_order: number;
|
|
}
|
|
export interface LearningQuestion {
|
|
id: number;
|
|
question_text: string;
|
|
question_type: 'single' | 'multi' | 'true_false' | string;
|
|
explanation?: string | null;
|
|
options: LearningOption[];
|
|
}
|
|
export interface LearningProgressEntry {
|
|
score: number;
|
|
total: number;
|
|
completed_at: string;
|
|
}
|
|
export interface LearningContentFull {
|
|
id: number;
|
|
title: string;
|
|
slug: string;
|
|
subject?: string | null;
|
|
body?: string;
|
|
content_type: string;
|
|
category_name?: string | null;
|
|
category_slug?: string | null;
|
|
author_name?: string | null;
|
|
questions: LearningQuestion[];
|
|
progress: LearningProgressEntry[];
|
|
}
|
|
export interface LearningContentOk {
|
|
content: LearningContentFull;
|
|
}
|
|
// Quiz submit
|
|
export interface QuizAnswer {
|
|
questionId: number;
|
|
optionId?: number | null;
|
|
optionIds?: number[];
|
|
}
|
|
export interface QuizResultEntry {
|
|
questionId: number;
|
|
questionType: string;
|
|
questionText: string;
|
|
isCorrect: boolean;
|
|
selectedOptionId?: number | null;
|
|
selectedOptionIds?: number[];
|
|
correctOptionId?: number | null;
|
|
correctOptionIds?: number[];
|
|
correctOptionText?: string;
|
|
selectedExplanation?: string;
|
|
generalExplanation?: string;
|
|
}
|
|
export interface QuizSubmitOk {
|
|
score: number;
|
|
total: number;
|
|
percentage: number;
|
|
results: QuizResultEntry[];
|
|
}
|
|
// /api/learning/content/:slug/slides (Marp rendering)
|
|
export interface LearningSlidesOk {
|
|
css: string;
|
|
slides: string[]; // pre-rendered HTML per slide from the server
|
|
}
|
|
|
|
// Public config for the auth screen (anonymous users allowed).
|
|
// /api/auth/public-config
|
|
export interface PublicConfigOk {
|
|
registrationEnabled: boolean;
|
|
turnstileSiteKey: string | null;
|
|
oidcEnabled: boolean;
|
|
disableLocalAuth: boolean;
|
|
ssoButtonLabel: string;
|
|
}
|
|
|
|
// ── Extensions (pagers/directory) ────────────────────────────
|
|
export interface Extension {
|
|
id: number;
|
|
location: string;
|
|
name: string;
|
|
number: string;
|
|
type: 'extension' | 'pager';
|
|
notes?: string;
|
|
deletedAt?: string | null;
|
|
}
|
|
export interface ExtensionsListOk {
|
|
items: Extension[];
|
|
}
|
|
|
|
// ── Memories (saved templates / style hints) ─────────────────
|
|
export interface Memory {
|
|
id: number;
|
|
category: string;
|
|
name: string;
|
|
content: string;
|
|
}
|
|
export interface MemoriesListOk {
|
|
items: Memory[];
|
|
}
|
|
|
|
// ── Learning hub ─────────────────────────────────────────────
|
|
export interface LearningContentItem {
|
|
id: number | string;
|
|
slug?: string;
|
|
title: string;
|
|
category?: string;
|
|
excerpt?: string;
|
|
body?: string;
|
|
}
|
|
export interface LearningFeedOk {
|
|
content: LearningContentItem[];
|
|
total?: number;
|
|
}
|
|
|
|
// ── Encounters (saved drafts) ────────────────────────────────
|
|
export interface EncounterSummary {
|
|
id: number;
|
|
label: string;
|
|
type: 'encounter' | 'dictation' | 'hospital' | 'chart' | 'wellvisit' | 'sickvisit' | 'soap';
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
export interface EncountersListOk {
|
|
items: EncounterSummary[];
|
|
}
|
|
|
|
// ── Health ───────────────────────────────────────────────────
|
|
export interface HealthOk {
|
|
ok: boolean;
|
|
}
|