PT
Overview

Building JoBoEco, Part 4: the eligibility engine

March 24, 2026
6 min read

The problem of “who can submit what”

It sounds simple: enrolled participants can submit papers. But then you start unpacking the real rules and realize it’s considerably more complex.

In JoBoEco, the eligibility rules for submission include:

  1. Are submissions currently open? (global flag configurable by the admin)
  2. Is the participant enrolled in the event? (basic check)
  3. Is the participant’s registration paid? (or exempt from payment for some reason)
  4. Does the participant’s category allow submission in this area?
  5. If the category requires an enrollment proof, has it been submitted and approved?
  6. Does the participant still have free submission quota available in their category?
  7. If the free quota is exhausted, is there a submission fee for this area — and has it been paid?
  8. Has the event’s global submission limit not yet been reached?

Eight verification dimensions. Each can be configured independently by the admin. And any change to the configuration needs to be reflected immediately, without a redeploy.

In the first version of the system, this code was scattered across the submission API handler — a series of nested ifs that mixed validation, database access, and HTTP responses. It worked, but was impossible to test and hard to modify without breaking some other rule.

The refactor to a pure service

The decision was to extract all eligibility logic into a separate module, src/lib/eligibility.ts, that:

  1. Does not call the database directly — receives everything it needs as parameters
  2. Has no side effects — doesn’t update anything, only evaluates
  3. Returns a structured type — not an error string, but an object with full context
  4. Is testable in isolation — you can write unit tests without database mocks

The contract ended up like this:

src/lib/eligibility.ts
export type EligibilityStatus =
| 'ELIGIBLE'
| 'NOT_ENROLLED'
| 'ENROLLMENT_PROOF_PENDING'
| 'ENROLLMENT_PROOF_REJECTED'
| 'FREE_QUOTA_EXHAUSTED_NO_PAID_OPTION'
| 'NEEDS_SUBMISSION_PAYMENT'
| 'SUBMISSIONS_CLOSED'
| 'GLOBAL_LIMIT_REACHED'
| 'CATEGORY_NOT_ALLOWED'
export interface EligibilityContext {
status: EligibilityStatus
eligible: boolean
// additional context for the frontend to render the right message
freeSubmissionsUsed?: number
freeSubmissionsTotal?: number
submissionFee?: number
pendingPaymentId?: string
}
export function checkSubmissionEligibility(
input: EligibilityInput,
): EligibilityContext {
// all the logic here, no side effects
}

The eligible: boolean return is the quick answer for the frontend to decide whether to show the submission button. The status enum allows rendering a specific message for each case. The optional fields provide context for cases that need specific UX (like showing how many submissions are still available).

Why return context instead of throwing an exception

The first version threw an EligibilityError with a message. The problem: the message was a string, and the frontend needed to parse the text to decide how to display the error.

That’s a violation of the principle that contracts between layers should be typed, not based on magic strings. With the EligibilityStatus type, the frontend can do:

switch (eligibility.status) {
case "ENROLLMENT_PROOF_PENDING":
return <ProofPendingBanner />;
case "NEEDS_SUBMISSION_PAYMENT":
return <SubmissionPaymentFlow fee={eligibility.submissionFee} />;
case "FREE_QUOTA_EXHAUSTED_NO_PAID_OPTION":
return <QuotaExhaustedMessage used={eligibility.freeSubmissionsUsed} />;
// ...
}

Each state produces a different component, with typed props. Zero string parsing.

Peer review: three modes, one table

JoBoEco supports three review modes:

  • OPEN: the reviewer sees the author’s name, the author sees the reviewer’s name
  • SINGLE_BLIND: the reviewer sees the author’s name, the author does not see who is reviewing
  • DOUBLE_BLIND: neither the reviewer sees the author, nor the author sees the reviewer

This configuration lives in siteSettings.reviewMode and is enforced at the API layer — not the frontend. This is important: never trust the client to censor sensitive data.

src/app/api/admin/submissions/[id]/route.ts
const reviewMode = await getSiteConfig('reviewMode')
// If double blind, remove author data before returning to the reviewer
if (reviewMode === 'DOUBLE_BLIND' && userIsReviewer) {
submission.authorName = null
submission.authorEmail = null
submission.authorInstitution = null
}

