360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { prisma } from "../db.js";
|
|
import { logJobEvent } from "../queue/jobEvents.js";
|
|
import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.js";
|
|
import { createReadStream } from "node:fs";
|
|
import { access, unlink } from "node:fs/promises";
|
|
import { cleanupExpiredExports } from "./exportCleanup.js";
|
|
|
|
const roleSchema = z.object({
|
|
role: z.enum(["USER", "ADMIN"])
|
|
});
|
|
|
|
const activeSchema = z.object({
|
|
isActive: z.boolean()
|
|
});
|
|
|
|
const resetSchema = z.object({
|
|
password: z.string().min(10)
|
|
});
|
|
|
|
export async function adminRoutes(app: FastifyInstance) {
|
|
app.addHook("preHandler", app.requireAdmin);
|
|
|
|
app.get("/tenants", async () => {
|
|
const tenants = await prisma.tenant.findMany({
|
|
include: { _count: { select: { users: true, mailboxAccounts: true, jobs: true } } },
|
|
orderBy: { createdAt: "desc" }
|
|
});
|
|
return { tenants };
|
|
});
|
|
|
|
app.get("/tenants/:id/export", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const query = request.query as { format?: string; scope?: string };
|
|
const tenant = await prisma.tenant.findUnique({
|
|
where: { id: params.id },
|
|
include: {
|
|
users: true,
|
|
mailboxAccounts: true,
|
|
rules: { include: { conditions: true, actions: true } },
|
|
jobs: { include: { unsubscribeAttempts: true, events: true } }
|
|
}
|
|
});
|
|
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
|
|
const scope = query.scope ?? "all";
|
|
if (query.format === "csv") {
|
|
const rows = [
|
|
["type", "id", "email", "name", "createdAt"].join(",")
|
|
];
|
|
if (scope === "all" || scope === "users") {
|
|
for (const user of tenant.users) {
|
|
rows.push(["user", user.id, user.email, tenant.name, user.createdAt.toISOString()].join(","));
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "accounts") {
|
|
for (const account of tenant.mailboxAccounts) {
|
|
rows.push(["account", account.id, account.email, account.provider, account.createdAt.toISOString()].join(","));
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "jobs") {
|
|
for (const job of tenant.jobs) {
|
|
rows.push(["job", job.id, "", job.status, job.createdAt.toISOString()].join(","));
|
|
}
|
|
}
|
|
reply.header("Content-Type", "text/csv");
|
|
return reply.send(rows.join("\n"));
|
|
}
|
|
|
|
if (query.format === "zip" || query.format === "async") {
|
|
const exportJob = await prisma.exportJob.create({
|
|
data: {
|
|
tenantId: tenant.id,
|
|
format: "zip",
|
|
scope: scope
|
|
}
|
|
});
|
|
await queueExportJob(exportJob.id);
|
|
return { jobId: exportJob.id };
|
|
}
|
|
|
|
if (scope === "users") return { users: tenant.users };
|
|
if (scope === "accounts") return { accounts: tenant.mailboxAccounts };
|
|
if (scope === "jobs") return { jobs: tenant.jobs };
|
|
if (scope === "rules") return { rules: tenant.rules };
|
|
return { tenant };
|
|
});
|
|
|
|
app.get("/exports/:id", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const job = await prisma.exportJob.findUnique({ where: { id: params.id } });
|
|
if (!job) return reply.code(404).send({ message: "Export job not found" });
|
|
|
|
return { job };
|
|
});
|
|
|
|
app.get("/exports/:id/download", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const job = await prisma.exportJob.findUnique({ where: { id: params.id } });
|
|
if (!job || !job.filePath) return reply.code(404).send({ message: "Export file not ready" });
|
|
if (job.expiresAt && job.expiresAt < new Date()) {
|
|
return reply.code(410).send({ message: "Export expired" });
|
|
}
|
|
|
|
try {
|
|
await access(job.filePath);
|
|
} catch {
|
|
return reply.code(404).send({ message: "Export file missing" });
|
|
}
|
|
|
|
reply.header("Content-Type", "application/zip");
|
|
reply.header("Content-Disposition", `attachment; filename=export-${job.id}.zip`);
|
|
return reply.send(createReadStream(job.filePath));
|
|
});
|
|
|
|
app.get("/exports", async (request) => {
|
|
const query = request.query as { status?: string };
|
|
const exports = await prisma.exportJob.findMany({
|
|
where: query.status ? { status: query.status as "QUEUED" | "RUNNING" | "DONE" | "FAILED" } : undefined,
|
|
orderBy: { createdAt: "desc" }
|
|
});
|
|
const sanitized = exports.map((job) => ({
|
|
id: job.id,
|
|
status: job.status,
|
|
format: job.format,
|
|
scope: job.scope,
|
|
progress: job.progress,
|
|
expiresAt: job.expiresAt,
|
|
createdAt: job.createdAt
|
|
}));
|
|
return { exports: sanitized };
|
|
});
|
|
|
|
app.post("/exports/purge", async () => {
|
|
await cleanupExpiredExports();
|
|
return { success: true };
|
|
});
|
|
|
|
app.delete("/exports/:id", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const job = await prisma.exportJob.findUnique({ where: { id: params.id } });
|
|
if (!job) return reply.code(404).send({ message: "Export job not found" });
|
|
if (job.filePath) {
|
|
try {
|
|
await unlink(job.filePath);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
await prisma.exportJob.delete({ where: { id: job.id } });
|
|
return { success: true };
|
|
});
|
|
|
|
app.delete("/tenants/:id", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const tenant = await prisma.tenant.findUnique({ where: { id: params.id } });
|
|
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
const jobs = await tx.cleanupJob.findMany({ where: { tenantId: tenant.id } });
|
|
const jobIds = jobs.map((job) => job.id);
|
|
await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } });
|
|
await tx.unsubscribeAttempt.deleteMany({ where: { jobId: { in: jobIds } } });
|
|
await tx.cleanupJob.deleteMany({ where: { tenantId: tenant.id } });
|
|
await tx.ruleAction.deleteMany({ where: { rule: { tenantId: tenant.id } } });
|
|
await tx.ruleCondition.deleteMany({ where: { rule: { tenantId: tenant.id } } });
|
|
await tx.rule.deleteMany({ where: { tenantId: tenant.id } });
|
|
await tx.mailItem.deleteMany({ where: { folder: { mailboxAccount: { tenantId: tenant.id } } } });
|
|
await tx.mailboxFolder.deleteMany({ where: { mailboxAccount: { tenantId: tenant.id } } });
|
|
await tx.mailboxAccount.deleteMany({ where: { tenantId: tenant.id } });
|
|
await tx.user.deleteMany({ where: { tenantId: tenant.id } });
|
|
await tx.tenant.delete({ where: { id: tenant.id } });
|
|
});
|
|
|
|
return { success: true };
|
|
});
|
|
|
|
app.put("/tenants/:id", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const input = activeSchema.parse(request.body);
|
|
const tenant = await prisma.tenant.findUnique({ where: { id: params.id } });
|
|
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
|
|
|
|
const updated = await prisma.tenant.update({
|
|
where: { id: params.id },
|
|
data: { isActive: input.isActive }
|
|
});
|
|
|
|
return { tenant: updated };
|
|
});
|
|
|
|
app.get("/users", async () => {
|
|
const users = await prisma.user.findMany({
|
|
include: { tenant: true },
|
|
orderBy: { createdAt: "desc" }
|
|
});
|
|
const sanitized = users.map((user) => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
isActive: user.isActive,
|
|
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name } : null
|
|
}));
|
|
return { users: sanitized };
|
|
});
|
|
|
|
app.put("/users/:id/role", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const input = roleSchema.parse(request.body);
|
|
|
|
const user = await prisma.user.findUnique({ where: { id: params.id } });
|
|
if (!user) return reply.code(404).send({ message: "User not found" });
|
|
|
|
const updated = await prisma.user.update({
|
|
where: { id: params.id },
|
|
data: { role: input.role }
|
|
});
|
|
|
|
return { user: updated };
|
|
});
|
|
|
|
app.put("/users/:id", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const input = activeSchema.parse(request.body);
|
|
|
|
const user = await prisma.user.findUnique({ where: { id: params.id } });
|
|
if (!user) return reply.code(404).send({ message: "User not found" });
|
|
|
|
const updated = await prisma.user.update({
|
|
where: { id: params.id },
|
|
data: { isActive: input.isActive }
|
|
});
|
|
|
|
return { user: updated };
|
|
});
|
|
|
|
app.post("/users/:id/reset", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const input = resetSchema.parse(request.body);
|
|
|
|
const user = await prisma.user.findUnique({ where: { id: params.id } });
|
|
if (!user) return reply.code(404).send({ message: "User not found" });
|
|
|
|
const argon2 = (await import("argon2")).default;
|
|
const hashed = await argon2.hash(input.password);
|
|
|
|
const updated = await prisma.user.update({
|
|
where: { id: params.id },
|
|
data: { password: hashed }
|
|
});
|
|
|
|
return { success: true };
|
|
});
|
|
|
|
app.get("/accounts", async () => {
|
|
const accounts = await prisma.mailboxAccount.findMany({
|
|
include: { tenant: true },
|
|
orderBy: { createdAt: "desc" }
|
|
});
|
|
|
|
const sanitized = accounts.map((account) => ({
|
|
id: account.id,
|
|
email: account.email,
|
|
provider: account.provider,
|
|
isActive: account.isActive,
|
|
tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name } : null,
|
|
imapHost: account.imapHost,
|
|
imapPort: account.imapPort,
|
|
imapTLS: account.imapTLS,
|
|
oauthExpiresAt: account.oauthExpiresAt,
|
|
oauthLastCheckedAt: account.oauthLastCheckedAt,
|
|
oauthLastErrorCode: account.oauthLastErrorCode,
|
|
hasOauth: Boolean(account.oauthRefreshToken || account.oauthAccessToken)
|
|
}));
|
|
|
|
return { accounts: sanitized };
|
|
});
|
|
|
|
app.put("/accounts/:id", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const input = activeSchema.parse(request.body);
|
|
|
|
const account = await prisma.mailboxAccount.findUnique({ where: { id: params.id } });
|
|
if (!account) return reply.code(404).send({ message: "Account not found" });
|
|
|
|
const updated = await prisma.mailboxAccount.update({
|
|
where: { id: params.id },
|
|
data: { isActive: input.isActive }
|
|
});
|
|
|
|
return { account: updated };
|
|
});
|
|
|
|
app.get("/jobs", async () => {
|
|
const jobs = await prisma.cleanupJob.findMany({
|
|
include: { tenant: true, mailboxAccount: true },
|
|
orderBy: { createdAt: "desc" }
|
|
});
|
|
const sanitized = jobs.map((job) => ({
|
|
id: job.id,
|
|
status: job.status,
|
|
createdAt: job.createdAt,
|
|
tenant: job.tenant ? { id: job.tenant.id, name: job.tenant.name } : null,
|
|
mailboxAccount: job.mailboxAccount ? { id: job.mailboxAccount.id, email: job.mailboxAccount.email } : null
|
|
}));
|
|
return { jobs: sanitized };
|
|
});
|
|
|
|
app.get("/jobs/:id/events", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const job = await prisma.cleanupJob.findUnique({ where: { id: params.id } });
|
|
if (!job) return reply.code(404).send({ message: "Job not found" });
|
|
|
|
const events = await prisma.cleanupJobEvent.findMany({
|
|
where: { jobId: job.id },
|
|
orderBy: { createdAt: "asc" }
|
|
});
|
|
return { events };
|
|
});
|
|
|
|
app.post("/jobs/:id/cancel", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const job = await prisma.cleanupJob.findUnique({ where: { id: params.id } });
|
|
if (!job) return reply.code(404).send({ message: "Job not found" });
|
|
|
|
await prisma.cleanupJob.update({ where: { id: job.id }, data: { status: "CANCELED" } });
|
|
await removeQueueJob(job.id);
|
|
await logJobEvent(job.id, "info", "Job canceled by admin", 100);
|
|
return { success: true };
|
|
});
|
|
|
|
app.post("/jobs/:id/retry", async (request, reply) => {
|
|
const params = request.params as { id: string };
|
|
const job = await prisma.cleanupJob.findUnique({ where: { id: params.id } });
|
|
if (!job) return reply.code(404).send({ message: "Job not found" });
|
|
|
|
const newJob = await prisma.cleanupJob.create({
|
|
data: {
|
|
tenantId: job.tenantId,
|
|
mailboxAccountId: job.mailboxAccountId,
|
|
dryRun: job.dryRun,
|
|
unsubscribeEnabled: job.unsubscribeEnabled,
|
|
routingEnabled: job.routingEnabled
|
|
}
|
|
});
|
|
await queueCleanupJob(newJob.id, newJob.mailboxAccountId);
|
|
await logJobEvent(newJob.id, "info", "Job queued by admin", 5);
|
|
return { jobId: newJob.id };
|
|
});
|
|
|
|
app.post("/impersonate/:userId", async (request, reply) => {
|
|
const params = request.params as { userId: string };
|
|
const user = await prisma.user.findUnique({ where: { id: params.userId } });
|
|
if (!user) return reply.code(404).send({ message: "User not found" });
|
|
|
|
const token = app.jwt.sign({ sub: user.id, tenantId: user.tenantId });
|
|
return { token };
|
|
});
|
|
}
|