Invitation Lifecycle: Token Links and Presigned Attachment Uploads
An invitation in youteacher is one employer or recruiter reaching out to one teacher about one specific job. The whole thing hangs off a single secret token: the invitation is created server-side, the token travels to the teacher, and every action the teacher takes afterwards — viewing, accepting, declining, attaching a file — is authenticated by that token alone, with no account required.
Creation: snapshot, token, expiry, and a duplicate guard
The Invitation aggregate is created through a factory that stamps three things at once: a unique id, a random token, and an expiry date. The token is generated from 16 random bytes rendered as hex; the expiry defaults to seven days from the send time, computed as a plain offset on the sent timestamp. The aggregate is not just a pointer — it carries a snapshot of the job at send time: title, location, salary range and currency, plus the employer's name and verified flag. If the salary or a follow-up detail on the live job changes later, the invitation still shows what was actually offered.
Before an invitation is written, the send handler validates that both a job id and a talent id are present, then asks the repository whether a duplicate already exists. Duplication is defined narrowly: same employer, same talent, same job. A second attempt on that triple is refused with a conflict rather than silently creating a twin. The handler also fills in the teacher's display name from the search index when the caller didn't supply one, so the stored invitation is self-describing.
Status is a small state machine
An invitation moves through five states: pending, viewed, accepted, declined, expired. The transitions are guarded:
- pending → viewed happens the first time the teacher opens the link, and only from
pending; re-opening an already-viewed or responded invitation doesn't reset it. - → accepted / declined is allowed only while the invitation can still be responded to; a terminal invitation rejects a second response.
- expired is derived, not stored by a clock — an invitation is expired the moment the current time passes its expiry date, and the response paths check that before anything else.
Accepting records the acceptance time, the response time, an optional note, and any accepted attachments; declining records its own time and an optional reason. The persisted row keeps viewedAt, acceptedAt, declinedAt, and respondedAt as separate nullable timestamps, so the history of a response is legible after the fact.
Public token endpoints
The teacher-facing surface is a small set of routes keyed on the token in the path: a GET to fetch the invitation, and POSTs to accept, decline, or request an upload URL. The accept path enforces the response rules and its own input limits: the note is capped at 400 characters, and no more than five attachments are allowed; expired or already-answered invitations are turned away with the matching status. Errors are returned as typed HTTP errors with stable codes (for example invitation_not_found, expired, already_accepted) rather than raw failures.
Attachments: presigned upload, allowlisted and time-boxed
Teachers don't upload files through the API server. Instead they ask for a presigned upload URL and send the bytes straight to object storage. The presign handler is where the rules live:
- The content type must be on a fixed allowlist — JPEG, PNG, WebP, or PDF — otherwise the request is rejected before any storage call.
- The token must resolve to an invitation that is not expired and still actionable (
pendingorviewed); a responded invitation can no longer receive files. - The storage key is namespaced per invitation (
<prefix>/<invitationId>/<fileId>.<ext>), so one invitation's uploads never collide with another's.
The presigned URL binds the content type and carries a short lifetime (one hour); the handler also returns the maximum file size it expects the client to honor. Downloads work the same way in reverse — a presigned GET URL with the same bounded lifetime — so stored files are never served through a permanently public link unless a prefix is explicitly opened for public read.
Where it's persisted
The invitation lives in a single Postgres table. The token column is unique, the status is a database enum defaulting to pending, and there are indexes on the talent+status pair, the sender, the employer, and the expiry date — the last one making it cheap to sweep for invitations that have aged out.
Inside this node
- Aggregate + rules:
domain/invitation/Invitation.ts - Create / duplicate guard:
application/invitation/commands/SendInvitation.ts - Accept path:
application/invitation/commands/AcceptInvitation.ts - Presigned upload:
application/invitation/commands/RequestInvitationPresignUpload.ts - Token routes:
interfaces/rest/talents/InvitationTokenController.ts - Object storage adapter:
infrastructure/storage/S3FileStorage.ts - Schema:
prisma/schema.prisma