A HIPAA-aware document signing platform for small and medium medical practices. Providers sign in with Google, upload a PDF form, place signature and text fields on it, send it to a patient via email, and the patient signs in their browser without needing to create an account. The final signed PDF is stored securely and both parties get a copy.
This is a vertical slice of PandaDoc targeted at healthcare. Scope is deliberately narrow to demonstrate production-quality engineering over feature breadth.
Two phases. Do not start Phase 2 until Phase 1 is working end-to-end.
- Phase 1 - Core (priority): end-to-end signing flow working locally and deployed. Clerk auth, document upload, field placement, send via email, patient signs, signed PDF generated, audit log, dashboard shows status.
- Phase 2 - AI differentiators (stretch): auto-detect fields on upload (Claude vision), plain-language summary for patients, optional AI chat on document. Only start after Phase 1 demo flows perfectly.
- Provider (doctor, clinic admin): signs in with Google via Clerk, creates and sends documents, tracks signing status. Full authenticated access.
- Patient (document recipient): receives email with magic link, signs in browser without account creation. No login, time-limited tokenized access only.
- Landing page has "Sign in with Google" via Clerk prebuilt components
- First login creates User + Organization in our DB via Clerk webhook (user.created event)
- Subsequent logins find the User by Clerk userId and load clinic context
- Session managed by Clerk (no JWT work on our side)
- Logout clears Clerk session
- Provider clicks "New Document" on dashboard
- Uploads a PDF (max 10MB)
- PDF renders in editor. Provider drags signature, text, date, checkbox, initial fields onto the PDF
- Each field has x, y, width, height, page number, required flag
- Provider adds recipient: name + email
- Clicks "Send". Backend generates signed JWT magic link, sends email to patient.
- Document status moves from DRAFT to SENT
- Provider returns to dashboard which shows the document with "Sent" badge
- Patient receives email with "Review and sign" button
- Click opens /sign/:token in browser
- Token validated (not expired, not already signed, not voided)
- PDF renders with fields highlighted
- "Get started" button walks patient through each field
- For signature fields, modal opens with two options: type name (renders in cursive font) or draw on canvas
- Patient submits, backend:
- Validates all required fields filled
- Fetches original PDF from S3
- Overlays field values onto PDF using pdf-lib
- Stores signed PDF in S3 with different key
- Creates AuditLog entry with IP, user agent, timestamp
- Updates Document status to SIGNED
- Sends completion email to both provider and patient with download link
- Patient sees thank-you page with download link
- Dashboard shows document with "Signed" badge
- Click opens document detail page
- Shows: original PDF, signed PDF download, full audit trail, recipient info
- Provider can download signed PDF
- Expired tokens (after 7 days) move document to EXPIRED status
- Provider can resend (new token generated)
- Provider can void a document (status VOIDED, tokens invalidated)
- After PDF upload, a button "Auto-detect fields" appears in the editor
- Clicking it sends PDF pages (as images) to Claude Sonnet vision
- Claude returns a structured JSON: array of detected field suggestions with type, page, x, y, width, height
- Suggestions appear as semi-transparent overlays on the PDF
- Provider accepts/rejects each, or "Accept all"
- Streams results so the UI feels responsive
- When patient opens signing link, show a "What am I signing?" card at the top
- Backend has pre-generated a plain-English summary when the document was sent
- Summary is stored alongside the document; generated once
- 2-3 sentences explaining what the form does in lay terms
- Patient sees "Ask a question about this form" button on signing page
- Opens a chat panel
- Questions answered with PDF text as context (RAG pattern, but for a single doc so no vector DB needed)
- Claude streams the answer
These will be called out in README as future work:
- Building documents from scratch (upload PDF only)
- Multiple recipients with signing order
- Document templates library
- Payment collection
- CRM integrations
- Real-time collaboration / comments
- SMS delivery
- Custom branding per clinic
- Multi-clinic enterprise features
| Layer | Choice | Why |
|---|---|---|
| Repo | Turborepo monorepo | Shared types, one-command setup |
| Frontend | Next.js 16 App Router + TypeScript | Modern, typed, fast |
| UI | Tailwind + shadcn/ui | Professional look, accessible |
| Forms | React Hook Form + Zod | Type-safe validation |
| Server state | TanStack Query | Caching, loading states |
| PDF rendering | react-pdf | Standard library |
| Canvas signature | signature_pad | Lightweight, mobile-friendly |
| Backend | Node.js + Express + TypeScript | Straightforward, flexible |
| Auth | Clerk (provider) + signed tokens (patient) | Free tier covers everything we need; saves hours of auth work |
| Validation | Zod | Same library both sides for symmetry |
| Database | PostgreSQL | Relational, ACID, proven |
| ORM | Prisma | Type-safe, migrations, great DX |
| PDF manipulation | pdf-lib | Pure JS, runs in Node |
| File storage | AWS S3 | HIPAA-eligible, scalable |
| Resend (dev) / AWS SES (prod) | Resend has nicer DX, SES has HIPAA story | |
| Logging | Pino | Structured JSON logs |
| Testing | Vitest | Fast, modern |
| AWS persistence | RDS (PostgreSQL) + S3 | Provisioned via Pulumi (infra/) |
| AI (Phase 2) | Anthropic Claude Sonnet 4.6 | Vision for field detection, chat for RAG |
| Deployment | Vercel (apps/web) + AWS (ECR → ECS Fargate + ALB for apps/api) + RDS + S3 |
See infra/README.md |
model User {
id String @id @default(uuid())
clerkUserId String @unique
email String @unique
name String
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
documents Document[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([clerkUserId])
}
model Organization {
id String @id @default(uuid())
name String
users User[]
documents Document[]
createdAt DateTime @default(now())
}
model Document {
id String @id @default(uuid())
title String
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
createdByUserId String
createdBy User @relation(fields: [createdByUserId], references: [id])
originalPdfKey String
signedPdfKey String?
status DocumentStatus @default(DRAFT)
expiresAt DateTime?
sentAt DateTime?
signedAt DateTime?
voidedAt DateTime?
plainSummary String? // Phase 2: AI-generated summary
fields DocumentField[]
recipients DocumentRecipient[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, status])
@@index([createdByUserId])
}
enum DocumentStatus {
DRAFT
SENT
VIEWED
SIGNED
EXPIRED
VOIDED
}
model DocumentField {
id String @id @default(uuid())
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
type FieldType
page Int
x Float
y Float
width Float
height Float
required Boolean @default(true)
value String?
recipientId String?
recipient DocumentRecipient? @relation(fields: [recipientId], references: [id])
aiGenerated Boolean @default(false) // Phase 2: track AI-suggested fields
@@index([documentId])
}
enum FieldType {
SIGNATURE
TEXT
DATE
CHECKBOX
INITIAL
}
model DocumentRecipient {
id String @id @default(uuid())
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
name String
email String
tokenHash String @unique
tokenExpiresAt DateTime
signedAt DateTime?
signedFromIp String?
signedUserAgent String?
signatureImageKey String?
fields DocumentField[]
createdAt DateTime @default(now())
@@index([documentId])
}
model AuditLog {
id String @id @default(uuid())
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
actorType ActorType
actorId String?
eventType EventType
metadata Json
timestamp DateTime @default(now())
@@index([documentId, timestamp])
}
enum ActorType {
PROVIDER
RECIPIENT
SYSTEM
}
enum EventType {
DOCUMENT_CREATED
DOCUMENT_SENT
DOCUMENT_VIEWED
DOCUMENT_FIELD_FILLED
DOCUMENT_SIGNED
DOCUMENT_EXPIRED
DOCUMENT_VOIDED
DOCUMENT_RESENT
DOCUMENT_DOWNLOADED
AI_FIELDS_DETECTED
AI_SUMMARY_GENERATED
}All endpoints return JSON. All mutations return updated resource. All errors follow shape:
{
"error": {
"code": "DOCUMENT_NOT_FOUND",
"message": "Document with id xxx not found"
}
}- Clerk handles signin, signup, session management, JWT issuance automatically
- Our backend verifies Clerk JWTs via
@clerk/expressmiddleware POST /api/webhooks/clerk- receives Clerk webhooks (user.created, user.deleted) to sync our DB
GET /api/documents- query: ?status, ?page, ?limit - returns paginated listPOST /api/documents- multipart: pdf file + title - returns draft documentGET /api/documents/:id- returns document with fields, recipients, audit logPATCH /api/documents/:id- body: partial update (title, fields[]) - returns updatedDELETE /api/documents/:id- soft delete (status VOIDED)POST /api/documents/:id/send- body: { recipientName, recipientEmail } - generates token, sends email, returns updated documentPOST /api/documents/:id/resend- regenerates token, sends email againGET /api/documents/:id/download?type=original|signed- returns presigned S3 URL
GET /api/sign/:token- returns { document, fields, summary? } for recipient to fillPOST /api/sign/:token/complete- body: { fieldValues: [{fieldId, value}] } - finalizes signing
GET /api/documents/:id/audit- returns audit log entries
POST /api/documents/:id/ai/detect-fields- kicks off AI field detection, returns detected fields (streaming response)POST /api/documents/:id/ai/summarize- generates plain-language summary, stores on documentPOST /api/sign/:token/ai/chat- (stretch) ask a question about the document, streams response
clinicsign/
├── apps/
│ ├── web/
│ │ ├── app/
│ │ │ ├── (public)/
│ │ │ │ ├── page.tsx (landing)
│ │ │ │ ├── sign-in/[[...sign-in]]/page.tsx (Clerk)
│ │ │ │ └── sign-up/[[...sign-up]]/page.tsx (Clerk)
│ │ │ ├── (dashboard)/
│ │ │ │ ├── dashboard/page.tsx
│ │ │ │ ├── documents/
│ │ │ │ │ ├── new/page.tsx
│ │ │ │ │ └── [id]/page.tsx
│ │ │ ├── sign/[token]/page.tsx
│ │ │ ├── layout.tsx (wraps with ClerkProvider)
│ │ │ └── proxy.ts (Clerk `clerkMiddleware` for protected routes; Next.js 16 convention)
│ │ ├── components/
│ │ │ ├── ui/ (shadcn)
│ │ │ ├── document-editor/
│ │ │ ├── signing-flow/
│ │ │ └── dashboard/
│ │ ├── lib/
│ │ │ ├── api-client.ts
│ │ │ └── utils.ts
│ │ └── package.json
│ └── api/
│ ├── src/
│ │ ├── config/
│ │ │ ├── env.ts (zod-validated env)
│ │ │ └── logger.ts (pino setup)
│ │ ├── middleware/
│ │ │ ├── clerk-auth.ts (wraps @clerk/express)
│ │ │ ├── error-handler.ts
│ │ │ ├── rate-limit.ts
│ │ │ └── request-id.ts
│ │ ├── routes/
│ │ │ ├── documents.routes.ts
│ │ │ ├── signing.routes.ts
│ │ │ ├── webhooks.routes.ts (clerk webhooks)
│ │ │ └── ai.routes.ts (Phase 2)
│ │ ├── controllers/
│ │ ├── services/
│ │ │ ├── documents.service.ts
│ │ │ ├── signing.service.ts
│ │ │ ├── pdf.service.ts
│ │ │ ├── s3.service.ts
│ │ │ ├── email.service.ts
│ │ │ ├── audit.service.ts
│ │ │ ├── user-sync.service.ts (Clerk -> DB sync)
│ │ │ └── ai.service.ts (Phase 2)
│ │ ├── repositories/
│ │ ├── schemas/
│ │ ├── types/
│ │ ├── utils/
│ │ ├── app.ts
│ │ └── server.ts
│ ├── prisma/
│ ├── tests/
│ └── package.json
├── packages/
│ ├── shared-types/
│ └── config/
├── infra/ Pulumi: VPC, RDS, S3, KMS, IAM, ECR (see infra/README.md)
├── docker-compose.yml Optional: API container only; DB is AWS RDS
├── turbo.json
├── package.json
└── README.md
- TypeScript strict mode everywhere, no
any - Zod schema at every API boundary (request body, query params, env vars)
- No hardcoded secrets, everything in .env (committed .env.example with placeholders)
- Every API endpoint wrapped in try-catch with typed errors
- Structured logs (pino) with correlation IDs, never console.log in production code
- Repository pattern: Prisma calls only live in
repositories/ - Service layer for business logic, controllers just orchestrate
- Components under 200 lines, split when larger
- No business logic in React components, move to hooks or services
- Commit messages follow conventional commits (feat:, fix:, chore:, docs:, test:)
- README must explain setup in 3 commands or less for the reviewer
- Use
@clerk/nextjsin apps/web for frontend - Use
@clerk/expressin apps/api for backend (verifies Clerk JWT on each request) - Set up a webhook endpoint that receives
user.createdfrom Clerk to create a User + Organization in our DB - The Clerk user ID is stored on our User row as
clerkUserId- this is our join key - For this MVP: each new Clerk user gets their own Organization automatically (name defaults to user's name + "'s workspace", editable in settings later)
- Proxy at
apps/web/proxy.ts(Next.js 16 naming; ClerkclerkMiddleware) protects dashboard routes - Public routes: landing, sign-in, sign-up, /sign/:token
- Clerk provides
<SignIn />,<SignUp />,<UserButton />components - use them, don't build custom
Writing this in the README for reviewer context:
Clerk was chosen for authentication to focus engineering time on the core signing workflow, which is the novel part of this take-home. For a production HIPAA-compliant deployment, we would either (a) upgrade to Clerk's HIPAA BAA tier, or (b) migrate to a self-hosted auth stack (Auth.js + our own BAA partners). The
clerkUserIdfield on User provides a clean seam for that migration.
Medical, clean, calm. Think One Medical, Forward, Oscar Health. Not PandaDoc green (too corporate-sales). Not hospital fluorescent. A warm, soft, trustworthy feel.
- Primary: soft sage/teal (#5A9B8E or similar)
- Accent: warm neutral
- Background: warm off-white (#FAFAF7 or similar), not pure white
- Text: near-black (#1A1A1A), high contrast
- Font: Inter or Geist Sans, larger base size than typical SaaS (16-17px base)
- Spacing: generous, 8pt grid
- Corners: medium rounded (8-12px), not sharp, not overly rounded
Patient-facing pages especially should have large tap targets, large fonts, and clear one-primary-action-per-screen flow. Older users might use this.
The app is not HIPAA-certified because that requires a BAA with AWS and Clerk, a SOC 2 audit, employee training, and formal breach procedures, none of which fit in a 2-day timeline. However, the architecture is designed to be HIPAA-compliant once those organizational steps are in place.
Technical controls implemented:
- All AWS services used (S3, SES, RDS, ECS, CloudWatch) are HIPAA-eligible
- Clerk is HIPAA-compatible on their Enterprise BAA tier
- PHI encrypted at rest via S3 server-side encryption with KMS
- PHI encrypted in transit via TLS 1.2+
- Magic link tokens are signed JWTs with short expiry and single-use enforcement
- Audit log is append-only (no update or delete operations)
- Database credentials and JWT secrets in env vars, never in code
- Least-privilege IAM roles for each service
- Rate limiting on auth and signing endpoints
Controls that would need to be added for full compliance:
- BAA with AWS and Clerk (or migration to self-hosted auth)
- Annual SOC 2 audit
- Intrusion detection and centralized security monitoring
- Automated PHI access reports
- Formal breach notification procedures
- Employee HIPAA training documentation