7.1 KB267 lines
Blame
1import type { FastifyInstance } from "fastify";
2import { z } from "zod";
3import type Database from "better-sqlite3";
4
5const nameSchema = z
6 .string()
7 .min(2)
8 .max(39)
9 .regex(/^[a-zA-Z0-9_-]+$/);
10
11const createOrgSchema = z.object({
12 name: nameSchema,
13 display_name: z.string().max(100).optional(),
14});
15
16const addMemberSchema = z.object({
17 username: z.string().min(1),
18});
19
20export function isNameTaken(
21 db: Database.Database,
22 name: string
23): boolean {
24 const userExists = db
25 .prepare("SELECT 1 FROM users WHERE username = ?")
26 .get(name);
27 const orgExists = db
28 .prepare("SELECT 1 FROM orgs WHERE name = ?")
29 .get(name);
30 return !!(userExists || orgExists);
31}
32
33export async function orgRoutes(app: FastifyInstance) {
34 const db = (app as any).db as Database.Database;
35
36 // Create org
37 app.post(
38 "/",
39 { preHandler: [(app as any).authenticate] },
40 async (request: any, reply: any) => {
41 const parsed = createOrgSchema.safeParse(request.body);
42 if (!parsed.success) {
43 return reply.code(400).send({ error: parsed.error.flatten() });
44 }
45
46 const { name, display_name } = parsed.data;
47 const userId = request.user.id;
48
49 if (isNameTaken(db, name)) {
50 return reply.code(409).send({ error: "Name already taken" });
51 }
52
53 const result = db
54 .prepare(
55 "INSERT INTO orgs (name, display_name, created_by) VALUES (?, ?, ?)"
56 )
57 .run(name, display_name ?? name, userId);
58
59 db.prepare(
60 "INSERT INTO org_members (org_id, user_id) VALUES (?, ?)"
61 ).run(result.lastInsertRowid, userId);
62
63 const org = db
64 .prepare("SELECT * FROM orgs WHERE id = ?")
65 .get(result.lastInsertRowid);
66
67 return reply.code(201).send({ org });
68 }
69 );
70
71 // List orgs current user is a member of
72 app.get(
73 "/",
74 { preHandler: [(app as any).authenticate] },
75 async (request: any) => {
76 const userId = request.user.id;
77
78 const orgs = db
79 .prepare(
80 `SELECT o.* FROM orgs o
81 JOIN org_members m ON o.id = m.org_id
82 WHERE m.user_id = ?
83 ORDER BY o.name`
84 )
85 .all(userId);
86
87 return { orgs };
88 }
89 );
90
91 // Get org details + members
92 app.get<{ Params: { name: string } }>(
93 "/:name",
94 async (request, reply) => {
95 const { name } = request.params;
96
97 const org = db
98 .prepare("SELECT * FROM orgs WHERE name = ?")
99 .get(name) as any;
100
101 if (!org) {
102 return reply.code(404).send({ error: "Organization not found" });
103 }
104
105 const members = db
106 .prepare(
107 `SELECT u.id as user_id, u.username, u.display_name, m.created_at
108 FROM org_members m
109 JOIN users u ON m.user_id = u.id
110 WHERE m.org_id = ?
111 ORDER BY m.created_at`
112 )
113 .all(org.id);
114
115 return { org, members };
116 }
117 );
118
119 // Add member
120 app.post<{ Params: { name: string } }>(
121 "/:name/members",
122 { preHandler: [(app as any).authenticate] },
123 async (request: any, reply: any) => {
124 const { name } = request.params;
125 const parsed = addMemberSchema.safeParse(request.body);
126 if (!parsed.success) {
127 return reply.code(400).send({ error: parsed.error.flatten() });
128 }
129
130 const org = db
131 .prepare("SELECT * FROM orgs WHERE name = ?")
132 .get(name) as any;
133 if (!org) {
134 return reply.code(404).send({ error: "Organization not found" });
135 }
136
137 // Check requester is a member
138 const isMember = db
139 .prepare(
140 "SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?"
141 )
142 .get(org.id, request.user.id);
143 if (!isMember) {
144 return reply.code(403).send({ error: "Not a member of this organization" });
145 }
146
147 const targetUser = db
148 .prepare("SELECT id, username, display_name FROM users WHERE username = ?")
149 .get(parsed.data.username) as any;
150 if (!targetUser) {
151 return reply.code(404).send({ error: "User not found" });
152 }
153
154 const alreadyMember = db
155 .prepare(
156 "SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?"
157 )
158 .get(org.id, targetUser.id);
159 if (alreadyMember) {
160 return reply.code(409).send({ error: "User is already a member" });
161 }
162
163 db.prepare(
164 "INSERT INTO org_members (org_id, user_id) VALUES (?, ?)"
165 ).run(org.id, targetUser.id);
166
167 return reply.code(201).send({
168 member: {
169 user_id: targetUser.id,
170 username: targetUser.username,
171 display_name: targetUser.display_name,
172 },
173 });
174 }
175 );
176
177 // Remove member
178 app.delete<{ Params: { name: string; username: string } }>(
179 "/:name/members/:username",
180 { preHandler: [(app as any).authenticate] },
181 async (request: any, reply: any) => {
182 const { name, username } = request.params;
183
184 const org = db
185 .prepare("SELECT * FROM orgs WHERE name = ?")
186 .get(name) as any;
187 if (!org) {
188 return reply.code(404).send({ error: "Organization not found" });
189 }
190
191 // Check requester is a member
192 const isMember = db
193 .prepare(
194 "SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?"
195 )
196 .get(org.id, request.user.id);
197 if (!isMember) {
198 return reply.code(403).send({ error: "Not a member of this organization" });
199 }
200
201 const targetUser = db
202 .prepare("SELECT id FROM users WHERE username = ?")
203 .get(username) as any;
204 if (!targetUser) {
205 return reply.code(404).send({ error: "User not found" });
206 }
207
208 // Can't remove the creator
209 if (targetUser.id === org.created_by) {
210 return reply
211 .code(400)
212 .send({ error: "Cannot remove the organization creator" });
213 }
214
215 const result = db
216 .prepare(
217 "DELETE FROM org_members WHERE org_id = ? AND user_id = ?"
218 )
219 .run(org.id, targetUser.id);
220
221 if (result.changes === 0) {
222 return reply.code(404).send({ error: "User is not a member" });
223 }
224
225 return reply.code(204).send();
226 }
227 );
228
229 // Delete org
230 app.delete<{ Params: { name: string } }>(
231 "/:name",
232 { preHandler: [(app as any).authenticate] },
233 async (request: any, reply: any) => {
234 const { name } = request.params;
235
236 const org = db
237 .prepare("SELECT * FROM orgs WHERE name = ?")
238 .get(name) as any;
239 if (!org) {
240 return reply.code(404).send({ error: "Organization not found" });
241 }
242
243 if (org.created_by !== request.user.id) {
244 return reply
245 .code(403)
246 .send({ error: "Only the creator can delete an organization" });
247 }
248
249 // Check no repos owned by this org
250 const repoCount = db
251 .prepare(
252 "SELECT COUNT(*) as count FROM repos WHERE owner_id = ? AND owner_type = 'org'"
253 )
254 .get(org.id) as any;
255 if (repoCount?.count > 0) {
256 return reply
257 .code(400)
258 .send({ error: "Cannot delete organization that owns repositories" });
259 }
260
261 db.prepare("DELETE FROM orgs WHERE id = ?").run(org.id);
262
263 return reply.code(204).send();
264 }
265 );
266}
267