diff --git a/api/src/index.js b/api/src/index.js index 26660a2..ace89a7 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -72,6 +72,7 @@ const start = async () => { await app.register(require("./routes/projects"), { prefix: "/api/v1" }); await app.register(require("./routes/deploy"), { prefix: "/api/v1" }); await app.register(require("./routes/system"), { prefix: "/api/v1" }); + await app.register(require("./routes/collaboration"), { prefix: "/api/v1" }); try { await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); diff --git a/api/src/routes/collaboration.js b/api/src/routes/collaboration.js new file mode 100644 index 0000000..aa27d79 --- /dev/null +++ b/api/src/routes/collaboration.js @@ -0,0 +1,353 @@ +// Task Team — Extended Collaboration — 2026-03-29 + +async function collaborationRoutes(app) { + + // === SUBTASKS === + + // List subtasks for a task + app.get('/tasks/:taskId/subtasks', async (req) => { + const { rows } = await app.db.query( + 'SELECT s.*, u.name as assignee_name FROM subtasks s LEFT JOIN users u ON s.assigned_to = u.id WHERE s.parent_task_id = $1 ORDER BY s.order_index', + [req.params.taskId] + ); + return { data: rows }; + }); + + // Create subtask + app.post('/tasks/:taskId/subtasks', async (req) => { + const { title, description, assigned_to, order_index } = req.body; + if (!title || typeof title !== 'string' || title.trim().length === 0) { + throw { statusCode: 400, message: 'title is required' }; + } + // Verify parent task exists + const { rows: parentCheck } = await app.db.query('SELECT id FROM tasks WHERE id = $1', [req.params.taskId]); + if (!parentCheck.length) throw { statusCode: 404, message: 'Parent task not found' }; + + const { rows } = await app.db.query( + 'INSERT INTO subtasks (parent_task_id, title, description, assigned_to, order_index) VALUES ($1,$2,$3,$4,$5) RETURNING *', + [req.params.taskId, title.trim(), description || '', assigned_to || null, order_index || 0] + ); + return { data: rows[0] }; + }); + + // Update subtask + app.put('/tasks/:taskId/subtasks/:id', async (req) => { + const { title, description, assigned_to, status } = req.body; + const sets = []; + const params = []; + let i = 1; + if (title !== undefined) { sets.push('title=$' + i); params.push(title); i++; } + if (description !== undefined) { sets.push('description=$' + i); params.push(description); i++; } + if (assigned_to !== undefined) { sets.push('assigned_to=$' + i); params.push(assigned_to); i++; } + if (status) { + sets.push('status=$' + i); params.push(status); i++; + if (status === 'done' || status === 'completed') { + sets.push('completed_at=NOW()'); + } + } + if (sets.length === 0) throw { statusCode: 400, message: 'No fields to update' }; + sets.push('updated_at=NOW()'); + params.push(req.params.id); + const { rows } = await app.db.query( + 'UPDATE subtasks SET ' + sets.join(',') + ' WHERE id=$' + i + ' RETURNING *', params + ); + if (!rows.length) throw { statusCode: 404, message: 'Subtask not found' }; + + // Check if all subtasks done -> auto-complete parent + const { rows: allSubs } = await app.db.query( + 'SELECT status FROM subtasks WHERE parent_task_id = $1', [req.params.taskId] + ); + if (allSubs.length > 0 && allSubs.every(s => s.status === 'done' || s.status === 'completed')) { + await app.db.query( + "UPDATE tasks SET status='completed', completed_at=NOW(), updated_at=NOW() WHERE id=$1", + [req.params.taskId] + ); + } + + return { data: rows[0] }; + }); + + // Delete subtask + app.delete('/tasks/:taskId/subtasks/:id', async (req) => { + const { rowCount } = await app.db.query('DELETE FROM subtasks WHERE id=$1 AND parent_task_id=$2', [req.params.id, req.params.taskId]); + if (!rowCount) throw { statusCode: 404, message: 'Subtask not found' }; + return { status: 'deleted' }; + }); + + // Reorder subtasks + app.put('/tasks/:taskId/subtasks/reorder', async (req) => { + const { order } = req.body; // [{id, order_index}] + if (!Array.isArray(order)) throw { statusCode: 400, message: 'order must be an array of {id, order_index}' }; + const client = await app.db.connect(); + try { + await client.query('BEGIN'); + for (const item of order) { + await client.query( + 'UPDATE subtasks SET order_index=$1, updated_at=NOW() WHERE id=$2 AND parent_task_id=$3', + [item.order_index, item.id, req.params.taskId] + ); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + return { status: 'ok' }; + }); + + // === COLLABORATION REQUESTS === + + // Send collaboration request (assign, transfer, collaborate, claim) + app.post('/tasks/:taskId/collaborate', async (req) => { + const { to_user_id, from_user_id, type, message } = req.body; + if (!['assign', 'transfer', 'collaborate', 'claim'].includes(type)) { + throw { statusCode: 400, message: 'Invalid type. Use: assign, transfer, collaborate, claim' }; + } + + // Verify task exists + const { rows: taskCheck } = await app.db.query('SELECT id FROM tasks WHERE id = $1', [req.params.taskId]); + if (!taskCheck.length) throw { statusCode: 404, message: 'Task not found' }; + + if (type === 'claim') { + // Direct claim — no approval needed, user takes the task + const claimUser = from_user_id || to_user_id; + if (!claimUser) throw { statusCode: 400, message: 'from_user_id or to_user_id required for claim' }; + + await app.db.query( + 'UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at=NOW() WHERE id=$2', + [claimUser, req.params.taskId] + ); + await app.db.query( + 'INSERT INTO task_collaboration (task_id, from_user_id, to_user_id, type, status, message) VALUES ($1,$2,$2,$3,$4,$5)', + [req.params.taskId, claimUser, 'claim', 'accepted', message || ''] + ); + return { status: 'claimed' }; + } + + // Other types need approval — to_user_id required + if (!to_user_id) throw { statusCode: 400, message: 'to_user_id is required' }; + + const { rows } = await app.db.query( + 'INSERT INTO task_collaboration (task_id, from_user_id, to_user_id, type, message) VALUES ($1,$2,$3,$4,$5) RETURNING *', + [req.params.taskId, from_user_id || null, to_user_id, type, message || ''] + ); + return { data: rows[0], status: 'pending' }; + }); + + // Respond to collaboration request + app.post('/collaboration/:id/respond', async (req) => { + const { accept } = req.body; + if (accept === undefined) throw { statusCode: 400, message: 'accept (true/false) is required' }; + + const { rows } = await app.db.query( + 'UPDATE task_collaboration SET status=$1, responded_at=NOW() WHERE id=$2 AND status=$3 RETURNING *', + [accept ? 'accepted' : 'rejected', req.params.id, 'pending'] + ); + if (!rows.length) throw { statusCode: 404, message: 'Pending request not found' }; + + const collab = rows[0]; + if (accept) { + if (collab.type === 'assign' || collab.type === 'collaborate') { + // Add user to assigned_to array + await app.db.query( + 'UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at=NOW() WHERE id=$2', + [collab.to_user_id, collab.task_id] + ); + } else if (collab.type === 'transfer') { + // Transfer ownership: set user_id and optionally remove from assigned_to + await app.db.query( + 'UPDATE tasks SET user_id=$1, updated_at=NOW() WHERE id=$2', + [collab.to_user_id, collab.task_id] + ); + } + } + return { data: collab }; + }); + + // List pending collaboration requests for a user + app.get('/collaboration/pending/:userId', async (req) => { + const { rows } = await app.db.query( + `SELECT tc.*, t.title as task_title, t.status as task_status, u.name as from_name + FROM task_collaboration tc + JOIN tasks t ON tc.task_id = t.id + LEFT JOIN users u ON tc.from_user_id = u.id + WHERE tc.to_user_id = $1 AND tc.status = 'pending' + ORDER BY tc.created_at DESC`, + [req.params.userId] + ); + return { data: rows }; + }); + + // List all collaboration history for a task + app.get('/tasks/:taskId/collaboration', async (req) => { + const { rows } = await app.db.query( + `SELECT tc.*, uf.name as from_name, ut.name as to_name + FROM task_collaboration tc + LEFT JOIN users uf ON tc.from_user_id = uf.id + LEFT JOIN users ut ON tc.to_user_id = ut.id + WHERE tc.task_id = $1 + ORDER BY tc.created_at DESC`, + [req.params.taskId] + ); + return { data: rows }; + }); + + // === ASSIGN TO GROUP === + + // Assign task to all members of a user group + app.post('/tasks/:taskId/assign-group', async (req) => { + const { group_id } = req.body; + if (!group_id) throw { statusCode: 400, message: 'group_id is required' }; + + const { rows } = await app.db.query('SELECT members FROM user_groups WHERE id=$1', [group_id]); + if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; + + const members = rows[0].members || []; + if (members.length === 0) throw { statusCode: 400, message: 'Group has no members' }; + + await app.db.query( + 'UPDATE tasks SET assigned_to = $1::uuid[], updated_at=NOW() WHERE id=$2', + [members, req.params.taskId] + ); + return { status: 'assigned', members_count: members.length }; + }); + + // === USER GROUPS (Teams) === + + app.get('/user-groups', async (req) => { + const { rows } = await app.db.query( + `SELECT ug.*, u.name as owner_name, + (SELECT COUNT(*) FROM unnest(ug.members)) as member_count + FROM user_groups ug + LEFT JOIN users u ON ug.owner_id = u.id + ORDER BY ug.name` + ); + return { data: rows }; + }); + + app.get('/user-groups/:id', async (req) => { + const { rows } = await app.db.query( + `SELECT ug.*, u.name as owner_name + FROM user_groups ug + LEFT JOIN users u ON ug.owner_id = u.id + WHERE ug.id = $1`, + [req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; + + // Resolve member names + const group = rows[0]; + if (group.members && group.members.length > 0) { + const { rows: memberRows } = await app.db.query( + 'SELECT id, name, email, avatar_url FROM users WHERE id = ANY($1)', + [group.members] + ); + group.member_details = memberRows; + } else { + group.member_details = []; + } + return { data: group }; + }); + + app.post('/user-groups', async (req) => { + const { name, description, owner_id, members } = req.body; + if (!name || typeof name !== 'string' || name.trim().length === 0) { + throw { statusCode: 400, message: 'name is required' }; + } + const { rows } = await app.db.query( + 'INSERT INTO user_groups (name, description, owner_id, members) VALUES ($1,$2,$3,$4) RETURNING *', + [name.trim(), description || '', owner_id || null, members || []] + ); + return { data: rows[0] }; + }); + + app.put('/user-groups/:id', async (req) => { + const { name, description } = req.body; + const sets = []; + const params = []; + let i = 1; + if (name !== undefined) { sets.push('name=$' + i); params.push(name); i++; } + if (description !== undefined) { sets.push('description=$' + i); params.push(description); i++; } + if (sets.length === 0) throw { statusCode: 400, message: 'No fields to update' }; + params.push(req.params.id); + const { rows } = await app.db.query( + 'UPDATE user_groups SET ' + sets.join(',') + ' WHERE id=$' + i + ' RETURNING *', params + ); + if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; + return { data: rows[0] }; + }); + + app.delete('/user-groups/:id', async (req) => { + const { rowCount } = await app.db.query('DELETE FROM user_groups WHERE id=$1', [req.params.id]); + if (!rowCount) throw { statusCode: 404, message: 'Group not found' }; + return { status: 'deleted' }; + }); + + // Add member to group + app.post('/user-groups/:id/members', async (req) => { + const { user_id } = req.body; + if (!user_id) throw { statusCode: 400, message: 'user_id is required' }; + + // Check user exists + const { rows: userCheck } = await app.db.query('SELECT id FROM users WHERE id = $1', [user_id]); + if (!userCheck.length) throw { statusCode: 404, message: 'User not found' }; + + // Prevent duplicates + const { rows: groupCheck } = await app.db.query('SELECT members FROM user_groups WHERE id = $1', [req.params.id]); + if (!groupCheck.length) throw { statusCode: 404, message: 'Group not found' }; + if (groupCheck[0].members && groupCheck[0].members.includes(user_id)) { + return { status: 'already_member' }; + } + + const { rows } = await app.db.query( + 'UPDATE user_groups SET members = array_append(members, $1::uuid) WHERE id=$2 RETURNING *', + [user_id, req.params.id] + ); + return { data: rows[0] }; + }); + + // Remove member from group + app.delete('/user-groups/:id/members/:userId', async (req) => { + const { rows } = await app.db.query( + 'UPDATE user_groups SET members = array_remove(members, $1::uuid) WHERE id=$2 RETURNING *', + [req.params.userId, req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; + return { data: rows[0] }; + }); + + // === WHO IS DOING WHAT === + + // Get workload overview: for each user, list tasks assigned to them and their status + app.get('/collaboration/workload', async (req) => { + const { rows } = await app.db.query( + `SELECT u.id, u.name, u.email, u.avatar_url, + (SELECT COUNT(*) FROM tasks t WHERE u.id = ANY(t.assigned_to) AND t.status NOT IN ('done','completed','cancelled')) as active_tasks, + (SELECT COUNT(*) FROM tasks t WHERE u.id = ANY(t.assigned_to) AND t.status IN ('done','completed')) as completed_tasks, + (SELECT COUNT(*) FROM subtasks s WHERE s.assigned_to = u.id AND s.status NOT IN ('done','completed')) as active_subtasks, + (SELECT COUNT(*) FROM task_collaboration tc WHERE tc.to_user_id = u.id AND tc.status = 'pending') as pending_requests + FROM users u + ORDER BY u.name` + ); + return { data: rows }; + }); + + // Get tasks for a specific user (assigned to them) + app.get('/collaboration/user/:userId/tasks', async (req) => { + const { rows } = await app.db.query( + `SELECT t.*, tg.name as group_name, tg.color as group_color, + (SELECT COUNT(*) FROM subtasks s WHERE s.parent_task_id = t.id) as subtask_count, + (SELECT COUNT(*) FROM subtasks s WHERE s.parent_task_id = t.id AND s.status IN ('done','completed')) as subtask_done_count + FROM tasks t + LEFT JOIN task_groups tg ON t.group_id = tg.id + WHERE $1::uuid = ANY(t.assigned_to) + ORDER BY t.priority DESC, t.created_at DESC`, + [req.params.userId] + ); + return { data: rows }; + }); +} + +module.exports = collaborationRoutes; diff --git a/api/src/routes/tasks.js b/api/src/routes/tasks.js index f149eb7..24a3765 100644 --- a/api/src/routes/tasks.js +++ b/api/src/routes/tasks.js @@ -251,61 +251,6 @@ async function taskRoutes(app) { return { data: rows[0] }; }); - // === Team Collaboration Endpoints === - - // Assign task to user - app.post("/tasks/:id/assign", async (req) => { - const { user_id } = req.body; - if (!user_id) throw { statusCode: 400, message: "user_id is required" }; - const { rows } = await app.db.query( - "UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *", - [user_id, req.params.id] - ); - if (!rows.length) throw { statusCode: 404, message: "Task not found" }; - await invalidateTaskCaches(); - return { data: rows[0] }; - }); - - // Transfer task (creates pending transfer) - app.post("/tasks/:id/transfer", async (req) => { - const { to_user_id, message } = req.body; - if (!to_user_id) throw { statusCode: 400, message: "to_user_id is required" }; - await app.db.query( - "INSERT INTO task_comments (task_id, content, is_ai) VALUES ($1, $2, true)", - [req.params.id, JSON.stringify({ type: "transfer_request", to: to_user_id, message: message || "", status: "pending" })] - ); - return { status: "transfer_requested" }; - }); - - // Accept/reject transfer - app.post("/tasks/:id/transfer/respond", async (req) => { - const { accept, user_id } = req.body; - if (accept) { - if (!user_id) throw { statusCode: 400, message: "user_id is required" }; - const { rows } = await app.db.query( - "UPDATE tasks SET user_id = $1, updated_at = NOW() WHERE id = $2 RETURNING *", - [user_id, req.params.id] - ); - if (!rows.length) throw { statusCode: 404, message: "Task not found" }; - await invalidateTaskCaches(); - return { data: rows[0], status: "transferred" }; - } - return { status: "rejected" }; - }); - - // Collaborate on task - app.post("/tasks/:id/collaborate", async (req) => { - const { user_id } = req.body; - if (!user_id) throw { statusCode: 400, message: "user_id is required" }; - const { rows } = await app.db.query( - "UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *", - [user_id, req.params.id] - ); - if (!rows.length) throw { statusCode: 404, message: "Task not found" }; - await invalidateTaskCaches(); - return { data: rows[0] }; - }); - // === Task Recurrence Endpoints ===