Initial commit: AMS Backend - Deno + Oak + MongoDB
This commit is contained in:
33
src/db/mongo.ts
Normal file
33
src/db/mongo.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { MongoClient } from "npm:mongodb@6.10.0";
|
||||
|
||||
const MONGO_URI = Deno.env.get("MONGO_URI");
|
||||
if (!MONGO_URI) {
|
||||
throw new Error("MONGO_URI environment variable is required");
|
||||
}
|
||||
|
||||
const DB_NAME = new URL(MONGO_URI).pathname.replace("/", "") || "ams";
|
||||
|
||||
let client: MongoClient | null = null;
|
||||
|
||||
export async function getMongoClient(): Promise<MongoClient> {
|
||||
if (!client) {
|
||||
console.log("Connecting to MongoDB...");
|
||||
client = new MongoClient(MONGO_URI);
|
||||
await client.connect();
|
||||
console.log("✅ MongoDB connected successfully");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function getDB() {
|
||||
const mongoClient = await getMongoClient();
|
||||
return mongoClient.db(DB_NAME);
|
||||
}
|
||||
|
||||
export async function closeMongoConnection() {
|
||||
if (client) {
|
||||
await client.close();
|
||||
client = null;
|
||||
console.log("MongoDB connection closed");
|
||||
}
|
||||
}
|
||||
100
src/main.ts
Normal file
100
src/main.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Application } from "@oak/oak";
|
||||
import "@std/dotenv/load";
|
||||
import { getMongoClient } from "./db/mongo.ts";
|
||||
import { corsMiddleware } from "./middleware/cors.ts";
|
||||
import { authRouter } from "./routes/auth.ts";
|
||||
import { tasksRouter } from "./routes/tasks.ts";
|
||||
import { agentTasksRouter } from "./routes/agenttasks.ts";
|
||||
import { secretsRouter, secretFoldersRouter } from "./routes/secrets.ts";
|
||||
import { secretAuditLogsRouter } from "./routes/secretAuditLogs.ts";
|
||||
import { workspaceRouter } from "./routes/workspace.ts";
|
||||
import { labelsRouter } from "./routes/labels.ts";
|
||||
import { cronJobsRouter } from "./routes/cronjobs.ts";
|
||||
import { quickTextsRouter } from "./routes/quicktexts.ts";
|
||||
import { transcribeRouter } from "./routes/transcribe.ts";
|
||||
import { messagingRouter } from "./routes/messaging.ts";
|
||||
import { tokensRouter } from "./routes/tokens.ts";
|
||||
import { settingsRouter } from "./routes/settings.ts";
|
||||
import { commentsRouter } from "./routes/comments.ts";
|
||||
import { attachmentsRouter } from "./routes/attachments.ts";
|
||||
import { agentsRouter } from "./routes/agents.ts";
|
||||
import { logsRouter } from "./routes/logs.ts";
|
||||
import { userSettingsRouter } from "./routes/usersettings.ts";
|
||||
import gitlabRouter from "./routes/gitlab.ts";
|
||||
import { dockerRouter } from "./routes/docker.ts";
|
||||
import { exportRouter } from "./routes/export.ts";
|
||||
import { appUpdateRouter } from "./routes/appUpdate.ts";
|
||||
import { handleWebSocket } from "./ws/handler.ts";
|
||||
|
||||
const app = new Application();
|
||||
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
||||
|
||||
// Connect to MongoDB
|
||||
await getMongoClient();
|
||||
|
||||
// Middleware
|
||||
app.use(corsMiddleware);
|
||||
|
||||
// Routes
|
||||
app.use(authRouter.routes());
|
||||
app.use(authRouter.allowedMethods());
|
||||
app.use(tasksRouter.routes());
|
||||
app.use(tasksRouter.allowedMethods());
|
||||
app.use(agentTasksRouter.routes());
|
||||
app.use(agentTasksRouter.allowedMethods());
|
||||
app.use(secretsRouter.routes());
|
||||
app.use(secretsRouter.allowedMethods());
|
||||
app.use(secretFoldersRouter.routes());
|
||||
app.use(secretFoldersRouter.allowedMethods());
|
||||
app.use(secretAuditLogsRouter.routes());
|
||||
app.use(secretAuditLogsRouter.allowedMethods());
|
||||
app.use(workspaceRouter.routes());
|
||||
app.use(workspaceRouter.allowedMethods());
|
||||
app.use(labelsRouter.routes());
|
||||
app.use(labelsRouter.allowedMethods());
|
||||
app.use(cronJobsRouter.routes());
|
||||
app.use(cronJobsRouter.allowedMethods());
|
||||
app.use(quickTextsRouter.routes());
|
||||
app.use(quickTextsRouter.allowedMethods());
|
||||
app.use(transcribeRouter.routes());
|
||||
app.use(transcribeRouter.allowedMethods());
|
||||
app.use(messagingRouter.routes());
|
||||
app.use(messagingRouter.allowedMethods());
|
||||
app.use(tokensRouter.routes());
|
||||
app.use(tokensRouter.allowedMethods());
|
||||
app.use(settingsRouter.routes());
|
||||
app.use(settingsRouter.allowedMethods());
|
||||
app.use(commentsRouter.routes());
|
||||
app.use(commentsRouter.allowedMethods());
|
||||
app.use(attachmentsRouter.routes());
|
||||
app.use(attachmentsRouter.allowedMethods());
|
||||
app.use(agentsRouter.routes());
|
||||
app.use(agentsRouter.allowedMethods());
|
||||
app.use(logsRouter.routes());
|
||||
app.use(logsRouter.allowedMethods());
|
||||
app.use(userSettingsRouter.routes());
|
||||
app.use(userSettingsRouter.allowedMethods());
|
||||
app.use(gitlabRouter.routes());
|
||||
app.use(gitlabRouter.allowedMethods());
|
||||
app.use(dockerRouter.routes());
|
||||
app.use(dockerRouter.allowedMethods());
|
||||
app.use(exportRouter.routes());
|
||||
app.use(exportRouter.allowedMethods());
|
||||
app.use(appUpdateRouter.routes());
|
||||
app.use(appUpdateRouter.allowedMethods());
|
||||
|
||||
console.log(`AMS Backend running on port ${PORT}`);
|
||||
|
||||
// Use Deno.serve for WebSocket support
|
||||
Deno.serve({ port: PORT }, async (req: Request): Promise<Response> => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Handle WebSocket upgrades
|
||||
if (url.pathname === "/ws" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||
return handleWebSocket(req);
|
||||
}
|
||||
|
||||
// Forward to Oak for regular HTTP
|
||||
const oakResponse = await app.handle(req);
|
||||
return oakResponse || new Response("Not Found", { status: 404 });
|
||||
});
|
||||
46
src/middleware/auth.ts
Normal file
46
src/middleware/auth.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { verifyJWT } from "../utils/jwt.ts";
|
||||
|
||||
export interface AuthState {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function authMiddleware(ctx: Context, next: Next) {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "No token provided" };
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// Service-API-Key für Agent-Zugriff (kein JWT nötig)
|
||||
const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY");
|
||||
if (serviceKey && token === serviceKey) {
|
||||
ctx.state.user = {
|
||||
id: "service:pixel",
|
||||
email: "pixel@agentenbude.de",
|
||||
username: "Pixel (Agent)",
|
||||
role: "agent",
|
||||
};
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verifyJWT(token);
|
||||
ctx.state.user = payload;
|
||||
await next();
|
||||
} catch {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Invalid or expired token" };
|
||||
}
|
||||
}
|
||||
|
||||
49
src/middleware/cors.ts
Normal file
49
src/middleware/cors.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Context, Next } from "@oak/oak";
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
"https://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"https://127.0.0.1:5173",
|
||||
"http://localhost:3000",
|
||||
"https://localhost:3000",
|
||||
"https://ams.kronos-soulution.de",
|
||||
"http://ams.kronos-soulution.de",
|
||||
"https://api.ams.kronos-soulution.de"
|
||||
];
|
||||
|
||||
const ALLOWED_DOMAIN_SUFFIX = ".kronos-soulution.de";
|
||||
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
if (ALLOWED_ORIGINS.includes(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return url.hostname === "kronos-soulution.de" ||
|
||||
url.hostname.endsWith(ALLOWED_DOMAIN_SUFFIX);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function corsMiddleware(ctx: Context, next: Next) {
|
||||
const origin = ctx.request.headers.get("origin") || "";
|
||||
|
||||
if (isAllowedOrigin(origin)) {
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
}
|
||||
|
||||
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
ctx.response.headers.set("Access-Control-Max-Age", "86400");
|
||||
|
||||
if (ctx.request.method === "OPTIONS") {
|
||||
ctx.response.status = 204;
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
375
src/routes/agents.ts
Normal file
375
src/routes/agents.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const agentsRouter = new Router({ prefix: "/api/agents" });
|
||||
|
||||
interface Agent {
|
||||
_id: ObjectId;
|
||||
name: string;
|
||||
emoji: string;
|
||||
role: string;
|
||||
status: "online" | "idle" | "offline" | "busy";
|
||||
supervisor?: string; // ID of supervising agent
|
||||
discordId?: string;
|
||||
containerId?: string;
|
||||
containerIp?: string;
|
||||
lastSeen?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Get all agents
|
||||
agentsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const agents = await db.collection<Agent>("agents")
|
||||
.find({})
|
||||
.sort({ name: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { agents };
|
||||
});
|
||||
|
||||
// Get single agent
|
||||
agentsRouter.get("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const id = ctx.params.id;
|
||||
|
||||
let agent;
|
||||
try {
|
||||
agent = await db.collection<Agent>("agents").findOne({ _id: new ObjectId(id) });
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Agent not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { agent };
|
||||
});
|
||||
|
||||
// Create agent (admin only)
|
||||
agentsRouter.post("/", 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, emoji, role, status, supervisor, discordId, containerId, containerIp } = body;
|
||||
|
||||
if (!name || !role) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Name and role are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const now = new Date();
|
||||
|
||||
const result = await db.collection<Agent>("agents").insertOne({
|
||||
name,
|
||||
emoji: emoji || "🤖",
|
||||
role,
|
||||
status: status || "offline",
|
||||
supervisor,
|
||||
discordId,
|
||||
containerId,
|
||||
containerIp,
|
||||
lastSeen: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
} as Agent);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Agent created",
|
||||
id: result.insertedId.toString()
|
||||
};
|
||||
});
|
||||
|
||||
// Update agent
|
||||
agentsRouter.put("/: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, emoji, role, status, supervisor, discordId, containerId, containerIp } = body;
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
const updateFields: Partial<Agent> = { updatedAt: new Date() };
|
||||
if (name !== undefined) updateFields.name = name;
|
||||
if (emoji !== undefined) updateFields.emoji = emoji;
|
||||
if (role !== undefined) updateFields.role = role;
|
||||
if (status !== undefined) updateFields.status = status;
|
||||
if (supervisor !== undefined) updateFields.supervisor = supervisor;
|
||||
if (discordId !== undefined) updateFields.discordId = discordId;
|
||||
if (containerId !== undefined) updateFields.containerId = containerId;
|
||||
if (containerIp !== undefined) updateFields.containerIp = containerIp;
|
||||
|
||||
try {
|
||||
const result = await db.collection<Agent>("agents").updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Agent not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Agent updated" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Update agent status (can be called by agents themselves)
|
||||
agentsRouter.patch("/:id/status", authMiddleware, async (ctx) => {
|
||||
const id = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const { status } = body;
|
||||
|
||||
if (!["online", "idle", "offline", "busy"].includes(status)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid status. Must be: online, idle, offline, or busy" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const result = await db.collection<Agent>("agents").updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{ $set: { status, lastSeen: new Date(), updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Agent not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Status updated" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ AGENT FILES ============
|
||||
|
||||
interface AgentFile {
|
||||
_id: ObjectId;
|
||||
agentId: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
updatedBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const ALLOWED_FILENAMES = [
|
||||
"SOUL.md", "MEMORY.md", "AGENTS.md", "USER.md", "TOOLS.md",
|
||||
"HEARTBEAT.md", "IDENTITY.md", "WORKFLOW.md", "BOOTSTRAP.md", "README.md",
|
||||
"CHECKLIST.md"
|
||||
];
|
||||
|
||||
// Validate file path: must end with .md, no "..", max depth 2
|
||||
function isValidFilePath(filepath: string): boolean {
|
||||
if (!filepath.endsWith(".md")) return false;
|
||||
if (filepath.includes("..")) return false;
|
||||
const parts = filepath.split("/");
|
||||
if (parts.length > 2) return false;
|
||||
return parts.every(p => p.length > 0 && !p.startsWith("."));
|
||||
}
|
||||
|
||||
// GET /api/agents/:id/files - List agent's MD files
|
||||
// Optional query: ?path=memory (filter by subdirectory prefix)
|
||||
agentsRouter.get("/:id/files", authMiddleware, async (ctx) => {
|
||||
const agentId = ctx.params.id;
|
||||
const pathFilter = ctx.request.url.searchParams.get("path");
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const agent = await db.collection("agents").findOne({ _id: new ObjectId(agentId) });
|
||||
if (!agent) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Agent not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
const query: Record<string, unknown> = { agentId };
|
||||
if (pathFilter) {
|
||||
query.filename = { $regex: `^${pathFilter}/` };
|
||||
}
|
||||
|
||||
const files = await db.collection<AgentFile>("agent_files")
|
||||
.find(query)
|
||||
.project({ content: 0 })
|
||||
.sort({ filename: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { files };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/agents/:id/files/:filename - Read file content
|
||||
// Supports subdirectory paths via query ?path=memory/2026-02-08.md
|
||||
// or direct :filename for root-level files
|
||||
agentsRouter.get("/:id/files/:filename", authMiddleware, async (ctx) => {
|
||||
const agentId = ctx.params.id;
|
||||
const pathParam = ctx.request.url.searchParams.get("path");
|
||||
const filename = pathParam || ctx.params.filename;
|
||||
|
||||
if (!isValidFilePath(filename)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültiger Dateipfad. Nur .md-Dateien, max. 1 Unterordner." };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const file = await db.collection<AgentFile>("agent_files")
|
||||
.findOne({ agentId, filename });
|
||||
|
||||
if (!file) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "File not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { file };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/agents/:id/files/:filename - Create or update file
|
||||
// Supports subdirectory paths via query ?path=memory/2026-02-08.md
|
||||
agentsRouter.put("/:id/files/:filename", authMiddleware, async (ctx) => {
|
||||
const agentId = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const pathParam = body.path || ctx.request.url.searchParams.get("path") || ctx.params.filename;
|
||||
const filename = pathParam;
|
||||
const { content } = body;
|
||||
|
||||
if (!isValidFilePath(filename)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültiger Dateipfad. Nur .md-Dateien, max. 1 Unterordner." };
|
||||
return;
|
||||
}
|
||||
|
||||
if (content === undefined) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Content is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.length > 500 * 1024) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "File too large. Maximum 500KB." };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
// Verify agent exists
|
||||
const agent = await db.collection("agents").findOne({ _id: new ObjectId(agentId) });
|
||||
if (!agent) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Agent not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const result = await db.collection<AgentFile>("agent_files").updateOne(
|
||||
{ agentId, filename },
|
||||
{
|
||||
$set: { content, updatedBy: ctx.state.user.id, updatedAt: now },
|
||||
$setOnInsert: { agentId, filename, createdAt: now }
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
const isNew = result.upsertedCount > 0;
|
||||
ctx.response.status = isNew ? 201 : 200;
|
||||
ctx.response.body = { message: isNew ? "File created" : "File updated" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/agents/:id/files/:filename - Delete file
|
||||
// Supports subdirectory paths via query ?path=memory/2026-02-08.md
|
||||
agentsRouter.delete("/:id/files/:filename", authMiddleware, async (ctx) => {
|
||||
const pathParam = ctx.request.url.searchParams.get("path") || ctx.params.filename;
|
||||
const agentId = ctx.params.id;
|
||||
const filename = pathParam;
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const result = await db.collection("agent_files").deleteOne({ agentId, filename });
|
||||
if (result.deletedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "File not found" };
|
||||
return;
|
||||
}
|
||||
ctx.response.body = { message: "File deleted" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete agent (admin only)
|
||||
agentsRouter.delete("/: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<Agent>("agents").deleteOne({ _id: new ObjectId(id) });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Agent not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Cascade delete agent files
|
||||
await db.collection("agent_files").deleteMany({ agentId: id });
|
||||
|
||||
ctx.response.body = { message: "Agent deleted" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid agent ID" };
|
||||
}
|
||||
});
|
||||
|
||||
281
src/routes/agenttasks.ts
Normal file
281
src/routes/agenttasks.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getNextNumber } from "./tasks.ts";
|
||||
|
||||
export const agentTasksRouter = new Router({ prefix: "/api/agent-tasks" });
|
||||
|
||||
interface AgentTask {
|
||||
_id: ObjectId;
|
||||
number?: number;
|
||||
message: string;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
linkedTaskIds: string[];
|
||||
linkedTaskTitles: string[];
|
||||
status: "pending" | "in_progress" | "done" | "rejected";
|
||||
createdBy: string;
|
||||
createdByName: string;
|
||||
result?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Neuen Auftrag erstellen
|
||||
agentTasksRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const body = await ctx.request.body.json();
|
||||
const { message, projectId, projectName, agentId, agentName, linkedTaskIds, linkedTaskTitles } = body;
|
||||
|
||||
if (!message || !message.trim()) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Nachricht ist erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const number = await getNextNumber("agent_task_number");
|
||||
const doc: Omit<AgentTask, "_id"> = {
|
||||
number,
|
||||
message: message.trim(),
|
||||
projectId: projectId || undefined,
|
||||
projectName: projectName || undefined,
|
||||
agentId: agentId || undefined,
|
||||
agentName: agentName || undefined,
|
||||
linkedTaskIds: linkedTaskIds || [],
|
||||
linkedTaskTitles: linkedTaskTitles || [],
|
||||
status: "pending",
|
||||
createdBy: ctx.state.user.id,
|
||||
createdByName: ctx.state.user.username,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await db.collection<AgentTask>("agent_tasks").insertOne(doc as AgentTask);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Auftrag erstellt",
|
||||
id: result.insertedId.toString(),
|
||||
};
|
||||
});
|
||||
|
||||
// Alle Aufträge laden (mit optionalen Filtern)
|
||||
agentTasksRouter.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 agentId = url.searchParams.get("agentId");
|
||||
if (agentId) filter.agentId = agentId;
|
||||
|
||||
const tasks = await db.collection<AgentTask>("agent_tasks")
|
||||
.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(100)
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { tasks };
|
||||
});
|
||||
|
||||
// Pending Aufträge für Agenten (Kurzform)
|
||||
agentTasksRouter.get("/pending", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
|
||||
const tasks = await db.collection<AgentTask>("agent_tasks")
|
||||
.find({ status: "pending" })
|
||||
.sort({ createdAt: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { tasks };
|
||||
});
|
||||
|
||||
// Auftrag nach Nummer laden
|
||||
agentTasksRouter.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: "Ungültige Nummer" };
|
||||
return;
|
||||
}
|
||||
const task = await db.collection<AgentTask>("agent_tasks").findOne({ number: num });
|
||||
if (!task) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Auftrag nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
ctx.response.body = { task };
|
||||
});
|
||||
|
||||
// Einzelnen Auftrag laden
|
||||
agentTasksRouter.get("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
const task = await db.collection<AgentTask>("agent_tasks")
|
||||
.findOne({ _id: new ObjectId(id) });
|
||||
|
||||
if (!task) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Auftrag nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { task };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// OpenClaw Agent-Proxy — startet isolierten Agent-Run über WireGuard VPN (hooks/agent endpoint)
|
||||
agentTasksRouter.post("/wake", authMiddleware, async (ctx) => {
|
||||
const openclawBaseUrl = Deno.env.get("OPENCLAW_WAKE_URL"); // e.g. http://10.10.0.3:18789/hooks/wake
|
||||
const openclawToken = Deno.env.get("OPENCLAW_WAKE_TOKEN");
|
||||
|
||||
if (!openclawBaseUrl || !openclawToken) {
|
||||
ctx.response.status = 503;
|
||||
ctx.response.body = { error: "OpenClaw Wake nicht konfiguriert" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending Tasks laden für den Prompt
|
||||
const db = await getDB();
|
||||
const pendingTasks = await db.collection<AgentTask>("agent_tasks")
|
||||
.find({ status: "pending" })
|
||||
.sort({ createdAt: 1 })
|
||||
.toArray();
|
||||
|
||||
if (pendingTasks.length === 0) {
|
||||
ctx.response.body = { message: "Keine pending Tasks" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-Timeout: Tasks die >30 Minuten in_progress sind, auf pending zurücksetzen
|
||||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const timeoutThreshold = new Date(Date.now() - TIMEOUT_MS);
|
||||
await db.collection<AgentTask>("agent_tasks").updateMany(
|
||||
{ status: "in_progress", updatedAt: { $lt: timeoutThreshold } },
|
||||
{ $set: { status: "pending", updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
// Lock: Nur ein Task gleichzeitig pro Agent
|
||||
const inProgressCount = await db.collection<AgentTask>("agent_tasks")
|
||||
.countDocuments({ status: "in_progress" });
|
||||
|
||||
if (inProgressCount > 0) {
|
||||
ctx.response.body = { message: "Agent arbeitet bereits an einem Task", blocked: true };
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt mit Task-Details bauen
|
||||
const taskList = pendingTasks.map((t, i) =>
|
||||
`${i + 1}. [${t._id}] "${t.message}" (Projekt: ${t.projectName || "–"}, von: ${t.createdByName})`
|
||||
).join("\n");
|
||||
|
||||
const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY") || "";
|
||||
// Nur den ERSTEN pending Task bearbeiten (1 Task pro Run)
|
||||
const firstTask = pendingTasks[0];
|
||||
const prompt = `WICHTIG: Lade zuerst CHECKLIST.md und WORKFLOW.md aus deinem Workspace und halte dich strikt daran!
|
||||
|
||||
WICHTIG: KEINE Sub-Agenten spawnen (sessions_spawn)! Du bearbeitest Tasks SELBST, einzeln, nacheinander. Nur 1 Task pro Run!
|
||||
|
||||
Dein Agent-Task:
|
||||
[${firstTask._id}] "${firstTask.message}" (Projekt: ${firstTask.projectName || "–"}, von: ${firstTask.createdByName})
|
||||
|
||||
Bearbeite diesen Task nach der CHECKLIST.md:
|
||||
1. Task im AMS auf "in_progress" setzen: curl -X PUT https://api.ams.agentenbude.de/api/tasks/<LINKED_TASK_ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"in_progress"}'
|
||||
2. Feature-Branch erstellen (feature/beschreibung)
|
||||
3. Backend ZUERST implementieren (bei Full-Stack)
|
||||
4. Kein \`any\`, kein \`console.log\`, keine Secrets im Code
|
||||
5. Dark Theme beachten! Alle UI-Elemente müssen zum bestehenden Dark-Mode-Design passen (Farben: #1a1a2e Hintergrund, rgba(255,255,255,0.05-0.15) für Karten/Borders, helle Textfarben)
|
||||
6. Self-Review: npm run build (FE) / deno check (BE)
|
||||
7. Conventional Commits auf DEUTSCH
|
||||
8. MR erstellen und mergen
|
||||
9. Tag + Deploy
|
||||
10. Agent-Task auf "done" setzen: curl -X PUT https://api.ams.agentenbude.de/api/agent-tasks/<ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"done","result":"<Zusammenfassung>"}'
|
||||
11. Task-Kommentar mit Zusammenfassung + MR + Version
|
||||
12. Melde dem Chef das Ergebnis.
|
||||
|
||||
API-Auth: Bearer ${serviceKey}
|
||||
AMS-API: https://api.ams.agentenbude.de/api`;
|
||||
|
||||
// /hooks/agent statt /hooks/wake aufrufen
|
||||
const agentUrl = openclawBaseUrl.replace("/hooks/wake", "/hooks/agent");
|
||||
|
||||
try {
|
||||
const response = await fetch(agentUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${openclawToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: prompt,
|
||||
name: "AMS-Task",
|
||||
sessionKey: "hook:ams-task",
|
||||
wakeMode: "now",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "7150608398",
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
ctx.response.body = { message: "Agent gestartet", tasks: pendingTasks.length };
|
||||
} else {
|
||||
ctx.response.status = 502;
|
||||
ctx.response.body = { error: "Agent-Start fehlgeschlagen", status: response.status };
|
||||
}
|
||||
} catch {
|
||||
ctx.response.status = 502;
|
||||
ctx.response.body = { error: "OpenClaw nicht erreichbar" };
|
||||
}
|
||||
});
|
||||
|
||||
// Auftrag-Status aktualisieren (Agent nimmt an / erledigt / lehnt ab)
|
||||
agentTasksRouter.put("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const id = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const { status, result } = body;
|
||||
|
||||
const validStatuses = ["pending", "in_progress", "done", "rejected"];
|
||||
if (status && !validStatuses.includes(status)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Ungültiger Status. Erlaubt: ${validStatuses.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (status) updateFields.status = status;
|
||||
if (result !== undefined) updateFields.result = result;
|
||||
|
||||
try {
|
||||
const res = await db.collection<AgentTask>("agent_tasks").updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (res.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Auftrag nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Auftrag aktualisiert" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
153
src/routes/appUpdate.ts
Normal file
153
src/routes/appUpdate.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { getMongoClient } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
const router = new Router({ prefix: "/api/app" });
|
||||
|
||||
interface AppRelease {
|
||||
_id?: ObjectId;
|
||||
version: string;
|
||||
versionCode: number;
|
||||
downloadUrl: string;
|
||||
releaseNotes: string;
|
||||
size?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
async function getReleasesCollection() {
|
||||
const client = await getMongoClient();
|
||||
const db = client.db("ams");
|
||||
return db.collection<AppRelease>("app_releases");
|
||||
}
|
||||
|
||||
// Öffentlich: Version prüfen (App ruft das ohne Auth auf)
|
||||
router.get("/update/check", async (ctx) => {
|
||||
const currentVersion = ctx.request.url.searchParams.get("currentVersion") || "0";
|
||||
const currentCode = parseInt(ctx.request.url.searchParams.get("currentCode") || "0");
|
||||
|
||||
const col = await getReleasesCollection();
|
||||
const latest = await col.findOne({}, { sort: { versionCode: -1 } });
|
||||
|
||||
if (!latest) {
|
||||
ctx.response.body = { updateAvailable: false };
|
||||
return;
|
||||
}
|
||||
|
||||
const updateAvailable = latest.versionCode > currentCode;
|
||||
|
||||
ctx.response.body = {
|
||||
updateAvailable,
|
||||
currentVersion,
|
||||
latestVersion: latest.version,
|
||||
latestVersionCode: latest.versionCode,
|
||||
downloadUrl: latest.downloadUrl,
|
||||
releaseNotes: latest.releaseNotes,
|
||||
size: latest.size,
|
||||
};
|
||||
});
|
||||
|
||||
// Öffentlich: Download-Redirect zur APK
|
||||
router.get("/update/download", async (ctx) => {
|
||||
const col = await getReleasesCollection();
|
||||
const latest = await col.findOne({}, { sort: { versionCode: -1 } });
|
||||
|
||||
if (!latest) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Kein Release verfügbar" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.redirect(latest.downloadUrl);
|
||||
});
|
||||
|
||||
// Admin: Neues Release anlegen
|
||||
router.post("/update/release", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { version, versionCode, downloadUrl, releaseNotes, size } = body;
|
||||
|
||||
if (!version || !versionCode || !downloadUrl) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "version, versionCode und downloadUrl sind Pflichtfelder" };
|
||||
return;
|
||||
}
|
||||
|
||||
const col = await getReleasesCollection();
|
||||
const now = new Date();
|
||||
|
||||
const result = await col.insertOne({
|
||||
version,
|
||||
versionCode,
|
||||
downloadUrl,
|
||||
releaseNotes: releaseNotes || "",
|
||||
size: size || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { id: result.insertedId, version, versionCode };
|
||||
});
|
||||
|
||||
// Admin: Alle Releases auflisten
|
||||
router.get("/update/releases", authMiddleware, async (ctx) => {
|
||||
const col = await getReleasesCollection();
|
||||
const releases = await col.find({}).sort({ versionCode: -1 }).toArray();
|
||||
ctx.response.body = { releases };
|
||||
});
|
||||
|
||||
// Admin: Release löschen
|
||||
router.delete("/update/release/:id", authMiddleware, async (ctx) => {
|
||||
const id = ctx.params.id;
|
||||
const col = await getReleasesCollection();
|
||||
await col.deleteOne({ _id: new ObjectId(id) });
|
||||
ctx.response.body = { deleted: true };
|
||||
});
|
||||
|
||||
// Service-API: Release per API-Key anlegen (für CI-Pipeline)
|
||||
router.post("/update/release/ci", async (ctx) => {
|
||||
const apiKey = ctx.request.headers.get("X-API-Key") ||
|
||||
ctx.request.url.searchParams.get("apiKey");
|
||||
const expectedKey = Deno.env.get("AMS_SERVICE_API_KEY");
|
||||
|
||||
if (!apiKey || apiKey !== expectedKey) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Ungültiger API-Key" };
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.json();
|
||||
const { version, versionCode, downloadUrl, releaseNotes, size } = body;
|
||||
|
||||
if (!version || !versionCode || !downloadUrl) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "version, versionCode und downloadUrl sind Pflichtfelder" };
|
||||
return;
|
||||
}
|
||||
|
||||
const col = await getReleasesCollection();
|
||||
const now = new Date();
|
||||
|
||||
// Upsert by versionCode
|
||||
await col.updateOne(
|
||||
{ versionCode },
|
||||
{
|
||||
$set: {
|
||||
version,
|
||||
versionCode,
|
||||
downloadUrl,
|
||||
releaseNotes: releaseNotes || "",
|
||||
size: size || 0,
|
||||
updatedAt: now,
|
||||
},
|
||||
$setOnInsert: { createdAt: now },
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { success: true, version, versionCode };
|
||||
});
|
||||
|
||||
export const appUpdateRouter = router;
|
||||
241
src/routes/attachments.ts
Normal file
241
src/routes/attachments.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId, GridFSBucket } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const attachmentsRouter = new Router({ prefix: "/api/attachments" });
|
||||
|
||||
// Valid parent types for attachments
|
||||
const VALID_PARENT_TYPES = ["task", "comment", "project", "agent"] as const;
|
||||
type ParentType = typeof VALID_PARENT_TYPES[number];
|
||||
|
||||
interface AttachmentMeta {
|
||||
_id: ObjectId;
|
||||
parentType: ParentType;
|
||||
parentId: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
gridfsFileId?: ObjectId;
|
||||
data?: string; // legacy base64 — kept for backward compat during migration
|
||||
uploadedBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
async function getGridFSBucket(): Promise<GridFSBucket> {
|
||||
const db = await getDB();
|
||||
return new GridFSBucket(db, { bucketName: "attachments" });
|
||||
}
|
||||
|
||||
// Helper: read GridFS file to base64
|
||||
async function gridfsToBase64(fileId: ObjectId): Promise<string> {
|
||||
const bucket = await getGridFSBucket();
|
||||
const stream = bucket.openDownloadStream(fileId);
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
|
||||
const combined = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
// Helper: write base64 to GridFS, return fileId
|
||||
async function base64ToGridFS(base64: string, filename: string): Promise<ObjectId> {
|
||||
const bucket = await getGridFSBucket();
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
|
||||
const uploadStream = bucket.openUploadStream(filename);
|
||||
const fileId = uploadStream.id;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
uploadStream.on("finish", resolve);
|
||||
uploadStream.on("error", reject);
|
||||
uploadStream.write(bytes);
|
||||
uploadStream.end();
|
||||
});
|
||||
|
||||
return fileId as ObjectId;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SPECIFIC ROUTES FIRST (before wildcards!)
|
||||
// ==========================================
|
||||
|
||||
// Get single attachment with data
|
||||
attachmentsRouter.get("/file/:attachmentId", authMiddleware, async (ctx) => {
|
||||
const attachmentId = ctx.params.attachmentId;
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const attachment = await db.collection<AttachmentMeta>("attachments").findOne({
|
||||
_id: new ObjectId(attachmentId)
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Attachment not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Read data: prefer GridFS, fall back to legacy base64 field
|
||||
let data: string;
|
||||
if (attachment.gridfsFileId) {
|
||||
data = await gridfsToBase64(attachment.gridfsFileId);
|
||||
} else if (attachment.data) {
|
||||
data = attachment.data;
|
||||
} else {
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Attachment data not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = {
|
||||
attachment: {
|
||||
_id: attachment._id,
|
||||
parentType: attachment.parentType,
|
||||
parentId: attachment.parentId,
|
||||
filename: attachment.filename,
|
||||
originalName: attachment.originalName,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
data
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid attachment ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete attachment
|
||||
attachmentsRouter.delete("/:attachmentId", authMiddleware, async (ctx) => {
|
||||
const attachmentId = ctx.params.attachmentId;
|
||||
const db = await getDB();
|
||||
|
||||
if (!/^[a-f\d]{24}$/i.test(attachmentId)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid attachment ID format" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const attachment = await db.collection<AttachmentMeta>("attachments").findOne({
|
||||
_id: new ObjectId(attachmentId)
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Attachment not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.uploadedBy !== ctx.state.user.id && ctx.state.user.role !== "admin") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Not authorized to delete this attachment" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete GridFS file if exists
|
||||
if (attachment.gridfsFileId) {
|
||||
try {
|
||||
const bucket = await getGridFSBucket();
|
||||
await bucket.delete(attachment.gridfsFileId);
|
||||
} catch {
|
||||
// GridFS file may already be deleted, continue
|
||||
}
|
||||
}
|
||||
|
||||
await db.collection("attachments").deleteOne({ _id: new ObjectId(attachmentId) });
|
||||
ctx.response.body = { message: "Attachment deleted" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid attachment ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// WILDCARD ROUTES
|
||||
// ==========================================
|
||||
|
||||
// Get attachments for any parent (metadata only, no data)
|
||||
attachmentsRouter.get("/:parentType/:parentId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId } = ctx.params;
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const attachments = await db.collection<AttachmentMeta>("attachments")
|
||||
.find({ parentType, parentId })
|
||||
.project({ data: 0, gridfsFileId: 0 })
|
||||
.sort({ createdAt: -1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { attachments };
|
||||
});
|
||||
|
||||
// Upload attachment — stores in GridFS
|
||||
attachmentsRouter.post("/:parentType/:parentId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId } = ctx.params;
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.json();
|
||||
const { filename, data, mimeType } = body;
|
||||
|
||||
if (!filename || !data) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Filename and data are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeBytes = Math.round(data.length * 0.75);
|
||||
if (sizeBytes > MAX_FILE_SIZE) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "File too large. Maximum 5MB allowed." };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const storedFilename = `${Date.now()}-${filename}`;
|
||||
|
||||
// Store binary data in GridFS
|
||||
const gridfsFileId = await base64ToGridFS(data, storedFilename);
|
||||
|
||||
const result = await db.collection<AttachmentMeta>("attachments").insertOne({
|
||||
parentType: parentType as ParentType,
|
||||
parentId,
|
||||
filename: storedFilename,
|
||||
originalName: filename,
|
||||
mimeType: mimeType || "application/octet-stream",
|
||||
size: sizeBytes,
|
||||
gridfsFileId,
|
||||
uploadedBy: ctx.state.user.id,
|
||||
createdAt: new Date()
|
||||
} as AttachmentMeta);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Attachment uploaded",
|
||||
id: result.insertedId.toString()
|
||||
};
|
||||
});
|
||||
211
src/routes/auth.ts
Normal file
211
src/routes/auth.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { signJWT } from "../utils/jwt.ts";
|
||||
import { hashPassword, verifyPassword } from "../utils/password.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const authRouter = new Router({ prefix: "/api/auth" });
|
||||
|
||||
interface User {
|
||||
_id: ObjectId;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: "admin" | "agent" | "user";
|
||||
gitlabToken?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Register
|
||||
authRouter.post("/register", async (ctx) => {
|
||||
const db = await getDB();
|
||||
|
||||
// Check if registration is allowed
|
||||
const settings = await db.collection("settings").findOne({ _id: "global" });
|
||||
if (settings && !settings.allowRegistration) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Registrierung ist derzeit deaktiviert" };
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.json();
|
||||
const { email, username, password } = body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Email, username and password are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Password must be at least 8 characters" };
|
||||
return;
|
||||
}
|
||||
|
||||
const users = db.collection<User>("users");
|
||||
|
||||
// Check if user exists
|
||||
const existing = await users.findOne({ $or: [{ email }, { username }] });
|
||||
if (existing) {
|
||||
ctx.response.status = 409;
|
||||
ctx.response.body = { error: "User with this email or username already exists" };
|
||||
return;
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = new Date();
|
||||
|
||||
const result = await users.insertOne({
|
||||
email,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
role: "user",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
} as User);
|
||||
|
||||
const token = await signJWT({
|
||||
id: result.insertedId.toString(),
|
||||
email,
|
||||
username,
|
||||
role: "user"
|
||||
});
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "User registered successfully",
|
||||
token,
|
||||
user: { id: result.insertedId.toString(), email, username, role: "user" }
|
||||
};
|
||||
});
|
||||
|
||||
// Login
|
||||
authRouter.post("/login", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { email, password } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Email and password are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const users = db.collection<User>("users");
|
||||
|
||||
const user = await users.findOne({ email });
|
||||
if (!user) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Invalid credentials" };
|
||||
return;
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(password, user.password);
|
||||
if (!validPassword) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Invalid credentials" };
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await signJWT({
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
ctx.response.body = {
|
||||
message: "Login successful",
|
||||
token,
|
||||
user: {
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Get current user (with gitlabToken)
|
||||
authRouter.get("/me", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const users = db.collection<User>("users");
|
||||
const user = await users.findOne({ _id: new ObjectId(ctx.state.user.id) });
|
||||
|
||||
ctx.response.body = {
|
||||
user: ctx.state.user,
|
||||
gitlabToken: user?.gitlabToken || ''
|
||||
};
|
||||
});
|
||||
|
||||
// List all users (id, username, email) for sharing dialogs
|
||||
authRouter.get("/users", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const users = await db.collection<User>("users")
|
||||
.find({}, { projection: { _id: 1, username: 1, email: 1, role: 1 } })
|
||||
.sort({ username: 1 })
|
||||
.toArray();
|
||||
ctx.response.body = { users: users.map(u => ({ _id: u._id.toString(), username: u.username, email: u.email, role: u.role })) };
|
||||
});
|
||||
|
||||
// Update current user (gitlabToken)
|
||||
authRouter.put("/me", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { gitlabToken } = body;
|
||||
|
||||
const db = await getDB();
|
||||
const users = db.collection<User>("users");
|
||||
|
||||
await users.updateOne(
|
||||
{ _id: new ObjectId(ctx.state.user.id) },
|
||||
{ $set: { gitlabToken: gitlabToken || '', updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
ctx.response.body = { message: "Settings updated successfully" };
|
||||
});
|
||||
|
||||
// Change password
|
||||
authRouter.post("/change-password", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { currentPassword, newPassword } = body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Current and new password are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "New password must be at least 8 characters" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const users = db.collection<User>("users");
|
||||
|
||||
const user = await users.findOne({ _id: new ObjectId(ctx.state.user.id) });
|
||||
if (!user) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "User not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(currentPassword, user.password);
|
||||
if (!validPassword) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Current password is incorrect" };
|
||||
return;
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
await users.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { password: hashedPassword, updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
ctx.response.body = { message: "Password changed successfully" };
|
||||
});
|
||||
274
src/routes/comments.ts
Normal file
274
src/routes/comments.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const commentsRouter = new Router({ prefix: "/api/comments" });
|
||||
|
||||
// Valid parent types for comments
|
||||
const VALID_PARENT_TYPES = ["task", "project", "agent"] as const;
|
||||
type ParentType = typeof VALID_PARENT_TYPES[number];
|
||||
|
||||
interface Quote {
|
||||
text: string;
|
||||
commentId?: string;
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
_id: ObjectId;
|
||||
parentType: ParentType;
|
||||
parentId: string;
|
||||
parentCommentId?: string; // For replies to other comments
|
||||
userId: string;
|
||||
username: string;
|
||||
content: string;
|
||||
codeBlock?: { language: string; code: string };
|
||||
quote?: Quote; // For quoting other comments
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Get comments for any parent
|
||||
// GET /api/comments/:parentType/:parentId
|
||||
// Returns flat list with parentCommentId for replies - frontend builds tree
|
||||
commentsRouter.get("/:parentType/:parentId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId } = ctx.params;
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const comments = await db.collection<Comment>("comments")
|
||||
.find({ parentType, parentId })
|
||||
.sort({ createdAt: 1 }) // oldest first for conversation flow
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { comments };
|
||||
});
|
||||
|
||||
// Add comment to any parent
|
||||
// POST /api/comments/:parentType/:parentId
|
||||
// Body: { content, codeBlock?, parentCommentId?, quote? }
|
||||
commentsRouter.post("/:parentType/:parentId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId } = ctx.params;
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.json();
|
||||
const { content, codeBlock, parentCommentId, quote } = body;
|
||||
|
||||
if (!content?.trim() && !codeBlock?.code?.trim()) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Content or code block is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
// Validate parentCommentId if provided
|
||||
if (parentCommentId) {
|
||||
try {
|
||||
const parentComment = await db.collection<Comment>("comments").findOne({
|
||||
_id: new ObjectId(parentCommentId),
|
||||
parentType,
|
||||
parentId
|
||||
});
|
||||
if (!parentComment) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Parent comment not found or not in same thread" };
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid parent comment ID" };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build quote object if provided
|
||||
let quoteData: Quote | undefined;
|
||||
if (quote?.text) {
|
||||
quoteData = {
|
||||
text: quote.text,
|
||||
commentId: quote.commentId,
|
||||
authorName: quote.authorName
|
||||
};
|
||||
}
|
||||
|
||||
const result = await db.collection<Comment>("comments").insertOne({
|
||||
parentType: parentType as ParentType,
|
||||
parentId,
|
||||
parentCommentId: parentCommentId || undefined,
|
||||
userId: ctx.state.user.id,
|
||||
username: ctx.state.user.username,
|
||||
content: content || "",
|
||||
codeBlock: codeBlock?.code ? codeBlock : undefined,
|
||||
quote: quoteData,
|
||||
createdAt: new Date()
|
||||
} as Comment);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Comment added",
|
||||
id: result.insertedId.toString()
|
||||
};
|
||||
});
|
||||
|
||||
// Delete comment (and all replies)
|
||||
// DELETE /api/comments/:commentId
|
||||
commentsRouter.delete("/:commentId", authMiddleware, async (ctx) => {
|
||||
const commentId = ctx.params.commentId;
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const comment = await db.collection<Comment>("comments").findOne({
|
||||
_id: new ObjectId(commentId)
|
||||
});
|
||||
|
||||
if (!comment) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Comment not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Only author or admin can delete
|
||||
if (comment.userId !== ctx.state.user.id && ctx.state.user.role !== "admin") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Not authorized to delete this comment" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all replies (recursively) to delete
|
||||
const commentsToDelete = [commentId];
|
||||
const findReplies = async (parentIds: string[]) => {
|
||||
const replies = await db.collection<Comment>("comments")
|
||||
.find({ parentCommentId: { $in: parentIds } })
|
||||
.toArray();
|
||||
if (replies.length > 0) {
|
||||
const replyIds = replies.map(r => r._id.toString());
|
||||
commentsToDelete.push(...replyIds);
|
||||
await findReplies(replyIds);
|
||||
}
|
||||
};
|
||||
await findReplies([commentId]);
|
||||
|
||||
// Delete all attachments for these comments
|
||||
await db.collection("attachments").deleteMany({
|
||||
parentType: "comment",
|
||||
parentId: { $in: commentsToDelete }
|
||||
});
|
||||
|
||||
// Delete all label assignments for these comments
|
||||
await db.collection("label_assignments").deleteMany({
|
||||
parentType: "comment",
|
||||
parentId: { $in: commentsToDelete }
|
||||
});
|
||||
|
||||
// Delete all comments (original + replies)
|
||||
await db.collection("comments").deleteMany({
|
||||
_id: { $in: commentsToDelete.map(id => new ObjectId(id)) }
|
||||
});
|
||||
|
||||
ctx.response.body = {
|
||||
message: "Comment, replies, and attachments deleted",
|
||||
deletedCount: commentsToDelete.length
|
||||
};
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid comment ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Update comment
|
||||
// PATCH /api/comments/:commentId
|
||||
commentsRouter.patch("/:commentId", authMiddleware, async (ctx) => {
|
||||
const commentId = ctx.params.commentId;
|
||||
const body = await ctx.request.body.json();
|
||||
const { content, codeBlock, quote } = body;
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const comment = await db.collection<Comment>("comments").findOne({
|
||||
_id: new ObjectId(commentId)
|
||||
});
|
||||
|
||||
if (!comment) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Comment not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Only author can edit
|
||||
if (comment.userId !== ctx.state.user.id) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Not authorized to edit this comment" };
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<Comment> = {};
|
||||
if (content !== undefined) updates.content = content;
|
||||
if (codeBlock !== undefined) updates.codeBlock = codeBlock?.code ? codeBlock : undefined;
|
||||
if (quote !== undefined) {
|
||||
updates.quote = quote?.text ? {
|
||||
text: quote.text,
|
||||
commentId: quote.commentId,
|
||||
authorName: quote.authorName
|
||||
} : undefined;
|
||||
}
|
||||
|
||||
await db.collection("comments").updateOne(
|
||||
{ _id: new ObjectId(commentId) },
|
||||
{ $set: updates }
|
||||
);
|
||||
|
||||
ctx.response.body = { message: "Comment updated" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid comment ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get single comment by ID (for API access)
|
||||
// GET /api/comments/single/:commentId
|
||||
commentsRouter.get("/single/:commentId", authMiddleware, async (ctx) => {
|
||||
const commentId = ctx.params.commentId;
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const comment = await db.collection<Comment>("comments").findOne({
|
||||
_id: new ObjectId(commentId)
|
||||
});
|
||||
|
||||
if (!comment) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Comment not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { comment };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid comment ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get replies for a comment
|
||||
// GET /api/comments/replies/:commentId
|
||||
commentsRouter.get("/replies/:commentId", authMiddleware, async (ctx) => {
|
||||
const commentId = ctx.params.commentId;
|
||||
const db = await getDB();
|
||||
|
||||
const replies = await db.collection<Comment>("comments")
|
||||
.find({ parentCommentId: commentId })
|
||||
.sort({ createdAt: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { replies };
|
||||
});
|
||||
366
src/routes/cronjobs.ts
Normal file
366
src/routes/cronjobs.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const cronJobsRouter = new Router({ prefix: "/api/cronjobs" });
|
||||
|
||||
interface CronJob {
|
||||
_id: ObjectId;
|
||||
name: string;
|
||||
description: string;
|
||||
intervalMinutes: number;
|
||||
enabled: boolean;
|
||||
lastRun: Date | null;
|
||||
lastResult: string | null;
|
||||
nextRun: Date | null;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// In-Memory Timer Storage
|
||||
const activeTimers = new Map<string, number>();
|
||||
|
||||
function calculateNextRun(intervalMinutes: number): Date {
|
||||
return new Date(Date.now() + intervalMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function executeWakeCheck(jobId: string) {
|
||||
const db = await getDB();
|
||||
|
||||
// Prüfen ob pending Agent-Tasks existieren
|
||||
const pendingCount = await db.collection("agent_tasks")
|
||||
.countDocuments({ status: "pending" });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Job-Daten für nextRun-Berechnung laden
|
||||
const currentJob = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(jobId) });
|
||||
const nextRun = currentJob ? calculateNextRun(currentJob.intervalMinutes) : null;
|
||||
|
||||
if (pendingCount === 0) {
|
||||
await db.collection<CronJob>("cronjobs").updateOne(
|
||||
{ _id: new ObjectId(jobId) },
|
||||
{ $set: { lastRun: now, lastResult: `Keine pending Tasks (${now.toISOString()})`, nextRun } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock: Kein Wake wenn bereits ein Task in Bearbeitung
|
||||
// Auto-Timeout: Tasks die >30 Minuten in_progress sind, auf pending zurücksetzen
|
||||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const timeoutThreshold = new Date(Date.now() - TIMEOUT_MS);
|
||||
|
||||
const staleResult = await db.collection("agent_tasks").updateMany(
|
||||
{ status: "in_progress", updatedAt: { $lt: timeoutThreshold } },
|
||||
{ $set: { status: "pending", updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
if (staleResult.modifiedCount > 0) {
|
||||
// deno-lint-ignore no-console
|
||||
console.log(`[CronJobs] ${staleResult.modifiedCount} hängende Task(s) auf pending zurückgesetzt`);
|
||||
}
|
||||
|
||||
const inProgressCount = await db.collection("agent_tasks")
|
||||
.countDocuments({ status: "in_progress" });
|
||||
|
||||
if (inProgressCount > 0) {
|
||||
await db.collection<CronJob>("cronjobs").updateOne(
|
||||
{ _id: new ObjectId(jobId) },
|
||||
{ $set: { lastRun: now, lastResult: `Agent arbeitet bereits (${inProgressCount} in_progress)`, nextRun } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wake-Webhook aufrufen
|
||||
const openclawBaseUrl = Deno.env.get("OPENCLAW_WAKE_URL");
|
||||
const openclawToken = Deno.env.get("OPENCLAW_WAKE_TOKEN");
|
||||
const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY") || "";
|
||||
|
||||
if (!openclawBaseUrl || !openclawToken) {
|
||||
await db.collection<CronJob>("cronjobs").updateOne(
|
||||
{ _id: new ObjectId(jobId) },
|
||||
{ $set: { lastRun: now, lastResult: "Fehler: OpenClaw nicht konfiguriert" } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending Tasks laden für Prompt
|
||||
const pendingTasks = await db.collection("agent_tasks")
|
||||
.find({ status: "pending" })
|
||||
.sort({ createdAt: 1 })
|
||||
.toArray();
|
||||
|
||||
const taskList = pendingTasks.map((t, i) =>
|
||||
`${i + 1}. [${t._id}] "${t.message}" (Projekt: ${t.projectName || "–"}, von: ${t.createdByName})`
|
||||
).join("\n");
|
||||
|
||||
const prompt = `WICHTIG: Lade zuerst CHECKLIST.md und WORKFLOW.md aus deinem Workspace und halte dich strikt daran!
|
||||
|
||||
Neue Agent-Tasks im AMS (pending):
|
||||
${taskList}
|
||||
|
||||
Bearbeite jeden Task nach der CHECKLIST.md:
|
||||
1. Task im AMS auf "in_progress" setzen
|
||||
2. Feature-Branch erstellen
|
||||
3. Backend ZUERST implementieren (bei Full-Stack)
|
||||
4. Dark Theme beachten!
|
||||
5. Self-Review: npm run build / deno check
|
||||
6. Conventional Commits auf DEUTSCH
|
||||
7. MR erstellen und mergen
|
||||
8. Tag + Deploy
|
||||
9. Agent-Task auf "done" setzen: curl -X PUT https://api.ams.agentenbude.de/api/agent-tasks/<ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"done","result":"<Zusammenfassung>"}'
|
||||
10. Task-Kommentar + Chef informieren
|
||||
|
||||
API-Auth: Bearer ${serviceKey}
|
||||
AMS-API: https://api.ams.agentenbude.de/api`;
|
||||
|
||||
const agentUrl = openclawBaseUrl.replace("/hooks/wake", "/hooks/agent");
|
||||
|
||||
try {
|
||||
const response = await fetch(agentUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${openclawToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: prompt,
|
||||
name: "AMS-Task",
|
||||
sessionKey: "hook:ams-task",
|
||||
wakeMode: "now",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "7150608398",
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
const resultText = response.ok
|
||||
? `Agent gestartet (${pendingCount} Tasks, ${now.toISOString()})`
|
||||
: `Fehler: HTTP ${response.status}`;
|
||||
|
||||
// nextRun basierend auf intervalMinutes des Jobs berechnen
|
||||
const jobDoc = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(jobId) });
|
||||
const nextRun = jobDoc ? calculateNextRun(jobDoc.intervalMinutes) : null;
|
||||
|
||||
await db.collection<CronJob>("cronjobs").updateOne(
|
||||
{ _id: new ObjectId(jobId) },
|
||||
{ $set: { lastRun: now, lastResult: resultText, nextRun } }
|
||||
);
|
||||
} catch (err) {
|
||||
const jobDoc = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(jobId) }).catch(() => null);
|
||||
const nextRun = jobDoc ? calculateNextRun(jobDoc.intervalMinutes) : null;
|
||||
|
||||
await db.collection<CronJob>("cronjobs").updateOne(
|
||||
{ _id: new ObjectId(jobId) },
|
||||
{ $set: { lastRun: now, lastResult: `Fehler: ${String(err)}`, nextRun } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer(job: CronJob) {
|
||||
stopTimer(job._id.toString());
|
||||
|
||||
const id = job._id.toString();
|
||||
const intervalMs = job.intervalMinutes * 60 * 1000;
|
||||
|
||||
// Berechne Delay bis zum nächsten Lauf
|
||||
let initialDelay = intervalMs;
|
||||
if (job.nextRun) {
|
||||
const msUntilNext = new Date(job.nextRun).getTime() - Date.now();
|
||||
if (msUntilNext <= 0) {
|
||||
// Überfällig → sofort ausführen, dann normales Intervall
|
||||
executeWakeCheck(id);
|
||||
initialDelay = intervalMs;
|
||||
} else {
|
||||
initialDelay = msUntilNext;
|
||||
}
|
||||
}
|
||||
|
||||
// Erster Lauf nach initialDelay, dann reguläres Intervall
|
||||
const firstTimer = setTimeout(() => {
|
||||
executeWakeCheck(id);
|
||||
// Ab jetzt reguläres Intervall
|
||||
const recurringTimer = setInterval(() => {
|
||||
executeWakeCheck(id);
|
||||
}, intervalMs);
|
||||
activeTimers.set(id, recurringTimer as unknown as number);
|
||||
}, initialDelay);
|
||||
|
||||
activeTimers.set(id, firstTimer as unknown as number);
|
||||
}
|
||||
|
||||
function stopTimer(jobId: string) {
|
||||
const existing = activeTimers.get(jobId);
|
||||
if (existing) {
|
||||
clearInterval(existing);
|
||||
activeTimers.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Server-Start: aktive Jobs laden und Timer starten
|
||||
export async function initCronJobs() {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const jobs = await db.collection<CronJob>("cronjobs")
|
||||
.find({ enabled: true })
|
||||
.toArray();
|
||||
|
||||
for (const job of jobs) {
|
||||
startTimer(job);
|
||||
}
|
||||
|
||||
if (jobs.length > 0) {
|
||||
// deno-lint-ignore no-console
|
||||
console.log(`[CronJobs] ${jobs.length} aktive Job(s) gestartet`);
|
||||
}
|
||||
} catch {
|
||||
// DB noch nicht bereit — wird beim ersten Request initialisiert
|
||||
}
|
||||
}
|
||||
|
||||
// Alle CronJobs listen
|
||||
cronJobsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const jobs = await db.collection<CronJob>("cronjobs")
|
||||
.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.toArray();
|
||||
|
||||
// Timer-Status hinzufügen
|
||||
const jobsWithStatus = jobs.map(j => ({
|
||||
...j,
|
||||
timerActive: activeTimers.has(j._id.toString()),
|
||||
}));
|
||||
|
||||
ctx.response.body = { jobs: jobsWithStatus };
|
||||
});
|
||||
|
||||
// CronJob erstellen
|
||||
cronJobsRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const body = await ctx.request.body.json();
|
||||
const { name, description, intervalMinutes, enabled } = body;
|
||||
|
||||
if (!name || !intervalMinutes || intervalMinutes < 1) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Name und intervalMinutes (>= 1) erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const doc: Omit<CronJob, "_id"> = {
|
||||
name,
|
||||
description: description || "",
|
||||
intervalMinutes: Number(intervalMinutes),
|
||||
enabled: enabled !== false,
|
||||
lastRun: null,
|
||||
lastResult: null,
|
||||
nextRun: enabled !== false ? calculateNextRun(Number(intervalMinutes)) : null,
|
||||
createdBy: ctx.state.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await db.collection<CronJob>("cronjobs").insertOne(doc as CronJob);
|
||||
const insertedJob = await db.collection<CronJob>("cronjobs").findOne({ _id: result.insertedId });
|
||||
|
||||
if (insertedJob && insertedJob.enabled) {
|
||||
startTimer(insertedJob);
|
||||
}
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { message: "CronJob erstellt", id: result.insertedId.toString() };
|
||||
});
|
||||
|
||||
// CronJob aktualisieren
|
||||
cronJobsRouter.put("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const id = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (body.name !== undefined) updateFields.name = body.name;
|
||||
if (body.description !== undefined) updateFields.description = body.description;
|
||||
if (body.intervalMinutes !== undefined) {
|
||||
if (Number(body.intervalMinutes) < 1) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "intervalMinutes muss >= 1 sein" };
|
||||
return;
|
||||
}
|
||||
updateFields.intervalMinutes = Number(body.intervalMinutes);
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
updateFields.enabled = body.enabled;
|
||||
if (body.enabled) {
|
||||
const interval = body.intervalMinutes || (await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(id) }))?.intervalMinutes || 30;
|
||||
updateFields.nextRun = calculateNextRun(Number(interval));
|
||||
} else {
|
||||
updateFields.nextRun = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await db.collection<CronJob>("cronjobs").updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (res.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "CronJob nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Timer neu starten/stoppen
|
||||
const updatedJob = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(id) });
|
||||
if (updatedJob) {
|
||||
if (updatedJob.enabled) {
|
||||
startTimer(updatedJob);
|
||||
} else {
|
||||
stopTimer(id);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "CronJob aktualisiert" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// CronJob löschen
|
||||
cronJobsRouter.delete("/:id", authMiddleware, async (ctx) => {
|
||||
const id = ctx.params.id;
|
||||
stopTimer(id);
|
||||
|
||||
const db = await getDB();
|
||||
try {
|
||||
await db.collection<CronJob>("cronjobs").deleteOne({ _id: new ObjectId(id) });
|
||||
ctx.response.body = { message: "CronJob gelöscht" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// CronJob manuell ausführen
|
||||
cronJobsRouter.post("/:id/run", authMiddleware, async (ctx) => {
|
||||
const id = ctx.params.id;
|
||||
|
||||
const db = await getDB();
|
||||
const job = await db.collection<CronJob>("cronjobs").findOne({ _id: new ObjectId(id) });
|
||||
|
||||
if (!job) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "CronJob nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
await executeWakeCheck(id);
|
||||
|
||||
ctx.response.body = { message: "CronJob manuell ausgeführt" };
|
||||
});
|
||||
240
src/routes/docker.ts
Normal file
240
src/routes/docker.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Router, Context, Next } from "@oak/oak";
|
||||
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||
|
||||
export const dockerRouter = new Router({ prefix: "/api/docker" });
|
||||
|
||||
/**
|
||||
* Admin-only guard — must be used AFTER authMiddleware.
|
||||
* Rejects non-admin users with 403.
|
||||
*/
|
||||
async function adminOnly(ctx: Context, next: Next) {
|
||||
const user = (ctx.state as AuthState).user;
|
||||
if (user.role !== "admin") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Admin access required" };
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the container id parameter to prevent command injection.
|
||||
* Allows alphanumeric, hyphens, underscores, and dots (Docker container names/IDs).
|
||||
*/
|
||||
function validateContainerId(id: string): boolean {
|
||||
return /^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$/.test(id);
|
||||
}
|
||||
|
||||
// List all Docker containers
|
||||
dockerRouter.get("/containers", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const cmd = new Deno.Command("docker", {
|
||||
args: ["ps", "-a", "--format", "json"],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { stdout, stderr, success } = await cmd.output();
|
||||
|
||||
if (!success) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Failed to list containers", details: errorText };
|
||||
return;
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const lines = output.trim().split("\n").filter(line => line.length > 0);
|
||||
const containers = lines.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(c => c !== null);
|
||||
|
||||
ctx.response.body = { success: true, containers };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error listing containers:", message);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error", message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get container details
|
||||
dockerRouter.get("/containers/:id", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id || !validateContainerId(id)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid container ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = new Deno.Command("docker", {
|
||||
args: ["inspect", id],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { stdout, stderr, success } = await cmd.output();
|
||||
|
||||
if (!success) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Container not found", details: errorText };
|
||||
return;
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const containerData = JSON.parse(output);
|
||||
|
||||
ctx.response.body = { success: true, container: containerData[0] };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error getting container:", message);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error", message };
|
||||
}
|
||||
});
|
||||
|
||||
// Start container
|
||||
dockerRouter.post("/containers/:id/start", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id || !validateContainerId(id)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid container ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = new Deno.Command("docker", {
|
||||
args: ["start", id],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { stderr, success } = await cmd.output();
|
||||
|
||||
if (!success) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Failed to start container", details: errorText };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { success: true, message: `Container ${id} started` };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error starting container:", message);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error", message };
|
||||
}
|
||||
});
|
||||
|
||||
// Stop container
|
||||
dockerRouter.post("/containers/:id/stop", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id || !validateContainerId(id)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid container ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = new Deno.Command("docker", {
|
||||
args: ["stop", id],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { stderr, success } = await cmd.output();
|
||||
|
||||
if (!success) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Failed to stop container", details: errorText };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { success: true, message: `Container ${id} stopped` };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error stopping container:", message);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error", message };
|
||||
}
|
||||
});
|
||||
|
||||
// Restart container
|
||||
dockerRouter.post("/containers/:id/restart", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id || !validateContainerId(id)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid container ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = new Deno.Command("docker", {
|
||||
args: ["restart", id],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { stderr, success } = await cmd.output();
|
||||
|
||||
if (!success) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Failed to restart container", details: errorText };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { success: true, message: `Container ${id} restarted` };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error restarting container:", message);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error", message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get container logs
|
||||
dockerRouter.get("/containers/:id/logs", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id || !validateContainerId(id)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid container ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
const tailParam = ctx.request.url.searchParams.get("tail") || "100";
|
||||
const tail = Math.min(Math.max(parseInt(tailParam, 10) || 100, 1), 5000).toString();
|
||||
|
||||
const cmd = new Deno.Command("docker", {
|
||||
args: ["logs", "--tail", tail, id],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
const { stdout, stderr, success } = await cmd.output();
|
||||
|
||||
if (!success) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Failed to get logs", details: errorText };
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = new TextDecoder().decode(stdout);
|
||||
ctx.response.body = { success: true, logs };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error getting logs:", message);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error", message };
|
||||
}
|
||||
});
|
||||
138
src/routes/export.ts
Normal file
138
src/routes/export.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||
import type { Context, Next } from "@oak/oak";
|
||||
|
||||
export const exportRouter = new Router({ prefix: "/api/data" });
|
||||
|
||||
async function adminOnly(ctx: Context, next: Next) {
|
||||
const user = (ctx.state as AuthState).user;
|
||||
if (user.role !== "admin") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Admin access required" };
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
// Export all data as JSON
|
||||
exportRouter.get("/export", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const url = ctx.request.url;
|
||||
|
||||
// Which collections to export (default: all)
|
||||
const collectionsParam = url.searchParams.get("collections");
|
||||
const available = ["tasks", "projects", "agents", "labels", "logs", "users"];
|
||||
const requested = collectionsParam
|
||||
? collectionsParam.split(",").filter((c) => available.includes(c))
|
||||
: available;
|
||||
|
||||
const data: Record<string, unknown[]> = {};
|
||||
for (const col of requested) {
|
||||
data[col] = await db.collection(col).find({}).toArray();
|
||||
}
|
||||
|
||||
const exportPayload = {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
collections: requested,
|
||||
data,
|
||||
};
|
||||
|
||||
ctx.response.headers.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="ams-export-${new Date().toISOString().slice(0, 10)}.json"`
|
||||
);
|
||||
ctx.response.type = "application/json";
|
||||
ctx.response.body = exportPayload;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Export failed", message };
|
||||
}
|
||||
});
|
||||
|
||||
// Import data from JSON
|
||||
exportRouter.post("/import", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const body = await ctx.request.body.json();
|
||||
const { data, mode } = body as {
|
||||
data: Record<string, unknown[]>;
|
||||
mode: "merge" | "replace";
|
||||
};
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid import data" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const available = ["tasks", "projects", "agents", "labels", "logs"];
|
||||
const results: Record<string, { inserted: number; skipped: number }> = {};
|
||||
|
||||
for (const [collection, documents] of Object.entries(data)) {
|
||||
// Skip users for security
|
||||
if (!available.includes(collection) || !Array.isArray(documents)) continue;
|
||||
|
||||
if (mode === "replace") {
|
||||
await db.collection(collection).deleteMany({});
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
const { _id, ...rest } = doc as Record<string, unknown>;
|
||||
if (mode === "merge" && _id) {
|
||||
// Try to find existing by _id, skip if exists
|
||||
const existing = await db
|
||||
.collection(collection)
|
||||
.findOne({ _id });
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await db.collection(collection).insertOne(rest);
|
||||
inserted++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
results[collection] = { inserted, skipped };
|
||||
}
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: "Import completed",
|
||||
results,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Import failed", message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get export stats (what's available to export)
|
||||
exportRouter.get("/stats", authMiddleware, adminOnly, async (ctx) => {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const collections = ["tasks", "projects", "agents", "labels", "logs", "users"];
|
||||
const stats: Record<string, number> = {};
|
||||
|
||||
for (const col of collections) {
|
||||
stats[col] = await db.collection(col).countDocuments({});
|
||||
}
|
||||
|
||||
ctx.response.body = { stats };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Failed to get stats", message };
|
||||
}
|
||||
});
|
||||
697
src/routes/gitlab.ts
Normal file
697
src/routes/gitlab.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import {
|
||||
fetchGitLabIssues,
|
||||
importGitLabIssue,
|
||||
fullSync,
|
||||
syncTaskToGitLab,
|
||||
createGitLabIssueFromTask,
|
||||
getMappingForTask,
|
||||
getMappingsForProject,
|
||||
deleteMapping,
|
||||
type GitLabIssue,
|
||||
type IssueMappingDoc,
|
||||
} from "../utils/gitlabSync.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// GitLab API configuration
|
||||
const GITLAB_URL = Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de";
|
||||
const GITLAB_TOKEN = Deno.env.get("GITLAB_TOKEN") || "";
|
||||
|
||||
// Get user's GitLab token or fall back to global
|
||||
async function getUserGitLabToken(userId: string): Promise<string> {
|
||||
const db = await getDB();
|
||||
const user = await db.collection("users").findOne({ _id: new ObjectId(userId) });
|
||||
return user?.gitlabToken || GITLAB_TOKEN;
|
||||
}
|
||||
|
||||
// Unicode-safe base64 decode (handles UTF-8 content)
|
||||
function base64Decode(base64: string): string {
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
// Unicode-safe base64 encode (handles UTF-8 content)
|
||||
function base64Encode(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
interface GitLabProject {
|
||||
id: number;
|
||||
name: string;
|
||||
name_with_namespace: string;
|
||||
path_with_namespace: string;
|
||||
description: string | null;
|
||||
default_branch: string;
|
||||
web_url: string;
|
||||
last_activity_at: string;
|
||||
}
|
||||
|
||||
interface GitLabBranch {
|
||||
name: string;
|
||||
commit: {
|
||||
id: string;
|
||||
short_id: string;
|
||||
title: string;
|
||||
author_name: string;
|
||||
authored_date: string;
|
||||
};
|
||||
default: boolean;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
interface GitLabCommit {
|
||||
id: string;
|
||||
short_id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
authored_date: string;
|
||||
committed_date: string;
|
||||
web_url: string;
|
||||
}
|
||||
|
||||
interface GitLabTreeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tree" | "blob";
|
||||
path: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
// Helper function to call GitLab API
|
||||
async function gitlabFetch<T>(endpoint: string, options: RequestInit = {}, token?: string): Promise<T> {
|
||||
const url = `${GITLAB_URL}/api/v4${endpoint}`;
|
||||
const headers = {
|
||||
"PRIVATE-TOKEN": token || GITLAB_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`GitLab API error (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// GET /api/gitlab/projects - List all accessible projects
|
||||
router.get("/api/gitlab/projects", authMiddleware, async (ctx) => {
|
||||
try {
|
||||
const token = await getUserGitLabToken(ctx.state.user.id);
|
||||
const projects = await gitlabFetch<GitLabProject[]>("/projects?membership=true&order_by=last_activity_at&per_page=100", {}, token);
|
||||
|
||||
ctx.response.body = projects.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
fullName: p.name_with_namespace,
|
||||
path: p.path_with_namespace,
|
||||
description: p.description,
|
||||
defaultBranch: p.default_branch,
|
||||
webUrl: p.web_url,
|
||||
lastActivity: p.last_activity_at,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab projects error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/projects/:id - Get single project details
|
||||
router.get("/api/gitlab/projects/:id", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
|
||||
try {
|
||||
const project = await gitlabFetch<GitLabProject>(`/projects/${projectId}`);
|
||||
|
||||
ctx.response.body = {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
fullName: project.name_with_namespace,
|
||||
path: project.path_with_namespace,
|
||||
description: project.description,
|
||||
defaultBranch: project.default_branch,
|
||||
webUrl: project.web_url,
|
||||
lastActivity: project.last_activity_at,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab project error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/projects/:id/branches - List branches
|
||||
router.get("/api/gitlab/projects/:id/branches", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
|
||||
try {
|
||||
const branches = await gitlabFetch<GitLabBranch[]>(`/projects/${projectId}/repository/branches?per_page=100`);
|
||||
|
||||
ctx.response.body = branches.map(b => ({
|
||||
name: b.name,
|
||||
isDefault: b.default,
|
||||
isProtected: b.protected,
|
||||
lastCommit: {
|
||||
id: b.commit.id,
|
||||
shortId: b.commit.short_id,
|
||||
title: b.commit.title,
|
||||
author: b.commit.author_name,
|
||||
date: b.commit.authored_date,
|
||||
},
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab branches error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/projects/:id/commits - List commits
|
||||
router.get("/api/gitlab/projects/:id/commits", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
const branch = ctx.request.url.searchParams.get("branch") || "main";
|
||||
const perPage = ctx.request.url.searchParams.get("per_page") || "50";
|
||||
const page = ctx.request.url.searchParams.get("page") || "1";
|
||||
|
||||
try {
|
||||
const commits = await gitlabFetch<GitLabCommit[]>(
|
||||
`/projects/${projectId}/repository/commits?ref_name=${encodeURIComponent(branch)}&per_page=${perPage}&page=${page}`
|
||||
);
|
||||
|
||||
ctx.response.body = commits.map(c => ({
|
||||
id: c.id,
|
||||
shortId: c.short_id,
|
||||
title: c.title,
|
||||
message: c.message,
|
||||
author: c.author_name,
|
||||
authorEmail: c.author_email,
|
||||
date: c.authored_date,
|
||||
webUrl: c.web_url,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab commits error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/projects/:id/tree - List files/folders in a path
|
||||
router.get("/api/gitlab/projects/:id/tree", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
const branch = ctx.request.url.searchParams.get("branch") || "main";
|
||||
const path = ctx.request.url.searchParams.get("path") || "";
|
||||
|
||||
try {
|
||||
let endpoint = `/projects/${projectId}/repository/tree?ref=${encodeURIComponent(branch)}&per_page=100`;
|
||||
if (path) {
|
||||
endpoint += `&path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
const items = await gitlabFetch<GitLabTreeItem[]>(endpoint);
|
||||
|
||||
// Sort: folders first, then files, both alphabetically
|
||||
const sorted = items.sort((a, b) => {
|
||||
if (a.type === "tree" && b.type === "blob") return -1;
|
||||
if (a.type === "blob" && b.type === "tree") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
ctx.response.body = sorted.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: item.type === "tree" ? "folder" : "file",
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab tree error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/projects/:id/file - Get file content
|
||||
router.get("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
const branch = ctx.request.url.searchParams.get("branch") || "main";
|
||||
const path = ctx.request.url.searchParams.get("path") || "";
|
||||
|
||||
if (!path) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Path parameter is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const file = await gitlabFetch<{ file_name: string; file_path: string; size: number; encoding: string; content: string; ref: string }>(
|
||||
`/projects/${projectId}/repository/files/${encodedPath}?ref=${encodeURIComponent(branch)}`
|
||||
);
|
||||
|
||||
// Unicode-safe base64 decode
|
||||
const content = base64Decode(file.content);
|
||||
|
||||
ctx.response.body = {
|
||||
name: file.file_name,
|
||||
path: file.file_path,
|
||||
size: file.size,
|
||||
content: content,
|
||||
branch: file.ref,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab file error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/gitlab/projects/:id/file - Update file content
|
||||
router.put("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const { path, branch, content, commitMessage } = body;
|
||||
|
||||
if (!path || !branch || content === undefined || !commitMessage) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "path, branch, content and commitMessage are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getUserGitLabToken(ctx.state.user.id);
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const encodedContent = base64Encode(content);
|
||||
|
||||
await gitlabFetch(
|
||||
`/projects/${projectId}/repository/files/${encodedPath}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
branch: branch,
|
||||
content: encodedContent,
|
||||
encoding: "base64",
|
||||
commit_message: commitMessage,
|
||||
}),
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
ctx.response.body = { success: true, message: "File updated successfully" };
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab file update error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/gitlab/projects/:id/file - Create new file
|
||||
router.post("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const { path, branch, content, commitMessage } = body;
|
||||
|
||||
if (!path || !branch || content === undefined || !commitMessage) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "path, branch, content and commitMessage are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getUserGitLabToken(ctx.state.user.id);
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const encodedContent = base64Encode(content);
|
||||
|
||||
await gitlabFetch(
|
||||
`/projects/${projectId}/repository/files/${encodedPath}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
branch: branch,
|
||||
content: encodedContent,
|
||||
encoding: "base64",
|
||||
commit_message: commitMessage,
|
||||
}),
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
ctx.response.body = { success: true, message: "File created successfully" };
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab file create error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/gitlab/projects/:id/file - Delete file
|
||||
router.delete("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const { path, branch, commitMessage } = body;
|
||||
|
||||
if (!path || !branch || !commitMessage) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "path, branch and commitMessage are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getUserGitLabToken(ctx.state.user.id);
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
|
||||
await gitlabFetch(
|
||||
`/projects/${projectId}/repository/files/${encodedPath}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({
|
||||
branch: branch,
|
||||
commit_message: commitMessage,
|
||||
}),
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
ctx.response.body = { success: true, message: "File deleted successfully" };
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab file delete error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/projects/:id/commit/:sha - Get single commit details
|
||||
router.get("/api/gitlab/projects/:id/commit/:sha", authMiddleware, async (ctx) => {
|
||||
const projectId = ctx.params.id;
|
||||
const sha = ctx.params.sha;
|
||||
|
||||
try {
|
||||
const commit = await gitlabFetch<GitLabCommit & { stats: { additions: number; deletions: number; total: number } }>(
|
||||
`/projects/${projectId}/repository/commits/${sha}`
|
||||
);
|
||||
|
||||
const diff = await gitlabFetch<Array<{ old_path: string; new_path: string; diff: string }>>(
|
||||
`/projects/${projectId}/repository/commits/${sha}/diff`
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
id: commit.id,
|
||||
shortId: commit.short_id,
|
||||
title: commit.title,
|
||||
message: commit.message,
|
||||
author: commit.author_name,
|
||||
authorEmail: commit.author_email,
|
||||
date: commit.authored_date,
|
||||
webUrl: commit.web_url,
|
||||
stats: commit.stats,
|
||||
diff: diff.map(d => ({
|
||||
oldPath: d.old_path,
|
||||
newPath: d.new_path,
|
||||
diff: d.diff,
|
||||
})),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab commit detail error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ GITLAB ISSUES SYNC ============
|
||||
|
||||
// GET /api/gitlab/projects/:id/issues - List GitLab issues
|
||||
router.get("/api/gitlab/projects/:id/issues", authMiddleware, async (ctx) => {
|
||||
const projectId = parseInt(ctx.params.id);
|
||||
const state = ctx.request.url.searchParams.get("state") || "all";
|
||||
const page = parseInt(ctx.request.url.searchParams.get("page") || "1");
|
||||
const perPage = parseInt(ctx.request.url.searchParams.get("per_page") || "50");
|
||||
|
||||
try {
|
||||
const token = await getUserGitLabToken(ctx.state.user.id);
|
||||
const issues = await fetchGitLabIssues(projectId, { state, page, perPage }, token);
|
||||
|
||||
// Check which issues are already mapped
|
||||
const db = await getDB();
|
||||
const mappings = await db.collection<IssueMappingDoc>("gitlab_issue_mappings")
|
||||
.find({ gitlabProjectId: projectId })
|
||||
.toArray();
|
||||
const mappedIids = new Set(mappings.map(m => m.gitlabIssueIid));
|
||||
|
||||
ctx.response.body = issues.map((issue: GitLabIssue) => ({
|
||||
id: issue.id,
|
||||
iid: issue.iid,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
state: issue.state,
|
||||
labels: issue.labels,
|
||||
author: issue.author?.name || issue.author?.username,
|
||||
assignees: issue.assignees?.map(a => a.name || a.username) || [],
|
||||
createdAt: issue.created_at,
|
||||
updatedAt: issue.updated_at,
|
||||
dueDate: issue.due_date,
|
||||
webUrl: issue.web_url,
|
||||
isMapped: mappedIids.has(issue.iid),
|
||||
amsTaskId: mappings.find(m => m.gitlabIssueIid === issue.iid)?.amsTaskId || null,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab issues error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/gitlab/issues/import - Import specific GitLab issues as AMS tasks
|
||||
router.post("/api/gitlab/issues/import", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { gitlabProjectId, issueIids, amsProjectId } = body;
|
||||
|
||||
if (!gitlabProjectId || !amsProjectId) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "gitlabProjectId and amsProjectId are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getUserGitLabToken(ctx.state.user.id);
|
||||
const results: { iid: number; taskId: string; status: string }[] = [];
|
||||
|
||||
if (issueIids && Array.isArray(issueIids)) {
|
||||
// Import specific issues
|
||||
for (const iid of issueIids) {
|
||||
const issues = await fetchGitLabIssues(gitlabProjectId, { state: "all", perPage: 1 }, token);
|
||||
// Fetch single issue by IID
|
||||
const issue = await (async () => {
|
||||
try {
|
||||
return await (await fetch(`${Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de"}/api/v4/projects/${gitlabProjectId}/issues/${iid}`, {
|
||||
headers: { "PRIVATE-TOKEN": token },
|
||||
})).json() as GitLabIssue;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (issue) {
|
||||
const { taskId } = await importGitLabIssue(issue, amsProjectId, ctx.state.user.id, token);
|
||||
results.push({ iid, taskId, status: "imported" });
|
||||
} else {
|
||||
results.push({ iid, taskId: "", status: "not_found" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.response.body = { results, count: results.filter(r => r.status === "imported").length };
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab import error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/gitlab/issues/sync - Full sync: import all issues from GitLab project
|
||||
router.post("/api/gitlab/issues/sync", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { gitlabProjectId, amsProjectId } = body;
|
||||
|
||||
if (!gitlabProjectId || !amsProjectId) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "gitlabProjectId and amsProjectId are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fullSync(gitlabProjectId, amsProjectId, ctx.state.user.id, ctx.state.user.id);
|
||||
ctx.response.body = result;
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab sync error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/gitlab/issues/push - Push AMS task changes to GitLab
|
||||
router.post("/api/gitlab/issues/push", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { amsTaskId } = body;
|
||||
|
||||
if (!amsTaskId) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "amsTaskId is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await syncTaskToGitLab(amsTaskId, ctx.state.user.id);
|
||||
if (success) {
|
||||
ctx.response.body = { message: "Task synced to GitLab" };
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "No mapping found or sync not enabled" };
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab push error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/gitlab/issues/create - Create GitLab issue from AMS task
|
||||
router.post("/api/gitlab/issues/create", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { amsTaskId, gitlabProjectId, amsProjectId } = body;
|
||||
|
||||
if (!amsTaskId || !gitlabProjectId) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "amsTaskId and gitlabProjectId are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await createGitLabIssueFromTask(amsTaskId, gitlabProjectId, amsProjectId || "", ctx.state.user.id);
|
||||
ctx.response.body = {
|
||||
message: "GitLab issue created",
|
||||
issue: {
|
||||
iid: issue.iid,
|
||||
webUrl: issue.web_url,
|
||||
title: issue.title,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab issue create error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/mappings/:taskId - Get mapping for a task
|
||||
router.get("/api/gitlab/mappings/:taskId", authMiddleware, async (ctx) => {
|
||||
try {
|
||||
const mapping = await getMappingForTask(ctx.params.taskId);
|
||||
if (!mapping) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "No mapping found" };
|
||||
return;
|
||||
}
|
||||
ctx.response.body = { mapping };
|
||||
} catch (error: unknown) {
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/gitlab/mappings/project/:projectId - Get all mappings for AMS project
|
||||
router.get("/api/gitlab/mappings/project/:projectId", authMiddleware, async (ctx) => {
|
||||
try {
|
||||
const mappings = await getMappingsForProject(ctx.params.projectId);
|
||||
ctx.response.body = { mappings };
|
||||
} catch (error: unknown) {
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/gitlab/mappings/:taskId - Remove mapping (unlink)
|
||||
router.delete("/api/gitlab/mappings/:taskId", authMiddleware, async (ctx) => {
|
||||
try {
|
||||
const deleted = await deleteMapping(ctx.params.taskId);
|
||||
if (deleted) {
|
||||
ctx.response.body = { message: "Mapping removed" };
|
||||
} else {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "No mapping found" };
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/gitlab/webhook - GitLab webhook for issue events (bidirectional sync)
|
||||
router.post("/api/gitlab/webhook", async (ctx) => {
|
||||
// Verify webhook token
|
||||
const webhookToken = ctx.request.headers.get("X-Gitlab-Token");
|
||||
const expectedToken = Deno.env.get("GITLAB_WEBHOOK_SECRET") || "";
|
||||
|
||||
if (expectedToken && webhookToken !== expectedToken) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Invalid webhook token" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.body.json();
|
||||
const eventType = body.object_kind;
|
||||
|
||||
if (eventType === "issue") {
|
||||
const issue = body.object_attributes as GitLabIssue & { action: string };
|
||||
const db = await getDB();
|
||||
|
||||
const mapping = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({
|
||||
gitlabProjectId: issue.project_id,
|
||||
gitlabIssueIid: issue.iid,
|
||||
});
|
||||
|
||||
if (mapping && mapping.syncDirection !== "ams_to_gitlab") {
|
||||
// Sync GitLab changes to AMS task
|
||||
const updateFields: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (issue.title) updateFields.title = `[GL#${issue.iid}] ${issue.title}`;
|
||||
if (issue.description !== undefined) updateFields.description = issue.description || "";
|
||||
if (issue.state) {
|
||||
updateFields.status = issue.state === "closed" ? "done" : "todo";
|
||||
}
|
||||
if (issue.due_date) updateFields.dueDate = new Date(issue.due_date);
|
||||
|
||||
await db.collection("tasks").updateOne(
|
||||
{ _id: new ObjectId(mapping.amsTaskId) },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
await db.collection("gitlab_issue_mappings").updateOne(
|
||||
{ _id: mapping._id },
|
||||
{ $set: { lastSyncedAt: new Date() } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.response.body = { received: true };
|
||||
} catch (error: unknown) {
|
||||
console.error("GitLab webhook error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
375
src/routes/labels.ts
Normal file
375
src/routes/labels.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const labelsRouter = new Router({ prefix: "/api/labels" });
|
||||
|
||||
// Valid parent types for label assignments
|
||||
const VALID_PARENT_TYPES = ["task", "project", "agent", "comment", "attachment"] as const;
|
||||
type ParentType = typeof VALID_PARENT_TYPES[number];
|
||||
|
||||
interface Label {
|
||||
_id: ObjectId;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface LabelAssignment {
|
||||
_id: ObjectId;
|
||||
labelId: string;
|
||||
parentType: ParentType;
|
||||
parentId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ============ LABEL MANAGEMENT ============
|
||||
|
||||
// Get all labels
|
||||
labelsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const labels = await db.collection<Label>("labels")
|
||||
.find({})
|
||||
.sort({ name: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { labels };
|
||||
});
|
||||
|
||||
// Create label (admin only)
|
||||
labelsRouter.post("/", 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, color } = body;
|
||||
|
||||
if (!name) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Name is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
// Check if label with same name exists
|
||||
const existing = await db.collection<Label>("labels").findOne({ name });
|
||||
if (existing) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Label with this name already exists" };
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.collection<Label>("labels").insertOne({
|
||||
name,
|
||||
color: color || "#6366f1",
|
||||
createdAt: new Date()
|
||||
} as Label);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Label created",
|
||||
id: result.insertedId.toString()
|
||||
};
|
||||
});
|
||||
|
||||
// Update label (admin only)
|
||||
labelsRouter.put("/: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, color } = body;
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
const updateFields: Partial<Label> = {};
|
||||
if (name !== undefined) updateFields.name = name;
|
||||
if (color !== undefined) updateFields.color = color;
|
||||
|
||||
const result = await db.collection<Label>("labels").updateOne(
|
||||
{ _id: new ObjectId(id) },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Label not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Label updated" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid label ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get label usage count
|
||||
// GET /api/labels/:id/usage
|
||||
labelsRouter.get("/:id/usage", authMiddleware, async (ctx) => {
|
||||
const id = ctx.params.id;
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
new ObjectId(id);
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid label ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
const assignmentCount = await db.collection("label_assignments").countDocuments({ labelId: id });
|
||||
const taskCount = await db.collection("tasks").countDocuments({ labels: id });
|
||||
|
||||
ctx.response.body = {
|
||||
labelId: id,
|
||||
usage: {
|
||||
assignments: assignmentCount,
|
||||
tasks: taskCount,
|
||||
total: assignmentCount + taskCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Delete label (admin only) - supports optional replacement
|
||||
// Query params: ?replacementId=... to replace with another label instead of just removing
|
||||
labelsRouter.delete("/: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 replacementId = ctx.request.url.searchParams.get("replacementId");
|
||||
const db = await getDB();
|
||||
|
||||
try {
|
||||
// Verify label exists
|
||||
const label = await db.collection<Label>("labels").findOne({ _id: new ObjectId(id) });
|
||||
if (!label) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Label not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// If replacement requested, verify replacement label exists
|
||||
if (replacementId) {
|
||||
const replacement = await db.collection<Label>("labels").findOne({ _id: new ObjectId(replacementId) });
|
||||
if (!replacement) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Replacement label not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace in label_assignments: update labelId, skip duplicates
|
||||
const assignments = await db.collection("label_assignments").find({ labelId: id }).toArray();
|
||||
for (const assignment of assignments) {
|
||||
const duplicate = await db.collection("label_assignments").findOne({
|
||||
labelId: replacementId,
|
||||
parentType: assignment.parentType,
|
||||
parentId: assignment.parentId,
|
||||
});
|
||||
if (duplicate) {
|
||||
await db.collection("label_assignments").deleteOne({ _id: assignment._id });
|
||||
} else {
|
||||
await db.collection("label_assignments").updateOne(
|
||||
{ _id: assignment._id },
|
||||
{ $set: { labelId: replacementId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace in tasks.labels array
|
||||
const tasksWithLabel = await db.collection("tasks").find({ labels: id }).toArray();
|
||||
for (const task of tasksWithLabel) {
|
||||
const taskLabels = (task.labels as string[]).filter((l: string) => l !== id);
|
||||
if (!taskLabels.includes(replacementId)) {
|
||||
taskLabels.push(replacementId);
|
||||
}
|
||||
await db.collection("tasks").updateOne(
|
||||
{ _id: task._id },
|
||||
{ $set: { labels: taskLabels } }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Just remove: delete assignments and pull from tasks
|
||||
await db.collection("label_assignments").deleteMany({ labelId: id });
|
||||
await db.collection("tasks").updateMany(
|
||||
{ labels: id },
|
||||
// deno-lint-ignore no-explicit-any
|
||||
{ $pull: { labels: id } } as any
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the label itself
|
||||
await db.collection<Label>("labels").deleteOne({ _id: new ObjectId(id) });
|
||||
|
||||
ctx.response.body = {
|
||||
message: replacementId ? "Label ersetzt und gelöscht" : "Label und Zuweisungen gelöscht",
|
||||
};
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid label ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ LABEL ASSIGNMENTS ============
|
||||
|
||||
// Get labels for a parent
|
||||
// GET /api/labels/for/:parentType/:parentId
|
||||
labelsRouter.get("/for/:parentType/:parentId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId } = ctx.params;
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
// Get assignments
|
||||
const assignments = await db.collection<LabelAssignment>("label_assignments")
|
||||
.find({ parentType, parentId })
|
||||
.toArray();
|
||||
|
||||
// Get label details
|
||||
const labelIds = assignments.map(a => new ObjectId(a.labelId));
|
||||
const labels = labelIds.length > 0
|
||||
? await db.collection<Label>("labels").find({ _id: { $in: labelIds } }).toArray()
|
||||
: [];
|
||||
|
||||
ctx.response.body = { labels };
|
||||
});
|
||||
|
||||
// Assign label to a parent
|
||||
// POST /api/labels/assign/:parentType/:parentId
|
||||
labelsRouter.post("/assign/:parentType/:parentId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId } = ctx.params;
|
||||
const body = await ctx.request.body.json();
|
||||
const { labelId } = body;
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
if (!labelId) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "labelId is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
// Check if label exists
|
||||
try {
|
||||
const label = await db.collection<Label>("labels").findOne({ _id: new ObjectId(labelId) });
|
||||
if (!label) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Label not found" };
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid label ID" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already assigned
|
||||
const existing = await db.collection<LabelAssignment>("label_assignments").findOne({
|
||||
labelId, parentType, parentId
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
ctx.response.body = { message: "Label already assigned" };
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection<LabelAssignment>("label_assignments").insertOne({
|
||||
labelId,
|
||||
parentType: parentType as ParentType,
|
||||
parentId,
|
||||
createdAt: new Date()
|
||||
} as LabelAssignment);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { message: "Label assigned" };
|
||||
});
|
||||
|
||||
// Remove label from a parent
|
||||
// DELETE /api/labels/assign/:parentType/:parentId/:labelId
|
||||
labelsRouter.delete("/assign/:parentType/:parentId/:labelId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId, labelId } = ctx.params;
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
const result = await db.collection("label_assignments").deleteOne({
|
||||
labelId, parentType, parentId
|
||||
});
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Assignment not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Label removed" };
|
||||
});
|
||||
|
||||
// Set all labels for a parent (replace existing)
|
||||
// PUT /api/labels/for/:parentType/:parentId
|
||||
labelsRouter.put("/for/:parentType/:parentId", authMiddleware, async (ctx) => {
|
||||
const { parentType, parentId } = ctx.params;
|
||||
const body = await ctx.request.body.json();
|
||||
const { labelIds } = body; // Array of label IDs
|
||||
|
||||
if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` };
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(labelIds)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "labelIds must be an array" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
// Remove all existing assignments
|
||||
await db.collection("label_assignments").deleteMany({ parentType, parentId });
|
||||
|
||||
// Add new assignments
|
||||
if (labelIds.length > 0) {
|
||||
const assignments = labelIds.map(labelId => ({
|
||||
labelId,
|
||||
parentType: parentType as ParentType,
|
||||
parentId,
|
||||
createdAt: new Date()
|
||||
}));
|
||||
|
||||
await db.collection("label_assignments").insertMany(assignments);
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Labels updated", count: labelIds.length };
|
||||
});
|
||||
188
src/routes/logs.ts
Normal file
188
src/routes/logs.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const logsRouter = new Router({ prefix: "/api/logs" });
|
||||
|
||||
interface LogEntry {
|
||||
_id: ObjectId;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
message: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Get logs (with filters)
|
||||
logsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const url = ctx.request.url;
|
||||
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
const agentId = url.searchParams.get("agentId");
|
||||
if (agentId) filter.agentId = agentId;
|
||||
|
||||
const level = url.searchParams.get("level");
|
||||
if (level) filter.level = level;
|
||||
|
||||
const limit = parseInt(url.searchParams.get("limit") || "100");
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
|
||||
// Time filter (last X hours)
|
||||
const hours = url.searchParams.get("hours");
|
||||
if (hours) {
|
||||
const since = new Date(Date.now() - parseInt(hours) * 60 * 60 * 1000);
|
||||
filter.timestamp = { $gte: since };
|
||||
}
|
||||
|
||||
const logs = await db.collection<LogEntry>("logs")
|
||||
.find(filter)
|
||||
.sort({ timestamp: -1 })
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
const total = await db.collection<LogEntry>("logs").countDocuments(filter);
|
||||
|
||||
ctx.response.body = { logs, total, limit, offset };
|
||||
});
|
||||
|
||||
// Create log entry (can be called by agents)
|
||||
logsRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { agentId, agentName, level, message, metadata } = body;
|
||||
|
||||
if (!message) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Message is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
const result = await db.collection<LogEntry>("logs").insertOne({
|
||||
agentId: agentId || ctx.state.user.id,
|
||||
agentName: agentName || ctx.state.user.username,
|
||||
level: level || "info",
|
||||
message,
|
||||
metadata,
|
||||
timestamp: new Date()
|
||||
} as LogEntry);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Log created",
|
||||
id: result.insertedId.toString()
|
||||
};
|
||||
});
|
||||
|
||||
// Bulk create logs (for batch uploads)
|
||||
logsRouter.post("/bulk", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { entries } = body;
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Entries array is required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
const docs = entries.map((entry: Partial<LogEntry>) => ({
|
||||
agentId: entry.agentId || ctx.state.user.id,
|
||||
agentName: entry.agentName || ctx.state.user.username,
|
||||
level: entry.level || "info",
|
||||
message: entry.message || "",
|
||||
metadata: entry.metadata,
|
||||
timestamp: entry.timestamp ? new Date(entry.timestamp) : new Date()
|
||||
}));
|
||||
|
||||
const result = await db.collection<LogEntry>("logs").insertMany(docs);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Logs created",
|
||||
count: result.insertedCount
|
||||
};
|
||||
});
|
||||
|
||||
// Get log statistics
|
||||
logsRouter.get("/stats", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const url = ctx.request.url;
|
||||
|
||||
const hours = parseInt(url.searchParams.get("hours") || "24");
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
const stats = await db.collection<LogEntry>("logs").aggregate([
|
||||
{ $match: { timestamp: { $gte: since } } },
|
||||
{
|
||||
$group: {
|
||||
_id: { agentId: "$agentId", level: "$level" },
|
||||
count: { $sum: 1 },
|
||||
agentName: { $first: "$agentName" }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$_id.agentId",
|
||||
agentName: { $first: "$agentName" },
|
||||
levels: {
|
||||
$push: { level: "$_id.level", count: "$count" }
|
||||
},
|
||||
total: { $sum: "$count" }
|
||||
}
|
||||
},
|
||||
{ $sort: { total: -1 } }
|
||||
]).toArray();
|
||||
|
||||
// Overall stats
|
||||
const overall = await db.collection<LogEntry>("logs").aggregate([
|
||||
{ $match: { timestamp: { $gte: since } } },
|
||||
{
|
||||
$group: {
|
||||
_id: "$level",
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]).toArray();
|
||||
|
||||
ctx.response.body = {
|
||||
byAgent: stats,
|
||||
overall: overall.reduce((acc, item) => {
|
||||
acc[item._id] = item.count;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
hours,
|
||||
since: since.toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
// Delete old logs (admin only)
|
||||
logsRouter.delete("/cleanup", authMiddleware, async (ctx) => {
|
||||
if (ctx.state.user.role !== "admin") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Admin access required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const url = ctx.request.url;
|
||||
const days = parseInt(url.searchParams.get("days") || "30");
|
||||
const before = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const db = await getDB();
|
||||
const result = await db.collection<LogEntry>("logs").deleteMany({
|
||||
timestamp: { $lt: before }
|
||||
});
|
||||
|
||||
ctx.response.body = {
|
||||
message: "Old logs deleted",
|
||||
deleted: result.deletedCount,
|
||||
olderThan: before.toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
70
src/routes/messaging.ts
Normal file
70
src/routes/messaging.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const messagingRouter = new Router({ prefix: "/api/messaging" });
|
||||
|
||||
interface UserSettings {
|
||||
userId: string;
|
||||
telegramBotToken?: string;
|
||||
telegramDefaultChatId?: string;
|
||||
}
|
||||
|
||||
// Nachricht via Telegram Bot senden
|
||||
messagingRouter.post("/telegram", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const { text, chatId } = body;
|
||||
|
||||
if (!text) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Text ist erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
// User-Settings laden
|
||||
const settings = await db.collection<UserSettings>("user_settings")
|
||||
.findOne({ userId });
|
||||
|
||||
if (!settings?.telegramBotToken) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Telegram Bot-Token nicht konfiguriert. Bitte in den Einstellungen hinterlegen." };
|
||||
return;
|
||||
}
|
||||
|
||||
const targetChatId = chatId || settings.telegramDefaultChatId;
|
||||
if (!targetChatId) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Keine Chat-ID angegeben und kein Standard-Chat konfiguriert." };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const telegramUrl = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`;
|
||||
const response = await fetch(telegramUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: targetChatId,
|
||||
text,
|
||||
parse_mode: "Markdown",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.ok) {
|
||||
console.error("Telegram API Fehler:", result);
|
||||
ctx.response.status = 502;
|
||||
ctx.response.body = { error: "Telegram-Nachricht konnte nicht gesendet werden", details: result.description };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Nachricht gesendet", messageId: result.result.message_id };
|
||||
} catch (err) {
|
||||
console.error("Telegram Fehler:", err);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Interner Fehler beim Senden" };
|
||||
}
|
||||
});
|
||||
120
src/routes/quicktexts.ts
Normal file
120
src/routes/quicktexts.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const quickTextsRouter = new Router({ prefix: "/api/quicktexts" });
|
||||
|
||||
interface QuickText {
|
||||
_id: ObjectId;
|
||||
userId: string;
|
||||
title: string;
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Alle Quick-Texts des Users laden
|
||||
quickTextsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
|
||||
const texts = await db.collection<QuickText>("quicktexts")
|
||||
.find({ userId })
|
||||
.sort({ sortOrder: 1, createdAt: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { quickTexts: texts };
|
||||
});
|
||||
|
||||
// Quick-Text erstellen
|
||||
quickTextsRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const { title, text } = body;
|
||||
|
||||
if (!title || !text) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Titel und Text sind erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Nächste sortOrder ermitteln
|
||||
const last = await db.collection<QuickText>("quicktexts")
|
||||
.find({ userId })
|
||||
.sort({ sortOrder: -1 })
|
||||
.limit(1)
|
||||
.toArray();
|
||||
const sortOrder = last.length > 0 ? (last[0].sortOrder || 0) + 1 : 0;
|
||||
|
||||
const now = new Date();
|
||||
const result = await db.collection<QuickText>("quicktexts").insertOne({
|
||||
userId,
|
||||
title,
|
||||
text,
|
||||
sortOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as QuickText);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { message: "Quick-Text erstellt", id: result.insertedId.toString() };
|
||||
});
|
||||
|
||||
// Quick-Text aktualisieren
|
||||
quickTextsRouter.put("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const { title, text, sortOrder } = body;
|
||||
|
||||
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (title !== undefined) updateFields.title = title;
|
||||
if (text !== undefined) updateFields.text = text;
|
||||
if (sortOrder !== undefined) updateFields.sortOrder = sortOrder;
|
||||
|
||||
try {
|
||||
const result = await db.collection<QuickText>("quicktexts").updateOne(
|
||||
{ _id: new ObjectId(id), userId },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Quick-Text nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Quick-Text aktualisiert" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Quick-Text löschen
|
||||
quickTextsRouter.delete("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
const result = await db.collection<QuickText>("quicktexts").deleteOne(
|
||||
{ _id: new ObjectId(id), userId }
|
||||
);
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Quick-Text nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { message: "Quick-Text gelöscht" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
131
src/routes/secretAuditLogs.ts
Normal file
131
src/routes/secretAuditLogs.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
type AuditAction =
|
||||
| "secret.created"
|
||||
| "secret.read"
|
||||
| "secret.updated"
|
||||
| "secret.deleted"
|
||||
| "secret.password_viewed"
|
||||
| "folder.created"
|
||||
| "folder.updated"
|
||||
| "folder.deleted";
|
||||
|
||||
interface SecretAuditLog {
|
||||
_id: ObjectId;
|
||||
userId: string;
|
||||
userName: string;
|
||||
action: AuditAction;
|
||||
targetType: "secret" | "folder";
|
||||
targetId: string;
|
||||
targetName: string;
|
||||
details: Record<string, unknown>;
|
||||
ip: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// --- Helper: Audit-Log schreiben ---
|
||||
|
||||
export async function logSecretAudit(params: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
action: AuditAction;
|
||||
targetType: "secret" | "folder";
|
||||
targetId: string;
|
||||
targetName: string;
|
||||
details?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.collection<SecretAuditLog>("secret_audit_logs").insertOne({
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
action: params.action,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId,
|
||||
targetName: params.targetName,
|
||||
details: params.details || {},
|
||||
ip: params.ip || "",
|
||||
createdAt: new Date(),
|
||||
} as SecretAuditLog);
|
||||
}
|
||||
|
||||
// --- Router: Audit-Logs abfragen ---
|
||||
|
||||
export const secretAuditLogsRouter = new Router({ prefix: "/api/secret-audit-logs" });
|
||||
|
||||
secretAuditLogsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const params = ctx.request.url.searchParams;
|
||||
|
||||
const page = Math.max(parseInt(params.get("page") || "1"), 1);
|
||||
const limit = Math.min(Math.max(parseInt(params.get("limit") || "50"), 1), 200);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
const action = params.get("action");
|
||||
if (action) filter.action = action;
|
||||
|
||||
const targetType = params.get("targetType");
|
||||
if (targetType && ["secret", "folder"].includes(targetType)) {
|
||||
filter.targetType = targetType;
|
||||
}
|
||||
|
||||
const targetId = params.get("targetId");
|
||||
if (targetId) filter.targetId = targetId;
|
||||
|
||||
const userId = params.get("userId");
|
||||
if (userId) filter.userId = userId;
|
||||
|
||||
// Datumsfilter
|
||||
const from = params.get("from");
|
||||
const to = params.get("to");
|
||||
if (from || to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (from) dateFilter.$gte = new Date(from);
|
||||
if (to) dateFilter.$lte = new Date(to);
|
||||
filter.createdAt = dateFilter;
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
db.collection<SecretAuditLog>("secret_audit_logs")
|
||||
.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db.collection<SecretAuditLog>("secret_audit_logs")
|
||||
.countDocuments(filter),
|
||||
]);
|
||||
|
||||
ctx.response.body = {
|
||||
logs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Logs für ein bestimmtes Secret/Folder
|
||||
secretAuditLogsRouter.get("/target/:targetId", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const targetId = ctx.params.targetId;
|
||||
const params = ctx.request.url.searchParams;
|
||||
const limit = Math.min(Math.max(parseInt(params.get("limit") || "50"), 1), 200);
|
||||
|
||||
const logs = await db.collection<SecretAuditLog>("secret_audit_logs")
|
||||
.find({ targetId })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { logs };
|
||||
});
|
||||
576
src/routes/secrets.ts
Normal file
576
src/routes/secrets.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { logSecretAudit } from "./secretAuditLogs.ts";
|
||||
|
||||
export const secretsRouter = new Router({ prefix: "/api/secrets" });
|
||||
export const secretFoldersRouter = new Router({ prefix: "/api/secret-folders" });
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
type SharingMode = "private" | "users" | "all";
|
||||
|
||||
interface SecretFolder {
|
||||
_id: ObjectId;
|
||||
userId: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
sharing: SharingMode;
|
||||
sharedWith: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type SecretType = "login" | "note" | "card" | "ssh-key" | "other";
|
||||
|
||||
interface Secret {
|
||||
_id: ObjectId;
|
||||
userId: string;
|
||||
folderId: string | null;
|
||||
type: SecretType;
|
||||
name: string;
|
||||
username: string;
|
||||
passwordEncrypted: string;
|
||||
passwordIv: string;
|
||||
passwordTag: string;
|
||||
url: string;
|
||||
notes: string;
|
||||
cardHolder: string;
|
||||
cardNumber: string;
|
||||
cardExpiry: string;
|
||||
cardCvv: string;
|
||||
sshPublicKey: string;
|
||||
sshPrivateKeyEncrypted: string;
|
||||
sshPrivateKeyIv: string;
|
||||
sshPrivateKeyTag: string;
|
||||
sharing: SharingMode;
|
||||
sharedWith: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// --- Encryption helpers ---
|
||||
|
||||
function getEncryptionKey(): CryptoKey | Promise<CryptoKey> {
|
||||
const keyHex = Deno.env.get("SECRETS_ENCRYPTION_KEY");
|
||||
if (!keyHex) {
|
||||
throw new Error("SECRETS_ENCRYPTION_KEY nicht gesetzt");
|
||||
}
|
||||
const keyBytes = new Uint8Array(keyHex.match(/.{1,2}/g)!.map((b: string) => parseInt(b, 16)));
|
||||
return crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
async function encrypt(plaintext: string): Promise<{ ciphertext: string; iv: string; tag: string }> {
|
||||
if (!plaintext) return { ciphertext: "", iv: "", tag: "" };
|
||||
const key = await getEncryptionKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
||||
const buf = new Uint8Array(encrypted);
|
||||
// AES-GCM appends 16-byte tag
|
||||
const ciphertext = buf.slice(0, buf.length - 16);
|
||||
const tag = buf.slice(buf.length - 16);
|
||||
return {
|
||||
ciphertext: toHex(ciphertext),
|
||||
iv: toHex(iv),
|
||||
tag: toHex(tag),
|
||||
};
|
||||
}
|
||||
|
||||
async function decrypt(ciphertext: string, ivHex: string, tagHex: string): Promise<string> {
|
||||
if (!ciphertext) return "";
|
||||
const key = await getEncryptionKey();
|
||||
const iv = fromHex(ivHex);
|
||||
const ct = fromHex(ciphertext);
|
||||
const tag = fromHex(tagHex);
|
||||
const combined = new Uint8Array(ct.length + tag.length);
|
||||
combined.set(ct);
|
||||
combined.set(tag, ct.length);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, combined);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
function toHex(buf: Uint8Array): string {
|
||||
return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array {
|
||||
return new Uint8Array(hex.match(/.{1,2}/g)!.map((b) => parseInt(b, 16)));
|
||||
}
|
||||
|
||||
// --- Password Generator ---
|
||||
|
||||
secretsRouter.get("/generate-password", authMiddleware, (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const length = Math.min(Math.max(parseInt(params.get("length") || "16"), 4), 128);
|
||||
const uppercase = params.get("uppercase") !== "false";
|
||||
const lowercase = params.get("lowercase") !== "false";
|
||||
const numbers = params.get("numbers") !== "false";
|
||||
const symbols = params.get("symbols") !== "false";
|
||||
|
||||
let charset = "";
|
||||
if (uppercase) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
if (lowercase) charset += "abcdefghijklmnopqrstuvwxyz";
|
||||
if (numbers) charset += "0123456789";
|
||||
if (symbols) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||
|
||||
if (!charset) charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
const randomValues = crypto.getRandomValues(new Uint8Array(length));
|
||||
const password = Array.from(randomValues).map((v) => charset[v % charset.length]).join("");
|
||||
|
||||
ctx.response.body = { password };
|
||||
});
|
||||
|
||||
// --- Secrets CRUD ---
|
||||
|
||||
secretsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const params = ctx.request.url.searchParams;
|
||||
const search = params.get("search") || "";
|
||||
const folderId = params.get("folder") || "";
|
||||
|
||||
// Eigene + geteilte Secrets (all oder user in sharedWith)
|
||||
const accessFilter = { $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] };
|
||||
const filter: Record<string, unknown> = { ...accessFilter };
|
||||
if (folderId) filter.folderId = folderId;
|
||||
if (search) {
|
||||
filter.$and = [
|
||||
{ $or: [
|
||||
{ name: { $regex: search, $options: "i" } },
|
||||
{ username: { $regex: search, $options: "i" } },
|
||||
{ url: { $regex: search, $options: "i" } },
|
||||
{ notes: { $regex: search, $options: "i" } },
|
||||
]}
|
||||
];
|
||||
}
|
||||
|
||||
const secrets = await db.collection<Secret>("secrets")
|
||||
.find(filter)
|
||||
.sort({ name: 1 })
|
||||
.toArray();
|
||||
|
||||
// Liste OHNE Passwörter — nur hasPassword-Flag
|
||||
const mapped = secrets.map((s) => ({
|
||||
_id: s._id,
|
||||
userId: s.userId,
|
||||
folderId: s.folderId,
|
||||
type: s.type,
|
||||
name: s.name,
|
||||
username: s.username || "",
|
||||
hasPassword: !!(s.passwordEncrypted),
|
||||
url: s.url || "",
|
||||
notes: s.notes || "",
|
||||
cardHolder: s.cardHolder || "",
|
||||
cardNumber: s.cardNumber || "",
|
||||
cardExpiry: s.cardExpiry || "",
|
||||
cardCvv: s.cardCvv || "",
|
||||
sshPublicKey: s.sshPublicKey || "",
|
||||
hasSshPrivateKey: !!(s.sshPrivateKeyEncrypted),
|
||||
sharing: s.sharing || "private",
|
||||
sharedWith: s.sharedWith || [],
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
}));
|
||||
|
||||
ctx.response.body = { secrets: mapped };
|
||||
});
|
||||
|
||||
// --- Einzelnes Secret abrufen (mit Passwort) ---
|
||||
|
||||
secretsRouter.get("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
const s = await db.collection<Secret>("secrets").findOne({
|
||||
_id: new ObjectId(id),
|
||||
$or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }],
|
||||
});
|
||||
|
||||
if (!s) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Secret nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Einzelabruf OHNE Passwort
|
||||
ctx.response.body = {
|
||||
secret: {
|
||||
_id: s._id,
|
||||
userId: s.userId,
|
||||
folderId: s.folderId,
|
||||
type: s.type,
|
||||
name: s.name,
|
||||
username: s.username || "",
|
||||
hasPassword: !!(s.passwordEncrypted),
|
||||
url: s.url || "",
|
||||
notes: s.notes || "",
|
||||
cardHolder: s.cardHolder || "",
|
||||
cardNumber: s.cardNumber || "",
|
||||
cardExpiry: s.cardExpiry || "",
|
||||
cardCvv: s.cardCvv || "",
|
||||
sshPublicKey: s.sshPublicKey || "",
|
||||
hasSshPrivateKey: !!(s.sshPrivateKeyEncrypted),
|
||||
sharing: s.sharing || "private",
|
||||
sharedWith: s.sharedWith || [],
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Passwort/sensible Daten abrufen (mit Audit-Log) ---
|
||||
|
||||
secretsRouter.get("/:id/reveal", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
const s = await db.collection<Secret>("secrets").findOne({
|
||||
_id: new ObjectId(id),
|
||||
$or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }],
|
||||
});
|
||||
|
||||
if (!s) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Secret nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
let password = "";
|
||||
let sshPrivateKey = "";
|
||||
let cardCvv = "";
|
||||
try { password = await decrypt(s.passwordEncrypted, s.passwordIv, s.passwordTag); } catch { /* empty */ }
|
||||
try { sshPrivateKey = await decrypt(s.sshPrivateKeyEncrypted || "", s.sshPrivateKeyIv || "", s.sshPrivateKeyTag || ""); } catch { /* empty */ }
|
||||
cardCvv = s.cardCvv || "";
|
||||
|
||||
// Audit-Log: Passwort angesehen
|
||||
await logSecretAudit({
|
||||
userId,
|
||||
userName: ctx.state.user.username || ctx.state.user.email || userId,
|
||||
action: "secret.password_viewed",
|
||||
targetType: "secret",
|
||||
targetId: id,
|
||||
targetName: s.name,
|
||||
ip: ctx.request.ip || "",
|
||||
});
|
||||
|
||||
ctx.response.body = { password, sshPrivateKey, cardCvv };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
secretsRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const { name, type, folderId, username, password, url, notes, cardHolder, cardNumber, cardExpiry, cardCvv, sshPublicKey, sshPrivateKey, sharing, sharedWith } = body;
|
||||
|
||||
if (!name || !type) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Name und Typ sind erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes: SecretType[] = ["login", "note", "card", "ssh-key", "other"];
|
||||
if (!validTypes.includes(type)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültiger Typ" };
|
||||
return;
|
||||
}
|
||||
|
||||
const encPassword = await encrypt(password || "");
|
||||
const encSshKey = await encrypt(sshPrivateKey || "");
|
||||
const now = new Date();
|
||||
|
||||
const result = await db.collection<Secret>("secrets").insertOne({
|
||||
userId,
|
||||
folderId: folderId || null,
|
||||
type,
|
||||
name,
|
||||
username: username || "",
|
||||
passwordEncrypted: encPassword.ciphertext,
|
||||
passwordIv: encPassword.iv,
|
||||
passwordTag: encPassword.tag,
|
||||
url: url || "",
|
||||
notes: notes || "",
|
||||
cardHolder: cardHolder || "",
|
||||
cardNumber: cardNumber || "",
|
||||
cardExpiry: cardExpiry || "",
|
||||
cardCvv: cardCvv || "",
|
||||
sshPublicKey: sshPublicKey || "",
|
||||
sshPrivateKeyEncrypted: encSshKey.ciphertext,
|
||||
sshPrivateKeyIv: encSshKey.iv,
|
||||
sshPrivateKeyTag: encSshKey.tag,
|
||||
sharing: (["private", "users", "all"].includes(sharing) ? sharing : "private") as SharingMode,
|
||||
sharedWith: Array.isArray(sharedWith) ? sharedWith : [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as Secret);
|
||||
|
||||
const ip = ctx.request.ip || "";
|
||||
await logSecretAudit({
|
||||
userId,
|
||||
userName: ctx.state.user.email || ctx.state.user.name || userId,
|
||||
action: "secret.created",
|
||||
targetType: "secret",
|
||||
targetId: result.insertedId.toString(),
|
||||
targetName: name,
|
||||
details: { type, folderId: folderId || null, sharing: sharing || "private" },
|
||||
ip,
|
||||
});
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { message: "Secret erstellt", id: result.insertedId.toString() };
|
||||
});
|
||||
|
||||
secretsRouter.put("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (body.name !== undefined) updateFields.name = body.name;
|
||||
if (body.type !== undefined) updateFields.type = body.type;
|
||||
if (body.folderId !== undefined) updateFields.folderId = body.folderId || null;
|
||||
if (body.username !== undefined) updateFields.username = body.username;
|
||||
if (body.url !== undefined) updateFields.url = body.url;
|
||||
if (body.notes !== undefined) updateFields.notes = body.notes;
|
||||
if (body.cardHolder !== undefined) updateFields.cardHolder = body.cardHolder;
|
||||
if (body.cardNumber !== undefined) updateFields.cardNumber = body.cardNumber;
|
||||
if (body.cardExpiry !== undefined) updateFields.cardExpiry = body.cardExpiry;
|
||||
if (body.cardCvv !== undefined) updateFields.cardCvv = body.cardCvv;
|
||||
if (body.sshPublicKey !== undefined) updateFields.sshPublicKey = body.sshPublicKey;
|
||||
if (body.sharing !== undefined && ["private", "users", "all"].includes(body.sharing)) updateFields.sharing = body.sharing;
|
||||
if (body.sharedWith !== undefined) updateFields.sharedWith = Array.isArray(body.sharedWith) ? body.sharedWith : [];
|
||||
|
||||
if (body.password !== undefined) {
|
||||
const enc = await encrypt(body.password);
|
||||
updateFields.passwordEncrypted = enc.ciphertext;
|
||||
updateFields.passwordIv = enc.iv;
|
||||
updateFields.passwordTag = enc.tag;
|
||||
}
|
||||
|
||||
if (body.sshPrivateKey !== undefined) {
|
||||
const enc = await encrypt(body.sshPrivateKey);
|
||||
updateFields.sshPrivateKeyEncrypted = enc.ciphertext;
|
||||
updateFields.sshPrivateKeyIv = enc.iv;
|
||||
updateFields.sshPrivateKeyTag = enc.tag;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.collection<Secret>("secrets").updateOne(
|
||||
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Secret nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
const changedFields = Object.keys(updateFields).filter((k) => k !== "updatedAt");
|
||||
await logSecretAudit({
|
||||
userId,
|
||||
userName: ctx.state.user.email || ctx.state.user.name || userId,
|
||||
action: "secret.updated",
|
||||
targetType: "secret",
|
||||
targetId: id,
|
||||
targetName: body.name || id,
|
||||
details: { changedFields, passwordChanged: body.password !== undefined },
|
||||
ip: ctx.request.ip || "",
|
||||
});
|
||||
|
||||
ctx.response.body = { message: "Secret aktualisiert" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
secretsRouter.delete("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
const result = await db.collection<Secret>("secrets").deleteOne(
|
||||
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] }
|
||||
);
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Secret nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
await logSecretAudit({
|
||||
userId,
|
||||
userName: ctx.state.user.email || ctx.state.user.name || userId,
|
||||
action: "secret.deleted",
|
||||
targetType: "secret",
|
||||
targetId: id,
|
||||
targetName: id,
|
||||
ip: ctx.request.ip || "",
|
||||
});
|
||||
|
||||
ctx.response.body = { message: "Secret gelöscht" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Secret Folders CRUD ---
|
||||
|
||||
secretFoldersRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
|
||||
const folders = await db.collection<SecretFolder>("secret_folders")
|
||||
.find({ $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] })
|
||||
.sort({ name: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { folders: folders.map(f => ({ ...f, sharing: f.sharing || "private", sharedWith: f.sharedWith || [] })) };
|
||||
});
|
||||
|
||||
secretFoldersRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const { name, parentId } = body;
|
||||
|
||||
if (!name) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Name ist erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const result = await db.collection<SecretFolder>("secret_folders").insertOne({
|
||||
userId,
|
||||
name,
|
||||
parentId: parentId || null,
|
||||
sharing: (["private", "users", "all"].includes(body.sharing) ? body.sharing : "private") as SharingMode,
|
||||
sharedWith: Array.isArray(body.sharedWith) ? body.sharedWith : [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as SecretFolder);
|
||||
|
||||
await logSecretAudit({
|
||||
userId,
|
||||
userName: ctx.state.user.email || ctx.state.user.name || userId,
|
||||
action: "folder.created",
|
||||
targetType: "folder",
|
||||
targetId: result.insertedId.toString(),
|
||||
targetName: name,
|
||||
details: { parentId: parentId || null },
|
||||
ip: ctx.request.ip || "",
|
||||
});
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { message: "Ordner erstellt", id: result.insertedId.toString() };
|
||||
});
|
||||
|
||||
secretFoldersRouter.put("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.name !== undefined) updateFields.name = body.name;
|
||||
if (body.parentId !== undefined) updateFields.parentId = body.parentId || null;
|
||||
if (body.sharing !== undefined && ["private", "users", "all"].includes(body.sharing)) updateFields.sharing = body.sharing;
|
||||
if (body.sharedWith !== undefined) updateFields.sharedWith = Array.isArray(body.sharedWith) ? body.sharedWith : [];
|
||||
|
||||
try {
|
||||
const result = await db.collection<SecretFolder>("secret_folders").updateOne(
|
||||
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Ordner nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
await logSecretAudit({
|
||||
userId,
|
||||
userName: ctx.state.user.email || ctx.state.user.name || userId,
|
||||
action: "folder.updated",
|
||||
targetType: "folder",
|
||||
targetId: id,
|
||||
targetName: body.name || id,
|
||||
details: { changedFields: Object.keys(updateFields).filter((k) => k !== "updatedAt") },
|
||||
ip: ctx.request.ip || "",
|
||||
});
|
||||
|
||||
ctx.response.body = { message: "Ordner aktualisiert" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
secretFoldersRouter.delete("/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
// Secrets in diesem Ordner auf null setzen
|
||||
await db.collection<Secret>("secrets").updateMany(
|
||||
{ folderId: id, $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
|
||||
{ $set: { folderId: null, updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
// Unterordner auf null setzen
|
||||
await db.collection<SecretFolder>("secret_folders").updateMany(
|
||||
{ parentId: id, $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] },
|
||||
{ $set: { parentId: null, updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
const result = await db.collection<SecretFolder>("secret_folders").deleteOne(
|
||||
{ _id: new ObjectId(id), $or: [{ userId }, { sharing: "all" }, { sharing: "users", sharedWith: userId }] }
|
||||
);
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Ordner nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
await logSecretAudit({
|
||||
userId,
|
||||
userName: ctx.state.user.email || ctx.state.user.name || userId,
|
||||
action: "folder.deleted",
|
||||
targetType: "folder",
|
||||
targetId: id,
|
||||
targetName: id,
|
||||
ip: ctx.request.ip || "",
|
||||
});
|
||||
|
||||
ctx.response.body = { message: "Ordner gelöscht" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
72
src/routes/settings.ts
Normal file
72
src/routes/settings.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const settingsRouter = new Router({ prefix: "/api/settings" });
|
||||
|
||||
interface Settings {
|
||||
_id: string;
|
||||
allowRegistration: boolean;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Omit<Settings, "_id"> = {
|
||||
allowRegistration: false,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// Get settings (public - needed for login page to know if registration is allowed)
|
||||
settingsRouter.get("/", async (ctx) => {
|
||||
const db = await getDB();
|
||||
const settings = db.collection<Settings>("settings");
|
||||
|
||||
let doc = await settings.findOne({ _id: "global" });
|
||||
if (!doc) {
|
||||
// Initialize default settings
|
||||
await settings.insertOne({ _id: "global", ...DEFAULT_SETTINGS } as Settings);
|
||||
doc = { _id: "global", ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
ctx.response.body = {
|
||||
allowRegistration: doc.allowRegistration
|
||||
};
|
||||
});
|
||||
|
||||
// Update settings (admin only)
|
||||
settingsRouter.put("/", authMiddleware, async (ctx) => {
|
||||
// Check if user is admin
|
||||
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 { allowRegistration } = body;
|
||||
|
||||
if (typeof allowRegistration !== "boolean") {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "allowRegistration must be a boolean" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const settings = db.collection<Settings>("settings");
|
||||
|
||||
await settings.updateOne(
|
||||
{ _id: "global" },
|
||||
{
|
||||
$set: {
|
||||
allowRegistration,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
message: "Settings updated",
|
||||
allowRegistration
|
||||
};
|
||||
});
|
||||
|
||||
884
src/routes/tasks.ts
Normal file
884
src/routes/tasks.ts
Normal file
@@ -0,0 +1,884 @@
|
||||
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 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;
|
||||
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 } = 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,
|
||||
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 } = 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 || [];
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
346
src/routes/tokens.ts
Normal file
346
src/routes/tokens.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const tokensRouter = new Router({ prefix: "/api/tokens" });
|
||||
|
||||
interface TokenUsage {
|
||||
_id?: ObjectId;
|
||||
agentId: ObjectId;
|
||||
agentName?: string;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
cost?: number;
|
||||
sessionId?: string;
|
||||
timestamp: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface TokenLimit {
|
||||
_id?: ObjectId;
|
||||
agentId?: ObjectId;
|
||||
dailyLimit?: number;
|
||||
monthlyLimit?: number;
|
||||
alertThreshold?: number;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// POST /api/tokens - Track token usage
|
||||
tokensRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { agentId, agentName, model, inputTokens, outputTokens, sessionId } = body;
|
||||
|
||||
if (!agentId || !model || inputTokens === undefined || outputTokens === undefined) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "agentId, model, inputTokens, and outputTokens are required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
const now = new Date();
|
||||
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
// Calculate cost (example pricing: $0.01 per 1K tokens)
|
||||
const cost = (totalTokens / 1000) * 0.01;
|
||||
|
||||
const usage: TokenUsage = {
|
||||
agentId: new ObjectId(agentId),
|
||||
agentName,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
cost,
|
||||
sessionId,
|
||||
timestamp: now,
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
const result = await db.collection<TokenUsage>("token_usage").insertOne(usage);
|
||||
|
||||
// Check limits and send alerts if needed
|
||||
await checkLimits(agentId, db);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Token usage recorded",
|
||||
id: result.insertedId.toString(),
|
||||
totalTokens,
|
||||
cost
|
||||
};
|
||||
});
|
||||
|
||||
// GET /api/tokens - Get token usage with filters
|
||||
tokensRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const url = new URL(ctx.request.url);
|
||||
|
||||
// Parse query params
|
||||
const agentId = url.searchParams.get("agentId");
|
||||
const model = url.searchParams.get("model");
|
||||
const startDate = url.searchParams.get("startDate");
|
||||
const endDate = url.searchParams.get("endDate");
|
||||
const page = parseInt(url.searchParams.get("page") || "1");
|
||||
const limit = parseInt(url.searchParams.get("limit") || "100");
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build query
|
||||
const query: Record<string, unknown> = {};
|
||||
|
||||
if (agentId) {
|
||||
query.agentId = new ObjectId(agentId);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
query.model = model;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
query.timestamp = {};
|
||||
if (startDate) query.timestamp.$gte = new Date(startDate);
|
||||
if (endDate) query.timestamp.$lte = new Date(endDate);
|
||||
}
|
||||
|
||||
// Get usage records
|
||||
const usage = await db.collection<TokenUsage>("token_usage")
|
||||
.find(query)
|
||||
.sort({ timestamp: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
// Get total count
|
||||
const total = await db.collection<TokenUsage>("token_usage").countDocuments(query);
|
||||
|
||||
ctx.response.body = {
|
||||
usage,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
};
|
||||
});
|
||||
|
||||
// GET /api/tokens/stats - Get usage statistics
|
||||
tokensRouter.get("/stats", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const url = new URL(ctx.request.url);
|
||||
|
||||
const agentId = url.searchParams.get("agentId");
|
||||
const period = url.searchParams.get("period") || "day"; // day, week, month
|
||||
|
||||
// Calculate date range
|
||||
let startDate = new Date();
|
||||
if (period === "day") {
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
} else if (period === "week") {
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
} else if (period === "month") {
|
||||
startDate.setMonth(startDate.getMonth() - 1);
|
||||
}
|
||||
|
||||
const query: Record<string, unknown> = {
|
||||
timestamp: { $gte: startDate }
|
||||
};
|
||||
|
||||
if (agentId) {
|
||||
query.agentId = new ObjectId(agentId);
|
||||
}
|
||||
|
||||
// Aggregate statistics
|
||||
const stats = await db.collection<TokenUsage>("token_usage").aggregate([
|
||||
{ $match: query },
|
||||
{
|
||||
$group: {
|
||||
_id: agentId ? "$model" : "$agentId",
|
||||
totalTokens: { $sum: "$totalTokens" },
|
||||
totalCost: { $sum: "$cost" },
|
||||
inputTokens: { $sum: "$inputTokens" },
|
||||
outputTokens: { $sum: "$outputTokens" },
|
||||
count: { $sum: 1 },
|
||||
agentName: { $first: "$agentName" }
|
||||
}
|
||||
},
|
||||
{ $sort: { totalTokens: -1 } }
|
||||
]).toArray();
|
||||
|
||||
// Overall totals
|
||||
const totals = await db.collection<TokenUsage>("token_usage").aggregate([
|
||||
{ $match: query },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalTokens: { $sum: "$totalTokens" },
|
||||
totalCost: { $sum: "$cost" },
|
||||
inputTokens: { $sum: "$inputTokens" },
|
||||
outputTokens: { $sum: "$outputTokens" },
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]).toArray();
|
||||
|
||||
ctx.response.body = {
|
||||
period,
|
||||
startDate: startDate.toISOString(),
|
||||
stats,
|
||||
totals: totals[0] || { totalTokens: 0, totalCost: 0, count: 0 }
|
||||
};
|
||||
});
|
||||
|
||||
// GET /api/tokens/timeseries - Zeitreihe für Charts (pro Tag/Woche)
|
||||
tokensRouter.get("/timeseries", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const url = new URL(ctx.request.url);
|
||||
|
||||
const agentId = url.searchParams.get("agentId");
|
||||
const days = parseInt(url.searchParams.get("days") || "30");
|
||||
const groupBy = url.searchParams.get("groupBy") || "day"; // day | agent
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const matchStage: Record<string, unknown> = {
|
||||
timestamp: { $gte: startDate }
|
||||
};
|
||||
if (agentId) matchStage.agentId = new ObjectId(agentId);
|
||||
|
||||
if (groupBy === "agent") {
|
||||
// Gruppiert nach Agent + Tag
|
||||
const data = await db.collection<TokenUsage>("token_usage").aggregate([
|
||||
{ $match: matchStage },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
date: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } },
|
||||
agentId: "$agentId",
|
||||
agentName: "$agentName"
|
||||
},
|
||||
totalTokens: { $sum: "$totalTokens" },
|
||||
inputTokens: { $sum: "$inputTokens" },
|
||||
outputTokens: { $sum: "$outputTokens" },
|
||||
totalCost: { $sum: "$cost" },
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { "_id.date": 1 } }
|
||||
]).toArray();
|
||||
|
||||
ctx.response.body = { data, days, groupBy };
|
||||
} else {
|
||||
// Gruppiert nur nach Tag
|
||||
const data = await db.collection<TokenUsage>("token_usage").aggregate([
|
||||
{ $match: matchStage },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } },
|
||||
totalTokens: { $sum: "$totalTokens" },
|
||||
inputTokens: { $sum: "$inputTokens" },
|
||||
outputTokens: { $sum: "$outputTokens" },
|
||||
totalCost: { $sum: "$cost" },
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { _id: 1 } }
|
||||
]).toArray();
|
||||
|
||||
ctx.response.body = { data, days, groupBy };
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tokens/limits - Get limits for agents
|
||||
tokensRouter.get("/limits", 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();
|
||||
const limits = await db.collection<TokenLimit>("token_limits").find({}).toArray();
|
||||
|
||||
ctx.response.body = { limits };
|
||||
});
|
||||
|
||||
// PUT /api/tokens/limits/:agentId - Set/update limits for an agent
|
||||
tokensRouter.put("/limits/:agentId", authMiddleware, async (ctx) => {
|
||||
if (ctx.state.user.role !== "admin") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Admin access required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = ctx.params.agentId;
|
||||
const body = await ctx.request.body.json();
|
||||
const { dailyLimit, monthlyLimit, alertThreshold, enabled } = body;
|
||||
|
||||
const db = await getDB();
|
||||
const now = new Date();
|
||||
|
||||
const limit: Partial<TokenLimit> = {
|
||||
agentId: new ObjectId(agentId),
|
||||
dailyLimit,
|
||||
monthlyLimit,
|
||||
alertThreshold,
|
||||
enabled: enabled !== undefined ? enabled : true,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
const result = await db.collection<TokenLimit>("token_limits").updateOne(
|
||||
{ agentId: new ObjectId(agentId) },
|
||||
{ $set: limit, $setOnInsert: { createdAt: now } },
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
message: "Limits updated",
|
||||
modified: result.modifiedCount > 0
|
||||
};
|
||||
});
|
||||
|
||||
// Helper function to check limits
|
||||
async function checkLimits(agentId: string, db: Awaited<ReturnType<typeof getDB>>): Promise<void> {
|
||||
const limit = await db.collection<TokenLimit>("token_limits").findOne({
|
||||
agentId: new ObjectId(agentId),
|
||||
enabled: true
|
||||
});
|
||||
|
||||
if (!limit) return;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Check daily usage
|
||||
if (limit.dailyLimit) {
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const dailyUsage = await db.collection<TokenUsage>("token_usage").aggregate([
|
||||
{
|
||||
$match: {
|
||||
agentId: new ObjectId(agentId),
|
||||
timestamp: { $gte: startOfDay }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
total: { $sum: "$totalTokens" }
|
||||
}
|
||||
}
|
||||
]).toArray();
|
||||
|
||||
const usage = dailyUsage[0]?.total || 0;
|
||||
|
||||
if (limit.alertThreshold && usage >= limit.dailyLimit * (limit.alertThreshold / 100)) {
|
||||
console.log(`⚠️ Agent ${agentId} reached ${limit.alertThreshold}% of daily limit`);
|
||||
// TODO: Send alert (email, webhook, etc.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
82
src/routes/transcribe.ts
Normal file
82
src/routes/transcribe.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const transcribeRouter = new Router({ prefix: "/api/transcribe" });
|
||||
|
||||
interface UserSettings {
|
||||
_id: string;
|
||||
userId: string;
|
||||
sttProvider: "openai" | "custom";
|
||||
openaiApiKey?: string;
|
||||
customSttUrl?: string;
|
||||
telegramBotToken?: string;
|
||||
telegramDefaultChatId?: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Audio transkribieren via OpenAI Whisper
|
||||
transcribeRouter.post("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
|
||||
// User-Settings laden
|
||||
const settings = await db.collection<UserSettings>("user_settings")
|
||||
.findOne({ userId });
|
||||
|
||||
if (!settings?.openaiApiKey) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "OpenAI API-Key nicht konfiguriert. Bitte in den Einstellungen hinterlegen." };
|
||||
return;
|
||||
}
|
||||
|
||||
// Multipart-Body parsen
|
||||
const contentType = ctx.request.headers.get("content-type") || "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Multipart form-data mit Audio-Datei erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formBody = ctx.request.body;
|
||||
const formData = await formBody.formData();
|
||||
const audioFile = formData.get("audio");
|
||||
|
||||
if (!audioFile || !(audioFile instanceof File)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Audio-Datei fehlt im Request" };
|
||||
return;
|
||||
}
|
||||
|
||||
// An OpenAI Whisper API senden
|
||||
const whisperForm = new FormData();
|
||||
whisperForm.append("file", audioFile, audioFile.name || "audio.webm");
|
||||
whisperForm.append("model", "whisper-1");
|
||||
whisperForm.append("language", "de");
|
||||
|
||||
const whisperResponse = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${settings.openaiApiKey}`,
|
||||
},
|
||||
body: whisperForm,
|
||||
});
|
||||
|
||||
if (!whisperResponse.ok) {
|
||||
const err = await whisperResponse.text();
|
||||
console.error("Whisper API Fehler:", err);
|
||||
ctx.response.status = 502;
|
||||
ctx.response.body = { error: "Transkription fehlgeschlagen", details: err };
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await whisperResponse.json();
|
||||
|
||||
ctx.response.body = { text: result.text };
|
||||
} catch (err) {
|
||||
console.error("Transkription Fehler:", err);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Interner Fehler bei der Transkription" };
|
||||
}
|
||||
});
|
||||
80
src/routes/usersettings.ts
Normal file
80
src/routes/usersettings.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const userSettingsRouter = new Router({ prefix: "/api/user-settings" });
|
||||
|
||||
interface UserSettings {
|
||||
userId: string;
|
||||
sttProvider: "openai" | "custom";
|
||||
openaiApiKey?: string;
|
||||
customSttUrl?: string;
|
||||
telegramBotToken?: string;
|
||||
telegramDefaultChatId?: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// User-Einstellungen laden
|
||||
userSettingsRouter.get("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
|
||||
const settings = await db.collection<UserSettings>("user_settings")
|
||||
.findOne({ userId });
|
||||
|
||||
if (!settings) {
|
||||
ctx.response.body = {
|
||||
settings: {
|
||||
sttProvider: "openai",
|
||||
openaiApiKey: "",
|
||||
customSttUrl: "",
|
||||
telegramBotToken: "",
|
||||
telegramDefaultChatId: "",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// API-Keys maskiert zurückgeben
|
||||
ctx.response.body = {
|
||||
settings: {
|
||||
sttProvider: settings.sttProvider || "openai",
|
||||
openaiApiKey: settings.openaiApiKey ? "••••" + settings.openaiApiKey.slice(-4) : "",
|
||||
customSttUrl: settings.customSttUrl || "",
|
||||
telegramBotToken: settings.telegramBotToken ? "••••" + settings.telegramBotToken.slice(-4) : "",
|
||||
telegramDefaultChatId: settings.telegramDefaultChatId || "",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// User-Einstellungen aktualisieren
|
||||
userSettingsRouter.put("/", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const userId = ctx.state.user.id;
|
||||
const body = await ctx.request.body.json();
|
||||
const { sttProvider, openaiApiKey, customSttUrl, telegramBotToken, telegramDefaultChatId } = body;
|
||||
|
||||
const updateFields: Record<string, unknown> = {
|
||||
userId,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (sttProvider !== undefined) updateFields.sttProvider = sttProvider;
|
||||
// Nur aktualisieren wenn nicht maskierter Wert
|
||||
if (openaiApiKey !== undefined && !openaiApiKey.startsWith("••••")) {
|
||||
updateFields.openaiApiKey = openaiApiKey;
|
||||
}
|
||||
if (customSttUrl !== undefined) updateFields.customSttUrl = customSttUrl;
|
||||
if (telegramBotToken !== undefined && !telegramBotToken.startsWith("••••")) {
|
||||
updateFields.telegramBotToken = telegramBotToken;
|
||||
}
|
||||
if (telegramDefaultChatId !== undefined) updateFields.telegramDefaultChatId = telegramDefaultChatId;
|
||||
|
||||
await db.collection<UserSettings>("user_settings").updateOne(
|
||||
{ userId },
|
||||
{ $set: updateFields },
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
ctx.response.body = { message: "Einstellungen gespeichert" };
|
||||
});
|
||||
337
src/routes/workspace.ts
Normal file
337
src/routes/workspace.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
export const workspaceRouter = new Router({ prefix: "/api/workspace" });
|
||||
|
||||
interface WorkspaceFile {
|
||||
_id: ObjectId;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
updatedBy: string;
|
||||
updatedByName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface WorkspaceFileHistory {
|
||||
_id: ObjectId;
|
||||
fileId: string;
|
||||
agentId: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
version: number;
|
||||
changedBy: string;
|
||||
changedByName: string;
|
||||
changeType: "create" | "update" | "restore";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Alle Dateien eines Agents auflisten
|
||||
workspaceRouter.get("/files", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const url = ctx.request.url;
|
||||
|
||||
const agentId = url.searchParams.get("agentId");
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (agentId) filter.agentId = agentId;
|
||||
|
||||
const files = await db.collection<WorkspaceFile>("workspace_files")
|
||||
.find(filter)
|
||||
.project({ content: 0 }) // Content nicht in Liste laden (Performance)
|
||||
.sort({ filename: 1 })
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { files };
|
||||
});
|
||||
|
||||
// Einzelne Datei laden (mit Content)
|
||||
workspaceRouter.get("/files/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
const file = await db.collection<WorkspaceFile>("workspace_files")
|
||||
.findOne({ _id: new ObjectId(id) });
|
||||
|
||||
if (!file) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Datei nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { file };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Datei nach Name laden (für Agenten)
|
||||
workspaceRouter.get("/files/by-name/:agentId/:filename", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const { agentId, filename } = ctx.params;
|
||||
|
||||
const file = await db.collection<WorkspaceFile>("workspace_files")
|
||||
.findOne({ agentId, filename });
|
||||
|
||||
if (!file) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Datei nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = { file };
|
||||
});
|
||||
|
||||
// Datei erstellen oder aktualisieren (Upsert)
|
||||
workspaceRouter.put("/files", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const body = await ctx.request.body.json();
|
||||
const { agentId, agentName, filename, content } = body;
|
||||
|
||||
if (!agentId || !filename || content === undefined) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "agentId, filename und content sind erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const existing = await db.collection<WorkspaceFile>("workspace_files")
|
||||
.findOne({ agentId, filename });
|
||||
|
||||
if (existing) {
|
||||
// History-Eintrag erstellen (alten Stand sichern)
|
||||
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
|
||||
fileId: existing._id.toString(),
|
||||
agentId,
|
||||
filename,
|
||||
content: existing.content,
|
||||
version: existing.version,
|
||||
changedBy: ctx.state.user.id,
|
||||
changedByName: ctx.state.user.username,
|
||||
changeType: "update",
|
||||
createdAt: now,
|
||||
} as WorkspaceFileHistory);
|
||||
|
||||
// Datei aktualisieren
|
||||
await db.collection<WorkspaceFile>("workspace_files").updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
content,
|
||||
updatedBy: ctx.state.user.id,
|
||||
updatedByName: ctx.state.user.username,
|
||||
updatedAt: now,
|
||||
version: existing.version + 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Log-Eintrag
|
||||
await db.collection("logs").insertOne({
|
||||
agentId,
|
||||
agentName: agentName || agentId,
|
||||
level: "info",
|
||||
message: `Workspace-Datei "${filename}" aktualisiert (v${existing.version + 1})`,
|
||||
metadata: { action: "workspace_file_update", filename, version: existing.version + 1, changedBy: ctx.state.user.username },
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
ctx.response.body = {
|
||||
message: "Datei aktualisiert",
|
||||
version: existing.version + 1,
|
||||
id: existing._id.toString(),
|
||||
};
|
||||
} else {
|
||||
// Neue Datei erstellen
|
||||
const doc: Omit<WorkspaceFile, "_id"> = {
|
||||
agentId,
|
||||
agentName: agentName || agentId,
|
||||
filename,
|
||||
content,
|
||||
updatedBy: ctx.state.user.id,
|
||||
updatedByName: ctx.state.user.username,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const result = await db.collection<WorkspaceFile>("workspace_files").insertOne(doc as WorkspaceFile);
|
||||
|
||||
// History-Eintrag
|
||||
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
|
||||
fileId: result.insertedId.toString(),
|
||||
agentId,
|
||||
filename,
|
||||
content,
|
||||
version: 1,
|
||||
changedBy: ctx.state.user.id,
|
||||
changedByName: ctx.state.user.username,
|
||||
changeType: "create",
|
||||
createdAt: now,
|
||||
} as WorkspaceFileHistory);
|
||||
|
||||
// Log-Eintrag
|
||||
await db.collection("logs").insertOne({
|
||||
agentId,
|
||||
agentName: agentName || agentId,
|
||||
level: "info",
|
||||
message: `Workspace-Datei "${filename}" erstellt`,
|
||||
metadata: { action: "workspace_file_create", filename, version: 1, changedBy: ctx.state.user.username },
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
message: "Datei erstellt",
|
||||
version: 1,
|
||||
id: result.insertedId.toString(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Datei löschen
|
||||
workspaceRouter.delete("/files/:id", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const id = ctx.params.id;
|
||||
|
||||
try {
|
||||
const file = await db.collection<WorkspaceFile>("workspace_files")
|
||||
.findOne({ _id: new ObjectId(id) });
|
||||
|
||||
if (!file) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Datei nicht gefunden" };
|
||||
return;
|
||||
}
|
||||
|
||||
// History-Eintrag
|
||||
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
|
||||
fileId: id,
|
||||
agentId: file.agentId,
|
||||
filename: file.filename,
|
||||
content: file.content,
|
||||
version: file.version,
|
||||
changedBy: ctx.state.user.id,
|
||||
changedByName: ctx.state.user.username,
|
||||
changeType: "update",
|
||||
createdAt: new Date(),
|
||||
} as WorkspaceFileHistory);
|
||||
|
||||
await db.collection<WorkspaceFile>("workspace_files").deleteOne({ _id: new ObjectId(id) });
|
||||
|
||||
// Log-Eintrag
|
||||
await db.collection("logs").insertOne({
|
||||
agentId: file.agentId,
|
||||
agentName: file.agentName,
|
||||
level: "warn",
|
||||
message: `Workspace-Datei "${file.filename}" gelöscht`,
|
||||
metadata: { action: "workspace_file_delete", filename: file.filename, deletedBy: ctx.state.user.username },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
ctx.response.body = { message: "Datei gelöscht" };
|
||||
} catch {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Ungültige ID" };
|
||||
}
|
||||
});
|
||||
|
||||
// Datei-History laden
|
||||
workspaceRouter.get("/files/:id/history", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const id = ctx.params.id;
|
||||
|
||||
const history = await db.collection<WorkspaceFileHistory>("workspace_files_history")
|
||||
.find({ fileId: id })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50)
|
||||
.toArray();
|
||||
|
||||
ctx.response.body = { history };
|
||||
});
|
||||
|
||||
// Bulk-Sync: Mehrere Dateien auf einmal hochladen (Agent → AMS Backup)
|
||||
workspaceRouter.post("/sync", authMiddleware, async (ctx) => {
|
||||
const db = await getDB();
|
||||
const body = await ctx.request.body.json();
|
||||
const { agentId, agentName, files } = body;
|
||||
|
||||
if (!agentId || !Array.isArray(files) || files.length === 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "agentId und files[] sind erforderlich" };
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.filename || file.content === undefined) continue;
|
||||
|
||||
const existing = await db.collection<WorkspaceFile>("workspace_files")
|
||||
.findOne({ agentId, filename: file.filename });
|
||||
|
||||
if (existing) {
|
||||
// Nur updaten wenn Content sich geändert hat
|
||||
if (existing.content !== file.content) {
|
||||
await db.collection<WorkspaceFileHistory>("workspace_files_history").insertOne({
|
||||
fileId: existing._id.toString(),
|
||||
agentId,
|
||||
filename: file.filename,
|
||||
content: existing.content,
|
||||
version: existing.version,
|
||||
changedBy: ctx.state.user.id,
|
||||
changedByName: ctx.state.user.username,
|
||||
changeType: "update",
|
||||
createdAt: now,
|
||||
} as WorkspaceFileHistory);
|
||||
|
||||
await db.collection<WorkspaceFile>("workspace_files").updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
content: file.content,
|
||||
updatedBy: ctx.state.user.id,
|
||||
updatedByName: ctx.state.user.username,
|
||||
updatedAt: now,
|
||||
version: existing.version + 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
} else {
|
||||
await db.collection<WorkspaceFile>("workspace_files").insertOne({
|
||||
agentId,
|
||||
agentName: agentName || agentId,
|
||||
filename: file.filename,
|
||||
content: file.content,
|
||||
updatedBy: ctx.state.user.id,
|
||||
updatedByName: ctx.state.user.username,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
} as WorkspaceFile);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log-Eintrag
|
||||
await db.collection("logs").insertOne({
|
||||
agentId,
|
||||
agentName: agentName || agentId,
|
||||
level: "info",
|
||||
message: `Workspace-Sync: ${created} erstellt, ${updated} aktualisiert (${files.length} Dateien)`,
|
||||
metadata: { action: "workspace_sync", created, updated, total: files.length, syncedBy: ctx.state.user.username },
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
ctx.response.body = { message: "Sync abgeschlossen", created, updated, total: files.length };
|
||||
});
|
||||
50
src/utils/eventEmitter.ts
Normal file
50
src/utils/eventEmitter.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
class EventEmitter {
|
||||
private handlers: Map<string, Set<EventHandler>> = new Map();
|
||||
|
||||
on(event: string, handler: EventHandler): void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
}
|
||||
|
||||
off(event: string, handler: EventHandler): void {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown): void {
|
||||
this.handlers.get(event)?.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (err) {
|
||||
console.error(`Event handler error for "${event}":`, err);
|
||||
}
|
||||
});
|
||||
// Also emit to wildcard listeners
|
||||
this.handlers.get("*")?.forEach((handler) => {
|
||||
try {
|
||||
handler({ event, data });
|
||||
} catch (err) {
|
||||
console.error(`Wildcard handler error:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const events = new EventEmitter();
|
||||
|
||||
// Event types
|
||||
export const AMS_EVENTS = {
|
||||
TASK_CREATED: "task:created",
|
||||
TASK_UPDATED: "task:updated",
|
||||
TASK_DELETED: "task:deleted",
|
||||
TASK_STATUS_CHANGED: "task:status_changed",
|
||||
AGENT_STATUS_CHANGED: "agent:status_changed",
|
||||
COMMENT_CREATED: "comment:created",
|
||||
COMMENT_DELETED: "comment:deleted",
|
||||
ATTACHMENT_UPLOADED: "attachment:uploaded",
|
||||
ATTACHMENT_DELETED: "attachment:deleted",
|
||||
} as const;
|
||||
380
src/utils/gitlabSync.ts
Normal file
380
src/utils/gitlabSync.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
|
||||
const GITLAB_URL = Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de";
|
||||
const GITLAB_TOKEN = Deno.env.get("GITLAB_TOKEN") || "";
|
||||
|
||||
// ============ Interfaces ============
|
||||
|
||||
export interface GitLabIssue {
|
||||
id: number;
|
||||
iid: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
state: "opened" | "closed";
|
||||
labels: string[];
|
||||
milestone: { id: number; title: string } | null;
|
||||
assignees: { id: number; username: string; name: string }[];
|
||||
author: { id: number; username: string; name: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
due_date: string | null;
|
||||
web_url: string;
|
||||
project_id: number;
|
||||
}
|
||||
|
||||
export interface IssueMappingDoc {
|
||||
_id?: ObjectId;
|
||||
gitlabProjectId: number;
|
||||
gitlabIssueIid: number;
|
||||
gitlabIssueId: number;
|
||||
amsTaskId: string;
|
||||
amsProjectId: string;
|
||||
lastSyncedAt: Date;
|
||||
syncDirection: "gitlab_to_ams" | "ams_to_gitlab" | "bidirectional";
|
||||
gitlabWebUrl: string;
|
||||
}
|
||||
|
||||
// ============ GitLab API ============
|
||||
|
||||
async function getUserGitLabToken(userId: string): Promise<string> {
|
||||
const db = await getDB();
|
||||
const user = await db.collection("users").findOne({ _id: new ObjectId(userId) }).catch(() => null);
|
||||
return user?.gitlabToken || GITLAB_TOKEN;
|
||||
}
|
||||
|
||||
export async function gitlabApiFetch<T>(endpoint: string, options: RequestInit = {}, token?: string): Promise<T> {
|
||||
const url = `${GITLAB_URL}/api/v4${endpoint}`;
|
||||
const headers: Record<string, string> = {
|
||||
"PRIVATE-TOKEN": token || GITLAB_TOKEN,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`GitLab API error (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============ Status Mapping ============
|
||||
|
||||
const GITLAB_TO_AMS_STATUS: Record<string, string> = {
|
||||
opened: "todo",
|
||||
closed: "done",
|
||||
};
|
||||
|
||||
const AMS_TO_GITLAB_STATE: Record<string, string> = {
|
||||
backlog: "reopen",
|
||||
todo: "reopen",
|
||||
in_progress: "reopen",
|
||||
review: "reopen",
|
||||
done: "close",
|
||||
};
|
||||
|
||||
const GITLAB_TO_AMS_LABEL_PRIORITY: Record<string, string> = {
|
||||
"priority::urgent": "urgent",
|
||||
"priority::high": "high",
|
||||
"priority::medium": "medium",
|
||||
"priority::low": "low",
|
||||
};
|
||||
|
||||
function mapGitLabPriority(labels: string[]): string {
|
||||
for (const label of labels) {
|
||||
const lower = label.toLowerCase();
|
||||
if (GITLAB_TO_AMS_LABEL_PRIORITY[lower]) {
|
||||
return GITLAB_TO_AMS_LABEL_PRIORITY[lower];
|
||||
}
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
|
||||
// ============ Sync Functions ============
|
||||
|
||||
/**
|
||||
* Fetch issues from a GitLab project
|
||||
*/
|
||||
export async function fetchGitLabIssues(
|
||||
gitlabProjectId: number,
|
||||
options: { state?: string; page?: number; perPage?: number; updatedAfter?: string } = {},
|
||||
token?: string
|
||||
): Promise<GitLabIssue[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.state) params.set("state", options.state);
|
||||
params.set("page", String(options.page || 1));
|
||||
params.set("per_page", String(options.perPage || 50));
|
||||
if (options.updatedAfter) params.set("updated_after", options.updatedAfter);
|
||||
params.set("order_by", "updated_at");
|
||||
params.set("sort", "desc");
|
||||
|
||||
return gitlabApiFetch<GitLabIssue[]>(
|
||||
`/projects/${gitlabProjectId}/issues?${params.toString()}`,
|
||||
{},
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a single GitLab issue as an AMS task
|
||||
*/
|
||||
export async function importGitLabIssue(
|
||||
issue: GitLabIssue,
|
||||
amsProjectId: string,
|
||||
createdBy: string,
|
||||
token?: string
|
||||
): Promise<{ taskId: string; mapping: IssueMappingDoc }> {
|
||||
const db = await getDB();
|
||||
|
||||
// Check if already mapped
|
||||
const existing = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({
|
||||
gitlabProjectId: issue.project_id,
|
||||
gitlabIssueIid: issue.iid,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing task
|
||||
await syncGitLabIssueToTask(issue, existing.amsTaskId);
|
||||
return { taskId: existing.amsTaskId, mapping: existing };
|
||||
}
|
||||
|
||||
// Create new AMS task
|
||||
const now = new Date();
|
||||
const task = {
|
||||
title: `[GL#${issue.iid}] ${issue.title}`,
|
||||
description: issue.description || "",
|
||||
status: GITLAB_TO_AMS_STATUS[issue.state] || "todo",
|
||||
priority: mapGitLabPriority(issue.labels),
|
||||
project: amsProjectId,
|
||||
labels: [] as string[],
|
||||
dueDate: issue.due_date ? new Date(issue.due_date) : undefined,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
gitlabIssueUrl: issue.web_url,
|
||||
};
|
||||
|
||||
const result = await db.collection("tasks").insertOne(task);
|
||||
const taskId = result.insertedId.toString();
|
||||
|
||||
// Store mapping
|
||||
const mapping: IssueMappingDoc = {
|
||||
gitlabProjectId: issue.project_id,
|
||||
gitlabIssueIid: issue.iid,
|
||||
gitlabIssueId: issue.id,
|
||||
amsTaskId: taskId,
|
||||
amsProjectId,
|
||||
lastSyncedAt: now,
|
||||
syncDirection: "bidirectional",
|
||||
gitlabWebUrl: issue.web_url,
|
||||
};
|
||||
|
||||
await db.collection("gitlab_issue_mappings").insertOne(mapping);
|
||||
|
||||
return { taskId, mapping };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a GitLab issue's data to an existing AMS task
|
||||
*/
|
||||
async function syncGitLabIssueToTask(issue: GitLabIssue, amsTaskId: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
const now = new Date();
|
||||
|
||||
const updateFields: Record<string, unknown> = {
|
||||
title: `[GL#${issue.iid}] ${issue.title}`,
|
||||
description: issue.description || "",
|
||||
status: GITLAB_TO_AMS_STATUS[issue.state] || "todo",
|
||||
priority: mapGitLabPriority(issue.labels),
|
||||
updatedAt: now,
|
||||
gitlabIssueUrl: issue.web_url,
|
||||
};
|
||||
|
||||
if (issue.due_date) {
|
||||
updateFields.dueDate = new Date(issue.due_date);
|
||||
}
|
||||
|
||||
await db.collection("tasks").updateOne(
|
||||
{ _id: new ObjectId(amsTaskId) },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
await db.collection<IssueMappingDoc>("gitlab_issue_mappings").updateOne(
|
||||
{ amsTaskId },
|
||||
{ $set: { lastSyncedAt: now } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push AMS task changes back to GitLab
|
||||
*/
|
||||
export async function syncTaskToGitLab(
|
||||
amsTaskId: string,
|
||||
userId?: string
|
||||
): Promise<boolean> {
|
||||
const db = await getDB();
|
||||
|
||||
const mapping = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({ amsTaskId });
|
||||
if (!mapping || mapping.syncDirection === "gitlab_to_ams") return false;
|
||||
|
||||
const task = await db.collection("tasks").findOne({ _id: new ObjectId(amsTaskId) });
|
||||
if (!task) return false;
|
||||
|
||||
const token = userId ? await getUserGitLabToken(userId) : GITLAB_TOKEN;
|
||||
|
||||
// Clean title (remove [GL#X] prefix)
|
||||
const cleanTitle = (task.title as string).replace(/^\[GL#\d+\]\s*/, "");
|
||||
|
||||
const stateEvent = AMS_TO_GITLAB_STATE[task.status as string];
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: cleanTitle,
|
||||
description: task.description || "",
|
||||
};
|
||||
|
||||
if (stateEvent) {
|
||||
body.state_event = stateEvent;
|
||||
}
|
||||
|
||||
if (task.dueDate) {
|
||||
body.due_date = new Date(task.dueDate as string).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
await gitlabApiFetch(
|
||||
`/projects/${mapping.gitlabProjectId}/issues/${mapping.gitlabIssueIid}`,
|
||||
{ method: "PUT", body: JSON.stringify(body) },
|
||||
token
|
||||
);
|
||||
|
||||
await db.collection<IssueMappingDoc>("gitlab_issue_mappings").updateOne(
|
||||
{ amsTaskId },
|
||||
{ $set: { lastSyncedAt: new Date() } }
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new GitLab issue from an AMS task
|
||||
*/
|
||||
export async function createGitLabIssueFromTask(
|
||||
amsTaskId: string,
|
||||
gitlabProjectId: number,
|
||||
amsProjectId: string,
|
||||
userId?: string
|
||||
): Promise<GitLabIssue> {
|
||||
const db = await getDB();
|
||||
const task = await db.collection("tasks").findOne({ _id: new ObjectId(amsTaskId) });
|
||||
if (!task) throw new Error("Task not found");
|
||||
|
||||
const token = userId ? await getUserGitLabToken(userId) : GITLAB_TOKEN;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: task.title as string,
|
||||
description: task.description || "",
|
||||
};
|
||||
|
||||
if (task.dueDate) {
|
||||
body.due_date = new Date(task.dueDate as string).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
const issue = await gitlabApiFetch<GitLabIssue>(
|
||||
`/projects/${gitlabProjectId}/issues`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
token
|
||||
);
|
||||
|
||||
// Update task title with GL prefix
|
||||
await db.collection("tasks").updateOne(
|
||||
{ _id: new ObjectId(amsTaskId) },
|
||||
{ $set: { title: `[GL#${issue.iid}] ${task.title}`, gitlabIssueUrl: issue.web_url, updatedAt: new Date() } }
|
||||
);
|
||||
|
||||
// Store mapping
|
||||
await db.collection("gitlab_issue_mappings").insertOne({
|
||||
gitlabProjectId: issue.project_id,
|
||||
gitlabIssueIid: issue.iid,
|
||||
gitlabIssueId: issue.id,
|
||||
amsTaskId,
|
||||
amsProjectId,
|
||||
lastSyncedAt: new Date(),
|
||||
syncDirection: "bidirectional",
|
||||
gitlabWebUrl: issue.web_url,
|
||||
});
|
||||
|
||||
return issue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync: import all issues from a GitLab project, update existing mappings
|
||||
*/
|
||||
export async function fullSync(
|
||||
gitlabProjectId: number,
|
||||
amsProjectId: string,
|
||||
createdBy: string,
|
||||
userId?: string
|
||||
): Promise<{ imported: number; updated: number; total: number }> {
|
||||
const token = userId ? await getUserGitLabToken(userId) : GITLAB_TOKEN;
|
||||
|
||||
let page = 1;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const issues = await fetchGitLabIssues(gitlabProjectId, { state: "all", page, perPage: 100 }, token);
|
||||
if (issues.length === 0) break;
|
||||
|
||||
const db = await getDB();
|
||||
|
||||
for (const issue of issues) {
|
||||
const existing = await db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({
|
||||
gitlabProjectId: issue.project_id,
|
||||
gitlabIssueIid: issue.iid,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await syncGitLabIssueToTask(issue, existing.amsTaskId);
|
||||
updated++;
|
||||
} else {
|
||||
await importGitLabIssue(issue, amsProjectId, createdBy, token);
|
||||
imported++;
|
||||
}
|
||||
total++;
|
||||
}
|
||||
|
||||
if (issues.length < 100) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return { imported, updated, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping for an AMS task
|
||||
*/
|
||||
export async function getMappingForTask(amsTaskId: string): Promise<IssueMappingDoc | null> {
|
||||
const db = await getDB();
|
||||
return db.collection<IssueMappingDoc>("gitlab_issue_mappings").findOne({ amsTaskId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings for a project
|
||||
*/
|
||||
export async function getMappingsForProject(amsProjectId: string): Promise<IssueMappingDoc[]> {
|
||||
const db = await getDB();
|
||||
return db.collection<IssueMappingDoc>("gitlab_issue_mappings").find({ amsProjectId }).toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a mapping
|
||||
*/
|
||||
export async function deleteMapping(amsTaskId: string): Promise<boolean> {
|
||||
const db = await getDB();
|
||||
const result = await db.collection("gitlab_issue_mappings").deleteOne({ amsTaskId });
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
98
src/utils/jwt.ts
Normal file
98
src/utils/jwt.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
interface JWTPayload {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...data))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64UrlDecode(str: string): Uint8Array {
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (str.length % 4) str += "=";
|
||||
return Uint8Array.from(atob(str), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the JWT secret meets minimum security requirements.
|
||||
* Must be set via JWT_SECRET environment variable.
|
||||
* Minimum length: 32 characters.
|
||||
*/
|
||||
function getSecret(): string {
|
||||
const secret = Deno.env.get("JWT_SECRET");
|
||||
if (!secret) {
|
||||
console.error("❌ FATAL: JWT_SECRET environment variable is not set.");
|
||||
console.error(" Set a strong secret: export JWT_SECRET=$(openssl rand -base64 48)");
|
||||
Deno.exit(1);
|
||||
}
|
||||
if (secret.length < 32) {
|
||||
console.error("❌ FATAL: JWT_SECRET must be at least 32 characters long.");
|
||||
Deno.exit(1);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
// Validate secret at module load time (fail fast on startup)
|
||||
const JWT_SECRET = getSecret();
|
||||
|
||||
async function getKey(): Promise<CryptoKey> {
|
||||
return await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(JWT_SECRET),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function signJWT(payload: Omit<JWTPayload, "exp" | "iat">): Promise<string> {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const fullPayload: JWTPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + 86400 * 7 // 7 days
|
||||
};
|
||||
|
||||
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(fullPayload)));
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await getKey();
|
||||
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
||||
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||
|
||||
return `${data}.${signatureB64}`;
|
||||
}
|
||||
|
||||
export async function verifyJWT(token: string): Promise<JWTPayload> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new Error("Invalid token format");
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const data = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = await getKey();
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
const valid = await crypto.subtle.verify("HMAC", key, new Uint8Array(signature).buffer as ArrayBuffer, encoder.encode(data));
|
||||
|
||||
if (!valid) throw new Error("Invalid signature");
|
||||
|
||||
const payload: JWTPayload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
throw new Error("Token expired");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
57
src/utils/password.ts
Normal file
57
src/utils/password.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"]
|
||||
);
|
||||
|
||||
const hash = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256"
|
||||
},
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
|
||||
const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
const hashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
return `${saltHex}:${hashHex}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
||||
const [saltHex, hashHex] = storedHash.split(":");
|
||||
const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map(byte => parseInt(byte, 16)));
|
||||
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"]
|
||||
);
|
||||
|
||||
const hash = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256"
|
||||
},
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
|
||||
const computedHashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
return computedHashHex === hashHex;
|
||||
}
|
||||
|
||||
58
src/utils/taskChangelog.ts
Normal file
58
src/utils/taskChangelog.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
import { getDB } from "../db/mongo.ts";
|
||||
|
||||
interface ChangelogEntry {
|
||||
taskId: string;
|
||||
field: string;
|
||||
oldValue: unknown;
|
||||
newValue: unknown;
|
||||
changedBy: string;
|
||||
changedAt: Date;
|
||||
}
|
||||
|
||||
const TRACKED_FIELDS = ["title", "description", "status", "priority", "assignee", "project", "labels", "dueDate"];
|
||||
|
||||
/**
|
||||
* Compare old and new task data, write changelog entries for changed fields.
|
||||
*/
|
||||
export async function logTaskChanges(
|
||||
taskId: string,
|
||||
oldTask: Record<string, unknown>,
|
||||
newFields: Record<string, unknown>,
|
||||
changedBy: string
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
const entries: ChangelogEntry[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const field of TRACKED_FIELDS) {
|
||||
if (!(field in newFields)) continue;
|
||||
|
||||
const oldVal = oldTask[field];
|
||||
const newVal = newFields[field];
|
||||
|
||||
// Compare arrays (labels)
|
||||
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
|
||||
const oldSorted = [...oldVal].sort().join(",");
|
||||
const newSorted = [...newVal].sort().join(",");
|
||||
if (oldSorted === newSorted) continue;
|
||||
} else if (oldVal === newVal) {
|
||||
continue;
|
||||
} else if (oldVal == null && newVal == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
taskId,
|
||||
field,
|
||||
oldValue: oldVal ?? null,
|
||||
newValue: newVal ?? null,
|
||||
changedBy,
|
||||
changedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
await db.collection("task_changelog").insertMany(entries);
|
||||
}
|
||||
}
|
||||
137
src/ws/connectionManager.ts
Normal file
137
src/ws/connectionManager.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { events } from "../utils/eventEmitter.ts";
|
||||
|
||||
interface WsClient {
|
||||
socket: WebSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
connectedAt: Date;
|
||||
messagesSent: number;
|
||||
messagesReceived: number;
|
||||
}
|
||||
|
||||
interface WsStats {
|
||||
startedAt: string;
|
||||
totalConnections: number;
|
||||
totalDisconnections: number;
|
||||
totalBroadcasts: number;
|
||||
totalMessagesSent: number;
|
||||
totalMessagesReceived: number;
|
||||
currentConnections: number;
|
||||
users: Array<{
|
||||
userId: string;
|
||||
username: string;
|
||||
connectedAt: string;
|
||||
messagesSent: number;
|
||||
messagesReceived: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
class ConnectionManager {
|
||||
private clients: Map<WebSocket, WsClient> = new Map();
|
||||
private readonly startedAt = new Date();
|
||||
private totalConnections = 0;
|
||||
private totalDisconnections = 0;
|
||||
private totalBroadcasts = 0;
|
||||
private totalMessagesSent = 0;
|
||||
private totalMessagesReceived = 0;
|
||||
|
||||
add(socket: WebSocket, userId: string, username: string): void {
|
||||
this.clients.set(socket, {
|
||||
socket,
|
||||
userId,
|
||||
username,
|
||||
connectedAt: new Date(),
|
||||
messagesSent: 0,
|
||||
messagesReceived: 0,
|
||||
});
|
||||
this.totalConnections++;
|
||||
console.log(`[WS] Client connected: ${username} (${this.clients.size} total)`);
|
||||
}
|
||||
|
||||
remove(socket: WebSocket): void {
|
||||
const client = this.clients.get(socket);
|
||||
if (client) {
|
||||
this.totalDisconnections++;
|
||||
console.log(`[WS] Client disconnected: ${client.username} (${this.clients.size - 1} total)`);
|
||||
}
|
||||
this.clients.delete(socket);
|
||||
}
|
||||
|
||||
trackReceived(socket: WebSocket): void {
|
||||
const client = this.clients.get(socket);
|
||||
if (client) {
|
||||
client.messagesReceived++;
|
||||
this.totalMessagesReceived++;
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(event: string, data: unknown): void {
|
||||
const message = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
|
||||
this.totalBroadcasts++;
|
||||
for (const [socket, client] of this.clients) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
socket.send(message);
|
||||
client.messagesSent++;
|
||||
this.totalMessagesSent++;
|
||||
} catch {
|
||||
// Client will be cleaned up on close
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendTo(userId: string, event: string, data: unknown): void {
|
||||
const message = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
|
||||
for (const [socket, client] of this.clients) {
|
||||
if (client.userId === userId && socket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
socket.send(message);
|
||||
client.messagesSent++;
|
||||
this.totalMessagesSent++;
|
||||
} catch {
|
||||
// Will clean up on close
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getConnectedCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
getConnectedUsers(): Array<{ userId: string; username: string; connectedAt: Date }> {
|
||||
return [...this.clients.values()].map((c) => ({
|
||||
userId: c.userId,
|
||||
username: c.username,
|
||||
connectedAt: c.connectedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
getStats(): WsStats {
|
||||
return {
|
||||
startedAt: this.startedAt.toISOString(),
|
||||
totalConnections: this.totalConnections,
|
||||
totalDisconnections: this.totalDisconnections,
|
||||
totalBroadcasts: this.totalBroadcasts,
|
||||
totalMessagesSent: this.totalMessagesSent,
|
||||
totalMessagesReceived: this.totalMessagesReceived,
|
||||
currentConnections: this.clients.size,
|
||||
users: [...this.clients.values()].map((c) => ({
|
||||
userId: c.userId,
|
||||
username: c.username,
|
||||
connectedAt: c.connectedAt.toISOString(),
|
||||
messagesSent: c.messagesSent,
|
||||
messagesReceived: c.messagesReceived,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const wsManager = new ConnectionManager();
|
||||
|
||||
// Bridge: EventEmitter → WebSocket broadcast
|
||||
events.on("*", (payload: unknown) => {
|
||||
const { event, data } = payload as { event: string; data: unknown };
|
||||
wsManager.broadcast(event, data);
|
||||
});
|
||||
69
src/ws/handler.ts
Normal file
69
src/ws/handler.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { verifyJWT } from "../utils/jwt.ts";
|
||||
import { wsManager } from "./connectionManager.ts";
|
||||
|
||||
export function handleWebSocket(req: Request): Response {
|
||||
// Parse URL - handle both full URLs and relative paths
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url, "http://localhost");
|
||||
} catch {
|
||||
return new Response("Invalid URL", { status: 400 });
|
||||
}
|
||||
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
return new Response("Missing token", { status: 401 });
|
||||
}
|
||||
|
||||
let socket: WebSocket;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
const upgrade = Deno.upgradeWebSocket(req);
|
||||
socket = upgrade.socket;
|
||||
response = upgrade.response;
|
||||
} catch (e) {
|
||||
console.error("WebSocket upgrade failed:", e);
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
// Authenticate async after upgrade
|
||||
(async () => {
|
||||
try {
|
||||
const payload = await verifyJWT(token);
|
||||
wsManager.add(socket, payload.id, payload.username || payload.email);
|
||||
|
||||
socket.onmessage = (e) => {
|
||||
wsManager.trackReceived(socket);
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "ping") {
|
||||
socket.send(JSON.stringify({ event: "pong", timestamp: new Date().toISOString() }));
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
wsManager.remove(socket);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
wsManager.remove(socket);
|
||||
};
|
||||
|
||||
// Send welcome
|
||||
socket.send(JSON.stringify({
|
||||
event: "connected",
|
||||
data: { userId: payload.id, connectedUsers: wsManager.getConnectedCount() },
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
} catch {
|
||||
socket.close(4001, "Invalid token");
|
||||
}
|
||||
})();
|
||||
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user