379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getDatabase, Task, db_helpers } from '@/lib/db';
|
|
import { eventBus } from '@/lib/event-bus';
|
|
import { requireRole } from '@/lib/auth';
|
|
import { mutationLimiter } from '@/lib/rate-limit';
|
|
import { logger } from '@/lib/logger';
|
|
import { validateBody, updateTaskSchema } from '@/lib/validation';
|
|
|
|
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
|
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
|
return `${prefix}-${String(num).padStart(3, '0')}`
|
|
}
|
|
|
|
function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record<string, unknown> } {
|
|
return {
|
|
...task,
|
|
tags: task.tags ? JSON.parse(task.tags) : [],
|
|
metadata: task.metadata ? JSON.parse(task.metadata) : {},
|
|
ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no),
|
|
}
|
|
}
|
|
|
|
function hasAegisApproval(
|
|
db: ReturnType<typeof getDatabase>,
|
|
taskId: number,
|
|
workspaceId: number
|
|
): boolean {
|
|
const review = db.prepare(`
|
|
SELECT status FROM quality_reviews
|
|
WHERE task_id = ? AND reviewer = 'aegis' AND workspace_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`).get(taskId, workspaceId) as { status?: string } | undefined
|
|
return review?.status === 'approved'
|
|
}
|
|
|
|
/**
|
|
* GET /api/tasks/[id] - Get a specific task
|
|
*/
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const auth = requireRole(request, 'viewer');
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
try {
|
|
const db = getDatabase();
|
|
const resolvedParams = await params;
|
|
const taskId = parseInt(resolvedParams.id);
|
|
const workspaceId = auth.user.workspace_id ?? 1;
|
|
|
|
if (isNaN(taskId)) {
|
|
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
|
|
}
|
|
|
|
const stmt = db.prepare(`
|
|
SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
|
|
FROM tasks t
|
|
LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
|
|
WHERE t.id = ? AND t.workspace_id = ?
|
|
`);
|
|
const task = stmt.get(taskId, workspaceId) as Task;
|
|
|
|
if (!task) {
|
|
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
}
|
|
|
|
// Parse JSON fields
|
|
const taskWithParsedData = mapTaskRow(task);
|
|
|
|
return NextResponse.json({ task: taskWithParsedData });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'GET /api/tasks/[id] error');
|
|
return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PUT /api/tasks/[id] - Update a specific task
|
|
*/
|
|
export async function PUT(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const auth = requireRole(request, 'operator');
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
const rateCheck = mutationLimiter(request);
|
|
if (rateCheck) return rateCheck;
|
|
|
|
try {
|
|
const db = getDatabase();
|
|
const resolvedParams = await params;
|
|
const taskId = parseInt(resolvedParams.id);
|
|
const workspaceId = auth.user.workspace_id ?? 1;
|
|
const validated = await validateBody(request, updateTaskSchema);
|
|
if ('error' in validated) return validated.error;
|
|
const body = validated.data;
|
|
|
|
if (isNaN(taskId)) {
|
|
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
|
|
}
|
|
|
|
// Get current task for comparison
|
|
const currentTask = db
|
|
.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
|
|
.get(taskId, workspaceId) as Task;
|
|
|
|
if (!currentTask) {
|
|
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
}
|
|
|
|
const {
|
|
title,
|
|
description,
|
|
status,
|
|
priority,
|
|
project_id,
|
|
assigned_to,
|
|
due_date,
|
|
estimated_hours,
|
|
actual_hours,
|
|
tags,
|
|
metadata
|
|
} = body;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Build dynamic update query
|
|
const fieldsToUpdate = [];
|
|
const updateParams: any[] = [];
|
|
let nextProjectTicketNo: number | null = null;
|
|
|
|
if (title !== undefined) {
|
|
fieldsToUpdate.push('title = ?');
|
|
updateParams.push(title);
|
|
}
|
|
if (description !== undefined) {
|
|
fieldsToUpdate.push('description = ?');
|
|
updateParams.push(description);
|
|
}
|
|
if (status !== undefined) {
|
|
if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
|
|
return NextResponse.json(
|
|
{ error: 'Aegis approval is required to move task to done.' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
fieldsToUpdate.push('status = ?');
|
|
updateParams.push(status);
|
|
}
|
|
if (priority !== undefined) {
|
|
fieldsToUpdate.push('priority = ?');
|
|
updateParams.push(priority);
|
|
}
|
|
if (project_id !== undefined) {
|
|
const project = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ? AND workspace_id = ? AND status = 'active'
|
|
`).get(project_id, workspaceId) as { id: number } | undefined
|
|
if (!project) {
|
|
return NextResponse.json({ error: 'Project not found or archived' }, { status: 400 })
|
|
}
|
|
if (project_id !== currentTask.project_id) {
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
|
|
WHERE id = ? AND workspace_id = ?
|
|
`).run(project_id, workspaceId)
|
|
const row = db.prepare(`
|
|
SELECT ticket_counter FROM projects
|
|
WHERE id = ? AND workspace_id = ?
|
|
`).get(project_id, workspaceId) as { ticket_counter: number } | undefined
|
|
if (!row || !row.ticket_counter) {
|
|
return NextResponse.json({ error: 'Failed to allocate project ticket number' }, { status: 500 })
|
|
}
|
|
nextProjectTicketNo = row.ticket_counter
|
|
}
|
|
fieldsToUpdate.push('project_id = ?');
|
|
updateParams.push(project_id);
|
|
if (nextProjectTicketNo !== null) {
|
|
fieldsToUpdate.push('project_ticket_no = ?');
|
|
updateParams.push(nextProjectTicketNo);
|
|
}
|
|
}
|
|
if (assigned_to !== undefined) {
|
|
fieldsToUpdate.push('assigned_to = ?');
|
|
updateParams.push(assigned_to);
|
|
}
|
|
if (due_date !== undefined) {
|
|
fieldsToUpdate.push('due_date = ?');
|
|
updateParams.push(due_date);
|
|
}
|
|
if (estimated_hours !== undefined) {
|
|
fieldsToUpdate.push('estimated_hours = ?');
|
|
updateParams.push(estimated_hours);
|
|
}
|
|
if (actual_hours !== undefined) {
|
|
fieldsToUpdate.push('actual_hours = ?');
|
|
updateParams.push(actual_hours);
|
|
}
|
|
if (tags !== undefined) {
|
|
fieldsToUpdate.push('tags = ?');
|
|
updateParams.push(JSON.stringify(tags));
|
|
}
|
|
if (metadata !== undefined) {
|
|
fieldsToUpdate.push('metadata = ?');
|
|
updateParams.push(JSON.stringify(metadata));
|
|
}
|
|
|
|
fieldsToUpdate.push('updated_at = ?');
|
|
updateParams.push(now);
|
|
updateParams.push(taskId, workspaceId);
|
|
|
|
if (fieldsToUpdate.length === 1) { // Only updated_at
|
|
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
|
}
|
|
|
|
const stmt = db.prepare(`
|
|
UPDATE tasks
|
|
SET ${fieldsToUpdate.join(', ')}
|
|
WHERE id = ? AND workspace_id = ?
|
|
`);
|
|
|
|
stmt.run(...updateParams);
|
|
|
|
// Track changes and log activities
|
|
const changes: string[] = [];
|
|
|
|
if (status && status !== currentTask.status) {
|
|
changes.push(`status: ${currentTask.status} → ${status}`);
|
|
|
|
// Create notification for status change if assigned
|
|
if (currentTask.assigned_to) {
|
|
db_helpers.createNotification(
|
|
currentTask.assigned_to,
|
|
'status_change',
|
|
'Task Status Updated',
|
|
`Task "${currentTask.title}" status changed to ${status}`,
|
|
'task',
|
|
taskId,
|
|
workspaceId
|
|
);
|
|
}
|
|
}
|
|
|
|
if (assigned_to !== undefined && assigned_to !== currentTask.assigned_to) {
|
|
changes.push(`assigned: ${currentTask.assigned_to || 'unassigned'} → ${assigned_to || 'unassigned'}`);
|
|
|
|
// Create notification for new assignee
|
|
if (assigned_to) {
|
|
db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId);
|
|
db_helpers.createNotification(
|
|
assigned_to,
|
|
'assignment',
|
|
'Task Assigned',
|
|
`You have been assigned to task: ${currentTask.title}`,
|
|
'task',
|
|
taskId,
|
|
workspaceId
|
|
);
|
|
}
|
|
}
|
|
|
|
if (title && title !== currentTask.title) {
|
|
changes.push('title updated');
|
|
}
|
|
|
|
if (priority && priority !== currentTask.priority) {
|
|
changes.push(`priority: ${currentTask.priority} → ${priority}`);
|
|
}
|
|
|
|
if (project_id !== undefined && project_id !== currentTask.project_id) {
|
|
changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`);
|
|
}
|
|
|
|
// Log activity if there were meaningful changes
|
|
if (changes.length > 0) {
|
|
db_helpers.logActivity(
|
|
'task_updated',
|
|
'task',
|
|
taskId,
|
|
auth.user.username,
|
|
`Task updated: ${changes.join(', ')}`,
|
|
{
|
|
changes: changes,
|
|
oldValues: {
|
|
title: currentTask.title,
|
|
status: currentTask.status,
|
|
priority: currentTask.priority,
|
|
assigned_to: currentTask.assigned_to
|
|
},
|
|
newValues: { title, status, priority, assigned_to }
|
|
},
|
|
workspaceId
|
|
);
|
|
}
|
|
|
|
// Fetch updated task
|
|
const updatedTask = db.prepare(`
|
|
SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
|
|
FROM tasks t
|
|
LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
|
|
WHERE t.id = ? AND t.workspace_id = ?
|
|
`).get(taskId, workspaceId) as Task;
|
|
const parsedTask = mapTaskRow(updatedTask);
|
|
|
|
// Broadcast to SSE clients
|
|
eventBus.broadcast('task.updated', parsedTask);
|
|
|
|
return NextResponse.json({ task: parsedTask });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'PUT /api/tasks/[id] error');
|
|
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/tasks/[id] - Delete a specific task
|
|
*/
|
|
export async function DELETE(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const auth = requireRole(request, 'operator');
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
const rateCheck = mutationLimiter(request);
|
|
if (rateCheck) return rateCheck;
|
|
|
|
try {
|
|
const db = getDatabase();
|
|
const resolvedParams = await params;
|
|
const taskId = parseInt(resolvedParams.id);
|
|
const workspaceId = auth.user.workspace_id ?? 1;
|
|
|
|
if (isNaN(taskId)) {
|
|
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
|
|
}
|
|
|
|
// Get task before deletion for logging
|
|
const task = db
|
|
.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
|
|
.get(taskId, workspaceId) as Task;
|
|
|
|
if (!task) {
|
|
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
}
|
|
|
|
// Delete task (cascades will handle comments)
|
|
const stmt = db.prepare('DELETE FROM tasks WHERE id = ? AND workspace_id = ?');
|
|
stmt.run(taskId, workspaceId);
|
|
|
|
// Log deletion
|
|
db_helpers.logActivity(
|
|
'task_deleted',
|
|
'task',
|
|
taskId,
|
|
auth.user.username,
|
|
`Deleted task: ${task.title}`,
|
|
{
|
|
title: task.title,
|
|
status: task.status,
|
|
assigned_to: task.assigned_to
|
|
},
|
|
workspaceId
|
|
);
|
|
|
|
// Broadcast to SSE clients
|
|
eventBus.broadcast('task.deleted', { id: taskId, title: task.title });
|
|
|
|
return NextResponse.json({ success: true });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'DELETE /api/tasks/[id] error');
|
|
return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 });
|
|
}
|
|
}
|