c1adb94abb
CI has been running `prettier --check` against a tree that was never formatted, so the check reported 63 files and failed every run. Nothing here is a behaviour change: `pnpm lint` and the four typecheck builds pass exactly as before. conduit-bringup-artifacts is added to .prettierignore instead. Those files are captured bring-up reports, not maintained sources; reflowing them would only churn a record of what happened.
291 lines
9.3 KiB
TypeScript
291 lines
9.3 KiB
TypeScript
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<string, boolean>;
|
|
};
|
|
|
|
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();
|
|
},
|
|
);
|
|
}
|