import type { FastifyInstance } from 'fastify'; import { eq, and, count } from 'drizzle-orm'; import { organizations, organizationMembers, users } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission, getOrgMembership } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; import type { PaginationQuery } from '../../lib/pagination.js'; import { createAuditLog } from '../../lib/audit.js'; import { CreateOrgSchema, UpdateOrgSchema, OrgIdParamSchema, AddMemberSchema, UpdateMemberSchema, MemberIdParamSchema, } from './schemas.js'; export default async function organizationRoutes(app: FastifyInstance) { // All org routes require authentication app.addHook('onRequest', app.authenticate); // GET /api/organizations — list user's organizations app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery); const userId = request.user.sub; if (request.user.isSuperAdmin) { const [totalResult] = await app.db.select({ count: count() }).from(organizations); const orgs = await app.db .select() .from(organizations) .limit(limit) .offset(offset) .orderBy(organizations.createdAt); return paginatedResponse(orgs, totalResult!.count, page, perPage); } const memberOrgs = await app.db .select({ id: organizations.id, name: organizations.name, slug: organizations.slug, ownerId: organizations.ownerId, maxServers: organizations.maxServers, maxNodes: organizations.maxNodes, createdAt: organizations.createdAt, updatedAt: organizations.updatedAt, role: organizationMembers.role, }) .from(organizationMembers) .innerJoin(organizations, eq(organizationMembers.organizationId, organizations.id)) .where(eq(organizationMembers.userId, userId)) .limit(limit) .offset(offset); const [totalResult] = await app.db .select({ count: count() }) .from(organizationMembers) .where(eq(organizationMembers.userId, userId)); return paginatedResponse(memberOrgs, totalResult!.count, page, perPage); }); // POST /api/organizations — create organization app.post('/', { schema: CreateOrgSchema }, async (request, reply) => { const { name, slug } = request.body as { name: string; slug: string }; const existing = await app.db.query.organizations.findFirst({ where: eq(organizations.slug, slug), }); if (existing) { throw AppError.conflict('Organization slug already in use', 'SLUG_TAKEN'); } const [org] = await app.db .insert(organizations) .values({ name, slug, ownerId: request.user.sub, }) .returning(); // Add creator as admin member await app.db.insert(organizationMembers).values({ organizationId: org!.id, userId: request.user.sub, role: 'admin', }); return reply.code(201).send(org); }); // GET /api/organizations/:orgId app.get('/:orgId', { schema: OrgIdParamSchema }, async (request) => { const { orgId } = request.params as { orgId: string }; await getOrgMembership(request, orgId); const org = await app.db.query.organizations.findFirst({ where: eq(organizations.id, orgId), }); if (!org) throw AppError.notFound('Organization not found'); return org; }); // PATCH /api/organizations/:orgId app.patch('/:orgId', { schema: { ...OrgIdParamSchema, ...UpdateOrgSchema } }, async (request) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'org.settings'); const body = request.body as { name?: string; maxServers?: number; maxNodes?: number }; const [updated] = await app.db .update(organizations) .set({ ...body, updatedAt: new Date() }) .where(eq(organizations.id, orgId)) .returning(); if (!updated) throw AppError.notFound('Organization not found'); await createAuditLog(app.db, request, { organizationId: orgId, action: 'organization.update', metadata: body, }); return updated; }); // DELETE /api/organizations/:orgId app.delete('/:orgId', { schema: OrgIdParamSchema }, async (request, reply) => { const { orgId } = request.params as { orgId: string }; const membership = await getOrgMembership(request, orgId); // Only owner or super admin can delete const org = await app.db.query.organizations.findFirst({ where: eq(organizations.id, orgId), }); if (!org) throw AppError.notFound('Organization not found'); if (membership !== 'super_admin' && org.ownerId !== request.user.sub) { throw AppError.forbidden('Only the organization owner can delete this organization'); } await app.db.delete(organizations).where(eq(organizations.id, orgId)); return reply.code(204).send(); }); // === Members === // GET /api/organizations/:orgId/members app.get('/:orgId/members', { schema: OrgIdParamSchema }, async (request) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'org.members'); const members = await app.db .select({ id: organizationMembers.id, userId: organizationMembers.userId, role: organizationMembers.role, customPermissions: organizationMembers.customPermissions, joinedAt: organizationMembers.joinedAt, email: users.email, username: users.username, avatarUrl: users.avatarUrl, }) .from(organizationMembers) .innerJoin(users, eq(organizationMembers.userId, users.id)) .where(eq(organizationMembers.organizationId, orgId)); return { data: members }; }); // POST /api/organizations/:orgId/members — invite by email app.post( '/:orgId/members', { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, async (request, reply) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'org.members'); const { email, role } = request.body as { email: string; role: 'admin' | 'user' }; const user = await app.db.query.users.findFirst({ where: eq(users.email, email), }); if (!user) throw AppError.notFound('User with this email not found'); const existing = await app.db.query.organizationMembers.findFirst({ where: and( eq(organizationMembers.organizationId, orgId), eq(organizationMembers.userId, user.id), ), }); if (existing) throw AppError.conflict('User is already a member'); const [member] = await app.db .insert(organizationMembers) .values({ organizationId: orgId, userId: user.id, role, }) .returning(); await createAuditLog(app.db, request, { organizationId: orgId, action: 'member.add', metadata: { userId: user.id, email, role }, }); return reply.code(201).send(member); }, ); // PATCH /api/organizations/:orgId/members/:memberId app.patch( '/:orgId/members/:memberId', { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, async (request) => { const { orgId, memberId } = request.params as { orgId: string; memberId: string }; await requirePermission(request, orgId, 'org.members'); const body = request.body as { role?: 'admin' | 'user'; customPermissions?: Record; }; const [updated] = await app.db .update(organizationMembers) .set(body) .where( and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)), ) .returning(); if (!updated) throw AppError.notFound('Member not found'); await createAuditLog(app.db, request, { organizationId: orgId, action: 'member.update', metadata: { memberId, ...body }, }); return updated; }, ); // DELETE /api/organizations/:orgId/members/:memberId app.delete( '/:orgId/members/:memberId', { schema: MemberIdParamSchema }, async (request, reply) => { const { orgId, memberId } = request.params as { orgId: string; memberId: string }; await requirePermission(request, orgId, 'org.members'); const member = await app.db.query.organizationMembers.findFirst({ where: and( eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId), ), }); if (!member) throw AppError.notFound('Member not found'); // Cannot remove org owner const org = await app.db.query.organizations.findFirst({ where: eq(organizations.id, orgId), }); if (org && member.userId === org.ownerId) { throw AppError.badRequest('Cannot remove the organization owner'); } await app.db .delete(organizationMembers) .where( and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)), ); await createAuditLog(app.db, request, { organizationId: orgId, action: 'member.remove', metadata: { memberId, userId: member.userId }, }); return reply.code(204).send(); }, ); }