admin-moderation-workflows

Admin Moderation Workflows and Domain State Machines

The admin backend of YouTeacher is where a human moderator does the work that can't be left to open self-service: deciding that a school is really who it says it is, handing an employer record to an owner, approving a recruiter, adjusting someone's quota, and hiding a job or editing a post that shouldn't stand as written. The interesting part is not the list of buttons — it's that the rules for when a button is allowed to work live in the domain layer as value-object state machines, not scattered across handlers. A moderation action is a request the transport layer validates and hands down; whether the transition is legal is decided by the aggregate itself, which throws rather than silently doing the wrong thing.

Employer verification is a guarded transition

An employer's verification status is one of four values: unverified, pending, verified, rejected. The VerificationStatus value object owns that set — it is the only place the strings are allowed to exist, and fromString refuses anything outside the set with a thrown error. The status is not a plain field you assign; it answers questions about itself. canVerify() and canReject() both return true only from unverified or pending. So an already-verified or already-rejected employer cannot be verified or rejected again — the door is closed at the value object, before any persistence happens.

The Employer aggregate builds on that. Calling verify() first asks canVerify(); if the answer is no it throws an error naming the current status and the status it had to be in. On success it does not mutate — it returns a new Employer with the status set to verified, the rejection reason cleared, and updatedAt refreshed. reject(reason) mirrors this: it guards on canReject(), then returns a new employer marked rejected with the reason stored. The aggregate's constructor is private and its props are read-only; existing records come back through a reconstitute factory. Every transition is therefore immutable — you never edit an employer in place, you derive the next one — which makes an illegal transition a thrown error at a single choke point rather than a corrupted field.

Ownership is a second, independent state machine

Verification (is this school real?) and ownership (does a human own this record?) are kept separate. assignOwner(userId) is guarded by canAssignOwner() and throws "already claimed" if it can't proceed; on success it returns a new employer carrying the user id and a claimed ownership status. removeOwner() is the inverse — guarded so it throws "not claimed" when there is nothing to remove, then returns an employer with the user id dropped and ownership back to unclaimed. The aggregate exposes isClaimed / isUnclaimed as computed reads that delegate to the ownership value object, the same pattern as isPending / isVerified / isRejected for verification. Two orthogonal machines, each refusing its own illegal moves.

The REST edge: validate, then delegate

The employer-verification routes show the shape of the whole admin surface. There is a paginated GET for pending employers (reading optional page and limit off the query), a POST to verify, and a POST to reject. The verify and reject handlers do almost nothing themselves: they parse the request body against a zod schema, call the use case, and send the result. Errors from the domain — including the thrown illegal-transition messages — are funneled through a single shared error handler rather than each route inventing its own response.

The request contracts are small and explicit

All admin request shapes are zod schemas in one place, so the vocabulary of every moderation action is legible at a glance:

  • Employer — verify takes just an employerId; reject takes an employerId plus an optional reason.
  • Recruiter — the same pair: an id to approve, an id plus optional reason to reject.
  • Quota — top-up (email, a positive amount) and set (email, a limit ≥ 0 and used ≥ 0), both carrying a quotaType that is either invitation or jobPublish and defaults to invitation. Two kinds of allowance, one schema family.
  • Ownership — assign takes an owner's email and optional notes; remove takes optional notes; a merge schema takes a sourceId and a targetId for folding one employer record into another.
  • Claim requests — approve (claimId, optional notes) and reject (claimId, optional reason, optional adminNotes), the request path by which someone asks to own an existing employer record.
  • Content — a post-update schema whose fields (title, content and its format, excerpt, featured image, category, meta title and description) are all optional, several explicitly nullable, so a moderator can edit or clear any single field without touching the rest.
  • Jobs — a visibility schema flipping a job between visible and hidden, and a bare jobId schema for id-only actions.

The through-line: the transport layer's job is to prove the request is well-formed (a non-empty id, a valid email, a number in range, an allowed enum), and the domain's job is to prove the transition is legal. Neither trusts the other to do its half.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →