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).
How it works
flowchart TD
dialog["Add Friends dialog<br/>(expire-after + max-uses selects)"] -->|createInvite| create["createInvite<br/>deletes old link, computes expiresAt via dayjs"]
create --> row[("invitesInMessage<br/>expiresAt · maxUses · uses")]
joiner["User with token"] -->|joinRoom| check{"single UPDATE … RETURNING<br/>expiresAt is null or > now()<br/>AND (maxUses = 0 or uses < maxUses)<br/>SET uses = uses + 1"}
row --> 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 Add Friends dialog's selects drive
createInvite; option values come from the dayjs-computedInviteExpireAfterMinutesMap(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, so two concurrent joins can't both consume the last use. Expired, exhausted, and unknown tokens all produce the sameNOT_FOUNDerror. - 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 /docs/esbabbler/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 }) | member | replace own invite with a new token + options; returns the row |
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/invitesInMessage.ts | table + check constraints |
packages/app/shared/services/room/invite/InviteExpireAfterMinutesMap.ts | dayjs-computed expiry options (single source) |
packages/app/shared/models/db/room/CreateInviteInput.ts | Zod input — only the fixed option values |
packages/app/server/services/message/checkIsInviteUsable.ts | shared usability predicate |
packages/app/server/services/message/readMyInvite.ts | own-invite read + lazy delete |
packages/app/app/store/message/room/invite.ts | shared per-room invite map (all surfaces) |
packages/app/app/components/Message/Model/Room/Invite/Manager.vue | invite manager with option selects |
packages/app/app/components/Message/Content/AddFriendsDialogButton.vue | Add Friends dialog hosting the manager |
packages/app/app/pages/messages/invite/[code].vue | invite landing page |
The invite manager renders in two surfaces (the Add Friends dialog and room settings → Invites). Both display from useInviteStore's per-room map — the server keeps one live invite per member per room, so regenerating the link on either surface updates the other instead of leaving it copying a replaced, dead link.