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:
2026-03-29 15:20:50 +00:00
parent db81100b5b
commit 606fb047f8
3 changed files with 354 additions and 55 deletions

View File

@@ -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" });

View 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;

View File

@@ -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 ===