Spaces:
Sleeping
Sleeping
| const { query } = require('../utils/db'); | |
| const { auditLog } = require('../utils/auditLogger'); | |
| const { v4: uuidv4 } = require('uuid'); | |
| const VALID_STATUSES = ['todo', 'in_progress', 'in_review', 'done', 'cancelled']; | |
| const VALID_PRIORITIES = ['low', 'medium', 'high', 'critical']; | |
| /** | |
| * GET /api/tasks | |
| * List tasks scoped to the authenticated user's organization. | |
| * Members only see their assigned or created tasks. | |
| * Admins see all tasks in the org. | |
| */ | |
| const listTasks = async (req, res) => { | |
| try { | |
| const { organization_id: orgId, id: userId, role } = req.user; | |
| const { | |
| status, priority, assignee_id, search, | |
| sort_by = 'created_at', sort_dir = 'desc', | |
| page = 1, limit = 20, | |
| } = req.query; | |
| const pageNum = Math.max(1, parseInt(page)); | |
| const limitNum = Math.min(100, Math.max(1, parseInt(limit))); | |
| const offset = (pageNum - 1) * limitNum; | |
| const conditions = [`t.organization_id = $1`]; | |
| const params = [orgId]; | |
| let idx = 2; | |
| // Members only see tasks they created or are assigned to | |
| if (role === 'member') { | |
| conditions.push(`(t.creator_id = $${idx} OR t.assignee_id = $${idx})`); | |
| params.push(userId); | |
| idx++; | |
| } | |
| if (status && VALID_STATUSES.includes(status)) { | |
| conditions.push(`t.status = $${idx++}`); | |
| params.push(status); | |
| } | |
| if (priority && VALID_PRIORITIES.includes(priority)) { | |
| conditions.push(`t.priority = $${idx++}`); | |
| params.push(priority); | |
| } | |
| if (assignee_id) { | |
| conditions.push(`t.assignee_id = $${idx++}`); | |
| params.push(assignee_id); | |
| } | |
| if (search) { | |
| conditions.push(`(t.title ILIKE $${idx} OR t.description ILIKE $${idx})`); | |
| params.push(`%${search}%`); | |
| idx++; | |
| } | |
| const allowed_sort = ['created_at', 'updated_at', 'due_date', 'priority', 'status', 'title']; | |
| const sortCol = allowed_sort.includes(sort_by) ? sort_by : 'created_at'; | |
| const sortDir = sort_dir === 'asc' ? 'ASC' : 'DESC'; | |
| const whereClause = `WHERE ${conditions.join(' AND ')}`; | |
| const countResult = await query( | |
| `SELECT COUNT(*) FROM tasks t ${whereClause}`, | |
| params | |
| ); | |
| const total = parseInt(countResult.rows[0].count); | |
| const { rows: tasks } = await query( | |
| `SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date, | |
| t.created_at, t.updated_at, | |
| t.creator_id, | |
| creator.name AS creator_name, creator.email AS creator_email, | |
| t.assignee_id, | |
| assignee.name AS assignee_name, assignee.email AS assignee_email | |
| FROM tasks t | |
| LEFT JOIN users creator ON creator.id = t.creator_id | |
| LEFT JOIN users assignee ON assignee.id = t.assignee_id | |
| ${whereClause} | |
| ORDER BY t.${sortCol} ${sortDir} | |
| LIMIT $${idx} OFFSET $${idx + 1}`, | |
| [...params, limitNum, offset] | |
| ); | |
| return res.json({ | |
| tasks, | |
| pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum) }, | |
| }); | |
| } catch (err) { | |
| console.error('List tasks error:', err); | |
| return res.status(500).json({ error: 'Failed to retrieve tasks.' }); | |
| } | |
| }; | |
| /** | |
| * GET /api/tasks/:id | |
| */ | |
| const getTask = async (req, res) => { | |
| try { | |
| const { organization_id: orgId, id: userId, role } = req.user; | |
| const { id } = req.params; | |
| const { rows } = await query( | |
| `SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date, | |
| t.created_at, t.updated_at, t.organization_id, | |
| t.creator_id, creator.name AS creator_name, creator.email AS creator_email, | |
| t.assignee_id, assignee.name AS assignee_name, assignee.email AS assignee_email | |
| FROM tasks t | |
| LEFT JOIN users creator ON creator.id = t.creator_id | |
| LEFT JOIN users assignee ON assignee.id = t.assignee_id | |
| WHERE t.id = $1 AND t.organization_id = $2`, | |
| [id, orgId] | |
| ); | |
| if (!rows.length) { | |
| return res.status(404).json({ error: 'Task not found.' }); | |
| } | |
| const task = rows[0]; | |
| // Members can only view tasks they created or are assigned to | |
| if (role === 'member' && task.creator_id !== userId && task.assignee_id !== userId) { | |
| return res.status(403).json({ error: 'You do not have permission to view this task.' }); | |
| } | |
| return res.json(task); | |
| } catch (err) { | |
| console.error('Get task error:', err); | |
| return res.status(500).json({ error: 'Failed to retrieve task.' }); | |
| } | |
| }; | |
| /** | |
| * POST /api/tasks | |
| */ | |
| const createTask = async (req, res) => { | |
| try { | |
| const { organization_id: orgId, id: userId, name: userName, email: userEmail } = req.user; | |
| const { title, description, status = 'todo', priority = 'medium', assignee_id, due_date } = req.body; | |
| // Validate assignee belongs to same org | |
| if (assignee_id) { | |
| const check = await query(`SELECT id FROM users WHERE id = $1 AND organization_id = $2`, [assignee_id, orgId]); | |
| if (!check.rows.length) { | |
| return res.status(400).json({ error: 'Assignee must belong to your organization.' }); | |
| } | |
| } | |
| const taskId = uuidv4(); | |
| const { rows } = await query( | |
| `INSERT INTO tasks (id, organization_id, title, description, status, priority, creator_id, assignee_id, due_date) | |
| VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) | |
| RETURNING *`, | |
| [taskId, orgId, title, description || null, status, priority, userId, assignee_id || null, due_date || null] | |
| ); | |
| const task = rows[0]; | |
| await auditLog({ | |
| organizationId: orgId, | |
| taskId: task.id, | |
| actorId: userId, | |
| actorName: userName, | |
| actorEmail: userEmail, | |
| action: 'TASK_CREATED', | |
| newValues: { title, status, priority, assignee_id: assignee_id || null }, | |
| }); | |
| return res.status(201).json(task); | |
| } catch (err) { | |
| console.error('Create task error:', err); | |
| return res.status(500).json({ error: 'Failed to create task.' }); | |
| } | |
| }; | |
| /** | |
| * PATCH /api/tasks/:id | |
| */ | |
| const updateTask = async (req, res) => { | |
| try { | |
| const { organization_id: orgId, id: userId, name: userName, email: userEmail, role } = req.user; | |
| const { id } = req.params; | |
| const { rows: existing } = await query( | |
| `SELECT * FROM tasks WHERE id = $1 AND organization_id = $2`, | |
| [id, orgId] | |
| ); | |
| if (!existing.length) { | |
| return res.status(404).json({ error: 'Task not found.' }); | |
| } | |
| const task = existing[0]; | |
| // Members can only update tasks they created or are assigned to | |
| if (role === 'member' && task.creator_id !== userId && task.assignee_id !== userId) { | |
| return res.status(403).json({ error: 'You do not have permission to update this task.' }); | |
| } | |
| const { title, description, status, priority, assignee_id, due_date } = req.body; | |
| const updates = {}; | |
| if (title !== undefined) updates.title = title; | |
| if (description !== undefined) updates.description = description; | |
| if (status !== undefined && VALID_STATUSES.includes(status)) updates.status = status; | |
| if (priority !== undefined && VALID_PRIORITIES.includes(priority)) updates.priority = priority; | |
| if (due_date !== undefined) updates.due_date = due_date || null; | |
| if (assignee_id !== undefined) { | |
| if (assignee_id === null) { | |
| updates.assignee_id = null; | |
| } else { | |
| const check = await query(`SELECT id FROM users WHERE id = $1 AND organization_id = $2`, [assignee_id, orgId]); | |
| if (!check.rows.length) { | |
| return res.status(400).json({ error: 'Assignee must belong to your organization.' }); | |
| } | |
| updates.assignee_id = assignee_id; | |
| } | |
| } | |
| if (!Object.keys(updates).length) { | |
| return res.status(400).json({ error: 'No valid fields provided for update.' }); | |
| } | |
| const setClauses = Object.keys(updates).map((k, i) => `${k} = $${i + 3}`); | |
| const setValues = Object.values(updates); | |
| const { rows: updated } = await query( | |
| `UPDATE tasks SET ${setClauses.join(', ')}, updated_at = NOW() | |
| WHERE id = $1 AND organization_id = $2 | |
| RETURNING *`, | |
| [id, orgId, ...setValues] | |
| ); | |
| await auditLog({ | |
| organizationId: orgId, | |
| taskId: id, | |
| actorId: userId, | |
| actorName: userName, | |
| actorEmail: userEmail, | |
| action: 'TASK_UPDATED', | |
| oldValues: { title: task.title, status: task.status, priority: task.priority, assignee_id: task.assignee_id }, | |
| newValues: updates, | |
| }); | |
| return res.json(updated[0]); | |
| } catch (err) { | |
| console.error('Update task error:', err); | |
| return res.status(500).json({ error: 'Failed to update task.' }); | |
| } | |
| }; | |
| /** | |
| * DELETE /api/tasks/:id | |
| */ | |
| const deleteTask = async (req, res) => { | |
| try { | |
| const { organization_id: orgId, id: userId, name: userName, email: userEmail, role } = req.user; | |
| const { id } = req.params; | |
| const { rows: existing } = await query( | |
| `SELECT * FROM tasks WHERE id = $1 AND organization_id = $2`, | |
| [id, orgId] | |
| ); | |
| if (!existing.length) { | |
| return res.status(404).json({ error: 'Task not found.' }); | |
| } | |
| const task = existing[0]; | |
| // Members can only delete tasks they created | |
| if (role === 'member' && task.creator_id !== userId) { | |
| return res.status(403).json({ error: 'You can only delete tasks you created.' }); | |
| } | |
| await query(`DELETE FROM tasks WHERE id = $1 AND organization_id = $2`, [id, orgId]); | |
| await auditLog({ | |
| organizationId: orgId, | |
| taskId: id, | |
| actorId: userId, | |
| actorName: userName, | |
| actorEmail: userEmail, | |
| action: 'TASK_DELETED', | |
| oldValues: { title: task.title, status: task.status, priority: task.priority }, | |
| }); | |
| return res.status(204).send(); | |
| } catch (err) { | |
| console.error('Delete task error:', err); | |
| return res.status(500).json({ error: 'Failed to delete task.' }); | |
| } | |
| }; | |
| /** | |
| * GET /api/tasks/stats | |
| * Dashboard stats for the org | |
| */ | |
| const getStats = async (req, res) => { | |
| try { | |
| const { organization_id: orgId, id: userId, role } = req.user; | |
| let scopeClause = `organization_id = $1`; | |
| const params = [orgId]; | |
| if (role === 'member') { | |
| scopeClause += ` AND (creator_id = $2 OR assignee_id = $2)`; | |
| params.push(userId); | |
| } | |
| const { rows: statusCounts } = await query( | |
| `SELECT status, COUNT(*) as count FROM tasks WHERE ${scopeClause} GROUP BY status`, | |
| params | |
| ); | |
| const { rows: priorityCounts } = await query( | |
| `SELECT priority, COUNT(*) as count FROM tasks WHERE ${scopeClause} GROUP BY priority`, | |
| params | |
| ); | |
| const { rows: overdue } = await query( | |
| `SELECT COUNT(*) as count FROM tasks | |
| WHERE ${scopeClause} AND due_date < NOW() AND status NOT IN ('done', 'cancelled')`, | |
| params | |
| ); | |
| const { rows: recent } = await query( | |
| `SELECT t.id, t.title, t.status, t.priority, t.updated_at, | |
| assignee.name AS assignee_name | |
| FROM tasks t | |
| LEFT JOIN users assignee ON assignee.id = t.assignee_id | |
| WHERE t.${scopeClause} | |
| ORDER BY t.updated_at DESC LIMIT 5`, | |
| params | |
| ); | |
| return res.json({ | |
| statusCounts: Object.fromEntries(statusCounts.map(r => [r.status, parseInt(r.count)])), | |
| priorityCounts: Object.fromEntries(priorityCounts.map(r => [r.priority, parseInt(r.count)])), | |
| overdueCount: parseInt(overdue[0].count), | |
| recentTasks: recent, | |
| }); | |
| } catch (err) { | |
| console.error('Stats error:', err); | |
| return res.status(500).json({ error: 'Failed to retrieve stats.' }); | |
| } | |
| }; | |
| module.exports = { listTasks, getTask, createTask, updateTask, deleteTask, getStats }; | |