Ready to submit is not a boolean
A student fills in a profile, uploads a passport and a transcript, picks a university, and presses submit. Somewhere behind that button is a function that decides whether the application is allowed to leave draft.
I wrote the obvious version first. Every required document type is uploaded, so return true.
It was wrong in three different ways, and none of them showed up in testing, because testing an application flow means uploading every document in one sitting as one person. The failures all live in the gaps between those uploads.
The first gap: uploaded where?
Documents in this system live in two places on purpose.
A student has a library: one passport, uploaded once, reused across every application they ever make. That's the whole point of it. Re-uploading the same identity document for six universities is the kind of small indignity that makes people abandon a product.
An application has its own documents, and each one either owns its file or points at a library document. Attaching is a reference, not a copy, so the same S3 object serves six applications and nobody is billed for six copies of a passport.
Which means "the student has a verified passport" and "this application has a verified passport" are different sentences. The naive check asks the first one and lets a student submit an application they never attached anything to. The library is a shelf; putting a book on the shelf isn't the same as putting it in the envelope.
So the rule is attachment plus verification, on this application:
const documents = await this.prisma.applicationDocument.findMany({
where: { applicationId, documentType: { in: attachedTypes } },
select: { documentType: true, status: true },
});
const notVerified = attachedTypes.filter(
(type) =>
!documents.some(
(d) => d.documentType === type && d.status === DocumentStatus.VERIFIED,
),
);The comment above it in the repo says the part that matters: a verified passport the student never attached should not unlock an application it isn't part of.
The second gap: a requirement with no document
English proficiency is required. English proficiency is also not necessarily a document.
A student can satisfy it with an IELTS or TOEFL score, which is a row in a test-score table with a number in it. There's no file, and more to the point there's nothing to attach: a test score has no attach-to-this-application concept, because it isn't a document, it's a fact about the student.
Run the attachment rule over it and you get a student who has proved their English, sees the requirement listed as unmet, and can never submit anything. Not a slow path. A permanent one.
So English proficiency is checked at the student level, against a rule that accepts either a test score or a library-verified document:
const englishReq = getEnglishProficiencyRequirement();
const englishOk = await this.prisma.student.findFirst({
where: { id: studentId, ...requirementWhere(englishReq, 'has') },
select: { id: true },
});
if (!englishOk) missing.push(DocumentType.ENGLISH_PROFICIENCY);requirementWhere is the piece I'd reach for again. Requirements are declarative objects that emit Prisma where fragments, in either a has or a missing mode: the same definition drives this gate, the admin filter that lists students missing English proficiency, and the notification group that emails them. One definition, three consumers, and adding a fourth requirement means adding an entry rather than editing three services.
The third gap: reviewed after the fact
The statement of purpose is required too, and it inverts the rule again.
An SOP is written for one specific university and program. It never goes in the library: there's nothing reusable about it. And it's read by a counselor after submission, as part of reviewing the application, not before it as a precondition.
Gate it on verification and you've built a deadlock: the student can't submit until it's verified, and nobody verifies it until it's submitted.
So SOP is gated on presence. Upload one and you may submit. With one exception that took a second pass to notice, if every copy the student uploaded was rejected, presence is technically satisfied and the application is definitely not ready. Rejected-only still blocks.
Three required things, three different rules, because they are three different kinds of claim: a document you supply, a fact about you, and a thing you write for this application specifically.
The error message is the feature
Here's what I actually changed my mind about while building this.
The gate is maybe fifty lines. The message it throws is about forty more, and I initially resented writing them. Then I thought about who reads it.
A student, on a deadline, at eleven at night, who cannot submit and does not know why. If the API says documents incomplete, that student emails the consultancy. A human reads the email, opens the admin panel, looks at four document rows, and writes back. That's twenty minutes of staff time to communicate something the server already knew precisely.
So the failure is sorted into four buckets, each meaning a different action:
if (missing.length) {
parts.push(`${labels(missing)} ${missing.length === 1 ? 'is' : 'are'} not attached`);
}
if (awaiting.length) {
parts.push(`${labels(awaiting)} ${awaiting.length === 1 ? 'is' : 'are'} not verified yet`);
}
if (notUploaded.length) {
parts.push(`${labels(notUploaded)} ${notUploaded.length === 1 ? 'is' : 'are'} not uploaded`);
}
if (rejected.length) {
parts.push(
`${labels(rejected)} ${rejected.length === 1 ? 'was' : 'were'} rejected: upload a revised copy`,
);
}
throw new BadRequestException(`Your documents aren't ready yet. ${parts.join('; ')}.`);Which produces: Your documents aren't ready yet. Passport is not attached; Transcripts are not verified yet.
Not attached means go to your library and attach it: thirty seconds. Not verified yet means wait, nobody is blocked on you. Not uploaded means go find the file. Rejected means look at the reason and send a new one. Four sentences, four different next actions, and only one of them is "wait."
The pluralisation is in there because "Transcripts is not verified" reads like the system is broken, and a student who thinks the system is broken emails anyway, which defeats the entire point.
What I'd take to the next project
The rule that generalises isn't about documents.
When a gate guards something a user genuinely wants to do, every branch of it is a message, and the messages are the part with the business value. The boolean is for the code. The four-way split is for the person stuck behind it.
And when one check needs three different rules, that's usually not the check being messy. It's the domain telling you that the three things you grouped together aren't the same kind of thing. I grouped them because they were all rows in one table. That's a storage detail, not a rule.
The version that works asks a different question per requirement: how would someone prove this? A document you attach. A fact we already hold. A thing you write for this application, which we'll read later. Three answers, three rules, and the shape of each rule follows from the answer rather than from where the data happens to be stored.