summaryrefslogtreecommitdiff
path: root/admin-panel/src/server/routes
diff options
context:
space:
mode:
authorArseney300 <Arseney300@gmail.com>2026-03-05 09:47:09 +0700
committerArseney300 <Arseney300@gmail.com>2026-03-05 09:47:09 +0700
commit0885d20ac18c252f6735cd288448d5d93e95b80f (patch)
treeedfb6814e97d4000605d9f6a918c30c6aca9e10c /admin-panel/src/server/routes
parent22749b796d7a5e4efa9494156a3fdcfe39028da7 (diff)
WIP: admin_panel: add featurefeature/admin_panel
Diffstat (limited to 'admin-panel/src/server/routes')
-rw-r--r--admin-panel/src/server/routes/dashboard.ts138
-rw-r--r--admin-panel/src/server/routes/gerrit.ts68
-rw-r--r--admin-panel/src/server/routes/pipeline.ts82
-rw-r--r--admin-panel/src/server/routes/projects.ts138
-rw-r--r--admin-panel/src/server/routes/settings.ts35
-rw-r--r--admin-panel/src/server/routes/tasks.ts248
6 files changed, 709 insertions, 0 deletions
diff --git a/admin-panel/src/server/routes/dashboard.ts b/admin-panel/src/server/routes/dashboard.ts
new file mode 100644
index 0000000..3142e8e
--- /dev/null
+++ b/admin-panel/src/server/routes/dashboard.ts
@@ -0,0 +1,138 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+
+const router = Router();
+
+interface Project {
+ id: number;
+ name: string;
+ display_name: string;
+}
+
+// GET /api/dashboard/stats
+router.get('/stats', (_req: Request, res: Response) => {
+ const adminDb = getAdminDb();
+ const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[];
+
+ let totalTasks = 0;
+ let completedToday = 0;
+ const tasksByStatus: Record<string, number> = {};
+
+ const today = new Date().toISOString().split('T')[0];
+
+ for (const project of projects) {
+ try {
+ const db = getProjectDb(project.name);
+ const tasks = db.prepare('SELECT status, completed_at FROM tasks').all() as Array<{
+ status: string;
+ completed_at: string | null;
+ }>;
+
+ totalTasks += tasks.length;
+
+ for (const task of tasks) {
+ tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1;
+ if (task.completed_at && task.completed_at.startsWith(today)) {
+ completedToday++;
+ }
+ }
+ } catch {
+ // Project DB might not exist yet
+ }
+ }
+
+ res.json({
+ totalProjects: projects.length,
+ totalTasks,
+ tasksByStatus,
+ completedToday,
+ });
+});
+
+// GET /api/dashboard/activity
+router.get('/activity', (_req: Request, res: Response) => {
+ const adminDb = getAdminDb();
+ const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[];
+
+ const activity: Array<{
+ project: string;
+ taskId: number;
+ taskTitle: string;
+ agent: string;
+ action: string;
+ timestamp: string;
+ }> = [];
+
+ for (const project of projects) {
+ try {
+ const db = getProjectDb(project.name);
+ const tasks = db.prepare('SELECT id, title, agent_log FROM tasks WHERE agent_log != \'[]\'').all() as Array<{
+ id: number;
+ title: string;
+ agent_log: string;
+ }>;
+
+ for (const task of tasks) {
+ try {
+ const logs = JSON.parse(task.agent_log) as Array<{
+ agent: string;
+ message: string;
+ timestamp: string;
+ }>;
+ for (const log of logs) {
+ activity.push({
+ project: project.name,
+ taskId: task.id,
+ taskTitle: task.title,
+ agent: log.agent,
+ action: log.message,
+ timestamp: log.timestamp,
+ });
+ }
+ } catch {
+ // Skip malformed logs
+ }
+ }
+ } catch {
+ // Project DB might not exist
+ }
+ }
+
+ // Sort by timestamp descending, take 20 most recent
+ activity.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+ res.json(activity.slice(0, 20));
+});
+
+// GET /api/projects/:project/stats
+router.get('/projects/:project', (req: Request, res: Response) => {
+ const { project } = req.params;
+
+ const adminDb = getAdminDb();
+ if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ try {
+ const db = getProjectDb(project as string);
+ const tasks = db.prepare('SELECT status FROM tasks').all() as Array<{ status: string }>;
+ const tasksByStatus: Record<string, number> = {};
+ for (const task of tasks) {
+ tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1;
+ }
+
+ const totalJobs = db.prepare('SELECT COUNT(*) as count FROM pipeline_jobs').get() as { count: number };
+ const totalTokens = db.prepare('SELECT SUM(tokens_used) as total FROM pipeline_jobs').get() as { total: number | null };
+
+ res.json({
+ totalTasks: tasks.length,
+ tasksByStatus,
+ totalPipelineJobs: totalJobs.count,
+ totalTokensUsed: totalTokens.total || 0,
+ });
+ } catch {
+ res.json({ totalTasks: 0, tasksByStatus: {}, totalPipelineJobs: 0, totalTokensUsed: 0 });
+ }
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/gerrit.ts b/admin-panel/src/server/routes/gerrit.ts
new file mode 100644
index 0000000..b48ea53
--- /dev/null
+++ b/admin-panel/src/server/routes/gerrit.ts
@@ -0,0 +1,68 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+import { getChange, getChangeByNumber } from '../services/gerrit.service.js';
+
+const router = Router({ mergeParams: true });
+
+type Params = { project: string; taskId: string };
+
+// GET /api/projects/:project/gerrit/:taskId
+router.get('/:taskId', async (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+
+ const adminDb = getAdminDb();
+ if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT gerrit_change_id, gerrit_change_number FROM tasks WHERE id = ?').get(parseInt(taskId)) as {
+ gerrit_change_id: string | null;
+ gerrit_change_number: number | null;
+ } | undefined;
+
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ if (!task.gerrit_change_id && !task.gerrit_change_number) {
+ res.json({ change: null });
+ return;
+ }
+
+ try {
+ const change = task.gerrit_change_id
+ ? await getChange(task.gerrit_change_id)
+ : task.gerrit_change_number
+ ? await getChangeByNumber(task.gerrit_change_number)
+ : null;
+ res.json({ change });
+ } catch (err) {
+ res.status(502).json({ error: `Gerrit API error: ${(err as Error).message}` });
+ }
+});
+
+// GET /api/projects/:project/diff/:taskId
+router.get('/diff/:taskId', (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+
+ const adminDb = getAdminDb();
+ if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT diff FROM tasks WHERE id = ?').get(parseInt(taskId)) as { diff: string | null } | undefined;
+
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ res.json({ diff: task.diff || '' });
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/pipeline.ts b/admin-panel/src/server/routes/pipeline.ts
new file mode 100644
index 0000000..39df35c
--- /dev/null
+++ b/admin-panel/src/server/routes/pipeline.ts
@@ -0,0 +1,82 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb } from '../db.js';
+import {
+ runFullPipeline,
+ runStep,
+ stopPipeline,
+ getPipelineStatus,
+ isPipelineRunning,
+} from '../services/pipeline.service.js';
+
+const router = Router({ mergeParams: true });
+
+type Params = { project: string; taskId: string };
+
+function projectExists(name: string): boolean {
+ const db = getAdminDb();
+ return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(name);
+}
+
+// POST /api/projects/:project/pipeline/run/:taskId
+router.post('/run/:taskId', async (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const id = parseInt(taskId);
+ if (isPipelineRunning(project, id)) {
+ res.status(409).json({ error: 'Pipeline already running for this task' });
+ return;
+ }
+
+ // Run async — don't block the response
+ runFullPipeline(project, id).catch(err => {
+ console.error(`Pipeline error for ${project}/${taskId}:`, err);
+ });
+
+ res.json({ status: 'started' });
+});
+
+// POST /api/projects/:project/pipeline/step/:taskId
+router.post('/step/:taskId', async (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ try {
+ await runStep(project, parseInt(taskId));
+ res.json({ status: 'completed' });
+ } catch (err) {
+ res.status(400).json({ error: (err as Error).message });
+ }
+});
+
+// POST /api/projects/:project/pipeline/stop/:taskId
+router.post('/stop/:taskId', (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ stopPipeline(project, parseInt(taskId));
+ res.json({ status: 'stopping' });
+});
+
+// GET /api/projects/:project/pipeline/status/:taskId
+router.get('/status/:taskId', (req: Request<Params>, res: Response) => {
+ const { project, taskId } = req.params;
+ if (!projectExists(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const status = getPipelineStatus(project, parseInt(taskId));
+ res.json(status);
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/projects.ts b/admin-panel/src/server/routes/projects.ts
new file mode 100644
index 0000000..b2e63a8
--- /dev/null
+++ b/admin-panel/src/server/routes/projects.ts
@@ -0,0 +1,138 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+import { createBareRepo, setRepoDescription, repoExists, deleteRepo } from '../services/git.service.js';
+
+const router = Router();
+
+interface Project {
+ id: number;
+ name: string;
+ display_name: string;
+ description: string | null;
+ repo_name: string;
+ repo_path: string;
+ default_branch: string;
+ created_at: string;
+ updated_at: string;
+}
+
+// GET /api/projects
+router.get('/', (_req: Request, res: Response) => {
+ const db = getAdminDb();
+ const projects = db.prepare('SELECT * FROM projects ORDER BY created_at DESC').all();
+ res.json(projects);
+});
+
+// POST /api/projects
+router.post('/', (req: Request, res: Response) => {
+ const { name, display_name, description } = req.body;
+
+ if (!name || !display_name) {
+ res.status(400).json({ error: 'name and display_name are required' });
+ return;
+ }
+
+ // Sanitize name: lowercase, alphanumeric + hyphens
+ const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
+ if (!safeName) {
+ res.status(400).json({ error: 'Invalid project name' });
+ return;
+ }
+
+ const repoName = `${safeName}.git`;
+ const db = getAdminDb();
+
+ // Check for duplicate
+ const existing = db.prepare('SELECT id FROM projects WHERE name = ?').get(safeName);
+ if (existing) {
+ res.status(409).json({ error: 'Project already exists' });
+ return;
+ }
+
+ // Create bare git repo
+ const repo = createBareRepo(repoName);
+ if (description) {
+ setRepoDescription(repoName, description);
+ }
+
+ // Insert into admin DB
+ const result = db.prepare(
+ 'INSERT INTO projects (name, display_name, description, repo_name, repo_path) VALUES (?, ?, ?, ?, ?)'
+ ).run(safeName, display_name, description || null, repoName, repo.path);
+
+ // Initialize project DB (creates tables)
+ getProjectDb(safeName);
+
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid);
+ res.status(201).json(project);
+});
+
+// GET /api/projects/:name
+router.get('/:name', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined;
+ if (!project) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+ res.json(project);
+});
+
+// PATCH /api/projects/:name
+router.patch('/:name', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined;
+ if (!project) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const { display_name, description } = req.body;
+ const updates: string[] = [];
+ const params: unknown[] = [];
+
+ if (display_name !== undefined) {
+ updates.push('display_name = ?');
+ params.push(display_name);
+ }
+ if (description !== undefined) {
+ updates.push('description = ?');
+ params.push(description);
+ if (description) {
+ setRepoDescription(project.repo_name, description);
+ }
+ }
+
+ if (updates.length === 0) {
+ res.json(project);
+ return;
+ }
+
+ updates.push("updated_at = datetime('now')");
+ params.push(req.params.name);
+
+ db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE name = ?`).run(...params);
+ const updated = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name);
+ res.json(updated);
+});
+
+// DELETE /api/projects/:name
+router.delete('/:name', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined;
+ if (!project) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ db.prepare('DELETE FROM projects WHERE name = ?').run(req.params.name);
+
+ // Optionally delete repo (controlled by query param)
+ if (req.query.delete_repo === 'true') {
+ deleteRepo(project.repo_name);
+ }
+
+ res.status(204).send();
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/settings.ts b/admin-panel/src/server/routes/settings.ts
new file mode 100644
index 0000000..4eed6f1
--- /dev/null
+++ b/admin-panel/src/server/routes/settings.ts
@@ -0,0 +1,35 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb } from '../db.js';
+
+const router = Router();
+
+// GET /api/settings
+router.get('/', (_req: Request, res: Response) => {
+ const db = getAdminDb();
+ const rows = db.prepare('SELECT key, value FROM settings').all() as Array<{
+ key: string;
+ value: string;
+ }>;
+ const settings: Record<string, string> = {};
+ for (const row of rows) {
+ settings[row.key] = row.value;
+ }
+ res.json(settings);
+});
+
+// PATCH /api/settings
+router.patch('/', (req: Request, res: Response) => {
+ const db = getAdminDb();
+ const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
+
+ const updates = req.body as Record<string, string>;
+ for (const [key, value] of Object.entries(updates)) {
+ if (typeof key === 'string' && typeof value === 'string') {
+ stmt.run(key, value);
+ }
+ }
+
+ res.status(204).send();
+});
+
+export default router;
diff --git a/admin-panel/src/server/routes/tasks.ts b/admin-panel/src/server/routes/tasks.ts
new file mode 100644
index 0000000..1ab1e8d
--- /dev/null
+++ b/admin-panel/src/server/routes/tasks.ts
@@ -0,0 +1,248 @@
+import { Router, Request, Response } from 'express';
+import { getAdminDb, getProjectDb } from '../db.js';
+import { broadcast } from '../ws.js';
+
+const router = Router({ mergeParams: true });
+
+type Params = { project: string; id?: string };
+
+interface Task {
+ id: number;
+ title: string;
+ status: string;
+ priority: string;
+ level: number;
+ description: string | null;
+ plan: string | null;
+ decision_log: string | null;
+ done_when: string | null;
+ implementation_notes: string | null;
+ tags: string;
+ plan_review_comments: string;
+ review_comments: string;
+ test_results: string;
+ agent_log: string;
+ current_agent: string | null;
+ plan_review_count: number;
+ impl_review_count: number;
+ branch_name: string | null;
+ gerrit_change_id: string | null;
+ gerrit_change_number: number | null;
+ diff: string | null;
+ rank: number;
+ created_at: string;
+ started_at: string | null;
+ planned_at: string | null;
+ reviewed_at: string | null;
+ tested_at: string | null;
+ completed_at: string | null;
+}
+
+const VALID_STATUSES = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done'];
+const VALID_PRIORITIES = ['high', 'medium', 'low'];
+
+function getProject(projectName: string): boolean {
+ const db = getAdminDb();
+ return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(projectName);
+}
+
+// GET /api/projects/:project/tasks
+router.get('/', (req: Request<Params>, res: Response) => {
+ const { project } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const tasks = db.prepare('SELECT * FROM tasks ORDER BY rank ASC, id ASC').all();
+ res.json(tasks);
+});
+
+// POST /api/projects/:project/tasks
+router.post('/', (req: Request<Params>, res: Response) => {
+ const { project } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const { title, description, priority, level, tags, status } = req.body;
+ if (!title) {
+ res.status(400).json({ error: 'title is required' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+
+ // Get max rank for ordering
+ const maxRank = (db.prepare('SELECT MAX(rank) as max FROM tasks').get() as { max: number | null })?.max || 0;
+
+ const result = db.prepare(
+ `INSERT INTO tasks (title, description, priority, level, tags, status, rank)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
+ ).run(
+ title,
+ description || null,
+ VALID_PRIORITIES.includes(priority) ? priority : 'medium',
+ [1, 2, 3].includes(level) ? level : 3,
+ JSON.stringify(tags || []),
+ VALID_STATUSES.includes(status) ? status : 'todo',
+ maxRank + 1
+ );
+
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid);
+ broadcast(project, { type: 'task:created', project, task });
+ res.status(201).json(task);
+});
+
+// GET /api/projects/:project/tasks/:id
+router.get('/:id', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+ res.json(task);
+});
+
+// PATCH /api/projects/:project/tasks/:id
+router.patch('/:id', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as Task | undefined;
+ if (!existing) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ const allowedFields = [
+ 'title', 'status', 'priority', 'level', 'description', 'plan',
+ 'decision_log', 'done_when', 'implementation_notes', 'tags',
+ 'plan_review_comments', 'review_comments', 'test_results',
+ 'agent_log', 'current_agent', 'plan_review_count', 'impl_review_count',
+ 'branch_name', 'gerrit_change_id', 'gerrit_change_number', 'diff', 'rank'
+ ];
+
+ const updates: string[] = [];
+ const params: unknown[] = [];
+
+ for (const field of allowedFields) {
+ if (req.body[field] !== undefined) {
+ let value = req.body[field];
+
+ // Validate specific fields
+ if (field === 'status' && !VALID_STATUSES.includes(value)) continue;
+ if (field === 'priority' && !VALID_PRIORITIES.includes(value)) continue;
+ if (field === 'level' && ![1, 2, 3].includes(value)) continue;
+
+ // Stringify JSON fields
+ if (['tags', 'plan_review_comments', 'review_comments', 'test_results', 'agent_log'].includes(field)) {
+ value = typeof value === 'string' ? value : JSON.stringify(value);
+ }
+
+ updates.push(`${field} = ?`);
+ params.push(value);
+ }
+ }
+
+ // Set timestamp fields based on status changes
+ if (req.body.status) {
+ const now = new Date().toISOString();
+ switch (req.body.status) {
+ case 'plan':
+ if (!existing.started_at) {
+ updates.push('started_at = ?');
+ params.push(now);
+ }
+ break;
+ case 'impl':
+ updates.push('planned_at = ?');
+ params.push(now);
+ break;
+ case 'test':
+ updates.push('reviewed_at = ?');
+ params.push(now);
+ break;
+ case 'done':
+ updates.push('completed_at = ?');
+ params.push(now);
+ break;
+ }
+ }
+
+ if (updates.length === 0) {
+ res.json(existing);
+ return;
+ }
+
+ params.push(id);
+ db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ broadcast(project, { type: 'task:updated', project, task });
+ res.json(task);
+});
+
+// DELETE /api/projects/:project/tasks/:id
+router.delete('/:id', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ if (!task) {
+ res.status(404).json({ error: 'Task not found' });
+ return;
+ }
+
+ db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
+ broadcast(project, { type: 'task:deleted', project, taskId: Number(id) });
+ res.status(204).send();
+});
+
+// PATCH /api/projects/:project/tasks/:id/reorder
+router.patch('/:id/reorder', (req: Request<Params>, res: Response) => {
+ const { project, id } = req.params;
+ if (!getProject(project)) {
+ res.status(404).json({ error: 'Project not found' });
+ return;
+ }
+
+ const { rank, status } = req.body;
+ if (rank === undefined) {
+ res.status(400).json({ error: 'rank is required' });
+ return;
+ }
+
+ const db = getProjectDb(project);
+ const updates: string[] = ['rank = ?'];
+ const params: unknown[] = [rank];
+
+ if (status && VALID_STATUSES.includes(status)) {
+ updates.push('status = ?');
+ params.push(status);
+ }
+
+ params.push(id);
+ db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
+ broadcast(project, { type: 'task:updated', project, task });
+ res.json(task);
+});
+
+export default router;