Files
threadcount-community/prisma/schema.prisma
T
ThreadCount 96d5c10537 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:38:24 +10:00

1296 lines
55 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum Role {
ADMIN
ISSUER
}
model Facility {
id String @id @default(cuid())
name String
location String @default("Linen Room")
coordinator String @default("")
/// How the linen room is reached, printed in the footer of the order form a ward manager signs. Held
/// here and nowhere else: the form carries a real health service's contacts, and the only place
/// a customer's own address and number may live is that customer's own settings.
coordinatorEmail String @default("")
coordinatorPhone String @default("")
/// The IANA zone this linen room actually stands in. Every date-only column (Issue.date,
/// Stocktake.date, Order.date, HandIn.date…) is "today" as this zone sees it, which is what
/// decides the month an issue is journalled in and the date printed on a credit slip. Brisbane
/// only because that is where the first facility was; a Perth or Hobart room sets its own.
timezone String @default("Australia/Brisbane")
defaultEntitlement Int @default(5)
/// The starting kit, in sets — a set being a top and a bottom — for the staff groups on the
/// starting-kit route (kitGroups): handed over on the first day, with more issued as needed up to
/// capSets. The FTE-table groups (nursingGroups) start on a number proposed by their hours instead,
/// and groups on manager approval start on none. A starting kit and not a yearly allowance, so it
/// is nothing to do with defaultEntitlement, which still counts garments per financial year for
/// everything that reads it.
initialSets Int @default(3)
/// The ceiling, in sets HELD at any one time, for every staff group in the building, whichever of
/// the three routes it is on. Six sets is twelve garments, and there is no financial year in it:
/// the only ways past it are a hand-in, which swaps rather than adds, and a coordinator's override,
/// recorded as one. The routes differ only in how somebody gets up to it — hours proposing a
/// number, a starting kit, or a manager approving a set at a time.
capSets Int @default(6)
defaultReorder Int @default(3)
exceptionHigh Int @default(10)
varianceReason Int @default(5) // a count gap this big or bigger has to carry a reason
glAccount String @default("")
journalDesc String @default("Uniform issues")
lastBackup String @default("")
logoData String @default("")
barcodeLookup Boolean @default(false) // ask a public barcode database for a product name when an unknown code is scanned
/// Single sign-on through the facility's own identity provider (SAML/OIDC via the Jackson
/// broker; the IdP metadata lives in Jackson, keyed by this facility's id). Domains decide whose
/// email reaches SSO; required means the password door is refused for everyone except admins
/// flagged break-glass; staff says wearers with an account may use it too (web only).
ssoEnabled Boolean @default(false)
ssoRequired Boolean @default(false)
ssoStaff Boolean @default(false)
ssoDomains String[] @default([])
staffGroups String[] @default([])
/// The staff groups on the FTE-table route: the hours they work propose their starting kit, off the
/// table on the signed order form, and a manager may sign above it up to capSets. A list of names
/// this facility chose rather than a test for letters in a name, because every employer names its
/// groups differently. Empty means none of its groups are on that route — there is no fallback.
/// Renaming a group renames it here too, which is the point: nobody's allowance may change because
/// a label was tidied up.
nursingGroups String[] @default([])
/// The staff groups on the starting-kit route: initialSets on the first day, then more as needed up
/// to capSets, with nothing to hand back first. Read and renamed exactly as nursingGroups is, and a
/// group may not be on both. A group on neither list is on manager approval — no starting kit, and
/// a manager approves a set at a time.
kitGroups String[] @default([])
orderSeq Int @default(0)
catalogSeq Int @default(0)
/// Numbers the staff-app requests (R-0001…). Incremented inside the same transaction that
/// creates the request, like orderSeq, so two people submitting at once can't collide on a code.
requestSeq Int @default(0)
/// Bumped once per successful mutation, by the three routes that can change anything here.
///
/// The screens already know how to reload themselves — every mutation ends in router.refresh().
/// What they had no way to know was that somebody ELSE had changed something, so a phone on a
/// ward showed whatever the catalogue looked like when it was opened. Polling the whole snapshot
/// to find out would mean re-reading the facility's history every few seconds on every device;
/// polling this integer costs a primary-key lookup and a couple of hundred bytes, and the
/// expensive fetch happens only when the number has actually moved.
rev Int @default(0)
/// Numbers the barcodes this room prints for garments that arrived without one. Incremented in
/// the same transaction that binds the code, like orderSeq, so two people labelling at once
/// cannot mint the same number.
barcodeSeq Int @default(0)
slipCollectionFooter String @default("Collect from the Linen Room during opening hours. Enquiries: see coordinator.")
slipDeliveryFooter String @default("After hours deliveries are left with the manager or team leader on duty.")
slipOrg String @default("")
/// The plan, and where the facility stands on it. Written only by ThreadCount and
/// by sign-up; read by lib/plan.ts, which turns these six columns into the entitlements every
/// write door checks. `plan` is a code (hosted_small · hosted_facility · health_service ·
/// private; empty means "whatever a new sign-up gets"), `planStatus` is what was last
/// set (free · trial · active · read_only) and the dates decide what that means today — a trial
/// past trialEndsAt, or a paid year past paidUntil, runs a fortnight of grace and then goes
/// read-only. Nothing is ever deleted for money: read-only keeps every report, export and the
/// backup working.
///
/// `grandfathered` is the promise the pricing page made while the product was free: every
/// facility created before plans went live keeps everything, hosted, for as long as it exists.
/// It survives a change of plan, so a grandfathered room that buys and later lapses falls back to
/// free, never to read-only. Only an explicit read_only overrides it.
plan String @default("")
planNote String @default("")
planStatus String @default("free")
trialEndsAt DateTime?
paidUntil DateTime?
/// Where invoices go. A contact, set by the coordinator from Settings Plan and shown to
/// nobody else.
billingEmail String @default("")
grandfathered Boolean @default(false)
isDemo Boolean @default(false)
demoResetAt DateTime?
createdAt DateTime @default(now())
/// The health service this facility belongs to, if any. Set by ThreadCount when a
/// Health Service plan is sold. A member facility's entitlements come from the organisation's
/// plan row, not its own (lib/plan.ts), and its catalogue and suppliers may be published to it
/// from the organisation's. Nothing about a wearer crosses this line: the organisation sees
/// roll-ups, and reaching a facility's records still takes a coordinator account there.
orgId String?
org Organisation? @relation(fields: [orgId], references: [id], onDelete: SetNull)
/// Card payments (Stripe), dormant until STRIPE_* is set. Ids only; no card data is ever here.
stripeCustomerId String @default("")
stripeSubscriptionId String @default("")
users User[]
items CatalogItem[]
barcodes Barcode[]
stock StockLevel[]
moves StockMove[]
depts Department[]
staff Staff[]
issues Issue[]
orders Order[]
pickups Pickup[]
stocktakes Stocktake[]
suppliers Supplier[]
approvals Approval[]
alterations Alteration[]
handins HandIn[]
photos Photo[]
locations Location[]
auditEvents AuditEvent[]
costChanges CostChange[]
staffAccounts StaffAccount[]
requests Request[]
waitlist WaitlistEntry[]
kitChecks KitCheck[]
notices LinenNotice[]
damageReports DamageReport[]
disputes RecordDispute[]
@@index([orgId])
}
/// A health service: several facilities under one owner, one plan, one sign-in for the people who
/// run it, and a catalogue and supplier list the facilities can inherit. It is not a facility and
/// holds no wearer, staff or issue records of its own — what it can see is what lib/orgdata.ts
/// computes as roll-ups. Created only by ThreadCount.
model Organisation {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
/// The plan the member facilities inherit — the same columns Facility carries, read by
/// lib/plan.ts in the facility's place whenever Facility.orgId is set.
plan String @default("health_service")
planStatus String @default("trial")
trialEndsAt DateTime?
paidUntil DateTime?
billingEmail String @default("")
planNote String @default("")
/// Organisation-level single sign-on: one IdP connection (Jackson tenant "org:<id>") that every
/// member facility's coordinators may sign in through. Domains route an email to it.
ssoEnabled Boolean @default(false)
ssoDomains String[] @default([])
facilities Facility[]
users OrgUser[]
items OrgCatalogItem[]
suppliers OrgSupplier[]
events OrgEvent[]
}
/// Somebody who runs a health service's uniform programme across its facilities. A fourth kind
/// of identity (see lib/org/session.ts), never a coordinator: holding this account opens the
/// organisation's roll-ups and its shared lists, and nothing inside any facility.
model OrgUser {
id String @id @default(cuid())
orgId String
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
email String @unique
name String
passwordHash String
totpSecret String @default("")
totpEnabledAt DateTime?
inactive Boolean @default(false)
createdAt DateTime @default(now())
lastSeenAt DateTime?
events OrgEvent[]
@@index([orgId])
}
/// A garment in the organisation's shared catalogue. Publishing writes it into each member
/// facility's own catalogue (CatalogItem.orgItemId links back); the facility keeps its stock,
/// barcodes, locations and reorder levels, and may change cost and supplier locally until the next
/// publish. Sizes are append-only in the facility, as everywhere else.
model OrgCatalogItem {
id String @id @default(cuid())
orgId String
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
item String
gender String @default("Unisex")
type String @default("")
sku String @default("")
supplier String @default("")
cost Float @default(0)
groups String[] @default([])
sizes String[] @default([])
notes String @default("")
archived Boolean @default(false)
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@unique([orgId, item, gender])
}
model OrgSupplier {
id String @id @default(cuid())
orgId String
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
contact String @default("")
phone String @default("")
account String @default("")
lead Int?
@@unique([orgId, name])
}
/// What the organisation's people did: sign-ins, publishes, membership changes. Append-only.
model OrgEvent {
id String @id @default(cuid())
orgId String
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
userId String?
user OrgUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String
detail String @default("")
ip String @default("")
at DateTime @default(now())
@@index([orgId, at])
}
model Supplier {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
name String
contact String @default("")
phone String @default("")
account String @default("")
lead Int?
sort Int @default(0)
@@unique([facilityId, name])
}
model Approval {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
staffId String
staff Staff @relation("ApprovalSubject", fields: [staffId], references: [id], onDelete: Cascade)
date String
/// The approver's name exactly as it was signed on the form. It is a copy, not a lookup, and it
/// stays one: a form that has been signed does not start reading differently because the person
/// who signed it was later married, promoted or taken off the register. Every other place this
/// schema writes a name down beside an id — Order, HandIn, Stocktake — does it for the same
/// reason.
byName String @default("")
/// Which person on the register that signature belongs to, when the coordinator picked the
/// manager out of the register rather than typing them. It is what lets a manager's own history
/// of approvals be found, and lets it survive that manager being renamed, which matching on
/// byName never could.
///
/// Nullable because it has to be: every approval recorded before the register search existed
/// carries a typed name and nothing else, and a manager who is not on the register — an agency
/// manager, one covering from another ward — still signs forms. A blank here means "we only have the signature",
/// never "nobody approved this".
///
/// It is also the only reliable way to see that a manager approved their own kit: staffId ==
/// byStaffId says so outright, where two spellings of the same typed name never could. That case
/// is now allowed, so anything showing an approval has to be able to say when it happened.
byStaffId String?
/// SetNull rather than Cascade or Restrict, and byName is what makes that safe. Cascade would
/// delete signed approvals along with the manager who signed them, taking the sets somebody was
/// actually issued off the record; Restrict would refuse to let the linen room remove a manager
/// who has ever approved anything, which is most of them. So the approval stays, the link goes,
/// and the name as signed is still there to read.
byStaff Staff? @relation("ApprovalApprover", fields: [byStaffId], references: [id], onDelete: SetNull)
sets Int
fte String @default("")
notes String @default("")
used Int @default(0)
photoId String? // photo of the signed order form
createdAt DateTime @default(now())
@@index([staffId])
// The history screen asks "what has this manager approved?", which without this is a scan of
// every approval the facility has ever recorded.
@@index([byStaffId])
}
model Alteration {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
date String
garment String
desc String @default("")
status String @default("Requested")
createdAt DateTime @default(now())
@@index([staffId])
}
model User {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
email String @unique
passwordHash String
first String
last String
title String @default("")
role Role @default(ISSUER)
inactive Boolean @default(false)
/// Keeps a working password when the facility requires single sign-on: the fire escape.
ssoBreakGlass Boolean @default(false)
createdAt DateTime @default(now())
/// Encrypted with a key derived from SESSION_SECRET — a one-time-password secret sitting in
/// plaintext is a second factor that one leaked dump removes for every account at once.
/// Set while enrolling; `totpEnabledAt` is what actually turns the requirement on.
totpSecret String @default("")
totpEnabledAt DateTime?
resets PasswordReset[]
recovery RecoveryCode[]
raisedRequests Request[]
@@index([facilityId])
}
/// ThreadCount's own administrative identity, separate from every facility.
///
/// Not a User. A User belongs to one facility and every query it can reach is scoped to that
/// facility; an Operator belongs to none and is the one identity that sees across them. Keeping it
/// in its own table means a missed role check on User can never become platform-wide access, and
/// its session is a third cookie with a third key and a third claim name, so no arrangement of the other two tokens can be presented as this one.
///
/// SUPPORT is read-only with no reveal. OWNER may reveal masked contacts, flip switches and open the
/// danger zone. Seeded with one OWNER row; adding a second person is a row plus an entry in the
/// access policy in front of it, not a refactor.
enum OperatorRole {
OWNER
SUPPORT
}
model Operator {
id String @id @default(cuid())
email String @unique
name String
role OperatorRole @default(SUPPORT)
passwordHash String
/// Encrypted like User.totpSecret, but with a key derived from its own secret rather than
/// SESSION_SECRET: rotating this secret must never touch a customer's second factor,
/// and the reverse.
totpSecret String @default("")
totpEnabledAt DateTime?
inactive Boolean @default(false)
createdAt DateTime @default(now())
lastSeenAt DateTime?
events OperatorEvent[]
grants RevealGrant[]
recovery OperatorRecoveryCode[]
}
/// Single-use codes for the day an operator's phone is lost. Hashed, shown once at enrolment.
/// Without these a lost phone closes the password door until somebody clears the second factor
/// over SSH — and until single sign-on exists, the password door is the only door.
model OperatorRecoveryCode {
id String @id @default(cuid())
operatorId String
operator Operator @relation(fields: [operatorId], references: [id], onDelete: Cascade)
codeHash String
usedAt DateTime?
@@index([operatorId])
}
/// The administrative audit trail. Append-only: nothing in the product updates or deletes a row, and an
/// Operator with rows cannot be deleted (Restrict) — deactivate them instead, the same rule the
/// register applies to a staff member with history.
///
/// `facilityId` is a plain column and deliberately NOT a relation. AuditEvent's facilityId cascades
/// on delete, which would let a facility that closes its account erase every record of what an
/// operator revealed about it. This table remembers regardless. It also lets an event be about
/// nothing in particular — a switch flipped, a sign-in — where AuditEvent has nowhere to file it.
model OperatorEvent {
id String @id @default(cuid())
operatorId String
operator Operator @relation(fields: [operatorId], references: [id], onDelete: Restrict)
at DateTime @default(now())
action String
facilityId String @default("")
subject String @default("")
detail String @default("")
ip String @default("")
@@index([at])
@@index([operatorId, at])
@@index([facilityId, at])
}
/// One row. Platform switches ThreadCount flips without a deploy. The environment
/// still wins when set (SIGNUPS_DISABLED=1, DEMO_DISABLED=1): a box can be locked down by hand and
/// nothing can open it again until the variable goes.
model PlatformSwitch {
id String @id @default("platform")
signupsDisabled Boolean @default(false)
demoDisabled Boolean @default(false)
/// Plans are live: new sign-ups start on Hosted Small instead of being grandfathered, and the
/// Plan tab appears in Settings. Off until the sixty days' notice to existing rooms has run.
plansLive Boolean @default(false)
updatedAt DateTime @updatedAt
}
/// A thirty-minute window in which one masked contact field of one facility is shown unmasked.
///
/// A server-side row rather than a claim in the session cookie, so a grant can be revoked before it
/// expires. The reason is recorded here and mirrored into OperatorEvent; the grant is the mechanism,
/// the event is the record, and both are kept forever.
model RevealGrant {
id String @id @default(cuid())
operatorId String
operator Operator @relation(fields: [operatorId], references: [id], onDelete: Restrict)
facilityId String
field String
reason String
at DateTime @default(now())
expiresAt DateTime
@@index([operatorId, facilityId, expiresAt])
}
/// Single-use codes for the day the phone is lost.
///
/// Without these a locked-out admin is a locked-out facility, because deleting the last admin
/// deletes everything. Stored hashed, like any other credential.
model RecoveryCode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
codeHash String
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// A password reset in flight.
///
/// The token is stored hashed, never in the clear: this table is in every backup, and a plaintext
/// token in a leaked dump is a live key to an account until it expires. Rows are single-use and
/// short-lived, and a successful reset changes the password hash, which invalidates every existing
/// session for that user through the session token's password version.
model PasswordReset {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
requestIp String @default("")
@@index([userId])
@@index([expiresAt])
}
/// Who changed what, and when.
///
/// Issues and stock movements already record their own actor, but nothing recorded the rest: who
/// edited a price, moved a par level, archived a garment, deleted a staff record or changed the
/// variance threshold. "Who changed this?" is the first question in any review of a hospital
/// system, and until now the answer was "nobody knows".
///
/// Deliberately holds no values — only the operation and the record ids it touched. An audit trail
/// that quoted the data would become a second copy of the staff register, growing forever, outside
/// every retention rule that applies to the first one.
model AuditEvent {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
at DateTime @default(now())
userId String
userName String
op String
target String @default("")
ip String @default("")
@@index([facilityId, at])
}
/// What a garment cost, and when it changed.
///
/// CatalogItem.cost is a single field, so raising a price silently overwrote what you used to pay
/// and "what did these cost us last year" became unanswerable — which is a question finance asks
/// every year. This keeps the old figures.
///
/// Unlike the audit trail this deliberately does hold a value, because the value is the entire
/// point. It is a price, not personal information.
model CostChange {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
cost Float
previous Float?
at DateTime @default(now())
byName String @default("")
@@index([facilityId, at])
@@index([itemId, at])
}
/// A request for uniform, from the person who will wear it.
///
/// The spine of the staff app. Two rules shape every field here.
///
/// First: **no money**. There is no price, no basket and no payment anywhere in this model, and
/// there must not be. Every garment is trust stock issued against a person's record; what it cost
/// is the linen room's business and lives on Issue.
///
/// Second: **the manager sees it before the linen room does**. `status` starts at `awaiting` and
/// the fulfilment fields stay null until someone approves. A request the linen room can act on is
/// one a named person put their name to.
model Request {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
code String // human reference, e.g. R-2291
/// Who the uniform is for. Always the wearer, never the person who typed it in.
subjectId String
subject Staff @relation("RequestSubject", fields: [subjectId], references: [id], onDelete: Cascade)
/// Who raised it, when that is not the subject: a ward clerk at the desk, or a coordinator at
/// the counter. Both names sit on the order — the design is explicit that the subject is never
/// hidden behind whoever typed it.
raisedByStaffId String?
raisedByStaff Staff? @relation("RequestRaisedByStaff", fields: [raisedByStaffId], references: [id], onDelete: SetNull)
raisedByUserId String?
raisedByUser User? @relation(fields: [raisedByUserId], references: [id], onDelete: SetNull)
/// Denormalised so the order still names them after the account goes.
raisedByName String @default("")
/// Why the uniform is needed, and anything the wearer wanted to add. Both sit on the request and
/// not on the line: a nurse asking for a tunic, trousers and a fleece is making one ask for one
/// reason, and making them justify each garment separately is the paperwork this replaces.
reason String @default("") // Worn out | Damaged | Lost | Extra for shifts
note String @default("")
/// awaiting | declined | accepted | picking | ready | round | collected | delivered
///
/// The machine is unchanged, but the decision step is now a ROLLUP of the lines: the manager
/// looks at the whole ask and approves in one action, while still being able to decline
/// individual lines. When they decide, if every line ended up `declined` the request goes
/// `declined`; if at least one line is `approved` it goes `accepted`, and only the approved
/// lines are picked, bagged and collected. One code, one pick, one collection either way.
status String @default("awaiting")
/// The approver, snapshotted at submit time. A later change of manager must not silently
/// re-address a request that is already with someone.
managerId String?
managerName String @default("")
decidedAt DateTime?
/// One of exactly three: Over allowance | Not needed right now | Wrong item for the role.
/// Always shown to the staff member — a decline without a reason is the thing this replaces.
/// Set only when the WHOLE request was refused; a partly-approved request carries its refusals
/// on the individual lines instead.
declineReason String?
/// counter | ward_round — chosen by the linen room, never by the requester, who finds out after
/// the fact.
route String?
/// Four digits shown at the counter. Only ever set for the counter route.
collectCode String?
holdUntil String @default("")
/// Anyone on the ward may sign for a round bag, and whoever does is named on the requester's
/// order — so a bag that never arrived has a name against it.
signerName String?
signerRole String?
signedAt DateTime?
/// Set when the bag HAS been picked up off the ward desk — by the requester on their own order,
/// or by the desk clerk clearing the pile (round.claim, from /my/orders/[id] and /my/round). The
/// unclaimed list is the delivered rows where this is still null; unclaimed bags are the linen
/// room's biggest waste, so the desk sees them above today's work.
claimedAt DateTime?
createdAt DateTime @default(now())
lines RequestLine[]
events RequestEvent[]
messages RequestMessage[]
@@unique([facilityId, code])
@@index([facilityId, status])
@@index([subjectId])
@@index([managerId])
}
/// One garment on a request.
///
/// A request used to carry a single garment, so a nurse who needed a tunic, trousers and a fleece
/// raised three of them: three codes, three approval emails to the same manager on the same day,
/// three rows on the linen room's queue, three bags. The garments moved onto lines so that one ask
/// is one request — but each line keeps its OWN status, because a manager quite reasonably wants to
/// approve the tunic and the trousers and refuse the fleece as over allowance. Only the approved
/// lines are picked.
model RequestLine {
id String @id @default(cuid())
requestId String
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
qty Int @default(1)
/// awaiting | approved | declined. Every line starts `awaiting` and is settled when the manager
/// decides the request; the request's own status is then the rollup of these.
status String @default("awaiting")
/// Why this one garment was refused, from the same three reasons as a whole-request decline.
/// The wearer is always told which line was knocked back and why — otherwise a bag turning up
/// two garments short looks like the linen room lost them.
declineReason String?
/// The order the requester entered them in, kept so the manager's screen, the pick list and the
/// bag all read the same way down the page.
sort Int @default(0)
@@index([requestId])
}
/// One line of a request's timeline. Append-only: the screen is a history, so nothing here is
/// ever updated or deleted, and the order they were written in is the order they are shown.
model RequestEvent {
id String @id @default(cuid())
requestId String
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
label String
meta String @default("")
actorName String @default("")
at DateTime @default(now())
@@index([requestId])
}
/// A message about one request. There is no general inbox and no direct messaging: every message
/// hangs off the thing it is about, which is what stops this becoming a chat app nobody staffs.
model RequestMessage {
id String @id @default(cuid())
requestId String
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
/// True when the wearer wrote it; false when the linen room did.
fromStaff Boolean
authorName String @default("")
body String
readAt DateTime?
createdAt DateTime @default(now())
@@index([requestId])
}
/// Waiting for a size that is not on the shelf.
///
/// Joining needs no approval — the design is deliberate that a queue is not a request. Approval
/// happens if and when the item lands and the person accepts it.
model WaitlistEntry {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
/// Set when stock arrives; the item is held for this person for 48 hours from here.
offeredAt DateTime?
acceptedAt DateTime?
leftAt DateTime?
createdAt DateTime @default(now())
/// One place in the queue per garment and size. Joining twice is a bug, not a strategy.
@@unique([staffId, itemId, sizeIndex])
@@index([facilityId, itemId, sizeIndex])
}
/// A round of "does this match what is in your locker?", run across the facility.
model KitCheck {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
dueBy String // ISO date
openedAt DateTime @default(now())
closedAt DateTime?
openedBy String @default("")
answers KitCheckAnswer[]
@@index([facilityId])
}
/// One person's answer about one garment. A shortfall is written off, never charged — the cycle
/// exists to correct the record and set par levels, not to chase anyone.
model KitCheckAnswer {
id String @id @default(cuid())
kitCheckId String
kitCheck KitCheck @relation(fields: [kitCheckId], references: [id], onDelete: Cascade)
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
onRecord Int
confirmed Int
answeredAt DateTime @default(now())
@@unique([kitCheckId, staffId, itemId, sizeIndex])
@@index([kitCheckId, staffId])
}
/// A garment reported damaged. Two jobs: take it off the record, and start the replacement — but
/// only when the person hands it in, which is why this is a report and not a write-off.
model DamageReport {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
issueId String?
issue Issue? @relation(fields: [issueId], references: [id], onDelete: SetNull)
kind String // Torn | Stained | Worn thin
note String @default("")
photoId String?
requestId String? // the replacement, if they asked for one
handedInAt DateTime?
createdAt DateTime @default(now())
@@index([facilityId])
@@index([staffId])
}
/// "This isn't right" — someone saying the linen room's record of what they hold is wrong.
///
/// It needs its own model because it is the one thing a staff member can raise that has no order
/// behind it, and the product rule is one message thread per order. Rather than bend that rule
/// into a general inbox, a dispute is its own small object with its own queue on the linen-room
/// side. Nothing about the record changes until a coordinator agrees it should.
model RecordDispute {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
itemId String?
item CatalogItem? @relation(fields: [itemId], references: [id], onDelete: SetNull)
sizeIndex Int?
body String
resolvedAt DateTime?
resolvedBy String @default("")
createdAt DateTime @default(now())
@@index([facilityId, resolvedAt])
@@index([staffId])
}
/// A short broadcast from the linen room, shown on every staff member's home screen. Not a
/// message — nobody replies to it, and it is not attached to an order.
model LinenNotice {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
body String
startsAt String @default("")
endsAt String @default("")
createdAt DateTime @default(now())
@@index([facilityId])
}
/// A staff member's own read-only account.
///
/// Deliberately NOT a User. Coordinator sessions are checked by currentUser(), and a wearer who
/// could satisfy that check would be one missed role test away from the issuing screens, the
/// catalogue and the staff register. A separate model with a separate cookie means the answer is
/// structural rather than a condition someone has to remember: a staff account cannot become a
/// coordinator session, because it is not the same kind of thing.
model StaffAccount {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
staffId String @unique
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
email String @unique
passwordHash String
createdAt DateTime @default(now())
lastSeenAt DateTime?
@@index([facilityId])
}
model CatalogItem {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
sort Int
item String
gender String @default("Unisex")
type String @default("") // garment type: Shirt, Pants, Jacket… drives the tops/pants split for nursing entitlement
sku String @default("")
supplier String @default("")
cost Float @default(0)
/// The staff groups this garment is for, as the facility names them. EMPTY MEANS EVERY GROUP.
/// Compared ignoring case and outer spaces; ask through garmentForGroup() in lib/compute.
groups String[] @default([])
notes String @default("")
sizes String[] @default([])
archived Boolean @default(false)
createdAt DateTime @default(now())
/// The organisation catalogue item this was published from, when it was. Plain string, no
/// relation: a facility that leaves its organisation keeps the garment and loses the link.
orgItemId String?
barcodes Barcode[]
stock StockLevel[]
moves StockMove[]
issues Issue[]
orderLines OrderLine[]
receiptLines ReceiptLine[]
pickupLines PickupLine[]
stocktakeLines StocktakeLine[]
handinLines HandInLine[]
costs CostChange[]
requestLines RequestLine[]
waitlist WaitlistEntry[]
kitAnswers KitCheckAnswer[]
disputes RecordDispute[]
@@unique([facilityId, sort])
@@index([facilityId])
}
model Barcode {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
code String
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
source String @default("bound")
@@unique([facilityId, code])
@@index([itemId])
}
// Where a garment physically lives. A tree: rooms hold shelves, shelves hold bays.
// A variant has one home location (a placement, not a split of its quantity) — counting a
// location walks its descendants. Laundry and External exist so a garment that has left the
// shelf but not the building still has somewhere to be.
model Location {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
name String
kind String @default("Shelf") // Room | Shelf | Bay | Laundry | External
parentId String?
parent Location? @relation("LocationTree", fields: [parentId], references: [id], onDelete: SetNull)
children Location[] @relation("LocationTree")
sort Int @default(0)
archived Boolean @default(false)
createdAt DateTime @default(now())
levels StockLevel[]
stocktakes Stocktake[]
@@unique([facilityId, name])
@@index([facilityId])
@@index([parentId])
}
model StockLevel {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
opening Int @default(0)
adj Int @default(0)
reorder Int?
preloved Int @default(0) // pre-loved pool for this size: handed-in / seconds, reissued free at $0
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
@@unique([itemId, sizeIndex])
@@index([locationId])
@@index([facilityId])
}
model StockMove {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
date String
type String
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
qty Int
reason String @default("")
byName String @default("")
createdAt DateTime @default(now())
@@index([facilityId])
}
model Department {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
name String
cc String @default("")
sort Int @default(0)
@@unique([facilityId, name])
}
model Staff {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
num String
first String
last String
phone String @default("")
group String @default("")
dept String @default("")
top String @default("")
pants String @default("")
/// Which cut of uniform this person is offered: "Men's", "Women's", "Either" (both), or "" when
/// nobody has said yet. Named for what it decides — which garments the counter and the staff app
/// offer — and not for anybody's gender: a woman who wears the men's cut is set to Men's and
/// nobody has to argue about the label.
///
/// BLANK MEANS EVERY STYLE, exactly as "Either" does, and blank is what every record starts as, so
/// nothing changes for anybody until a coordinator sets it. The two are kept apart in storage
/// only so the register can list who nobody has decided yet. Ask through garmentForStyle() in
/// lib/compute; normalUniformStyle() is what turns a CSV cell or an old label into one of these.
uniformStyle String @default("")
ccOverride String @default("")
inactive Boolean @default(false)
ent Int?
/// Combined FTE as the order form writes it — "1.0" down to "0.1", or "Casual". A string, and the
/// same shape as Approval.fte, because Casual is not a number and rounding it to one loses the
/// discretion the form deliberately gives the manager. Blank on every existing row, and blank has to
/// go on behaving as it does today: the table then proposes nothing at all, rather than proposing
/// a kit of nought.
fte String @default("")
start String @default("")
notes String @default("")
createdAt DateTime @default(now())
/// The one-time code a staff member types to claim their own read-only account. Globally unique
/// because it is presented before we know which facility they belong to — the code alone has to
/// identify them. Cleared once used, and stale once activateCodeAt is old enough, so a printed
/// slip left on a desk stops working whether or not anyone ever claimed it.
activateCode String? @unique
/// When that code was printed. A slip is a bearer token on paper: whoever holds it can set an
/// e-mail and password on this record, so it has to go stale on its own rather than only when
/// somebody remembers to cancel it. Also what lets the register say how old an outstanding code
/// is instead of just that there is one.
activateCodeAt DateTime?
account StaffAccount?
/// The one named person who approves this staff member's requests. Not "any manager on the
/// ward" and not a rota — a request with no manager has no addressee and cannot be raised, so
/// the linen room has to set this before self-service is any use to them.
managerId String?
manager Staff? @relation("StaffManager", fields: [managerId], references: [id], onDelete: SetNull)
reports Staff[] @relation("StaffManager")
/// Ward clerks sign for the bags the round drops at their ward. Scoped to that ward — a flag,
/// not a role, because the person on the desk changes and the register is where that is already
/// tracked.
wardDesk Boolean @default(false)
requests Request[] @relation("RequestSubject")
raisedRequests Request[] @relation("RequestRaisedByStaff")
waitlist WaitlistEntry[]
kitAnswers KitCheckAnswer[]
damageReports DamageReport[]
issues Issue[]
orders Order[]
pickups Pickup[]
/// Approvals recorded ABOUT this person — their own signed order forms.
approvals Approval[] @relation("ApprovalSubject")
/// Approvals this person signed FOR somebody else. Empty for everyone who has never approved
/// anything, and empty too for an approval whose approver was typed rather than picked out of
/// the register.
approvalsGiven Approval[] @relation("ApprovalApprover")
alterations Alteration[]
handins HandIn[]
disputes RecordDispute[]
@@unique([facilityId, num])
}
model Issue {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
date String
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
qty Int
cond String @default("New")
cost Float @default(0) // unit cost at the time of issue — reports never re-price history
orderCode String @default("")
receipt Boolean @default(false)
returnedDate String?
returnedCond String?
override Boolean @default(false)
/// Issued outside the person's staff group on the coordinator's override. Kept apart from
/// `override`, which is the six-set ceiling's, so the record can tell the two apart.
offGroup Boolean @default(false)
/// Issued outside the person's uniform style on the coordinator's override — a men's cut to
/// somebody set to Women's, or the other way about. Its own flag beside offGroup and `override`
/// so the record says which rule was overridden, not merely that one was.
offStyle Boolean @default(false)
direct Boolean @default(false)
preloved Boolean @default(false) // issued free from the pre-loved pool: no cost, entitlement, approval or ledger effect
handedIn String? // date the garment came back via a hand-in (informational)
returnPhotoId String? // damage evidence photo attached to a return
createdAt DateTime @default(now())
damageReports DamageReport[]
@@index([facilityId, date])
@@index([staffId])
}
model Order {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
code String
date String
source String @default("Supplier Order")
orderFor String @default("Stock")
staffId String?
staff Staff? @relation(fields: [staffId], references: [id], onDelete: SetNull)
supplier String @default("")
status String @default("Draft")
ref String @default("")
invoice String @default("")
tracking String @default("")
expected String @default("")
received String @default("")
cc String @default("")
notes String @default("")
replenish Boolean @default(false)
parentId String? // back orders point at the order they were split from
parent Order? @relation("BackOrder", fields: [parentId], references: [id], onDelete: SetNull)
backOrders Order[] @relation("BackOrder")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lines OrderLine[]
receipts Receipt[]
pickups Pickup[]
@@unique([facilityId, code])
@@index([facilityId])
}
model OrderLine {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
size String
qty Int
sort Int @default(0)
@@index([orderId])
}
model Receipt {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
date String
invoice String @default("")
note String @default("")
photoId String? // photo of the supplier invoice
createdAt DateTime @default(now())
lines ReceiptLine[]
@@index([orderId])
}
model ReceiptLine {
id String @id @default(cuid())
receiptId String
receipt Receipt @relation(fields: [receiptId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
size String
qty Int
dest String @default("shelf")
cost Float @default(0)
@@index([receiptId])
}
model Pickup {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
received String
contacted Boolean @default(false)
pickedUp String?
deliveredTo String @default("") // delivery-round proof: who signed for it
sigId String? // signature PNG (Photo)
proofId String? // handover photo (Photo)
deliveredRound Boolean @default(false)
createdAt DateTime @default(now())
lines PickupLine[]
@@index([facilityId])
}
model PickupLine {
id String @id @default(cuid())
pickupId String
pickup Pickup @relation(fields: [pickupId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
size String
qty Int
@@index([pickupId])
}
model Stocktake {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
date String
byName String
counted Int
variances Int
mode String @default("shelf") // shelf | preloved
locationId String? // the location this count was scoped to; null = the whole linen room
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
lines StocktakeLine[]
@@index([facilityId])
}
model StocktakeLine {
id String @id @default(cuid())
stocktakeId String
stocktake Stocktake @relation(fields: [stocktakeId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
sys Int
counted Int
reason String @default("") // why the gap: At laundry | Condemned | Missing | Other
@@index([stocktakeId])
}
// Uniform hand-ins: good garments join the pre-loved pool, rags are tallied for disposal.
model HandIn {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
date String
staffId String
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
byName String
credit Boolean @default(false) // coordinator chose to credit the staff member's allowance
createdAt DateTime @default(now())
lines HandInLine[]
@@index([facilityId])
@@index([staffId])
}
model HandInLine {
id String @id @default(cuid())
handInId String
handIn HandIn @relation(fields: [handInId], references: [id], onDelete: Cascade)
itemId String
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
sizeIndex Int
qty Int
cond String @default("Good") // Good | Rag
laundered Boolean @default(true)
credited Int @default(0) // qty that earned allowance credit (matched a real, un-returned shelf issue)
@@index([handInId])
}
// Camera captures and signatures, stored server-side (JPEG/PNG data URLs, downscaled on the device to <= 900px).
// Only ids travel in the snapshot; the bytes are served by /api/photo/[id].
/// A signature or a photograph.
///
/// `data` used to hold the whole image as a base64 data URL, which put every picture in the
/// database and therefore in every dump. Images now live on disk and this row keeps only a
/// pointer. `data` stays for rows written before the move — they are read from it and rewritten to
/// disk by scripts/photos-to-disk.cjs — and is empty for everything new.
model Photo {
id String @id @default(cuid())
facilityId String
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
kind String // approval | receipt | return | sig | proof
data String @default("")
path String @default("")
mime String @default("")
bytes Int @default(0)
createdAt DateTime @default(now())
@@index([facilityId])
}
/// Messages from the public contact form. Not tied to a Facility — the sender usually has no account
/// yet. Read from the server; nothing in the app exposes these to a facility.
model ContactMessage {
id String @id @default(cuid())
name String
role String @default("")
facility String @default("")
email String
topic String @default("")
slot String @default("")
message String
ip String @default("")
emailed Boolean @default(false)
handled Boolean @default(false)
createdAt DateTime @default(now())
@@index([createdAt])
}