Extended collaboration: subtasks, claim, assign-group, workload tracking
- 3 new DB tables: subtasks, task_collaboration, user_groups - Subtasks CRUD with auto-complete parent when all done - Collaboration requests: assign, transfer, collaborate, claim - Claim = instant, others need approval (accept/reject) - Assign to whole user group at once - Workload tracking: per-user active/completed/pending - User groups (teams) CRUD with member management - 39/39 tests passed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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" });
|
||||
|
||||
353
api/src/routes/collaboration.js
Normal file
353
api/src/routes/collaboration.js
Normal file
@@ -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;
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
Reference in New Issue
Block a user