- New /api/gitea/* routes for Gitea API - List projects, branches, commits - File tree and content viewing - User token management
898 lines
27 KiB
TypeScript
898 lines
27 KiB
TypeScript
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<number> {
|
|
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<string, unknown> = {};
|
|
|
|
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<Task>("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<Task>("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<Task>("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<Task>("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<Task> = { 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<Task>("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<Task>("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<string, unknown>, 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<Task>("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<Task>("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<string, unknown>, { 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<Task>("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<string, string> = {};
|
|
|
|
// 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<ObjectId[]>((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<Project>("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<Project>("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<Project> = { 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<Project>("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<Project>("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<TaskCommit>("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<TaskCommit>("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<TaskCommit>("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<TaskCommit>("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<TaskCommit>("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<Task>("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<Task>("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<Task>("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<Task>("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<Task>("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<Task>("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,
|
|
};
|
|
});
|
|
|