368 lines
12 KiB
TypeScript
368 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, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
|
|
import { resolveMentionRecipients } from '@/lib/mentions';
|
|
|
|
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 resolveProjectId(db: ReturnType<typeof getDatabase>, workspaceId: number, requestedProjectId?: number): number {
|
|
if (typeof requestedProjectId === 'number' && Number.isFinite(requestedProjectId)) {
|
|
const project = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ? AND workspace_id = ? AND status = 'active'
|
|
LIMIT 1
|
|
`).get(requestedProjectId, workspaceId) as { id: number } | undefined
|
|
if (project) return project.id
|
|
}
|
|
|
|
const fallback = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE workspace_id = ? AND status = 'active'
|
|
ORDER BY CASE WHEN slug = 'general' THEN 0 ELSE 1 END, id ASC
|
|
LIMIT 1
|
|
`).get(workspaceId) as { id: number } | undefined
|
|
|
|
if (!fallback) {
|
|
throw new Error('No active project available in workspace')
|
|
}
|
|
return fallback.id
|
|
}
|
|
|
|
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 - List all tasks with optional filtering
|
|
* Query params: status, assigned_to, priority, project_id, limit, offset
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
const auth = requireRole(request, 'viewer');
|
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
try {
|
|
const db = getDatabase();
|
|
const workspaceId = auth.user.workspace_id;
|
|
const { searchParams } = new URL(request.url);
|
|
|
|
// Parse query parameters
|
|
const status = searchParams.get('status');
|
|
const assigned_to = searchParams.get('assigned_to');
|
|
const priority = searchParams.get('priority');
|
|
const projectIdParam = Number.parseInt(searchParams.get('project_id') || '', 10);
|
|
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
|
const offset = parseInt(searchParams.get('offset') || '0');
|
|
|
|
// Build dynamic query
|
|
let query = `
|
|
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.workspace_id = ?
|
|
`;
|
|
const params: any[] = [workspaceId];
|
|
|
|
if (status) {
|
|
query += ' AND t.status = ?';
|
|
params.push(status);
|
|
}
|
|
|
|
if (assigned_to) {
|
|
query += ' AND t.assigned_to = ?';
|
|
params.push(assigned_to);
|
|
}
|
|
|
|
if (priority) {
|
|
query += ' AND t.priority = ?';
|
|
params.push(priority);
|
|
}
|
|
|
|
if (Number.isFinite(projectIdParam)) {
|
|
query += ' AND t.project_id = ?';
|
|
params.push(projectIdParam);
|
|
}
|
|
|
|
query += ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?';
|
|
params.push(limit, offset);
|
|
|
|
const stmt = db.prepare(query);
|
|
const tasks = stmt.all(...params) as Task[];
|
|
|
|
// Parse JSON fields
|
|
const tasksWithParsedData = tasks.map(mapTaskRow);
|
|
|
|
// Get total count for pagination
|
|
let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?';
|
|
const countParams: any[] = [workspaceId];
|
|
if (status) {
|
|
countQuery += ' AND status = ?';
|
|
countParams.push(status);
|
|
}
|
|
if (assigned_to) {
|
|
countQuery += ' AND assigned_to = ?';
|
|
countParams.push(assigned_to);
|
|
}
|
|
if (priority) {
|
|
countQuery += ' AND priority = ?';
|
|
countParams.push(priority);
|
|
}
|
|
if (Number.isFinite(projectIdParam)) {
|
|
countQuery += ' AND project_id = ?';
|
|
countParams.push(projectIdParam);
|
|
}
|
|
const countRow = db.prepare(countQuery).get(...countParams) as { total: number };
|
|
|
|
return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'GET /api/tasks error');
|
|
return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/tasks - Create a new task
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
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 workspaceId = auth.user.workspace_id;
|
|
const validated = await validateBody(request, createTaskSchema);
|
|
if ('error' in validated) return validated.error;
|
|
const body = validated.data;
|
|
|
|
const user = auth.user
|
|
const {
|
|
title,
|
|
description,
|
|
status = 'inbox',
|
|
priority = 'medium',
|
|
project_id,
|
|
assigned_to,
|
|
created_by = user?.username || 'system',
|
|
due_date,
|
|
estimated_hours,
|
|
tags = [],
|
|
metadata = {}
|
|
} = body;
|
|
|
|
// Check for duplicate title
|
|
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
|
|
if (existingTask) {
|
|
return NextResponse.json({ error: 'Task with this title already exists' }, { status: 409 });
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const mentionResolution = resolveMentionRecipients(description || '', db, workspaceId);
|
|
if (mentionResolution.unresolved.length > 0) {
|
|
return NextResponse.json({
|
|
error: `Unknown mentions: ${mentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`,
|
|
missing_mentions: mentionResolution.unresolved
|
|
}, { status: 400 });
|
|
}
|
|
|
|
const createTaskTx = db.transaction(() => {
|
|
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
|
|
WHERE id = ? AND workspace_id = ?
|
|
`).run(resolvedProjectId, workspaceId)
|
|
const row = db.prepare(`
|
|
SELECT ticket_counter FROM projects
|
|
WHERE id = ? AND workspace_id = ?
|
|
`).get(resolvedProjectId, workspaceId) as { ticket_counter: number } | undefined
|
|
if (!row || !row.ticket_counter) throw new Error('Failed to allocate project ticket number')
|
|
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO tasks (
|
|
title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by,
|
|
created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`)
|
|
|
|
const dbResult = insertStmt.run(
|
|
title,
|
|
description,
|
|
status,
|
|
priority,
|
|
resolvedProjectId,
|
|
row.ticket_counter,
|
|
assigned_to,
|
|
created_by,
|
|
now,
|
|
now,
|
|
due_date,
|
|
estimated_hours,
|
|
JSON.stringify(tags),
|
|
JSON.stringify(metadata),
|
|
workspaceId
|
|
)
|
|
return Number(dbResult.lastInsertRowid)
|
|
})
|
|
|
|
const taskId = createTaskTx()
|
|
|
|
// Log activity
|
|
db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
|
|
title,
|
|
status,
|
|
priority,
|
|
assigned_to
|
|
}, workspaceId);
|
|
|
|
if (created_by) {
|
|
db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
|
|
}
|
|
|
|
for (const recipient of mentionResolution.recipients) {
|
|
db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
|
|
if (recipient === created_by) continue;
|
|
db_helpers.createNotification(
|
|
recipient,
|
|
'mention',
|
|
'You were mentioned in a task description',
|
|
`${created_by} mentioned you in task "${title}"`,
|
|
'task',
|
|
taskId,
|
|
workspaceId
|
|
);
|
|
}
|
|
|
|
// Create notification if assigned
|
|
if (assigned_to) {
|
|
db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)
|
|
db_helpers.createNotification(
|
|
assigned_to,
|
|
'assignment',
|
|
'Task Assigned',
|
|
`You have been assigned to task: ${title}`,
|
|
'task',
|
|
taskId,
|
|
workspaceId
|
|
);
|
|
}
|
|
|
|
// Fetch the created task
|
|
const createdTask = 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(createdTask);
|
|
|
|
// Broadcast to SSE clients
|
|
eventBus.broadcast('task.created', parsedTask);
|
|
|
|
return NextResponse.json({ task: parsedTask }, { status: 201 });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'POST /api/tasks error');
|
|
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PUT /api/tasks - Update multiple tasks (for drag-and-drop status changes)
|
|
*/
|
|
export async function PUT(request: NextRequest) {
|
|
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 workspaceId = auth.user.workspace_id;
|
|
const validated = await validateBody(request, bulkUpdateTaskStatusSchema);
|
|
if ('error' in validated) return validated.error;
|
|
const { tasks } = validated.data;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const updateStmt = db.prepare(`
|
|
UPDATE tasks
|
|
SET status = ?, updated_at = ?
|
|
WHERE id = ? AND workspace_id = ?
|
|
`);
|
|
|
|
const actor = auth.user.username
|
|
|
|
const transaction = db.transaction((tasksToUpdate: any[]) => {
|
|
for (const task of tasksToUpdate) {
|
|
const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(task.id, workspaceId) as Task;
|
|
if (!oldTask) continue;
|
|
|
|
if (task.status === 'done' && !hasAegisApproval(db, task.id, workspaceId)) {
|
|
throw new Error(`Aegis approval required for task ${task.id}`)
|
|
}
|
|
|
|
updateStmt.run(task.status, now, task.id, workspaceId);
|
|
|
|
// Log status change if different
|
|
if (oldTask && oldTask.status !== task.status) {
|
|
db_helpers.logActivity(
|
|
'task_updated',
|
|
'task',
|
|
task.id,
|
|
actor,
|
|
`Task moved from ${oldTask.status} to ${task.status}`,
|
|
{ oldStatus: oldTask.status, newStatus: task.status },
|
|
workspaceId
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
transaction(tasks);
|
|
|
|
// Broadcast status changes to SSE clients
|
|
for (const task of tasks) {
|
|
eventBus.broadcast('task.status_changed', {
|
|
id: task.id,
|
|
status: task.status,
|
|
updated_at: Math.floor(Date.now() / 1000),
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({ success: true, updated: tasks.length });
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'PUT /api/tasks error');
|
|
const message = error instanceof Error ? error.message : 'Failed to update tasks'
|
|
if (message.includes('Aegis approval required')) {
|
|
return NextResponse.json({ error: message }, { status: 403 });
|
|
}
|
|
return NextResponse.json({ error: 'Failed to update tasks' }, { status: 500 });
|
|
}
|
|
}
|