members.ts133 lines · main
1import { ForbiddenError, NotFoundError, ValidationError } from '@briven/shared';
2import { and, eq } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { memberRole, projectMembers, projects, users, type MemberRole } from '../db/schema.js';
6
7export interface MemberRow {
8 userId: string;
9 email: string;
10 name: string | null;
11 role: MemberRole;
12 createdAt: Date;
13}
14
15export async function listMembers(projectId: string): Promise<MemberRow[]> {
16 const db = getDb();
17 const rows = await db
18 .select({
19 userId: projectMembers.userId,
20 email: users.email,
21 name: users.name,
22 role: projectMembers.role,
23 createdAt: projectMembers.createdAt,
24 })
25 .from(projectMembers)
26 .innerJoin(users, eq(users.id, projectMembers.userId))
27 .where(eq(projectMembers.projectId, projectId));
28 return rows;
29}
30
31export async function getMember(projectId: string, userId: string): Promise<MemberRow | null> {
32 const db = getDb();
33 const [row] = await db
34 .select({
35 userId: projectMembers.userId,
36 email: users.email,
37 name: users.name,
38 role: projectMembers.role,
39 createdAt: projectMembers.createdAt,
40 })
41 .from(projectMembers)
42 .innerJoin(users, eq(users.id, projectMembers.userId))
43 .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
44 .limit(1);
45 return row ?? null;
46}
47
48export interface AddMemberInput {
49 projectId: string;
50 email: string;
51 role: MemberRole;
52}
53
54export async function addMemberByEmail(input: AddMemberInput): Promise<MemberRow> {
55 if (!memberRole.includes(input.role)) {
56 throw new ValidationError('invalid role', { role: input.role });
57 }
58 if (input.role === 'owner') {
59 throw new ValidationError('owner role is reserved for the project creator');
60 }
61
62 const db = getDb();
63 const [user] = await db.select().from(users).where(eq(users.email, input.email)).limit(1);
64 if (!user) {
65 // Phase 3 will send an invite email here. For now, the user must already
66 // exist in the control plane.
67 throw new NotFoundError('user', input.email);
68 }
69
70 const existing = await getMember(input.projectId, user.id);
71 if (existing) {
72 throw new ValidationError('user is already a member of this project', {
73 userId: user.id,
74 });
75 }
76
77 await db.insert(projectMembers).values({
78 projectId: input.projectId,
79 userId: user.id,
80 role: input.role,
81 });
82
83 const added = await getMember(input.projectId, user.id);
84 if (!added) throw new Error('member insert returned no row');
85 return added;
86}
87
88export async function updateMemberRole(
89 projectId: string,
90 userId: string,
91 role: MemberRole,
92): Promise<MemberRow> {
93 if (!memberRole.includes(role)) {
94 throw new ValidationError('invalid role', { role });
95 }
96 if (role === 'owner') {
97 throw new ValidationError('owner role cannot be assigned; transfer ownership instead');
98 }
99
100 const existing = await getMember(projectId, userId);
101 if (!existing) throw new NotFoundError('project_member', userId);
102 if (existing.role === 'owner') {
103 throw new ForbiddenError("cannot change the owner's role");
104 }
105
106 const db = getDb();
107 await db
108 .update(projectMembers)
109 .set({ role, updatedAt: new Date() })
110 .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)));
111
112 const updated = await getMember(projectId, userId);
113 if (!updated) throw new Error('member update returned no row');
114 return updated;
115}
116
117export async function removeMember(projectId: string, userId: string): Promise<void> {
118 const existing = await getMember(projectId, userId);
119 if (!existing) throw new NotFoundError('project_member', userId);
120 if (existing.role === 'owner') {
121 throw new ForbiddenError('cannot remove the owner; transfer ownership first');
122 }
123 const db = getDb();
124 await db
125 .delete(projectMembers)
126 .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)));
127}
128
129export async function findProjectBySlug(slug: string) {
130 const db = getDb();
131 const [row] = await db.select().from(projects).where(eq(projects.slug, slug)).limit(1);
132 return row ?? null;
133}