Skip to content

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

Blood groups are A+, O-, AB+, and that is how you write them:

Terminal window
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.

Terminal window
pnpm create flare-framework clinic
cd clinic
docker compose up -d
Terminal window
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.

Terminal window
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.

Terminal window
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.

Terminal window
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.

Terminal window
npx flare migrate
npx flare seed:resource Patient 60
npx flare seed:resource Clinician 10
npx flare seed:resource Appointment 90
npx flare seed:resource Visit 60
npx flare seed:resource Medicine 25
npx flare seed:resource Prescription 80
npx flare dev

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:

Terminal window
npx flare gen policy Visit --roles nurse,doctor,admin
npx flare gen policy Diagnosis --roles doctor,admin
npx flare gen policy Prescription --roles doctor,pharmacist,admin

Note 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.

  • 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.
  • Patients see their own records. The owner is on Patient, not on Visit, 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.