import { requireAuth, requireCap, hasCap } from '../auth.js' import { pool, getEffectiveStatuses } from '../db.js' import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js' const PRIORITIES = ['low', 'medium', 'high'] // Resolves a status value against a project's own pipeline if it has one // defined, else the shared global pipeline — same rule as the task_statuses // view, for the one-off lookups (old/new status on a PATCH) that don't want // a full join. async function getStatus(value, projectId) { if (projectId) { const { rows: [ps] } = await pool.query( 'SELECT * FROM project_statuses WHERE project_id = $1 AND value = $2', [projectId, value] ) if (ps) return ps const { rows: [any] } = await pool.query('SELECT 1 FROM project_statuses WHERE project_id = $1', [projectId]) if (any) return null // project has its own pipeline and this value isn't in it } const { rows: [s] } = await pool.query('SELECT * FROM statuses WHERE value = $1', [value]) return s || null } // Load a task only if the caller owns it — the guard used for full edits // (reassigning, deleting, changing status/fields). Tagging someone in does // NOT grant this. async function loadOwned(id, email) { const { rows } = await pool.query( 'SELECT * FROM tasks WHERE id = $1 AND owner_id = $2', [id, email] ) return rows[0] || null } // Load a task the caller can edit: they own it, or the task sits in a // project they created or are an 'editor' member of. Narrower than it sounds // — see the PATCH route, which still blocks a non-owner editor from touching // list_id/project_id/assigned_to (those stay reserved for the owner or the // assign/reallocate caps). async function loadEditable(id, email) { const { rows } = await pool.query( `SELECT t.* FROM tasks t WHERE t.id = $1 AND ( t.owner_id = $2 OR (t.project_id IS NOT NULL AND EXISTS ( SELECT 1 FROM projects p WHERE p.id = t.project_id AND p.created_by = $2 )) OR (t.project_id IS NOT NULL AND EXISTS ( SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2 AND pm.role = 'editor' )) )`, [id, email] ) return rows[0] || null } // Load a task the caller can see: they own it, it was assigned to them, // they've been tagged in, or it sits in a project they're a member of // (viewer or editor). Used for read access and collaborative actions // (comments, checklist items) that a tagged-in collaborator should be able // to do without full edit rights. async function loadVisible(id, email) { const { rows } = await pool.query( `SELECT t.* FROM tasks t WHERE t.id = $1 AND (t.owner_id = $2 OR t.assigned_to = $2 OR EXISTS ( SELECT 1 FROM task_tags tt WHERE tt.task_id = t.id AND tt.user_email = $2 ) OR (t.project_id IS NOT NULL AND EXISTS ( SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2 )))`, [id, email] ) return rows[0] || null } // Spawn the next occurrence of a recurring task when it's completed. The fresh // copy keeps the title/notes/priority/list/category/project/rule and a reset // checklist, dated one interval on from the old due date (or from now if it // had none). async function spawnRecurrence(task) { if (!task.recur_rule || !RECUR_RULES.includes(task.recur_rule)) return const base = task.due_at ? new Date(task.due_at) : new Date() const newDue = nextOccurrence(task.recur_rule, base) let newRemind = null if (task.remind_at) { if (task.due_at) { const delta = new Date(task.due_at).getTime() - new Date(task.remind_at).getTime() newRemind = new Date(newDue.getTime() - delta) } else { newRemind = nextOccurrence(task.recur_rule, new Date(task.remind_at)) } } const { rows: [next] } = await pool.query( `INSERT INTO tasks (list_id, owner_id, created_by, category_id, project_id, title, notes, priority, due_at, remind_at, recur_rule, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, [task.list_id, task.owner_id, task.created_by, task.category_id, task.project_id, task.title, task.notes, task.priority, newDue, newRemind, task.recur_rule, task.sort_order] ) await pool.query( `INSERT INTO task_items (task_id, label, is_done, sort_order) SELECT $1, label, FALSE, sort_order FROM task_items WHERE task_id = $2`, [next.id, task.id] ) } export async function taskRoutes(app) { app.addHook('preHandler', requireAuth) // List tasks. ?view=today = every open task I own (the frontend buckets // these into overdue/today/next-7-days/pinned/other — the backend only // excludes done ones, so the view can show an "other open tasks" section // instead of looking empty whenever nothing happens to be due). // ?view=assigned = tasks handed to me via the 'assign' cap. // ?view=shared = tasks I've been tagged into (not mine, not assigned to me). // ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap. // Otherwise defaults to everything I own. ?list_id/?status/?category_id/ // ?project_id/?search narrow further (search matches title or notes, case-insensitive). app.get('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => { const { list_id, view, status, category_id, project_id, search } = req.query const vals = [req.user.email] let i = 2 const clauses = [] let joinTags = '' if (view === 'all') { if (!hasCap(req, 'reallocate')) return reply.status(403).send({ error: 'Missing capability: reallocate' }) // $1 (email) isn't needed for the filter itself, but every other branch // binds it — keep it referenced so the placeholder numbering below stays // valid (an unused bind param makes Postgres reject the query outright). clauses.push('$1::text IS NOT NULL') } else if (view === 'shared') { joinTags = 'JOIN task_tags tt ON tt.task_id = t.id AND tt.user_email = $1' clauses.push('TRUE') } else if (view === 'assigned') { clauses.push(`t.assigned_to = $1`) } else { clauses.push(`t.owner_id = $1`) } if (list_id) { clauses.push(`t.list_id = $${i++}`); vals.push(list_id) } if (status) { clauses.push(`t.status = $${i++}`); vals.push(status) } if (category_id) { clauses.push(`t.category_id = $${i++}`); vals.push(category_id) } if (project_id) { clauses.push(`t.project_id = $${i++}`); vals.push(project_id) } if (search) { const idx = i++; clauses.push(`(t.title ILIKE $${idx} OR t.notes ILIKE $${idx})`); vals.push(`%${search}%`) } if (view === 'today') { clauses.push(`NOT s.is_done`) } const { rows } = await pool.query( `SELECT t.*, l.name AS list_name, l.colour AS list_colour, s.status_label AS status_label, s.is_done, s.is_hold, c.name AS category_name, c.colour AS category_colour, p.name AS project_name, p.colour AS project_colour, (SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id) AS item_count, (SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id AND ti.is_done) AS item_done FROM tasks t ${joinTags} LEFT JOIN lists l ON l.id = t.list_id JOIN task_statuses s ON s.task_id = t.id LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN projects p ON p.id = t.project_id WHERE ${clauses.join(' AND ')} ORDER BY s.is_done, s.status_position, t.sort_order, t.due_at NULLS LAST, t.id`, vals ) return rows.map(r => ({ ...r, item_count: Number(r.item_count), item_done: Number(r.item_done) })) }) app.get('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => { const task = await loadVisible(req.params.id, req.user.email) if (!task) return reply.status(404).send({ error: 'task not found' }) const { rows: items } = await pool.query( 'SELECT * FROM task_items WHERE task_id = $1 ORDER BY sort_order, id', [task.id] ) const { rows: comments } = await pool.query( 'SELECT * FROM task_comments WHERE task_id = $1 ORDER BY created_at', [task.id] ) const { rows: tags } = await pool.query( 'SELECT user_email, tagged_by, created_at FROM task_tags WHERE task_id = $1 ORDER BY created_at', [task.id] ) return { ...task, items, comments, tags } }) app.post('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => { const { list_id, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id } = req.body || {} if (!title?.trim()) return reply.status(400).send({ error: 'title required' }) // list_id is optional — omitting it lands the task "unfiled" (list_id // NULL), the same state assign/reallocate already use. Projects need this // so a task can be created straight into a project without first needing // a personal list, since projects are a cross-cutting grouping, not a // list replacement. if (list_id) { const { rows: [list] } = await pool.query( 'SELECT id FROM lists WHERE id = $1 AND owner_id = $2', [list_id, req.user.email] ) if (!list) return reply.status(400).send({ error: 'invalid list' }) } if (priority && !PRIORITIES.includes(priority)) return reply.status(400).send({ error: 'bad priority' }) if (recur_rule && !RECUR_RULES.includes(recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' }) if (category_id) { const { rows: [c] } = await pool.query('SELECT id FROM categories WHERE id = $1', [category_id]) if (!c) return reply.status(400).send({ error: 'invalid category' }) } if (project_id) { const { rows: [p] } = await pool.query('SELECT id FROM projects WHERE id = $1', [project_id]) if (!p) return reply.status(400).send({ error: 'invalid project' }) } const { rows: [max] } = await pool.query( list_id ? 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1' : 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id IS NULL', list_id ? [list_id] : [] ) // A project with its own custom pipeline won't necessarily have a 'todo' // value in it — default to its own lowest-position non-done status instead. const effectiveStatuses = await getEffectiveStatuses(project_id || null) const defaultStatus = effectiveStatuses.find(s => !s.is_done)?.value ?? effectiveStatuses[0]?.value ?? 'todo' const { rows: [task] } = await pool.query( `INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, status, sort_order) VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [list_id || null, req.user.email, title.trim(), notes || null, priority || null, due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, defaultStatus, Number(max.m) + 1] ) return task }) app.patch('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => { // A non-owner can only get here via a project 'editor' membership — see // loadEditable. They can change a task's content/status, but not its // container identity (list/project) or who owns/is assigned to it; those // stay reserved for the owner or the assign/reallocate caps below. const task = await loadEditable(req.params.id, req.user.email) if (!task) return reply.status(404).send({ error: 'task not found' }) const isOwner = task.owner_id === req.user.email const b = req.body || {} if (!isOwner && (b.list_id !== undefined || b.project_id !== undefined || b.assigned_to !== undefined)) { return reply.status(403).send({ error: 'Only the owner can change list, project, or assignment' }) } let newStatus = null if (b.status !== undefined) { newStatus = await getStatus(b.status, task.project_id) if (!newStatus) return reply.status(400).send({ error: 'bad status' }) } if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'bad priority' }) if (b.recur_rule && !RECUR_RULES.includes(b.recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' }) if (b.list_id !== undefined && b.list_id !== null) { const { rows: [l] } = await pool.query('SELECT id FROM lists WHERE id = $1 AND owner_id = $2', [b.list_id, req.user.email]) if (!l) return reply.status(400).send({ error: 'invalid list' }) } if (b.category_id !== undefined && b.category_id !== null) { const { rows: [c] } = await pool.query('SELECT id FROM categories WHERE id = $1', [b.category_id]) if (!c) return reply.status(400).send({ error: 'invalid category' }) } if (b.project_id !== undefined && b.project_id !== null) { const { rows: [p] } = await pool.query('SELECT id FROM projects WHERE id = $1', [b.project_id]) if (!p) return reply.status(400).send({ error: 'invalid project' }) } // Assigning is a real ownership transfer, gated behind the 'assign' cap — // separate from tagging, which anyone can do to share a task. if (b.assigned_to !== undefined) { if (!hasCap(req, 'assign')) return reply.status(403).send({ error: "Missing capability: assign" }) } const fields = [] const vals = [] let i = 1 for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id', 'pinned_today']) { if (b[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(b[key] === '' ? null : b[key]) } } // list_id can be explicitly nulled (unfiled) as well as changed. if (b.list_id !== undefined) { fields.push(`list_id = $${i++}`); vals.push(b.list_id || null) } // Reset the reminder clock whenever remind_at is (re)set, so the sweep re-fires it. if (b.remind_at !== undefined) fields.push('reminded_at = NULL') if (b.assigned_to !== undefined) { const target = b.assigned_to || null fields.push(`assigned_to = $${i++}`); vals.push(target) // Handing off moves the task onto the assignee's board — they don't // share the old owner's lists, so it lands unfiled. Clearing the // assignment (target === null) just removes the marker; ownership stays put. if (target && target !== task.owner_id) { fields.push(`owner_id = $${i++}`); vals.push(target) // Only force list_id to NULL if this request didn't already set it explicitly. if (b.list_id === undefined) fields.push(`list_id = NULL`) } } const oldStatus = newStatus ? await getStatus(task.status, task.project_id) : null const wasDone = !!oldStatus?.is_done if (newStatus) { fields.push(`status = $${i++}`); vals.push(b.status) if (newStatus.is_done && !wasDone) fields.push('completed_at = NOW()') if (!newStatus.is_done && wasDone) fields.push('completed_at = NULL') } fields.push('updated_at = NOW()') vals.push(task.id) const { rows: [updated] } = await pool.query( `UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`, vals ) if (newStatus?.is_done && !wasDone) await spawnRecurrence(task) return updated }) // Dispatcher move — the 'reallocate' cap can hand ANY task to ANY user, // regardless of who currently owns it (unlike 'assign', which only lets an // owner hand off their own work). Deliberately narrow: this only moves the // task, it doesn't grant editing rights over someone else's task content. app.post('/api/tasks/:id/reallocate', { preHandler: requireCap('reallocate') }, async (req, reply) => { const to = req.body?.to?.trim() if (!to) return reply.status(400).send({ error: 'to (email) required' }) const { rows: [updated] } = await pool.query( `UPDATE tasks SET owner_id = $1, assigned_to = $1, list_id = NULL, updated_at = NOW() WHERE id = $2 RETURNING *`, [to, req.params.id] ) if (!updated) return reply.status(404).send({ error: 'task not found' }) return updated }) app.delete('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => { const { rowCount } = await pool.query( 'DELETE FROM tasks WHERE id = $1 AND owner_id = $2', [req.params.id, req.user.email] ) if (!rowCount) return reply.status(404).send({ error: 'task not found' }) return { ok: true } }) // Persist a manual reorder within a list. Body: { ids: [taskId, ...] } in order. app.post('/api/tasks/reorder', { preHandler: requireCap('use') }, async (req, reply) => { const ids = req.body?.ids if (!Array.isArray(ids)) return reply.status(400).send({ error: 'ids array required' }) for (let idx = 0; idx < ids.length; idx++) { await pool.query( 'UPDATE tasks SET sort_order = $1 WHERE id = $2 AND owner_id = $3', [idx, ids[idx], req.user.email] ) } return { ok: true } }) // ── Sharing (tag/untag) — owner-managed, no cap required ──── app.post('/api/tasks/:id/tags', { preHandler: requireCap('use') }, async (req, reply) => { const task = await loadOwned(req.params.id, req.user.email) if (!task) return reply.status(404).send({ error: 'task not found' }) const email = req.body?.email?.trim() if (!email) return reply.status(400).send({ error: 'email required' }) await pool.query( `INSERT INTO task_tags (task_id, user_email, tagged_by) VALUES ($1, $2, $3) ON CONFLICT (task_id, user_email) DO NOTHING`, [task.id, email, req.user.email] ) return { ok: true } }) app.delete('/api/tasks/:id/tags/:email', { preHandler: requireCap('use') }, async (req, reply) => { const task = await loadOwned(req.params.id, req.user.email) if (!task) return reply.status(404).send({ error: 'task not found' }) await pool.query('DELETE FROM task_tags WHERE task_id = $1 AND user_email = $2', [task.id, req.params.email]) return { ok: true } }) // ── Checklist items — owner or tagged collaborators ───────── app.post('/api/tasks/:id/items', { preHandler: requireCap('use') }, async (req, reply) => { const task = await loadVisible(req.params.id, req.user.email) if (!task) return reply.status(404).send({ error: 'task not found' }) if (!req.body?.label?.trim()) return reply.status(400).send({ error: 'label required' }) const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(sort_order), 0) AS m FROM task_items WHERE task_id = $1', [task.id]) const { rows: [item] } = await pool.query( 'INSERT INTO task_items (task_id, label, sort_order) VALUES ($1, $2, $3) RETURNING *', [task.id, req.body.label.trim(), Number(max.m) + 1] ) return item }) app.patch('/api/tasks/items/:itemId', { preHandler: requireCap('use') }, async (req, reply) => { const { rows: [existing] } = await pool.query('SELECT task_id FROM task_items WHERE id = $1', [req.params.itemId]) if (!existing || !(await loadVisible(existing.task_id, req.user.email))) return reply.status(404).send({ error: 'item not found' }) const fields = [] const vals = [] let i = 1 for (const key of ['label', 'is_done', 'sort_order']) { if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) } } if (!fields.length) return reply.status(400).send({ error: 'nothing to update' }) vals.push(req.params.itemId) const { rows: [item] } = await pool.query( `UPDATE task_items SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`, vals ) return item }) app.delete('/api/tasks/items/:itemId', { preHandler: requireCap('use') }, async (req, reply) => { const { rows: [existing] } = await pool.query('SELECT task_id FROM task_items WHERE id = $1', [req.params.itemId]) if (!existing || !(await loadVisible(existing.task_id, req.user.email))) return reply.status(404).send({ error: 'item not found' }) await pool.query('DELETE FROM task_items WHERE id = $1', [req.params.itemId]) return { ok: true } }) // ── Comments / activity — owner or tagged collaborators ───── app.post('/api/tasks/:id/comments', { preHandler: requireCap('use') }, async (req, reply) => { const task = await loadVisible(req.params.id, req.user.email) if (!task) return reply.status(404).send({ error: 'task not found' }) if (!req.body?.body?.trim()) return reply.status(400).send({ error: 'body required' }) const { rows: [c] } = await pool.query( `INSERT INTO task_comments (task_id, author_email, author_name, body) VALUES ($1, $2, $3, $4) RETURNING *`, [task.id, req.user.email, req.user.name, req.body.body.trim()] ) return c }) }