import { Router } from "@oak/oak"; import { ObjectId } from "mongodb"; import { getDB } from "../db/mongo.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { logTaskChanges } from "../utils/taskChangelog.ts"; import { events, AMS_EVENTS } from "../utils/eventEmitter.ts"; export const tasksRouter = new Router({ prefix: "/api/tasks" }); // --- Counter Helper für Nummernkreise --- export async function getNextNumber(counterName: string): Promise { const db = await getDB(); const result = await db.collection("counters").findOneAndUpdate( { _id: counterName }, { $inc: { seq: 1 } }, { upsert: true, returnDocument: "after" } ); return (result as unknown as { seq: number }).seq; } interface Reminder { enabled: boolean; datetime: Date; // Next trigger time interval: 'once' | 'daily' | 'hourly' | 'minutes'; intervalValue?: number; // For 'minutes': every X minutes lastNotified?: Date; } interface Task { _id: ObjectId; number?: number; title: string; description?: string; status: "backlog" | "todo" | "in_progress" | "review" | "done"; priority: "low" | "medium" | "high" | "urgent"; assignee?: string; // Agent ID project?: string; // Project ID labels: string[]; dueDate?: Date; reminder?: Reminder; createdBy: string; createdAt: Date; updatedAt: Date; } interface GiteaProjectRef { projectId: number; path: string; url: string; name: string; } interface GitLabProjectRef { projectId: number; path: string; url: string; name: string; } interface Project { _id: ObjectId; name: string; description?: string; color: string; rules?: string; // GitLab Integration - Multiple projects gitlabProjects?: GitLabProjectRef[]; // Legacy single project (for backwards compatibility) gitlabProjectId?: number; gitlabUrl?: string; gitlabPath?: string; // Gitea Integration giteaProjects?: GiteaProjectRef[]; createdAt: Date; updatedAt: Date; } // ============ TASKS ============ // Get all tasks (with optional filters) tasksRouter.get("/", authMiddleware, async (ctx) => { const db = await getDB(); const url = ctx.request.url; const filter: Record = {}; const status = url.searchParams.get("status"); if (status) filter.status = status; const assignee = url.searchParams.get("assignee"); if (assignee) filter.assignee = assignee; const project = url.searchParams.get("project"); if (project) filter.project = project; const priority = url.searchParams.get("priority"); if (priority) filter.priority = priority; const tasks = await db.collection("tasks") .find(filter) .sort({ priority: -1, createdAt: -1 }) .toArray(); ctx.response.body = { tasks }; }); // Get task by number tasksRouter.get("/by-number/:number", authMiddleware, async (ctx) => { const db = await getDB(); const num = parseInt(ctx.params.number); if (isNaN(num)) { ctx.response.status = 400; ctx.response.body = { error: "Invalid number" }; return; } const task = await db.collection("tasks").findOne({ number: num }); if (!task) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } ctx.response.body = { task }; }); // Get task by ID tasksRouter.get("/:id", authMiddleware, async (ctx) => { const db = await getDB(); const id = ctx.params.id; try { const task = await db.collection("tasks").findOne({ _id: new ObjectId(id) }); if (!task) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } ctx.response.body = { task }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // Create task tasksRouter.post("/", authMiddleware, async (ctx) => { const body = await ctx.request.body.json(); const { title, description, status, priority, assignee, project, labels, dueDate } = body; if (!title) { ctx.response.status = 400; ctx.response.body = { error: "Title is required" }; return; } const db = await getDB(); const now = new Date(); const number = await getNextNumber("task_number"); const result = await db.collection("tasks").insertOne({ number, title, description: description || "", status: status || "backlog", priority: priority || "medium", assignee, project, labels: labels || [], dueDate: dueDate ? new Date(dueDate) : undefined, createdBy: ctx.state.user.id, createdAt: now, updatedAt: now } as Task); const taskId = result.insertedId.toString(); events.emit(AMS_EVENTS.TASK_CREATED, { taskId, title, status: status || "backlog", priority: priority || "medium" }); ctx.response.status = 201; ctx.response.body = { message: "Task created", id: taskId }; }); // Update task tasksRouter.put("/:id", authMiddleware, async (ctx) => { const id = ctx.params.id; const body = await ctx.request.body.json(); const { title, description, status, priority, assignee, project, labels, dueDate } = body; const db = await getDB(); const updateFields: Partial = { updatedAt: new Date() }; if (title !== undefined) updateFields.title = title; if (description !== undefined) updateFields.description = description; if (status !== undefined) updateFields.status = status; if (priority !== undefined) updateFields.priority = priority; if (assignee !== undefined) updateFields.assignee = assignee; if (project !== undefined) updateFields.project = project; if (labels !== undefined) updateFields.labels = labels; if (dueDate !== undefined) updateFields.dueDate = dueDate ? new Date(dueDate) : undefined; try { // Load old task for changelog const oldTask = await db.collection("tasks").findOne({ _id: new ObjectId(id) }); if (!oldTask) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } const result = await db.collection("tasks").updateOne( { _id: new ObjectId(id) }, { $set: updateFields } ); if (result.matchedCount === 0) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } // Log changes await logTaskChanges(id, oldTask as unknown as Record, updateFields, ctx.state.user.id); events.emit(AMS_EVENTS.TASK_UPDATED, { taskId: id, changes: updateFields }); ctx.response.body = { message: "Task updated" }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // Update task status only (for drag & drop) tasksRouter.patch("/:id/status", authMiddleware, async (ctx) => { const id = ctx.params.id; const body = await ctx.request.body.json(); const { status } = body; if (!["backlog", "todo", "in_progress", "review", "done"].includes(status)) { ctx.response.status = 400; ctx.response.body = { error: "Invalid status" }; return; } const db = await getDB(); try { const oldTask = await db.collection("tasks").findOne({ _id: new ObjectId(id) }); if (!oldTask) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } const result = await db.collection("tasks").updateOne( { _id: new ObjectId(id) }, { $set: { status, updatedAt: new Date() } } ); if (result.matchedCount === 0) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } await logTaskChanges(id, oldTask as unknown as Record, { status }, ctx.state.user.id); events.emit(AMS_EVENTS.TASK_STATUS_CHANGED, { taskId: id, oldStatus: oldTask.status, newStatus: status }); ctx.response.body = { message: "Status updated" }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // Delete task (with cascade delete of comments and attachments) tasksRouter.delete("/:id", authMiddleware, async (ctx) => { const id = ctx.params.id; const db = await getDB(); try { // First, get all comments for this task to delete their attachments const comments = await db.collection("comments") .find({ $or: [{ taskId: id }, { parentType: "task", parentId: id }] }) .toArray(); // Delete attachments for each comment for (const comment of comments) { await db.collection("attachments").deleteMany({ parentType: "comment", parentId: comment._id.toString() }); } // Delete all comments for this task (both old taskId format and new parentType format) await db.collection("comments").deleteMany({ $or: [{ taskId: id }, { parentType: "task", parentId: id }] }); // Delete all attachments directly on task (both old taskId format and new parentType format) await db.collection("attachments").deleteMany({ $or: [{ taskId: id }, { parentType: "task", parentId: id }] }); // Finally delete the task const result = await db.collection("tasks").deleteOne({ _id: new ObjectId(id) }); if (result.deletedCount === 0) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } events.emit(AMS_EVENTS.TASK_DELETED, { taskId: id }); ctx.response.body = { message: "Task and related data deleted" }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // Get task changelog tasksRouter.get("/:id/changelog", authMiddleware, async (ctx) => { const taskId = ctx.params.id; const db = await getDB(); try { // Validate task exists const task = await db.collection("tasks").findOne({ _id: new ObjectId(taskId) }); if (!task) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } const entries = await db.collection("task_changelog") .find({ taskId }) .sort({ changedAt: -1 }) .limit(100) .toArray(); // Resolve changedBy IDs to names const userIds = [...new Set(entries.map(e => e.changedBy).filter(id => id && !id.startsWith("service:")))]; const nameMap: Record = {}; // Service accounts for (const entry of entries) { if (entry.changedBy?.startsWith("service:")) { const agentName = entry.changedBy.replace("service:", ""); nameMap[entry.changedBy] = agentName.charAt(0).toUpperCase() + agentName.slice(1); } } // Lookup users if (userIds.length > 0) { const objectIds = userIds.reduce((acc, id) => { try { acc.push(new ObjectId(id)); } catch { /* skip invalid */ } return acc; }, []); if (objectIds.length > 0) { const users = await db.collection("users").find({ _id: { $in: objectIds } }).toArray(); for (const user of users) { nameMap[user._id.toString()] = user.name || user.email || user._id.toString(); } // Also check agents collection const agents = await db.collection("agents").find({ _id: { $in: objectIds } }).toArray(); for (const agent of agents) { nameMap[agent._id.toString()] = agent.emoji ? `${agent.emoji} ${agent.name}` : agent.name; } } } const enrichedEntries = entries.map(e => ({ ...e, changedByName: nameMap[e.changedBy] || e.changedBy, })); ctx.response.body = { changelog: enrichedEntries }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // ============ PROJECTS ============ // Get all projects tasksRouter.get("/projects/list", authMiddleware, async (ctx) => { const db = await getDB(); const projects = await db.collection("projects") .find({}) .sort({ name: 1 }) .toArray(); ctx.response.body = { projects }; }); // Create project tasksRouter.post("/projects", authMiddleware, async (ctx) => { if (ctx.state.user.role !== "admin") { ctx.response.status = 403; ctx.response.body = { error: "Admin access required" }; return; } const body = await ctx.request.body.json(); const { name, description, color, gitlabProjectId, gitlabUrl, gitlabPath, gitlabProjects, giteaProjects, rules } = body; if (!name) { ctx.response.status = 400; ctx.response.body = { error: "Name is required" }; return; } const db = await getDB(); const now = new Date(); const result = await db.collection("projects").insertOne({ name, description: description || "", color: color || "#6366f1", gitlabProjectId: gitlabProjectId || undefined, gitlabUrl: gitlabUrl || undefined, gitlabPath: gitlabPath || undefined, gitlabProjects: gitlabProjects || [], giteaProjects: giteaProjects || [], rules: rules || "", createdAt: now, updatedAt: now } as Project); ctx.response.status = 201; ctx.response.body = { message: "Project created", id: result.insertedId.toString() }; }); // Update project tasksRouter.put("/projects/:id", authMiddleware, async (ctx) => { if (ctx.state.user.role !== "admin") { ctx.response.status = 403; ctx.response.body = { error: "Admin access required" }; return; } const id = ctx.params.id; const body = await ctx.request.body.json(); const { name, description, color, rules, gitlabProjectId, gitlabUrl, gitlabPath, gitlabProjects, giteaProjects } = body; const db = await getDB(); const updateFields: Partial = { updatedAt: new Date() }; if (name !== undefined) updateFields.name = name; if (description !== undefined) updateFields.description = description; if (color !== undefined) updateFields.color = color; if (rules !== undefined) (updateFields as any).rules = rules; // GitLab fields - Multiple projects (new) if (gitlabProjects !== undefined) updateFields.gitlabProjects = gitlabProjects || []; if (giteaProjects !== undefined) (updateFields as any).giteaProjects = giteaProjects || []; // Legacy single project fields (for backwards compatibility) if (gitlabProjectId !== undefined) updateFields.gitlabProjectId = gitlabProjectId || undefined; if (gitlabUrl !== undefined) updateFields.gitlabUrl = gitlabUrl || undefined; if (gitlabPath !== undefined) updateFields.gitlabPath = gitlabPath || undefined; try { const result = await db.collection("projects").updateOne( { _id: new ObjectId(id) }, { $set: updateFields } ); if (result.matchedCount === 0) { ctx.response.status = 404; ctx.response.body = { error: "Project not found" }; return; } ctx.response.body = { message: "Project updated" }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid project ID" }; } }); // Delete project tasksRouter.delete("/projects/:id", authMiddleware, async (ctx) => { if (ctx.state.user.role !== "admin") { ctx.response.status = 403; ctx.response.body = { error: "Admin access required" }; return; } const id = ctx.params.id; const db = await getDB(); try { const result = await db.collection("projects").deleteOne({ _id: new ObjectId(id) }); if (result.deletedCount === 0) { ctx.response.status = 404; ctx.response.body = { error: "Project not found" }; return; } ctx.response.body = { message: "Project deleted" }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid project ID" }; } }); // ============ GITLAB COMMITS ============ interface TaskCommit { _id: ObjectId; taskId: string; commitSha: string; shortId: string; gitlabProjectId: number; gitlabProjectPath: string; commitTitle: string; commitAuthor: string; commitDate: string; commitUrl: string; linkedBy: string; linkedByName: string; linkedAt: Date; } // GET /api/tasks/:id/commits - Verknüpfte Commits abrufen tasksRouter.get("/:id/commits", authMiddleware, async (ctx) => { const taskId = ctx.params.id; const db = await getDB(); try { const task = await db.collection("tasks").findOne({ _id: new ObjectId(taskId) }); if (!task) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } const commits = await db.collection("task_commits") .find({ taskId }) .sort({ linkedAt: -1 }) .toArray(); ctx.response.body = { commits }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // POST /api/tasks/:id/commits - Commit mit Task verknüpfen tasksRouter.post("/:id/commits", authMiddleware, async (ctx) => { const taskId = ctx.params.id; const body = await ctx.request.body.json(); const { commitSha, shortId, gitlabProjectId, gitlabProjectPath, commitTitle, commitAuthor, commitDate, commitUrl } = body; if (!commitSha || !gitlabProjectId) { ctx.response.status = 400; ctx.response.body = { error: "commitSha and gitlabProjectId are required" }; return; } const db = await getDB(); try { const task = await db.collection("tasks").findOne({ _id: new ObjectId(taskId) }); if (!task) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } // Prüfen ob schon verknüpft const existing = await db.collection("task_commits").findOne({ taskId, commitSha, gitlabProjectId, }); if (existing) { ctx.response.status = 409; ctx.response.body = { error: "Commit already linked to this task" }; return; } const now = new Date(); const result = await db.collection("task_commits").insertOne({ taskId, commitSha, shortId: shortId || commitSha.substring(0, 8), gitlabProjectId, gitlabProjectPath: gitlabProjectPath || "", commitTitle: commitTitle || "", commitAuthor: commitAuthor || "", commitDate: commitDate || now.toISOString(), commitUrl: commitUrl || "", linkedBy: ctx.state.user.id, linkedByName: ctx.state.user.username, linkedAt: now, } as TaskCommit); // Changelog-Eintrag await db.collection("task_changelog").insertOne({ taskId, field: "commits", oldValue: null, newValue: `${shortId || commitSha.substring(0, 8)} - ${commitTitle}`, changedBy: ctx.state.user.id, changedAt: now, }); // Log-Eintrag await db.collection("logs").insertOne({ agentId: ctx.state.user.id, agentName: ctx.state.user.username, level: "info", message: `Commit ${shortId || commitSha.substring(0, 8)} mit Task "${task.title}" verknüpft`, metadata: { taskId, commitSha, gitlabProjectId, gitlabProjectPath }, timestamp: now, }); events.emit(AMS_EVENTS.TASK_UPDATED, { taskId, changes: { commits: "added" } }); ctx.response.status = 201; ctx.response.body = { message: "Commit linked", id: result.insertedId.toString() }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // DELETE /api/tasks/:id/commits/:commitId - Commit-Verknüpfung entfernen tasksRouter.delete("/:id/commits/:commitId", authMiddleware, async (ctx) => { const taskId = ctx.params.id; const commitId = ctx.params.commitId; const db = await getDB(); try { const commit = await db.collection("task_commits").findOne({ _id: new ObjectId(commitId), taskId, }); if (!commit) { ctx.response.status = 404; ctx.response.body = { error: "Commit link not found" }; return; } await db.collection("task_commits").deleteOne({ _id: new ObjectId(commitId) }); const now = new Date(); // Changelog-Eintrag await db.collection("task_changelog").insertOne({ taskId, field: "commits", oldValue: `${commit.shortId} - ${commit.commitTitle}`, newValue: null, changedBy: ctx.state.user.id, changedAt: now, }); // Log-Eintrag await db.collection("logs").insertOne({ agentId: ctx.state.user.id, agentName: ctx.state.user.username, level: "info", message: `Commit ${commit.shortId} von Task entfernt`, metadata: { taskId, commitSha: commit.commitSha }, timestamp: now, }); ctx.response.body = { message: "Commit unlinked" }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid ID" }; } }); // ============ REMINDERS ============ // Set/update reminder for a task // PATCH /api/tasks/:id/reminder tasksRouter.patch("/:id/reminder", authMiddleware, async (ctx) => { const taskId = ctx.params.id; const body = await ctx.request.body.json(); const { datetime, interval, intervalValue } = body; if (!datetime) { ctx.response.status = 400; ctx.response.body = { error: "datetime is required" }; return; } const validIntervals = ['once', 'daily', 'hourly', 'minutes']; if (interval && !validIntervals.includes(interval)) { ctx.response.status = 400; ctx.response.body = { error: `Invalid interval. Must be one of: ${validIntervals.join(', ')}` }; return; } const db = await getDB(); const reminder: Reminder = { enabled: true, datetime: new Date(datetime), interval: interval || 'once', intervalValue: interval === 'minutes' ? (intervalValue || 30) : undefined, lastNotified: undefined }; try { const result = await db.collection("tasks").updateOne( { _id: new ObjectId(taskId) }, { $set: { reminder, updatedAt: new Date() } } ); if (result.matchedCount === 0) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } ctx.response.body = { message: "Reminder set", reminder }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // Delete reminder from a task // DELETE /api/tasks/:id/reminder tasksRouter.delete("/:id/reminder", authMiddleware, async (ctx) => { const taskId = ctx.params.id; const db = await getDB(); try { const result = await db.collection("tasks").updateOne( { _id: new ObjectId(taskId) }, { $unset: { reminder: "" }, $set: { updatedAt: new Date() } } ); if (result.matchedCount === 0) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } ctx.response.body = { message: "Reminder removed" }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // Get due reminders (for cron/polling) // GET /api/tasks/reminders/due tasksRouter.get("/reminders/due", authMiddleware, async (ctx) => { const db = await getDB(); const now = new Date(); // Find all tasks with enabled reminders where datetime <= now const tasks = await db.collection("tasks") .find({ "reminder.enabled": true, "reminder.datetime": { $lte: now } }) .toArray(); ctx.response.body = { tasks, count: tasks.length }; }); // Mark reminder as notified and schedule next (for recurring) // POST /api/tasks/:id/reminder/notified tasksRouter.post("/:id/reminder/notified", authMiddleware, async (ctx) => { const taskId = ctx.params.id; const db = await getDB(); try { const task = await db.collection("tasks").findOne({ _id: new ObjectId(taskId) }); if (!task) { ctx.response.status = 404; ctx.response.body = { error: "Task not found" }; return; } if (!task.reminder) { ctx.response.status = 400; ctx.response.body = { error: "Task has no reminder" }; return; } const now = new Date(); let nextDatetime: Date | null = null; // Calculate next reminder time based on interval switch (task.reminder.interval) { case 'once': // Disable after one-time notification await db.collection("tasks").updateOne( { _id: new ObjectId(taskId) }, { $set: { "reminder.enabled": false, "reminder.lastNotified": now } } ); break; case 'daily': nextDatetime = new Date(task.reminder.datetime); nextDatetime.setDate(nextDatetime.getDate() + 1); break; case 'hourly': nextDatetime = new Date(task.reminder.datetime); nextDatetime.setHours(nextDatetime.getHours() + 1); break; case 'minutes': nextDatetime = new Date(task.reminder.datetime); nextDatetime.setMinutes(nextDatetime.getMinutes() + (task.reminder.intervalValue || 30)); break; } if (nextDatetime) { await db.collection("tasks").updateOne( { _id: new ObjectId(taskId) }, { $set: { "reminder.datetime": nextDatetime, "reminder.lastNotified": now } } ); } ctx.response.body = { message: "Reminder marked as notified", nextDatetime: nextDatetime?.toISOString() || null, disabled: task.reminder.interval === 'once' }; } catch { ctx.response.status = 400; ctx.response.body = { error: "Invalid task ID" }; } }); // ============ MIGRATION: Nummernkreise ============ // POST /api/tasks/migrate/numbers - Bestehende Tasks/Agent-Tasks mit Nummern versehen tasksRouter.post("/migrate/numbers", authMiddleware, async (ctx) => { if (ctx.state.user.role !== "admin") { ctx.response.status = 403; ctx.response.body = { error: "Admin access required" }; return; } const db = await getDB(); let taskCount = 0; let agentTaskCount = 0; // Tasks ohne Nummer (sortiert nach createdAt) const tasksWithoutNumber = await db.collection("tasks") .find({ number: { $exists: false } }) .sort({ createdAt: 1 }) .toArray(); for (const task of tasksWithoutNumber) { const num = await getNextNumber("task_number"); await db.collection("tasks").updateOne( { _id: task._id }, { $set: { number: num } } ); taskCount++; } // Agent-Tasks ohne Nummer const agentTasksWithoutNumber = await db.collection("agent_tasks") .find({ number: { $exists: false } }) .sort({ createdAt: 1 }) .toArray(); for (const at of agentTasksWithoutNumber) { const num = await getNextNumber("agent_task_number"); await db.collection("agent_tasks").updateOne( { _id: at._id }, { $set: { number: num } } ); agentTaskCount++; } ctx.response.body = { message: "Migration abgeschlossen", tasksNumbered: taskCount, agentTasksNumbered: agentTaskCount, }; });