Initial commit: personal task planner with categories, projects, and tiered assignment

Personal-first lists/tasks/reminders/recurrence, plus an admin-editable status
pipeline, shared categories, project containers, and a three-tier permission
model for cross-user work: assign (hand off your own tasks), reallocate
(dispatcher — move anyone's tasks), admin (reserved, full manage-all).
This commit is contained in:
jtricerolph 2026-09-17 08:07:00 +00:00
commit d8dc4e2b81
54 changed files with 10908 additions and 0 deletions

366
backend/src/routes/tasks.js Normal file
View file

@ -0,0 +1,366 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js'
const PRIORITIES = ['low', 'medium', 'high']
async function getStatus(value) {
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 see: they own it, it was assigned to them, or
// they've been tagged in. 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
))`,
[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 = cross-list agenda (non-done, due within 8 days
// or overdue). ?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 narrow further.
app.get('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
const { list_id, view, status, category_id, project_id } = 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 (view === 'today') {
clauses.push(`NOT s.is_done`)
clauses.push(`t.due_at IS NOT NULL`)
clauses.push(`t.due_at < (NOW() + INTERVAL '8 days')`)
}
const { rows } = await pool.query(
`SELECT t.*, l.name AS list_name, l.colour AS list_colour,
s.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 statuses s ON s.value = t.status
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.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' })
// Verify the target list belongs to the caller.
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(
'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1',
[list_id]
)
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, sort_order)
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11)
RETURNING *`,
[list_id, req.user.email, title.trim(), notes || null, priority || null,
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1]
)
return task
})
app.patch('/api/tasks/:id', { 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 b = req.body || {}
let newStatus = null
if (b.status !== undefined) {
newStatus = await getStatus(b.status)
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']) {
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) : 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, req.user.email)
const { rows: [updated] } = await pool.query(
`UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_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
})
}