import crypto from 'node:crypto'; import { db } from '../core/db.js'; import { bus } from '../core/bus.js'; import { requireMember } from '../core/guards.js'; function sendError(reply, status, code, message) { return reply.code(status).send({ error: { code, message } }); } function hasField(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); } function isMember(spaceId, userId) { if (userId === null || userId === undefined) return true; return Boolean( db .prepare('SELECT id FROM space_members WHERE space_id = ? AND user_id = ?') .get(spaceId, userId) ); } function validateNullableTimestamp(value, name) { if (value === null || value === undefined) return null; if (!Number.isInteger(value)) return `${name} должен быть unix ms или null`; return null; } export default async function tasksRoutes(fastify) { fastify.get('/spaces/:id/tasks', async (request) => { await requireMember(request, request.params.id); const tasks = db .prepare('SELECT * FROM tasks WHERE space_id = ? ORDER BY created_at DESC') .all(request.params.id); return { tasks }; }); fastify.post('/spaces/:id/tasks', async (request, reply) => { await requireMember(request, request.params.id); const spaceId = request.params.id; const { title, assignee_id = null, due_at = null } = request.body || {}; if (!title || typeof title !== 'string') { return sendError(reply, 400, 'VALIDATION', 'title обязателен'); } if (!isMember(spaceId, assignee_id)) { return sendError(reply, 400, 'VALIDATION', 'assignee_id должен быть участником пространства или null'); } const dueError = validateNullableTimestamp(due_at, 'due_at'); if (dueError) { return sendError(reply, 400, 'VALIDATION', dueError); } const task = { id: crypto.randomUUID(), space_id: spaceId, title, assignee_id, done: 0, due_at, created_at: Date.now(), }; db.prepare( `INSERT INTO tasks (id, space_id, title, assignee_id, done, due_at, created_at) VALUES (?,?,?,?,?,?,?)` ).run(task.id, task.space_id, task.title, task.assignee_id, task.done, task.due_at, task.created_at); bus.emit(spaceId, { type: 'task.created', space_id: spaceId, payload: task }); return reply.code(201).send({ task }); }); fastify.patch('/spaces/:id/tasks/:taskId', async (request, reply) => { await requireMember(request, request.params.id); const spaceId = request.params.id; const taskId = request.params.taskId; const body = request.body || {}; const existing = db.prepare('SELECT * FROM tasks WHERE id = ? AND space_id = ?').get(taskId, spaceId); if (!existing) { return sendError(reply, 404, 'NOT_FOUND', 'Задача не найдена'); } const task = { ...existing }; if (hasField(body, 'title')) { if (!body.title || typeof body.title !== 'string') { return sendError(reply, 400, 'VALIDATION', 'title должен быть непустой строкой'); } task.title = body.title; } if (hasField(body, 'assignee_id')) { if (!isMember(spaceId, body.assignee_id)) { return sendError(reply, 400, 'VALIDATION', 'assignee_id должен быть участником пространства или null'); } task.assignee_id = body.assignee_id; } if (hasField(body, 'done')) { if (typeof body.done !== 'boolean' && body.done !== 0 && body.done !== 1) { return sendError(reply, 400, 'VALIDATION', 'done должен быть boolean или 0|1'); } task.done = body.done ? 1 : 0; } db.prepare('UPDATE tasks SET title = ?, assignee_id = ?, done = ? WHERE id = ? AND space_id = ?') .run(task.title, task.assignee_id, task.done, task.id, task.space_id); bus.emit(spaceId, { type: 'task.updated', space_id: spaceId, payload: task }); return { task }; }); }