import { randomBytes } from "node:crypto"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, gt, inArray, isNotNull, isNull, ne, sql, } from "drizzle-orm"; import { z } from "zod"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { userOwnsEmail } from "@/server/lib/identity"; import { userEmailIdentities, workspaceInvites, workspaceMembers, workspaces, users, } from "@tasks/database/schema"; import { mergeInviteSuggestions, type InviteSuggestion, type InviteSuggestionKnownUser, type InviteSuggestionMember, type InviteSuggestionNewEmail, type InviteSuggestionPendingInvite, } from "@tasks/shared"; /** * Workspace invites. Owners and admins create invites for an email address; * the recipient redeems the opaque `token` at /invite/[token]. * * Security model: * - `create`, `list`, `revoke` are workspace-scoped and require the caller * to be `owner` or `admin` on the target workspace. * - `accept` is a *public* procedure (no workspace handle) — the token * itself is the capability. It does require an authenticated session * so we can write the `workspace_members.user_id` row, and it calls * `userOwnsEmail()` from Task 1 to make sure the human accepting the * invite actually controls the invited address under any of their * linked identities. Mismatch returns a structured error so the UI can * show the explainer instead of silently 403-ing. * * Email delivery is not in this task — `create` returns the accept URL so * an operator can copy/paste it. The Resend/Postmark integration is a * follow-up. */ const ROLE_VALUES = ["owner", "admin", "member"] as const; const inviteRoleSchema = z.enum(ROLE_VALUES); const inviteEmailSchema = z .string() .trim() .toLowerCase() .pipe(z.string().email({ message: "Please enter a valid email address" })); function assertCanManageInvites(role: string): void { if (role !== "owner" && role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Only owners and admins can manage invites", }); } } function generateInviteToken(): string { // 32 random bytes -> 43-char base64url. Enough entropy that a token guess // is astronomically improbable; short enough to fit in a copy-paste URL. return randomBytes(32).toString("base64url"); } /** * Escape the three ILIKE metacharacters (`%`, `_`, `\`) so a user-typed * query can be safely interpolated into an `ILIKE '...pattern...' ESCAPE * '\\'` clause without letting "100%" or "snake_case" match more than the * literal characters typed. */ function escapeIlike(value: string): string { return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); } function buildAcceptUrl(token: string): string { // `NEXT_PUBLIC_APP_URL` is the canonical origin for invite links. Falls // back to a path-only URL so the procedure still works in environments // without it set (the UI can prefix `window.location.origin` if needed). const base = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, ""); return base ? `${base}/invite/${token}` : `/invite/${token}`; } export const invitesRouter = router({ /** * Create or return-existing an open invite for `email` to the given * workspace. Idempotent on the (workspace_id, lower(email)) pair: if an * open invite already exists for that address, we return it instead of * inserting a duplicate (the partial unique constraint would block it * anyway). */ create: workspaceProcedure .input( z.object({ email: inviteEmailSchema, role: inviteRoleSchema, }), ) .mutation(async ({ ctx, input }) => { assertCanManageInvites(ctx.workspace.role); const inviterId = ctx.session.user.id; // Don't let inviters invite themselves — confusing failure mode. const [inviter] = await ctx.db .select({ email: users.email }) .from(users) .where(eq(users.id, inviterId)) .limit(1); if (inviter?.email.toLowerCase() === input.email) { throw new TRPCError({ code: "BAD_REQUEST", message: "You can't invite yourself.", }); } // Already a member? Surface a clear error so the inviter knows. const [existingMember] = await ctx.db .select({ userId: workspaceMembers.userId }) .from(workspaceMembers) .innerJoin(users, eq(users.id, workspaceMembers.userId)) .where( and( eq(workspaceMembers.workspaceId, ctx.workspace.id), eq(users.email, input.email), ), ) .limit(1); if (existingMember) { throw new TRPCError({ code: "CONFLICT", message: "This person is already a member of this workspace.", }); } // Reuse an open invite if one already exists for this (workspace, email). const [existingInvite] = await ctx.db .select() .from(workspaceInvites) .where( and( eq(workspaceInvites.workspaceId, ctx.workspace.id), eq(workspaceInvites.email, input.email), isNull(workspaceInvites.acceptedAt), isNull(workspaceInvites.revokedAt), ), ) .limit(1); if (existingInvite) { return { invite: existingInvite, acceptUrl: buildAcceptUrl(existingInvite.token), reused: true as const, }; } const token = generateInviteToken(); const [invite] = await ctx.db .insert(workspaceInvites) .values({ workspaceId: ctx.workspace.id, email: input.email, role: input.role, invitedByUserId: inviterId, token, }) .returning(); return { invite: invite!, acceptUrl: buildAcceptUrl(invite!.token), reused: false as const, }; }), /** Pending (non-accepted, non-revoked) invites for the workspace. */ list: workspaceProcedure.query(async ({ ctx }) => { assertCanManageInvites(ctx.workspace.role); return ctx.db .select({ id: workspaceInvites.id, email: workspaceInvites.email, role: workspaceInvites.role, token: workspaceInvites.token, expiresAt: workspaceInvites.expiresAt, createdAt: workspaceInvites.createdAt, invitedByUserId: workspaceInvites.invitedByUserId, invitedByName: users.name, invitedByEmail: users.email, }) .from(workspaceInvites) .innerJoin(users, eq(users.id, workspaceInvites.invitedByUserId)) .where( and( eq(workspaceInvites.workspaceId, ctx.workspace.id), isNull(workspaceInvites.acceptedAt), isNull(workspaceInvites.revokedAt), ), ) .orderBy(desc(workspaceInvites.createdAt)); }), /** Revoke an open invite. Caller must be owner/admin on the invite's workspace. */ revoke: protectedProcedure .input(z.object({ inviteId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const [invite] = await ctx.db .select({ id: workspaceInvites.id, workspaceId: workspaceInvites.workspaceId, acceptedAt: workspaceInvites.acceptedAt, revokedAt: workspaceInvites.revokedAt, }) .from(workspaceInvites) .where(eq(workspaceInvites.id, input.inviteId)) .limit(1); if (!invite) { throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" }); } if (invite.acceptedAt || invite.revokedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been closed.", }); } // Authorize against the invite's workspace, not via workspaceProcedure // (we don't take a workspace handle in this input; the invite tells us). const callerId = ctx.session.user.id; const [membership] = await ctx.db .select({ role: workspaceMembers.role }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, invite.workspaceId), eq(workspaceMembers.userId, callerId), ), ) .limit(1); if (!membership) { throw new TRPCError({ code: "FORBIDDEN" }); } assertCanManageInvites(membership.role); await ctx.db .update(workspaceInvites) .set({ revokedAt: new Date() }) .where(eq(workspaceInvites.id, invite.id)); return { ok: true as const }; }), /** * Public-by-token redemption. Caller must be authenticated AND own (under * any linked identity) the email the invite was sent to. On mismatch we * throw a `FORBIDDEN` with a structured `cause` the UI can render as the * "link this email first" explainer. */ accept: protectedProcedure .input(z.object({ token: z.string().min(8).max(128) })) .mutation(async ({ ctx, input }) => { const now = new Date(); const [invite] = await ctx.db .select({ id: workspaceInvites.id, workspaceId: workspaceInvites.workspaceId, email: workspaceInvites.email, role: workspaceInvites.role, acceptedAt: workspaceInvites.acceptedAt, revokedAt: workspaceInvites.revokedAt, expiresAt: workspaceInvites.expiresAt, }) .from(workspaceInvites) .where(eq(workspaceInvites.token, input.token)) .limit(1); if (!invite) { throw new TRPCError({ code: "NOT_FOUND", message: "This invite link is not valid.", }); } if (invite.revokedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has been revoked.", }); } if (invite.acceptedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has already been accepted.", }); } if (invite.expiresAt.getTime() < now.getTime()) { throw new TRPCError({ code: "BAD_REQUEST", message: "This invite has expired.", }); } const callerId = ctx.session.user.id; // Identity check: under Task 1's semantics, the caller must have a // verified identity row matching the invited email. We surface the // mismatch with a structured cause so the redeem page can render the // "link this email to your account first" explainer. const owns = await userOwnsEmail(callerId, invite.email); if (!owns) { throw new TRPCError({ code: "FORBIDDEN", message: `This invite was sent to ${invite.email}. Link that email to your account from your profile, then come back to this link.`, cause: { reason: "email_not_owned", invitedEmail: invite.email }, }); } // Already a member? Don't fail — just close the invite. Common when // someone accepts a re-invite after already being added by another flow. const [existingMembership] = await ctx.db .select({ id: workspaceMembers.id }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, invite.workspaceId), eq(workspaceMembers.userId, callerId), ), ) .limit(1); if (!existingMembership) { await ctx.db.insert(workspaceMembers).values({ workspaceId: invite.workspaceId, userId: callerId, role: invite.role, }); } await ctx.db .update(workspaceInvites) .set({ acceptedAt: now }) .where(eq(workspaceInvites.id, invite.id)); const [workspace] = await ctx.db .select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name }) .from(workspaces) .where(eq(workspaces.id, invite.workspaceId)) .limit(1); return { workspace: workspace!, role: invite.role, }; }), /** * Typeahead for the invite dialog's "Who do you want to invite?" field. * Returns up to 10 suggestions across four kinds (see `InviteSuggestion` * in `@tasks/shared`) so the UI can disambiguate between an existing * member, an outstanding pending invite, a user from one of the * inviter's other workspaces, and a brand-new email. * * Security model: * - Workspace-scoped (`workspaceProcedure`) and owner/admin only — * non-managers should not be probing this surface to enumerate * teammates. * - The `known_user` kind is fenced to workspaces the **inviter** also * belongs to. A global user search would leak the existence of * accounts across tenants; this restriction is the security contract * called out in the task spec (acceptance criterion: "Tenancy fence * holds"). * - All ILIKE patterns are escaped via `escapeIlike` so a query like * "100%" can't broaden the match. * * Empty / single-char queries return `[]` — the UI uses this as a "show * nothing yet" sentinel and avoids hammering the DB on every keystroke. */ suggestRecipient: workspaceProcedure .input(z.object({ query: z.string() })) .query(async ({ ctx, input }): Promise => { assertCanManageInvites(ctx.workspace.role); const raw = input.query.trim(); if (raw.length < 2) return []; const qLower = raw.toLowerCase(); const escaped = escapeIlike(qLower); const prefixPattern = `${escaped}%`; const substringPattern = `%${escaped}%`; const inviterId = ctx.session.user.id; const totalLimit = 10; const memberMatchSql = sql`( ${users.name} ILIKE ${substringPattern} ESCAPE '\\' OR ${users.email} ILIKE ${prefixPattern} ESCAPE '\\' OR ${userEmailIdentities.email} ILIKE ${prefixPattern} ESCAPE '\\' )`; const memberRows = await ctx.db .selectDistinct({ userId: users.id, name: users.name, email: users.email, role: workspaceMembers.role, }) .from(workspaceMembers) .innerJoin(users, eq(users.id, workspaceMembers.userId)) .leftJoin( userEmailIdentities, and( eq(userEmailIdentities.userId, users.id), isNotNull(userEmailIdentities.verifiedAt), ), ) .where( and(eq(workspaceMembers.workspaceId, ctx.workspace.id), memberMatchSql), ) .limit(totalLimit); const members: InviteSuggestionMember[] = memberRows.map((r) => ({ kind: "member", userId: r.userId, name: r.name, email: r.email, role: r.role, })); // `workspace_invites.email` is stored lowercased on insert // (see `invites.create`), so a prefix ILIKE against the column is // already case-insensitive without an extra `lower()` wrap. const inviteRows = await ctx.db .select({ inviteId: workspaceInvites.id, email: workspaceInvites.email, role: workspaceInvites.role, expiresAt: workspaceInvites.expiresAt, }) .from(workspaceInvites) .where( and( eq(workspaceInvites.workspaceId, ctx.workspace.id), isNull(workspaceInvites.acceptedAt), isNull(workspaceInvites.revokedAt), gt(workspaceInvites.expiresAt, new Date()), sql`${workspaceInvites.email} ILIKE ${prefixPattern} ESCAPE '\\'`, ), ) .limit(totalLimit); const pendingInvites: InviteSuggestionPendingInvite[] = inviteRows.map( (r) => ({ kind: "pending_invite", inviteId: r.inviteId, email: r.email, role: r.role, expiresAt: r.expiresAt, }), ); // Tenancy fence: the candidate pool for `known_user` is restricted to // users who share at least one workspace with the inviter. We resolve // that pool here as a concrete UUID array so the candidate query is a // plain `IN (...)` against the same `workspace_members` table, which // is easy to reason about and safe even if Drizzle's correlated // subquery alias rules change. If the inviter happens to share zero // workspaces with anyone (e.g. they only own a brand-new solo // workspace), we skip the candidate query entirely. const inviterWorkspaceRows = await ctx.db .selectDistinct({ workspaceId: workspaceMembers.workspaceId }) .from(workspaceMembers) .where(eq(workspaceMembers.userId, inviterId)); const inviterWorkspaceIds = inviterWorkspaceRows.map((r) => r.workspaceId); let knownUsers: InviteSuggestionKnownUser[] = []; if (inviterWorkspaceIds.length > 0) { const knownCandidates = await ctx.db .selectDistinct({ userId: users.id, name: users.name, email: users.email, }) .from(users) .innerJoin(workspaceMembers, eq(workspaceMembers.userId, users.id)) .leftJoin( userEmailIdentities, and( eq(userEmailIdentities.userId, users.id), isNotNull(userEmailIdentities.verifiedAt), ), ) .where( and( inArray(workspaceMembers.workspaceId, inviterWorkspaceIds), ne(users.id, inviterId), memberMatchSql, ), ) .limit(totalLimit * 2); if (knownCandidates.length > 0) { const candidateIds = knownCandidates.map((c) => c.userId); const sharedCountRows = await ctx.db .select({ userId: workspaceMembers.userId, count: sql`count(distinct ${workspaceMembers.workspaceId})::int`.as( "count", ), }) .from(workspaceMembers) .where( and( inArray(workspaceMembers.userId, candidateIds), inArray(workspaceMembers.workspaceId, inviterWorkspaceIds), ), ) .groupBy(workspaceMembers.userId); const countMap = new Map( sharedCountRows.map((r) => [r.userId, Number(r.count)]), ); knownUsers = knownCandidates.map((c) => ({ kind: "known_user", userId: c.userId, name: c.name, email: c.email, sharedWorkspaceCount: countMap.get(c.userId) ?? 0, })); } } const emailParse = z.string().email().safeParse(qLower); const newEmail: InviteSuggestionNewEmail | null = emailParse.success ? { kind: "new_email", email: qLower, valid: true } : null; return mergeInviteSuggestions({ members, pendingInvites, knownUsers, newEmail, limit: totalLimit, }); }), }); export type InvitesRouter = typeof invitesRouter; // Re-exports used by callers that want to share the schema (e.g. the smart // recipient autocomplete in Task 3). export const inviteRoleValues = ROLE_VALUES; export { inviteRoleSchema };