hub-api/src/routes/orgs.tsblame
View source
79efd411import type { FastifyInstance } from "fastify";
79efd412import { z } from "zod";
79efd413import type Database from "better-sqlite3";
79efd414
79efd415const nameSchema = z
79efd416 .string()
79efd417 .min(2)
79efd418 .max(39)
79efd419 .regex(/^[a-zA-Z0-9_-]+$/);
79efd4110
79efd4111const createOrgSchema = z.object({
79efd4112 name: nameSchema,
79efd4113 display_name: z.string().max(100).optional(),
79efd4114});
79efd4115
79efd4116const addMemberSchema = z.object({
79efd4117 username: z.string().min(1),
79efd4118});
79efd4119
79efd4120export function isNameTaken(
79efd4121 db: Database.Database,
79efd4122 name: string
79efd4123): boolean {
79efd4124 const userExists = db
79efd4125 .prepare("SELECT 1 FROM users WHERE username = ?")
79efd4126 .get(name);
79efd4127 const orgExists = db
79efd4128 .prepare("SELECT 1 FROM orgs WHERE name = ?")
79efd4129 .get(name);
79efd4130 return !!(userExists || orgExists);
79efd4131}
79efd4132
79efd4133export async function orgRoutes(app: FastifyInstance) {
79efd4134 const db = (app as any).db as Database.Database;
79efd4135
79efd4136 // Create org
79efd4137 app.post(
79efd4138 "/",
79efd4139 { preHandler: [(app as any).authenticate] },
79efd4140 async (request: any, reply: any) => {
79efd4141 const parsed = createOrgSchema.safeParse(request.body);
79efd4142 if (!parsed.success) {
79efd4143 return reply.code(400).send({ error: parsed.error.flatten() });
79efd4144 }
79efd4145
79efd4146 const { name, display_name } = parsed.data;
79efd4147 const userId = request.user.id;
79efd4148
79efd4149 if (isNameTaken(db, name)) {
79efd4150 return reply.code(409).send({ error: "Name already taken" });
79efd4151 }
79efd4152
79efd4153 const result = db
79efd4154 .prepare(
79efd4155 "INSERT INTO orgs (name, display_name, created_by) VALUES (?, ?, ?)"
79efd4156 )
79efd4157 .run(name, display_name ?? name, userId);
79efd4158
79efd4159 db.prepare(
79efd4160 "INSERT INTO org_members (org_id, user_id) VALUES (?, ?)"
79efd4161 ).run(result.lastInsertRowid, userId);
79efd4162
79efd4163 const org = db
79efd4164 .prepare("SELECT * FROM orgs WHERE id = ?")
79efd4165 .get(result.lastInsertRowid);
79efd4166
79efd4167 return reply.code(201).send({ org });
79efd4168 }
79efd4169 );
79efd4170
79efd4171 // List orgs current user is a member of
79efd4172 app.get(
79efd4173 "/",
79efd4174 { preHandler: [(app as any).authenticate] },
79efd4175 async (request: any) => {
79efd4176 const userId = request.user.id;
79efd4177
79efd4178 const orgs = db
79efd4179 .prepare(
79efd4180 `SELECT o.* FROM orgs o
79efd4181 JOIN org_members m ON o.id = m.org_id
79efd4182 WHERE m.user_id = ?
79efd4183 ORDER BY o.name`
79efd4184 )
79efd4185 .all(userId);
79efd4186
79efd4187 return { orgs };
79efd4188 }
79efd4189 );
79efd4190
79efd4191 // Get org details + members
79efd4192 app.get<{ Params: { name: string } }>(
79efd4193 "/:name",
79efd4194 async (request, reply) => {
79efd4195 const { name } = request.params;
79efd4196
79efd4197 const org = db
79efd4198 .prepare("SELECT * FROM orgs WHERE name = ?")
79efd4199 .get(name) as any;
79efd41100
79efd41101 if (!org) {
79efd41102 return reply.code(404).send({ error: "Organization not found" });
79efd41103 }
79efd41104
79efd41105 const members = db
79efd41106 .prepare(
79efd41107 `SELECT u.id as user_id, u.username, u.display_name, m.created_at
79efd41108 FROM org_members m
79efd41109 JOIN users u ON m.user_id = u.id
79efd41110 WHERE m.org_id = ?
79efd41111 ORDER BY m.created_at`
79efd41112 )
79efd41113 .all(org.id);
79efd41114
79efd41115 return { org, members };
79efd41116 }
79efd41117 );
79efd41118
79efd41119 // Add member
79efd41120 app.post<{ Params: { name: string } }>(
79efd41121 "/:name/members",
79efd41122 { preHandler: [(app as any).authenticate] },
79efd41123 async (request: any, reply: any) => {
79efd41124 const { name } = request.params;
79efd41125 const parsed = addMemberSchema.safeParse(request.body);
79efd41126 if (!parsed.success) {
79efd41127 return reply.code(400).send({ error: parsed.error.flatten() });
79efd41128 }
79efd41129
79efd41130 const org = db
79efd41131 .prepare("SELECT * FROM orgs WHERE name = ?")
79efd41132 .get(name) as any;
79efd41133 if (!org) {
79efd41134 return reply.code(404).send({ error: "Organization not found" });
79efd41135 }
79efd41136
79efd41137 // Check requester is a member
79efd41138 const isMember = db
79efd41139 .prepare(
79efd41140 "SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?"
79efd41141 )
79efd41142 .get(org.id, request.user.id);
79efd41143 if (!isMember) {
79efd41144 return reply.code(403).send({ error: "Not a member of this organization" });
79efd41145 }
79efd41146
79efd41147 const targetUser = db
79efd41148 .prepare("SELECT id, username, display_name FROM users WHERE username = ?")
79efd41149 .get(parsed.data.username) as any;
79efd41150 if (!targetUser) {
79efd41151 return reply.code(404).send({ error: "User not found" });
79efd41152 }
79efd41153
79efd41154 const alreadyMember = db
79efd41155 .prepare(
79efd41156 "SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?"
79efd41157 )
79efd41158 .get(org.id, targetUser.id);
79efd41159 if (alreadyMember) {
79efd41160 return reply.code(409).send({ error: "User is already a member" });
79efd41161 }
79efd41162
79efd41163 db.prepare(
79efd41164 "INSERT INTO org_members (org_id, user_id) VALUES (?, ?)"
79efd41165 ).run(org.id, targetUser.id);
79efd41166
79efd41167 return reply.code(201).send({
79efd41168 member: {
79efd41169 user_id: targetUser.id,
79efd41170 username: targetUser.username,
79efd41171 display_name: targetUser.display_name,
79efd41172 },
79efd41173 });
79efd41174 }
79efd41175 );
79efd41176
79efd41177 // Remove member
79efd41178 app.delete<{ Params: { name: string; username: string } }>(
79efd41179 "/:name/members/:username",
79efd41180 { preHandler: [(app as any).authenticate] },
79efd41181 async (request: any, reply: any) => {
79efd41182 const { name, username } = request.params;
79efd41183
79efd41184 const org = db
79efd41185 .prepare("SELECT * FROM orgs WHERE name = ?")
79efd41186 .get(name) as any;
79efd41187 if (!org) {
79efd41188 return reply.code(404).send({ error: "Organization not found" });
79efd41189 }
79efd41190
79efd41191 // Check requester is a member
79efd41192 const isMember = db
79efd41193 .prepare(
79efd41194 "SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?"
79efd41195 )
79efd41196 .get(org.id, request.user.id);
79efd41197 if (!isMember) {
79efd41198 return reply.code(403).send({ error: "Not a member of this organization" });
79efd41199 }
79efd41200
79efd41201 const targetUser = db
79efd41202 .prepare("SELECT id FROM users WHERE username = ?")
79efd41203 .get(username) as any;
79efd41204 if (!targetUser) {
79efd41205 return reply.code(404).send({ error: "User not found" });
79efd41206 }
79efd41207
79efd41208 // Can't remove the creator
79efd41209 if (targetUser.id === org.created_by) {
79efd41210 return reply
79efd41211 .code(400)
79efd41212 .send({ error: "Cannot remove the organization creator" });
79efd41213 }
79efd41214
79efd41215 const result = db
79efd41216 .prepare(
79efd41217 "DELETE FROM org_members WHERE org_id = ? AND user_id = ?"
79efd41218 )
79efd41219 .run(org.id, targetUser.id);
79efd41220
79efd41221 if (result.changes === 0) {
79efd41222 return reply.code(404).send({ error: "User is not a member" });
79efd41223 }
79efd41224
79efd41225 return reply.code(204).send();
79efd41226 }
79efd41227 );
79efd41228
79efd41229 // Delete org
79efd41230 app.delete<{ Params: { name: string } }>(
79efd41231 "/:name",
79efd41232 { preHandler: [(app as any).authenticate] },
79efd41233 async (request: any, reply: any) => {
79efd41234 const { name } = request.params;
79efd41235
79efd41236 const org = db
79efd41237 .prepare("SELECT * FROM orgs WHERE name = ?")
79efd41238 .get(name) as any;
79efd41239 if (!org) {
79efd41240 return reply.code(404).send({ error: "Organization not found" });
79efd41241 }
79efd41242
79efd41243 if (org.created_by !== request.user.id) {
79efd41244 return reply
79efd41245 .code(403)
79efd41246 .send({ error: "Only the creator can delete an organization" });
79efd41247 }
79efd41248
79efd41249 // Check no repos owned by this org
79efd41250 const repoCount = db
79efd41251 .prepare(
79efd41252 "SELECT COUNT(*) as count FROM repos WHERE owner_id = ? AND owner_type = 'org'"
79efd41253 )
79efd41254 .get(org.id) as any;
79efd41255 if (repoCount?.count > 0) {
79efd41256 return reply
79efd41257 .code(400)
79efd41258 .send({ error: "Cannot delete organization that owns repositories" });
79efd41259 }
79efd41260
79efd41261 db.prepare("DELETE FROM orgs WHERE id = ?").run(org.id);
79efd41262
79efd41263 return reply.code(204).send();
79efd41264 }
79efd41265 );
79efd41266}