import { Router } from "express"; import { and, eq, like, or } from "drizzle-orm"; import { z } from "zod"; import { db } from "../db/client.js"; import { scheduledTasks, servers } from "../db/schema.js"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const tasksRouter = Router(); tasksRouter.use(requireAuth); const TASK_COLUMNS = { id: scheduledTasks.id, serverId: scheduledTasks.serverId, serverName: servers.name, scheduleType: scheduledTasks.scheduleType, origin: scheduledTasks.origin, name: scheduledTasks.name, command: scheduledTasks.command, scheduleExpression: scheduledTasks.scheduleExpression, source: scheduledTasks.source, enabled: scheduledTasks.enabled, nextRunAt: scheduledTasks.nextRunAt, isStale: scheduledTasks.isStale, firstSeenAt: scheduledTasks.firstSeenAt, lastSeenAt: scheduledTasks.lastSeenAt, }; tasksRouter.get("/", asyncHandler(async (req, res) => { const serverId = req.query.serverId ? Number(req.query.serverId) : undefined; const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined; const search = typeof req.query.search === "string" ? req.query.search.trim() : undefined; const includeStale = req.query.includeStale === "true"; const conditions = []; if (serverId && Number.isInteger(serverId)) { conditions.push(eq(scheduledTasks.serverId, serverId)); } if (scheduleType) { conditions.push(eq(scheduledTasks.scheduleType, scheduleType)); } if (!includeStale) { conditions.push(eq(scheduledTasks.isStale, false)); } if (search) { const pattern = `%${search}%`; conditions.push(or(like(scheduledTasks.name, pattern), like(scheduledTasks.command, pattern))); } const rows = await db .select(TASK_COLUMNS) .from(scheduledTasks) .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(servers.name, scheduledTasks.scheduleType, scheduledTasks.name); res.json({ tasks: rows }); })); const manualTaskSchema = z.object({ serverId: z.number().int(), scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "n8n_workflow", "manual"]), name: z.string().min(1).max(200), command: z.string().max(1000).optional(), scheduleExpression: z.string().max(200).optional(), nextRunAt: z.string().optional(), enabled: z.boolean().optional(), }); tasksRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { const parsed = manualTaskSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } const server = await db.query.servers.findFirst({ where: eq(servers.id, parsed.data.serverId) }); if (!server) { return res.status(400).json({ error: "unknown_server" }); } const now = new Date().toISOString(); const [created] = await db .insert(scheduledTasks) .values({ serverId: parsed.data.serverId, scheduleType: parsed.data.scheduleType, origin: "manual", name: parsed.data.name, command: parsed.data.command, scheduleExpression: parsed.data.scheduleExpression, nextRunAt: parsed.data.nextRunAt, enabled: parsed.data.enabled ?? true, firstSeenAt: now, lastSeenAt: now, }) .returning({ id: scheduledTasks.id }); await recordAudit({ actor: req.currentUser!, category: "task", action: "create", targetType: "scheduled_task", targetId: created.id, detail: { name: parsed.data.name, serverId: parsed.data.serverId }, }); const [task] = await db .select(TASK_COLUMNS) .from(scheduledTasks) .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(eq(scheduledTasks.id, created.id)); res.status(201).json({ task }); })); const manualTaskUpdateSchema = manualTaskSchema.omit({ serverId: true }).partial(); tasksRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const parsed = manualTaskUpdateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); } const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) }); if (!existing) return res.status(404).json({ error: "not_found" }); if (existing.origin !== "manual") { return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be edited." }); } await db .update(scheduledTasks) .set({ ...parsed.data, lastSeenAt: new Date().toISOString() }) .where(eq(scheduledTasks.id, id)); await recordAudit({ actor: req.currentUser!, category: "task", action: "update", targetType: "scheduled_task", targetId: id, detail: { name: existing.name }, }); const [task] = await db .select(TASK_COLUMNS) .from(scheduledTasks) .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) .where(eq(scheduledTasks.id, id)); res.json({ task }); })); tasksRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) }); if (!existing) return res.status(404).json({ error: "not_found" }); if (existing.origin !== "manual") { return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be deleted." }); } await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id)); await recordAudit({ actor: req.currentUser!, category: "task", action: "delete", targetType: "scheduled_task", targetId: id, detail: { name: existing.name }, }); res.status(204).end(); }));