Files
doktainer-upstream/src/server/routes/organizations.ts
T

415 lines
11 KiB
TypeScript

import { FastifyInstance } from "fastify";
import { z } from "zod";
import prisma from "../lib/prisma";
import {
createOrganizationForUser,
listOrganizationsForUser,
setDefaultOrganizationForUser,
} from "../lib/organizations";
import { authenticate, requireRole } from "../middleware/auth";
import { auditLog } from "../services/audit.service";
import * as ssh from "../services/ssh.service";
const OrganizationCreateSchema = z.object({
name: z.string().trim().min(2).max(80),
logoUrl: z.string().trim().url().optional().or(z.literal("")),
});
const OrganizationUpdateSchema = z.object({
name: z.string().trim().min(2).max(80).optional(),
logoUrl: z.string().trim().url().optional().or(z.literal("")),
});
const OrganizationDeleteSchema = z.object({
confirmation: z.literal("DELETE"),
});
export async function organizationsRoutes(app: FastifyInstance) {
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
const organizations = await listOrganizationsForUser(req.userId!);
return reply.send({
success: true,
data: organizations.map((organization) => ({
...organization,
isActive: organization.id === req.organizationId,
})),
});
});
app.post(
"/",
{ preHandler: [requireRole("SUPER_ADMIN")] },
async (req, reply) => {
const body = OrganizationCreateSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const organization = await prisma.$transaction(async (tx) => {
const currentMembershipCount = await tx.organizationMembership.count({
where: { userId: req.userId! },
});
return createOrganizationForUser(tx, {
userId: req.userId!,
createdById: req.userId!,
name: body.data.name,
logoUrl: body.data.logoUrl || null,
makeDefault: currentMembershipCount === 0,
});
});
await auditLog({
userId: req.userId,
organizationId: organization.id,
action: "ORGANIZATION_CREATE",
category: "SYSTEM",
level: "SUCCESS",
message: `Organization "${organization.name}" created`,
});
return reply.status(201).send({ success: true, data: organization });
},
);
app.patch(
"/:id",
{ preHandler: [requireRole("SUPER_ADMIN")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = OrganizationUpdateSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const membership = await prisma.organizationMembership.findUnique({
where: {
organizationId_userId: {
organizationId: id,
userId: req.userId!,
},
},
select: { organizationId: true },
});
if (!membership) {
return reply.status(404).send({
success: false,
error: "Organization not found",
});
}
const organization = await prisma.organization.update({
where: { id },
data: {
...(body.data.name ? { name: body.data.name } : {}),
...(body.data.logoUrl !== undefined
? { logoUrl: body.data.logoUrl || null }
: {}),
},
});
await auditLog({
userId: req.userId,
organizationId: id,
action: "ORGANIZATION_UPDATE",
category: "SYSTEM",
level: "INFO",
message: `Organization "${organization.name}" updated`,
});
return reply.send({ success: true, data: organization });
},
);
app.post(
"/:id/default",
{ preHandler: [authenticate] },
async (req, reply) => {
const { id } = req.params as { id: string };
const membership = await prisma.organizationMembership.findUnique({
where: {
organizationId_userId: {
organizationId: id,
userId: req.userId!,
},
},
include: {
organization: {
select: {
id: true,
name: true,
},
},
},
});
if (!membership) {
return reply.status(404).send({
success: false,
error: "Organization not found",
});
}
await prisma.$transaction(async (tx) => {
await setDefaultOrganizationForUser(tx, req.userId!, id);
});
await auditLog({
userId: req.userId,
organizationId: id,
action: "ORGANIZATION_SET_DEFAULT",
category: "SYSTEM",
level: "INFO",
message: `Active organization changed to "${membership.organization.name}"`,
});
return reply.send({
success: true,
data: {
id: membership.organization.id,
name: membership.organization.name,
},
});
},
);
app.post(
"/:id/activate",
{ preHandler: [authenticate] },
async (req, reply) => {
const { id } = req.params as { id: string };
const membership = await prisma.organizationMembership.findUnique({
where: {
organizationId_userId: {
organizationId: id,
userId: req.userId!,
},
},
include: {
organization: {
select: {
id: true,
name: true,
},
},
},
});
if (!membership) {
return reply.status(404).send({
success: false,
error: "Organization not found",
});
}
await prisma.user.update({
where: { id: req.userId! },
data: { activeOrganizationId: id },
});
await auditLog({
userId: req.userId,
organizationId: id,
action: "ORGANIZATION_ACTIVATE",
category: "SYSTEM",
level: "INFO",
message: `Active organization switched to "${membership.organization.name}"`,
});
return reply.send({
success: true,
data: {
id: membership.organization.id,
name: membership.organization.name,
},
});
},
);
app.delete(
"/:id",
{ preHandler: [requireRole("SUPER_ADMIN")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = OrganizationDeleteSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const membership = await prisma.organizationMembership.findUnique({
where: {
organizationId_userId: {
organizationId: id,
userId: req.userId!,
},
},
include: {
organization: {
select: {
id: true,
name: true,
members: {
select: {
userId: true,
isDefault: true,
},
},
servers: {
select: {
id: true,
},
},
},
},
},
});
if (!membership) {
return reply.status(404).send({
success: false,
error: "Organization not found",
});
}
const remainingMemberships = await prisma.organizationMembership.count({
where: {
userId: req.userId!,
organizationId: { not: id },
},
});
if (remainingMemberships === 0) {
return reply.status(400).send({
success: false,
error: "You must keep at least one organization",
});
}
const serverIds = membership.organization.servers.map((server) => server.id);
for (const serverId of serverIds) {
ssh.closeConnection(serverId);
}
await prisma.$transaction(async (tx) => {
for (const member of membership.organization.members) {
const fallbackMembership = await tx.organizationMembership.findFirst({
where: {
userId: member.userId,
organizationId: { not: id },
},
orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
select: { organizationId: true },
});
if (fallbackMembership) {
if (member.isDefault) {
await setDefaultOrganizationForUser(
tx,
member.userId,
fallbackMembership.organizationId,
);
} else {
await tx.user.updateMany({
where: {
id: member.userId,
activeOrganizationId: id,
},
data: {
activeOrganizationId: fallbackMembership.organizationId,
},
});
}
} else {
await tx.user.updateMany({
where: {
id: member.userId,
activeOrganizationId: id,
},
data: { activeOrganizationId: null },
});
}
}
await tx.sslCert.deleteMany({
where: {
domain: {
organizationId: id,
},
},
});
await tx.domain.deleteMany({ where: { organizationId: id } });
if (serverIds.length > 0) {
await tx.auditLog.updateMany({
where: { serverId: { in: serverIds } },
data: { serverId: null },
});
await tx.userServerAccess.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.serverMetric.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.container.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.environment.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.network.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.firewallRulePreset.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.appInstall.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.backup.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.userStorageDestination.deleteMany({
where: { serverId: { in: serverIds } },
});
await tx.server.deleteMany({ where: { id: { in: serverIds } } });
}
await tx.auditLog.deleteMany({ where: { organizationId: id } });
await tx.apiKey.deleteMany({ where: { organizationId: id } });
await tx.userInvitation.deleteMany({ where: { organizationId: id } });
await tx.organizationMembership.deleteMany({
where: { organizationId: id },
});
await tx.organization.delete({ where: { id } });
});
await auditLog({
userId: req.userId,
action: "ORGANIZATION_DELETE",
category: "SYSTEM",
level: "WARNING",
message: `Organization "${membership.organization.name}" deleted with related records cleanup`,
});
return reply.send({
success: true,
message: "Organization and related data deleted",
});
},
);
}