invitations.ts229 lines · main
1import { createHash, randomBytes } from 'node:crypto';
2
3import { newId, NotFoundError, ValidationError } from '@briven/shared';
4import { and, eq, isNull } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import {
8 memberRole,
9 projectInvitations,
10 projectMembers,
11 projects,
12 users,
13 type MemberRole,
14 type ProjectInvitation,
15} from '../db/schema.js';
16import { sendInvitation } from '../lib/email.js';
17
18const EXPIRES_MS = 7 * 24 * 60 * 60 * 1000;
19
20export interface InviteInput {
21 projectId: string;
22 email: string;
23 role: MemberRole;
24 invitedBy: string;
25 callbackURL: string;
26}
27
28export async function createInvitation(input: InviteInput): Promise<ProjectInvitation> {
29 if (!memberRole.includes(input.role)) {
30 throw new ValidationError('invalid role', { role: input.role });
31 }
32 if (input.role === 'owner') {
33 throw new ValidationError('owner role is reserved for the project creator');
34 }
35
36 const token = randomBytes(32).toString('base64url');
37 const tokenHash = createHash('sha256').update(token).digest('hex');
38 const expiresAt = new Date(Date.now() + EXPIRES_MS);
39
40 const db = getDb();
41 const [row] = await db
42 .insert(projectInvitations)
43 .values({
44 id: newId('ev'),
45 projectId: input.projectId,
46 email: input.email.toLowerCase(),
47 role: input.role,
48 tokenHash,
49 invitedBy: input.invitedBy,
50 expiresAt,
51 })
52 .onConflictDoUpdate({
53 target: [projectInvitations.projectId, projectInvitations.email],
54 set: { tokenHash, role: input.role, expiresAt, revokedAt: null, acceptedAt: null },
55 })
56 .returning();
57 if (!row) throw new Error('invitation insert returned no row');
58
59 // The plaintext token only lives in transit — it rides in the invite
60 // email and in the `accept` URL the recipient clicks. The DB only has
61 // the hash, so an operator reading postgres cannot impersonate.
62 const acceptURL = `${input.callbackURL}?token=${encodeURIComponent(token)}`;
63 await sendInvitation(input.email, acceptURL);
64 return row;
65}
66
67export async function listInvitations(projectId: string): Promise<ProjectInvitation[]> {
68 const db = getDb();
69 return db.select().from(projectInvitations).where(eq(projectInvitations.projectId, projectId));
70}
71
72export async function revokeInvitation(projectId: string, invitationId: string): Promise<void> {
73 const db = getDb();
74 const [row] = await db
75 .select()
76 .from(projectInvitations)
77 .where(
78 and(eq(projectInvitations.id, invitationId), eq(projectInvitations.projectId, projectId)),
79 )
80 .limit(1);
81 if (!row) throw new NotFoundError('invitation', invitationId);
82 if (row.revokedAt || row.acceptedAt) return; // idempotent
83 await db
84 .update(projectInvitations)
85 .set({ revokedAt: new Date() })
86 .where(eq(projectInvitations.id, invitationId));
87}
88
89export interface AcceptedInvitation {
90 projectId: string;
91 userId: string;
92 role: MemberRole;
93}
94
95/**
96 * Accept an invitation on behalf of the currently-signed-in user. Throws
97 * ValidationError if the token is wrong / expired / already used.
98 */
99export async function acceptInvitation(
100 userId: string,
101 userEmail: string,
102 token: string,
103): Promise<AcceptedInvitation> {
104 const tokenHash = createHash('sha256').update(token).digest('hex');
105 const db = getDb();
106 const [invite] = await db
107 .select()
108 .from(projectInvitations)
109 .where(
110 and(
111 eq(projectInvitations.tokenHash, tokenHash),
112 isNull(projectInvitations.acceptedAt),
113 isNull(projectInvitations.revokedAt),
114 ),
115 )
116 .limit(1);
117 if (!invite) throw new ValidationError('invitation not found or already used');
118 if (invite.expiresAt.getTime() < Date.now()) {
119 throw new ValidationError('invitation expired');
120 }
121 if (invite.email.toLowerCase() !== userEmail.toLowerCase()) {
122 throw new ValidationError('invitation was sent to a different email');
123 }
124
125 // Mark accepted + add membership in one transaction.
126 await db.transaction(async (tx) => {
127 await tx
128 .update(projectInvitations)
129 .set({ acceptedAt: new Date() })
130 .where(eq(projectInvitations.id, invite.id));
131
132 // Upsert membership — a user accepting a second time is a no-op.
133 await tx
134 .insert(projectMembers)
135 .values({ projectId: invite.projectId, userId, role: invite.role })
136 .onConflictDoNothing();
137 });
138
139 return { projectId: invite.projectId, userId, role: invite.role };
140}
141
142/**
143 * Look up any pending invitations for a user's email that the signed-in
144 * user owns. Used by the dashboard's "pending invitations" surface. Joins
145 * to `projects` so the surface can show the project name without a
146 * second round-trip per row.
147 */
148export async function pendingInvitationsForEmail(email: string): Promise<
149 Array<{
150 id: string;
151 projectId: string;
152 projectName: string;
153 role: MemberRole;
154 invitedBy: string | null;
155 expiresAt: Date;
156 }>
157> {
158 const db = getDb();
159 const rows = await db
160 .select({
161 id: projectInvitations.id,
162 projectId: projectInvitations.projectId,
163 projectName: projects.name,
164 role: projectInvitations.role,
165 invitedBy: projectInvitations.invitedBy,
166 expiresAt: projectInvitations.expiresAt,
167 })
168 .from(projectInvitations)
169 .innerJoin(projects, eq(projects.id, projectInvitations.projectId))
170 .where(
171 and(
172 eq(projectInvitations.email, email.toLowerCase()),
173 isNull(projectInvitations.acceptedAt),
174 isNull(projectInvitations.revokedAt),
175 isNull(projects.deletedAt),
176 ),
177 );
178 return rows;
179}
180
181/**
182 * Accept an invitation by its id. Session-auth replaces the token —
183 * being signed in with an email that matches the invite is sufficient
184 * proof of identity, and avoids leaking the one-time token through the
185 * dashboard listing API. Token-based acceptance (the email-link flow,
186 * `acceptInvitation` above) stays intact for users who haven't signed
187 * in yet.
188 */
189export async function acceptInvitationById(
190 userId: string,
191 userEmail: string,
192 invitationId: string,
193): Promise<AcceptedInvitation> {
194 const db = getDb();
195 const [invite] = await db
196 .select()
197 .from(projectInvitations)
198 .where(
199 and(
200 eq(projectInvitations.id, invitationId),
201 isNull(projectInvitations.acceptedAt),
202 isNull(projectInvitations.revokedAt),
203 ),
204 )
205 .limit(1);
206 if (!invite) throw new NotFoundError('invitation', invitationId);
207 if (invite.expiresAt.getTime() < Date.now()) {
208 throw new ValidationError('invitation expired');
209 }
210 if (invite.email.toLowerCase() !== userEmail.toLowerCase()) {
211 throw new ValidationError('invitation was sent to a different email');
212 }
213
214 await db.transaction(async (tx) => {
215 await tx
216 .update(projectInvitations)
217 .set({ acceptedAt: new Date() })
218 .where(eq(projectInvitations.id, invite.id));
219 await tx
220 .insert(projectMembers)
221 .values({ projectId: invite.projectId, userId, role: invite.role })
222 .onConflictDoNothing();
223 });
224
225 return { projectId: invite.projectId, userId, role: invite.role };
226}
227
228// Keep users import referenced for type flow
229void users;