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 }; }); }