Invites
Rooms are joined through invite links: an 8-character alphanumeric token (invitesInMessage.id, generated by createId) embedded in a shareable /messages/invite/[code] URL. Each member holds at most one invite per room; creating with new options replaces their old link. Invites carry Discord's two options β expire after (30 minutes β¦ 7 days, or never) and max uses (1 β¦ 100, or unlimited).
Managing a room's links
The User Management β Invites settings panel is the management half of the dialog that creates them: every active link in the room, each with its creator, its uses against its cap, its expiry and a revoke. The room-wide pause sits above the list, closing the room without touching a single link.
The two acts are gated differently, and the split is the point. Creating an invite is ManageInvites, which
the default @everyone role carries. Acting on somebody else's link is ManageRoom: a permission every
member holds by default cannot also be the gate on a control over other people's links, which is the same line
Discord draws between creating an invite and managing the server's. revokeInvite is one procedure either way β
the predicate drops the owner clause for a caller holding ManageRoom rather than the procedure forking.
The list reads what a joiner could actually use β expired and exhausted rows are filtered by
checkIsInviteUsable, the same predicate every other reader applies, rather than by a second copy of it in SQL.
The filter runs after the page is cut rather than before, so a page can render fewer rows than it read and the
cursor still names the oldest row read: a batch of lapsed links narrows one page instead of ending the walk short
of the usable links behind it.
How it works
flowchart TD
dialog["Invite friends dialog<br/>(expire-after + max-uses selects)"] -->|createInvite| create["createInvite<br/>deletes old link, computes expiresAt from a Temporal.Duration"]
create --> row[("invitesInMessage<br/>expiresAt Β· maxUses Β· uses")]
joiner["User with token"] -->|joinRoom| check{"one conditional UPDATE β¦ RETURNING<br/>unexpired and under its cap?"}
row --> check
paused[("rooms.isInvitePaused")] -.->|"set β answered like an unknown token"| check
check -->|row returned| member["Joined room"]
check -->|"no row (expired Β· exhausted Β· unknown)"| invalid["one NOT_FOUND error<br/>β doesn't leak which"]
row -.->|"inert when expired/exhausted"| reads["readInvite / readMyInvite<br/>treat as absent, lazily delete"]
- Create: the invite dialog's selects drive
createInvite; option values come fromInviteExpireAfterMinutesMap(never manual minute math) andINVITE_MAX_USES_OPTIONS. The0sentinel means never expires / unlimited uses;maxUsesstores it as-is (the column isnotNull().default(0)), whileexpireAfterMinutesmaps to a nullexpiresAtsince timestamps have no empty value. Changing an option with a live link regenerates it. The dialog shows the real state ("expires in 7 days", "5 uses remaining") from the returned row. - Join:
joinRoomvalidates and consumes a use in oneUPDATE β¦ RETURNINGstatement β the row matches only while it is unexpired (expiresAtnull or still in the future) and under its cap (maxUseszero, orusesbelow it), and the same statement is what incrementsusesβ so two concurrent joins can't both consume the last use. Expired, exhausted, and unknown tokens all produce the sameNOT_FOUNDerror. - Pause:
roomsInMessage.isInvitePausedcloses the room to every link at once without deleting any of them β the control for a raid in progress, which the links have to survive. While it is setjoinRoomanswers a live link exactly as it answers an unknown one, so an outsider learns nothing about why, and any use consumed by its statement rolls back with the transaction, so pausing costs a link nothing.createInviterefuses too: a paused room minting credentials nobody can use is a slower way of handing out dead links. The button is aManageRoomwrite throughupdateRoom, like every other room field, and it carries the shape of the act rather than one shape for both:Pause Invitesis an error button because it closes the room,Enable Invitesis the primary one because it is what the reader of a paused room came to do, and the paused state says so in a warning line beside it rather than a note under it. - Revoke: a delete rather than a flag β the token is the credential, so the row's absence is what stops it working, and
readInvitealready treats a missing row as unknown. A member revokes their own link; a caller holdingManageRoomrevokes any of them, from the settings panel above. - Cleanup: no timer β expired/exhausted rows are inert.
readInvite(the landing page) treats them as absent andreadMyInvitelazily deletes them;createInvitereplaces them. - DM rooms reject invites entirely (see friends and DMs); banned users are rejected at join.
Procedures
All in server/trpc/routers/room/index.ts:
| Procedure | Auth | Purpose |
|---|---|---|
createInvite({ roomId, expireAfterMinutes, maxUses }) | ManageInvites | replace own invite with a new token + options; returns the row and its creator |
revokeInvite({ id, roomId }) | member (own row) or ManageRoom | delete an invite β the row's absence is what kills the token |
readRoomInvites({ roomId, cursor }) | ManageRoom | the room's usable links, each joined to its creator |
readMyInvite({ roomId }) | member | own invite row for the dialog (null if none/expired) |
readInvite(id) | authed | landing-page info; null for unknown/expired/exhausted |
joinRoom(id) | authed | atomic validate + consume + insert membership |
Key files
| File | Role |
|---|---|
packages/db-schema/src/schema/message/invitesInMessage.ts | table + check constraints |
apps/web/shared/services/room/invite/InviteExpireAfterMinutesMap.ts | Expiry options in minutes (single source) |
apps/web/shared/models/db/room/CreateInviteInput.ts | Zod input β only the fixed option values |
apps/web/shared/services/room/invite/checkIsInviteUsable.ts | shared usability predicate, client included |
apps/web/server/services/message/readMyInvite.ts | own-invite read + lazy delete |
apps/web/app/store/message/room/invite.ts | shared per-room invite map |
apps/web/app/store/message/room/roomInvite.ts | the panel's room-keyed list of every link |
apps/web/app/services/message/room/invite/inviteCreateHooks.ts | create fan-out from the dialog to the panel |
apps/web/app/components/Message/Model/Room/Invite/Manager.vue | invite manager with option selects |
apps/web/app/components/Message/Model/Room/Invite/Dialog.vue | the dialog hosting the manager |
apps/web/app/components/Message/Model/Room/Invite/Row.vue | the settings panel's row for a live link |
apps/web/app/composables/message/room/useReadMyInvite.ts | own-invite read every surface seeds from |
apps/web/app/pages/messages/invite/[code].vue | invite landing page |
Surfaces
Discord's arrangement, and the reason there are two: creating is a dialog, and settings lists what was created.
- The dialog β
Invite friends to <room>β is opened from the room's context menu in the sidebar (Invite people, offered where the reader holdsManageInvites) and from the settings panel's Create invite button. It hands over a usable link the moment it opens: the read that finds no live link mints one, and a read that finds one never replaces it, because a live link may already be in someone's inbox. Its option selects then regenerate deliberately. - The settings panel β User Management β Invites β is the management side, and where a room's invites are paused and resumed. It reads the room's whole set through
readRoomInvitesand never mounts the manager, so opening it mints nothing: it is Discord's table drawn as a list β the inviter, then the invite code, uses and expiry β one row per live link, the expiry anhh:mm:ssclock counting itself down, gaining add:part in front only while a day is left rather than a phrase that only moves when the page is re-read, with copy, anEdit invite linkinto the dialog on the reader's own row, a revoke on any of them, and Discord's empty state when there are none.
The dialog displays from useInviteStore's per-room map; the panel reads the room's whole set through a store of its own, keyed by room so a read for the room the reader just left cannot land over the list on screen. Neither may go on showing what the other replaced, so the two are kept in step in both directions: creating from the dialog fires inviteCreateHooks, which is how the panel's list gains the new link and loses the one it replaced without the invite store having to know the panel exists, and a revoke in the panel reaches back into that map when the row it killed was the reader's own β otherwise the dialog goes on offering a token the panel has already deleted.