Reviewer assignment to submissions is done by knowledge area — each reviewer is associated with one or more areas, and the admin assigns submissions within those areas. This prevents assigning an Ecology specialist to review a work on Systematic Botany.

The assignment data model ended up like this:

reviewerAreaAssignments {
reviewerId, -- user with REVIEWER role
areaId, -- knowledge area
eventId -- specific per event
}
submissionReviews {
submissionId,
reviewerId,
score, -- numeric
recommendation, -- APPROVED | REJECTED | REVISION
comments, -- feedback for the author
createdAt
}

A decision I made consciously: not implementing auto-assignment of reviewers. The matching algorithm is tempting to automate (balance workload per reviewer, check for conflicts of interest, ensure at least N reviews per submission), but for JoBoEco’s volume, the complexity doesn’t justify the benefit. The admin makes assignments manually through the panel and has full control over the process. Simple wins.

The admin panel as the central axis

One of the best architectural decisions was making the admin panel the place where all behavior configuration is managed at runtime.

Things the admin can change without a redeploy:

  • Open/close submissions (submissionsOpen)
  • Open/close proceedings publication (proceedingsActive)
  • Review mode (open / single-blind / double-blind)
  • Global submission limit per event
  • Free submission quotas per category
  • Submission fee per area
  • Certificate signatories (name and title)
  • Call for papers content

This is implemented as siteSettings in the D1 database, with key-value pairs:

// Reading
const value = await getSiteConfig('reviewMode')
// Admin update
await setSiteConfig('reviewMode', 'DOUBLE_BLIND')

The practical benefit: during the event, the organization can change the review mode, close submissions, or update certificate signatories without needing to call me for a deploy. For a system used in formal academic contexts, that autonomy matters.

The anti-pattern I avoided

There’s a common anti-pattern in systems like this: putting business logic in React components or in API routes in a distributed way.

The result is that the same check — “can the user submit?” — gets duplicated across N places: the component that shows the button, the API that processes the submission, the listing component that shows the status. Any rule change needs to be propagated to all those places manually.

The solution is old and has nothing new about it: a pure domain function that knows everything about eligibility, called once. The component displays the result. The API validates again (you never trust just the client). The logic lives in one place.

Handler → checkSubmissionEligibility(input) → EligibilityContext → render UI
API → checkSubmissionEligibility(input) → EligibilityContext → accept or reject

It sounds obvious on paper. It’s hard to maintain when you’re in a hurry.

Testing the eligibility engine

This was the module with the most unit tests in the project. Not out of dogma, but because the complexity justifies it: when you have 8 verification dimensions and any combination could be a real user’s situation, you need coverage to maintain confidence in changes.

The test pattern ended up like this:

src/__tests__/eligibility.test.ts
describe('checkSubmissionEligibility', () => {
it('returns ELIGIBLE when all conditions are met', () => {
const result = checkSubmissionEligibility({
submissionsOpen: true,
isEnrolled: true,
enrollmentPaid: true,
categoryAllowsSubmission: true,
enrollmentProofRequired: false,
freeQuotaUsed: 0,
freeQuotaTotal: 2,
globalLimitReached: false,
})
expect(result.eligible).toBe(true)
expect(result.status).toBe('ELIGIBLE')
})
it('returns ENROLLMENT_PROOF_PENDING when a pending proof blocks submission', () => {
const result = checkSubmissionEligibility({
// ...
enrollmentProofRequired: true,
enrollmentProofStatus: 'PENDING',
})
expect(result.eligible).toBe(false)
expect(result.status).toBe('ENROLLMENT_PROOF_PENDING')
})
// ... one test per blockable path
})

Each blocking state has its own test. Any refactor of the rules breaks the tests before breaking production.


In the next — and last — part of the series, I talk about the two systems I’m most proud of in the project: certificates with offline verification via HMAC, and proceedings publication with full-text search and BibTeX export. Plus QR Code check-in and the lessons from the first real event.