org-invitations.ts188 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 orgInvitations,
9 orgMembers,
10 orgRole,
11 organizations,
12 type OrgInvitation,
13 type OrgRole,
14} from '../db/schema.js';
15import { sendInvitation } from '../lib/email.js';
16
17/**
18 * Org-level invitations. Mirrors services/invitations.ts (project-level)
19 * but scoped to an orgId + carrying an OrgRole. Acceptance creates
20 * the orgMembers row + marks accepted_at on the invitation.
21 *
22 * Token shape: 32 bytes random → base64url plaintext sent in email,
23 * sha256 hash stored in db. Same pattern as project invitations so an
24 * operator reading the db can't impersonate.
25 */
26
27const EXPIRES_MS = 7 * 24 * 60 * 60 * 1000;
28
29export interface OrgInviteInput {
30 orgId: string;
31 email: string;
32 role: OrgRole;
33 invitedBy: string;
34 callbackURL: string;
35}
36
37export async function createOrgInvitation(input: OrgInviteInput): Promise<OrgInvitation> {
38 if (!orgRole.includes(input.role)) {
39 throw new ValidationError('invalid role', { role: input.role });
40 }
41 if (input.role === 'owner') {
42 throw new ValidationError('owner role is reserved for the org creator');
43 }
44
45 const token = randomBytes(32).toString('base64url');
46 const tokenHash = createHash('sha256').update(token).digest('hex');
47 const expiresAt = new Date(Date.now() + EXPIRES_MS);
48
49 const db = getDb();
50 const [row] = await db
51 .insert(orgInvitations)
52 .values({
53 id: newId('ev'),
54 orgId: input.orgId,
55 email: input.email.toLowerCase(),
56 role: input.role,
57 tokenHash,
58 invitedBy: input.invitedBy,
59 expiresAt,
60 })
61 .onConflictDoUpdate({
62 target: [orgInvitations.orgId, orgInvitations.email],
63 set: { tokenHash, role: input.role, expiresAt, revokedAt: null, acceptedAt: null },
64 })
65 .returning();
66 if (!row) throw new Error('org invitation insert returned no row');
67
68 const acceptURL = `${input.callbackURL}?token=${encodeURIComponent(token)}`;
69 await sendInvitation(input.email, acceptURL);
70 return row;
71}
72
73export async function listOrgInvitations(orgId: string): Promise<OrgInvitation[]> {
74 const db = getDb();
75 return db.select().from(orgInvitations).where(eq(orgInvitations.orgId, orgId));
76}
77
78export async function revokeOrgInvitation(orgId: string, invitationId: string): Promise<void> {
79 const db = getDb();
80 const [row] = await db
81 .select()
82 .from(orgInvitations)
83 .where(and(eq(orgInvitations.id, invitationId), eq(orgInvitations.orgId, orgId)))
84 .limit(1);
85 if (!row) throw new NotFoundError('invitation', invitationId);
86 if (row.acceptedAt) {
87 throw new ValidationError('cannot revoke an already-accepted invitation');
88 }
89 await db
90 .update(orgInvitations)
91 .set({ revokedAt: new Date() })
92 .where(eq(orgInvitations.id, invitationId));
93}
94
95export interface AcceptedOrgInvitation {
96 orgId: string;
97 role: OrgRole;
98}
99
100export async function acceptOrgInvitation(args: {
101 token: string;
102 userId: string;
103 userEmail: string;
104}): Promise<AcceptedOrgInvitation> {
105 const tokenHash = createHash('sha256').update(args.token).digest('hex');
106 const db = getDb();
107
108 const [inv] = await db
109 .select()
110 .from(orgInvitations)
111 .where(eq(orgInvitations.tokenHash, tokenHash))
112 .limit(1);
113 if (!inv) {
114 throw new ValidationError('invitation not found or already used');
115 }
116 if (inv.revokedAt) throw new ValidationError('invitation was revoked');
117 if (inv.acceptedAt) throw new ValidationError('invitation already accepted');
118 if (inv.expiresAt.getTime() < Date.now()) {
119 throw new ValidationError('invitation expired');
120 }
121 // Bind the invite to the recipient email — case-insensitive. Prevents
122 // forwarding the email link to a different account.
123 if (inv.email.toLowerCase() !== args.userEmail.toLowerCase()) {
124 throw new ValidationError('invitation email does not match the signed-in user');
125 }
126
127 // Make sure the org still exists + isn't soft-deleted.
128 const [org] = await db
129 .select()
130 .from(organizations)
131 .where(and(eq(organizations.id, inv.orgId), isNull(organizations.deletedAt)))
132 .limit(1);
133 if (!org) throw new ValidationError('org no longer exists');
134
135 await db
136 .insert(orgMembers)
137 .values({ orgId: inv.orgId, userId: args.userId, role: inv.role })
138 .onConflictDoUpdate({
139 target: [orgMembers.orgId, orgMembers.userId],
140 set: { role: inv.role },
141 });
142
143 await db
144 .update(orgInvitations)
145 .set({ acceptedAt: new Date() })
146 .where(eq(orgInvitations.id, inv.id));
147
148 return { orgId: inv.orgId, role: inv.role };
149}
150
151/**
152 * Pending org invitations for an email — used by the dashboard to surface
153 * "you have a pending team invitation" banners. Joins to organizations
154 * so the surface can render the team name.
155 */
156export async function pendingOrgInvitationsForEmail(email: string): Promise<
157 Array<{
158 id: string;
159 orgId: string;
160 orgName: string;
161 role: OrgRole;
162 expiresAt: Date;
163 }>
164> {
165 const db = getDb();
166 const rows = await db
167 .select({
168 id: orgInvitations.id,
169 orgId: orgInvitations.orgId,
170 orgName: organizations.name,
171 role: orgInvitations.role,
172 expiresAt: orgInvitations.expiresAt,
173 })
174 .from(orgInvitations)
175 .innerJoin(organizations, eq(organizations.id, orgInvitations.orgId))
176 .where(
177 and(
178 eq(orgInvitations.email, email.toLowerCase()),
179 isNull(orgInvitations.acceptedAt),
180 isNull(orgInvitations.revokedAt),
181 ),
182 );
183 // Filter expired in JS — the row count is tiny per email and avoids a
184 // gte() comparison against a parameterised now() that drizzle struggles
185 // to type narrowly.
186 const now = Date.now();
187 return rows.filter((r) => r.expiresAt.getTime() > now);
188}