import { randomBytes } from "node:crypto"; import { TRPCError } from "@trpc/server"; import { and, desc, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { userOwnsEmail } from "@/server/lib/identity"; import { workspaceInvites, workspaceMembers, workspaces, users, } from "@tasks/database/schema"; /** * 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"); } 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, }; }), }); 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 };