Campus clinic
Students book appointments, nurses record vitals, doctors diagnose, the pharmacy dispenses. The records are confidential, which changes how you think about every policy on the page.
New here: a visit that belongs to exactly one appointment, stock with expiry dates, and the one place where Flare’s field grammar says no.
| Resource | What it holds |
|---|---|
Patient |
The person, their blood group and allergies |
Clinician |
Nurse, doctor or pharmacist, with a licence |
Appointment |
A booking |
Visit |
What happened, with the vitals |
Diagnosis |
What was found |
Medicine |
The catalogue |
StockBatch |
A delivery, with an expiry date |
Prescription |
What was given, and when it was dispensed |
The enum with a plus sign in it
Section titled “The enum with a plus sign in it”Blood groups are A+, O-, AB+, and that is how you write them:
npx flare gen resource Patient --fields 'bloodGroup:enum(A+,A-,B+,B-,O+,O-,AB+,AB-)?'An option may use any printable character except a space, a comma, round
brackets, a backtick or $. The last two are excluded because the options are
written into a sql template literal in the generated Drizzle schema, where a
backtick would end it and ${ would interpolate; the others end the option
list in the grammar itself.
It is worth knowing what happens underneath, because the two stacks do
different things with it. On SQLite the column is text with a CHECK
constraint listing the strings, so A+ is stored as A+ and that is the end
of it. On Postgres it becomes a real enum, and a Prisma enum member has to be
an identifier — so the member is A_2b with @map("A+") carrying the real
value. Flare translates at the store boundary, so the value you read and write
is always A+; the member name never reaches your code.
Grades (A+, B-), fractions (1/2) and percentages (50%) work the same
way.
1. The app
Section titled “1. The app”pnpm create flare-framework cliniccd clinicdocker compose up -dpnpm create flare-framework clinic --stack cloudflarecd clinic2. People
Section titled “2. People”npx flare gen resource Patient --icon user --group Clinic \ --fields 'regNumber:string!, fullName:string, bloodGroup:enum(A+,A-,B+,B-,O+,O-,AB+,AB-)?, allergies:text?, emergencyContact:tel?, userId:string?'
npx flare gen resource Clinician --icon stethoscope --group Clinic \ --fields 'fullName:string, role:enum(nurse,doctor,pharmacist), licenceNo:string!, speciality:string?, userId:string?'Both have userId:string? — nullable. A patient exists in the register
before they ever sign in, and a clinician’s record outlives their account.
Same reasoning as the fees project.
The blood groups are spelled the way a nurse would write them. An enum option
may use any printable character except a space, a comma, round brackets, a
backtick or $, so A+ and AB- go in as they are — on Postgres they become
a real enum whose members carry the value in @map, and on SQLite a CHECK
constraint listing the strings.
allergies:text? is free text on purpose. A fixed list is wrong the first
time somebody is allergic to something not on it, and in a clinic that is
not a feature request.
3. Appointments and visits
Section titled “3. Appointments and visits”npx flare gen resource Appointment --icon calendar-check --group Clinic \ --fields 'scheduledAt:datetime, reason:text, status:enum(booked,seen,cancelled,noShow), cancelledBecause:text if status == cancelled, patient:belongsTo(Patient), clinician:belongsTo(Clinician)?'
npx flare gen resource Visit --icon clipboard-list --group Clinic \ --fields 'checkedInAt:datetime, temperature:float?, pulse:int?, weightKg:float?, bloodPressure:string?, notes:text?, appointment:belongsTo(Appointment)!, patient:belongsTo(Patient)'appointment:belongsTo(Appointment)! — unique, so one visit per
appointment. Turn up twice and that is two appointments.
The vitals are all optional, because triage is partial: a nurse takes a temperature now and a weight if the scale is free. A required vital means a nurse inventing a number to get past the form, which is worse than a blank.
bloodPressure:string? and not two integers — it is written and read as
120/80, and splitting it buys you arithmetic nobody does.
patient is on Visit as well as on Appointment. Denormalised on
purpose: every query about a patient’s history starts there, and going
through the appointment to find it is a join on every single read.
4. Diagnoses
Section titled “4. Diagnoses”npx flare gen resource Diagnosis --icon activity --group Clinic \ --fields 'code:string?, description:string, notes:text?, visit:belongsTo(Visit), clinician:belongsTo(Clinician)'A visit has many: people arrive with more than one thing wrong.
5. The pharmacy
Section titled “5. The pharmacy”npx flare gen resource Medicine --icon pill --group Pharmacy \ --fields 'name:string!, form:enum(tablet,capsule,syrup,injection,ointment), strength:string?, unitPrice:money'
npx flare gen resource StockBatch --icon boxes --group Pharmacy \ --fields 'quantity:int, expiresOn:date, receivedAt:date, medicine:belongsTo(Medicine)'
npx flare gen resource Prescription --icon file-pen --group Pharmacy \ --fields 'dosage:string, durationDays:int, quantity:int, dispensedAt:datetime?, dispensedById:string?, visit:belongsTo(Visit), medicine:belongsTo(Medicine)'Stock is batches, not a number. A quantity column on Medicine cannot
answer “what expires this month”, and expiry is the whole job of a pharmacy
store. Each delivery is a row with its own expiry; stock on hand is a sum,
and dispensing draws from the batch expiring soonest.
dispensedAt:datetime? null means prescribed but not collected — the same
pattern as the ticket in event ticketing.
6. Run it
Section titled “6. Run it”npx flare migratenpx flare seed:resource Patient 60npx flare seed:resource Clinician 10npx flare seed:resource Appointment 90npx flare seed:resource Visit 60npx flare seed:resource Medicine 25npx flare seed:resource Prescription 80npx flare devConfidentiality is the project
Section titled “Confidentiality is the project”Every other project on this site can be a bit loose about who reads what. This one cannot. Medical records have a different standard, and your examiner will push on it.
Generated resources arrive admin-only, which is the right default here. Open them deliberately:
npx flare gen policy Visit --roles nurse,doctor,adminnpx flare gen policy Diagnosis --roles doctor,adminnpx flare gen policy Prescription --roles doctor,pharmacist,adminNote what that does not say: a pharmacist can see a prescription and not a diagnosis. That is the point — access by role, per resource, rather than one “medical staff” gate that gives the pharmacy the psychiatric notes.
What you still owe
Section titled “What you still owe”- The access log. Every read of a record, by whom, when. The brief asks for it and it is the thing that makes confidentiality auditable rather than merely claimed. A hook on read, writing to an append-only resource.
- Stock deduction on dispensing, drawing from the batch expiring first, and refusing when there is not enough.
- Queue management — who is waiting, in what order.
- Referral letters as PDFs.
- The statistics dashboard, anonymised. Common illnesses by month without exposing who had them, which is harder than it sounds in a small clinic and is a good thing to write about.
Make it yours
Section titled “Make it yours”- Patients see their own records. The owner is on
Patient, not onVisit, so this is a hook rather than--own. - Soft delete everywhere clinical. A deleted visit should be
recoverable and visible to an auditor: pass
--soft-delete. - Expiry alerts from
StockBatch, as a scheduled job.