Esposter

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-computed 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, so two concurrent joins can't both consume the last use. Expired, exhausted, and unknown tokens all produce the same NOT_FOUND error.
  • 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 /docs/esbabbler/friends-and-dms); banned users are rejected at join.

Procedures

All in server/trpc/routers/room/index.ts:

ProcedureAuthPurpose
createInvite({ roomId, expireAfterMinutes, maxUses })memberreplace own invite with a new token + options; returns the row
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/invitesInMessage.tstable + check constraints
packages/app/shared/services/room/invite/InviteExpireAfterMinutesMap.tsdayjs-computed expiry options (single source)
packages/app/shared/models/db/room/CreateInviteInput.tsZod input — only the fixed option values
packages/app/server/services/message/checkIsInviteUsable.tsshared usability predicate
packages/app/server/services/message/readMyInvite.tsown-invite read + lazy delete
packages/app/app/store/message/room/invite.tsshared per-room invite map (all surfaces)
packages/app/app/components/Message/Model/Room/Invite/Manager.vueinvite manager with option selects
packages/app/app/components/Message/Content/AddFriendsDialogButton.vueAdd Friends dialog hosting the manager
packages/app/app/pages/messages/invite/[code].vueinvite 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.