invitations.ts229 lines · main
| 1 | import { createHash, randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 4 | import { and, eq, isNull } from 'drizzle-orm'; |
| 5 | |
| 6 | import { getDb } from '../db/client.js'; |
| 7 | import { |
| 8 | memberRole, |
| 9 | projectInvitations, |
| 10 | projectMembers, |
| 11 | projects, |
| 12 | users, |
| 13 | type MemberRole, |
| 14 | type ProjectInvitation, |
| 15 | } from '../db/schema.js'; |
| 16 | import { sendInvitation } from '../lib/email.js'; |
| 17 | |
| 18 | const EXPIRES_MS = 7 * 24 * 60 * 60 * 1000; |
| 19 | |
| 20 | export interface InviteInput { |
| 21 | projectId: string; |
| 22 | email: string; |
| 23 | role: MemberRole; |
| 24 | invitedBy: string; |
| 25 | callbackURL: string; |
| 26 | } |
| 27 | |
| 28 | export 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 | |
| 67 | export async function listInvitations(projectId: string): Promise<ProjectInvitation[]> { |
| 68 | const db = getDb(); |
| 69 | return db.select().from(projectInvitations).where(eq(projectInvitations.projectId, projectId)); |
| 70 | } |
| 71 | |
| 72 | export 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 | |
| 89 | export 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 | */ |
| 99 | export 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 | */ |
| 148 | export 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 | */ |
| 189 | export 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 |
| 229 | void users; |