Navigation

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).

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 from InviteExpireAfterMinutesMap (never manual minute math) and INVITE_MAX_USES_OPTIONS. The 0 sentinel means never expires / unlimited uses; maxUses stores it as-is (the column is notNull().default(0)), while expireAfterMinutes maps to a null expiresAt since 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: joinRoom validates and consumes a use in one UPDATE … RETURNING statement β€” the row matches only while it is unexpired (expiresAt null or still in the future) and under its cap (maxUses zero, or uses below it), and the same statement is what increments uses β€” so two concurrent joins can't both consume the last use. Expired, exhausted, and unknown tokens all produce the same NOT_FOUND error.
  • Pause: roomsInMessage.isInvitePaused closes 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 set joinRoom answers 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. createInvite refuses too: a paused room minting credentials nobody can use is a slower way of handing out dead links. The button is a ManageRoom write through updateRoom, like every other room field, and it carries the shape of the act rather than one shape for both: Pause Invites is an error button because it closes the room, Enable Invites is 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 readInvite already treats a missing row as unknown. A member revokes their own link; a caller holding ManageRoom revokes any of them, from the settings panel above.
  • Cleanup: no timer β€” expired/exhausted rows are inert. readInvite (the landing page) treats them as absent and readMyInvite lazily deletes them; createInvite replaces 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:

ProcedureAuthPurpose
createInvite({ roomId, expireAfterMinutes, maxUses })ManageInvitesreplace own invite with a new token + options; returns the row and its creator
revokeInvite({ id, roomId })member (own row) or ManageRoomdelete an invite β€” the row's absence is what kills the token
readRoomInvites({ roomId, cursor })ManageRoomthe room's usable links, each joined to its creator
readMyInvite({ roomId })memberown invite row for the dialog (null if none/expired)
readInvite(id)authedlanding-page info; null for unknown/expired/exhausted
joinRoom(id)authedatomic validate + consume + insert membership

Key files

FileRole
packages/db-schema/src/schema/message/invitesInMessage.tstable + check constraints
apps/web/shared/services/room/invite/InviteExpireAfterMinutesMap.tsExpiry options in minutes (single source)
apps/web/shared/models/db/room/CreateInviteInput.tsZod input β€” only the fixed option values
apps/web/shared/services/room/invite/checkIsInviteUsable.tsshared usability predicate, client included
apps/web/server/services/message/readMyInvite.tsown-invite read + lazy delete
apps/web/app/store/message/room/invite.tsshared per-room invite map
apps/web/app/store/message/room/roomInvite.tsthe panel's room-keyed list of every link
apps/web/app/services/message/room/invite/inviteCreateHooks.tscreate fan-out from the dialog to the panel
apps/web/app/components/Message/Model/Room/Invite/Manager.vueinvite manager with option selects
apps/web/app/components/Message/Model/Room/Invite/Dialog.vuethe dialog hosting the manager
apps/web/app/components/Message/Model/Room/Invite/Row.vuethe settings panel's row for a live link
apps/web/app/composables/message/room/useReadMyInvite.tsown-invite read every surface seeds from
apps/web/app/pages/messages/invite/[code].vueinvite 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 holds ManageInvites) 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 readRoomInvites and 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 an hh:mm:ss clock counting itself down, gaining a dd: part in front only while a day is left rather than a phrase that only moves when the page is re-read, with copy, an Edit invite link into 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.

Details

Command palette

Keyboard shortcuts