diff --git a/backend/src/db.js b/backend/src/db.js index 6127840..f4d425c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -50,7 +50,7 @@ export async function initDb() { -- A grouping container for tasks that can span multiple owners (once a task -- is assigned elsewhere it keeps its project_id). Progress is inferred from - -- child tasks' statuses — projects don't get their own status pipeline. + -- child tasks' statuses. CREATE TABLE IF NOT EXISTS projects ( id SERIAL PRIMARY KEY, created_by TEXT NOT NULL, @@ -62,6 +62,35 @@ export async function initDb() { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + -- Explicit project membership, separate from the task-level tag/assign/ + -- reallocate tiers. A project is otherwise only visible to its creator and + -- anyone who happens to own/be tagged on a task inside it — membership + -- lets someone see (viewer) or edit any task in the project (editor) + -- without needing to already own something there. + CREATE TABLE IF NOT EXISTS project_members ( + project_id INT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'editor', -- viewer | editor + added_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, user_email) + ); + + -- Optional per-project status pipeline override. Most projects have none + -- of these rows and just use the shared global 'statuses' table; a project + -- that defines any row here switches entirely to its own pipeline (see the + -- task_statuses view below for the resolution logic). + CREATE TABLE IF NOT EXISTS project_statuses ( + id SERIAL PRIMARY KEY, + project_id INT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + label TEXT NOT NULL, + value TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + is_done BOOLEAN NOT NULL DEFAULT FALSE, + is_hold BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (project_id, value) + ); + -- A single to-do. assigned_to is a real ownership transfer (gated behind the -- 'assign' cap) — when set, owner_id is moved to match it and the task is -- unfiled (list_id NULL) since the assignee doesn't share the old owner's @@ -85,6 +114,7 @@ export async function initDb() { reminded_at TIMESTAMPTZ, recur_rule TEXT, -- null | daily | weekdays | weekly | monthly completed_at TIMESTAMPTZ, + pinned_today BOOLEAN NOT NULL DEFAULT FALSE, -- forced into the Today agenda regardless of due_at sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -147,6 +177,33 @@ export async function initDb() { // if the constraint is already gone). await pool.query(`ALTER TABLE tasks ALTER COLUMN list_id DROP NOT NULL`) + // Safety net for databases created before pinned_today / per-project statuses. + await pool.query(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_today BOOLEAN NOT NULL DEFAULT FALSE`) + // status used to FK straight to the global statuses table. A project can now + // define its own status values (project_statuses), which that FK would + // reject outright, so validity is checked in application code instead (see + // getEffectiveStatus in routes/tasks.js). + await pool.query(`ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_status_fkey`) + + // Resolves each task's status against its project's own pipeline if it has + // one defined, falling back to the shared global pipeline otherwise (which + // is what every task without a project, or in a project with no custom + // statuses, always uses). + await pool.query(` + CREATE OR REPLACE VIEW task_statuses AS + SELECT t.id AS task_id, + COALESCE(ps.label, s.label) AS status_label, + COALESCE(ps.is_done, s.is_done, FALSE) AS is_done, + COALESCE(ps.is_hold, s.is_hold, FALSE) AS is_hold, + COALESCE(ps.position, s.position) AS status_position + FROM tasks t + LEFT JOIN project_statuses ps ON ps.project_id = t.project_id AND ps.value = t.status + LEFT JOIN statuses s ON s.value = t.status AND ( + t.project_id IS NULL + OR NOT EXISTS (SELECT 1 FROM project_statuses ps2 WHERE ps2.project_id = t.project_id) + ) + `) + await seedDefaults() await seedStatuses() } @@ -186,6 +243,21 @@ export async function getStatuses() { return rows } +// A project's own pipeline if it has defined any custom statuses, else the +// shared global one — the same resolution the task_statuses view applies +// per-task, exposed here for callers that need the whole ordered list (the +// board's columns, a new task's default status). +export async function getEffectiveStatuses(projectId) { + if (projectId) { + const { rows } = await pool.query( + 'SELECT * FROM project_statuses WHERE project_id = $1 ORDER BY position, id', + [projectId] + ) + if (rows.length) return rows + } + return getStatuses() +} + export async function getConfig() { const { rows } = await pool.query('SELECT key, value FROM config') return Object.fromEntries(rows.map(r => [r.key, r.value])) diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index d3be83f..79f9887 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -2,12 +2,16 @@ import { requireAuth, requireCap } from '../auth.js' import { pool } from '../db.js' // A project is visible to whoever created it, plus anyone who owns or is -// tagged on at least one task inside it — so a project stays visible to -// collaborators even though it isn't "theirs". `alias` lets the same clause -// be reused against different table aliases in one query. +// tagged on at least one task inside it, plus anyone added as a project +// member (viewer or editor) — membership exists specifically so someone can +// see a project (and everything in it) before they own anything there. +// `alias` lets the same clause be reused against different table aliases. const visibleTask = (alias) => `(${alias}.owner_id = $1 OR ${alias}.assigned_to = $1 OR EXISTS ( SELECT 1 FROM task_tags tt WHERE tt.task_id = ${alias}.id AND tt.user_email = $1 ))` +const isMember = (projectAlias) => `EXISTS ( + SELECT 1 FROM project_members pm WHERE pm.project_id = ${projectAlias}.id AND pm.user_email = $1 +)` async function canManageProject(email, isAdmin, projectId) { if (isAdmin) return true @@ -15,6 +19,15 @@ async function canManageProject(email, isAdmin, projectId) { return p?.created_by === email } +async function canEditProject(email, isAdmin, projectId) { + if (await canManageProject(email, isAdmin, projectId)) return true + const { rows: [m] } = await pool.query( + "SELECT 1 FROM project_members WHERE project_id = $1 AND user_email = $2 AND role = 'editor'", + [projectId, email] + ) + return !!m +} + export async function projectRoutes(app) { app.addHook('preHandler', requireAuth) @@ -22,12 +35,12 @@ export async function projectRoutes(app) { const { rows } = await pool.query( `SELECT p.*, COUNT(t.id) AS task_count, - COUNT(t.id) FILTER (WHERE s.is_done) AS done_count + COUNT(t.id) FILTER (WHERE ts.is_done) AS done_count FROM projects p LEFT JOIN tasks t ON t.project_id = p.id AND ${visibleTask('t')} - LEFT JOIN statuses s ON s.value = t.status + LEFT JOIN task_statuses ts ON ts.task_id = t.id WHERE p.is_archived = FALSE - AND (p.created_by = $1 OR EXISTS ( + AND (p.created_by = $1 OR ${isMember('p')} OR EXISTS ( SELECT 1 FROM tasks t2 WHERE t2.project_id = p.id AND ${visibleTask('t2')} )) GROUP BY p.id @@ -40,12 +53,26 @@ export async function projectRoutes(app) { app.get('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => { const { rows: [project] } = await pool.query('SELECT * FROM projects WHERE id = $1', [req.params.id]) if (!project) return reply.status(404).send({ error: 'project not found' }) + if (!(req.user.is_admin || project.created_by === req.user.email)) { + const { rows: [visible] } = await pool.query( + `SELECT 1 FROM project_members pm WHERE pm.project_id = $2 AND pm.user_email = $1 + UNION ALL + SELECT 1 FROM tasks t2 WHERE t2.project_id = $2 AND ${visibleTask('t2')} + LIMIT 1`, + [req.user.email, req.params.id] + ) + if (!visible) return reply.status(404).send({ error: 'project not found' }) + } + // Project members (viewer or editor) see every task inside, not just the + // ones they'd otherwise own/be tagged on — that's the point of membership. const { rows: tasks } = await pool.query( - `SELECT t.*, s.label AS status_label, s.is_done, s.is_hold + `SELECT t.*, ts.status_label, ts.is_done, ts.is_hold FROM tasks t - JOIN statuses s ON s.value = t.status - WHERE t.project_id = $2 AND ${visibleTask('t')} - ORDER BY s.position, t.sort_order, t.id`, + JOIN task_statuses ts ON ts.task_id = t.id + WHERE t.project_id = $2 AND (${visibleTask('t')} OR EXISTS ( + SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $1 + )) + ORDER BY ts.status_position, t.sort_order, t.id`, [req.user.email, req.params.id] ) return { ...project, tasks } @@ -89,4 +116,104 @@ export async function projectRoutes(app) { if (!rowCount) return reply.status(404).send({ error: 'project not found' }) return { ok: true } }) + + // ── Members (viewer/editor) — creator/admin manage, members themselves + // just need to be listed so the editor UI can show who's on a project ──── + app.get('/api/projects/:id/members', { preHandler: requireCap('use') }, async (req, reply) => { + const { rows: [project] } = await pool.query('SELECT id FROM projects WHERE id = $1', [req.params.id]) + if (!project) return reply.status(404).send({ error: 'project not found' }) + const { rows } = await pool.query( + 'SELECT user_email, role, added_by, created_at FROM project_members WHERE project_id = $1 ORDER BY created_at', + [req.params.id] + ) + return rows + }) + + app.post('/api/projects/:id/members', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator can add members' }) + const email = req.body?.email?.trim() + const role = req.body?.role === 'viewer' ? 'viewer' : 'editor' + if (!email) return reply.status(400).send({ error: 'email required' }) + const { rows: [m] } = await pool.query( + `INSERT INTO project_members (project_id, user_email, role, added_by) VALUES ($1, $2, $3, $4) + ON CONFLICT (project_id, user_email) DO UPDATE SET role = EXCLUDED.role + RETURNING user_email, role, added_by, created_at`, + [req.params.id, email, role, req.user.email] + ) + return m + }) + + app.delete('/api/projects/:id/members/:email', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator can remove members' }) + await pool.query( + 'DELETE FROM project_members WHERE project_id = $1 AND user_email = $2', + [req.params.id, req.params.email] + ) + return { ok: true } + }) + + // ── Per-project status pipeline (optional — most projects have none and + // just use the shared global pipeline; see task_statuses view in db.js) ── + app.get('/api/projects/:id/statuses', { preHandler: requireCap('use') }, async (req, reply) => { + const { rows: custom } = await pool.query( + 'SELECT * FROM project_statuses WHERE project_id = $1 ORDER BY position, id', + [req.params.id] + ) + if (custom.length) return custom + const { rows: global } = await pool.query('SELECT * FROM statuses ORDER BY position, id') + return global + }) + + app.post('/api/projects/:id/statuses', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' }) + const { label, value, is_hold } = req.body || {} + if (!label?.trim() || !value?.trim()) return reply.status(400).send({ error: 'label and value required' }) + if (!/^[a-z0-9_]+$/.test(value)) return reply.status(400).send({ error: 'value must be lowercase letters, numbers, underscores' }) + const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(position), -1) AS m FROM project_statuses WHERE project_id = $1', [req.params.id]) + const { rows: [status] } = await pool.query( + `INSERT INTO project_statuses (project_id, label, value, position, is_hold) VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [req.params.id, label.trim(), value.trim(), Number(max.m) + 1, !!is_hold] + ) + return status + }) + + app.patch('/api/projects/:id/statuses/:statusId', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' }) + const fields = [] + const vals = [] + let i = 1 + for (const key of ['label', 'position', 'is_done', 'is_hold']) { + 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.statusId, req.params.id) + const { rows } = await pool.query( + `UPDATE project_statuses SET ${fields.join(', ')} WHERE id = $${i++} AND project_id = $${i} RETURNING *`, + vals + ) + if (!rows.length) return reply.status(404).send({ error: 'status not found' }) + return rows[0] + }) + + // Removing the last custom status silently drops the project back onto the + // shared global pipeline (see task_statuses view) — any task still holding + // one of the deleted values just reads as "not done" until re-filed. + app.delete('/api/projects/:id/statuses/:statusId', { preHandler: requireCap('use') }, async (req, reply) => { + if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id))) + return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' }) + const { rows: [status] } = await pool.query( + 'SELECT value FROM project_statuses WHERE id = $1 AND project_id = $2', [req.params.statusId, req.params.id] + ) + if (!status) return reply.status(404).send({ error: 'status not found' }) + const { rows: [inUse] } = await pool.query( + 'SELECT 1 FROM tasks WHERE project_id = $1 AND status = $2 LIMIT 1', [req.params.id, status.value] + ) + if (inUse) return reply.status(409).send({ error: 'status is still in use by tasks in this project' }) + await pool.query('DELETE FROM project_statuses WHERE id = $1', [req.params.statusId]) + return { ok: true } + }) } diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index d7cdf2d..34daf17 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -1,10 +1,23 @@ import { requireAuth, requireCap, hasCap } from '../auth.js' -import { pool } from '../db.js' +import { pool, getEffectiveStatuses } from '../db.js' import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js' const PRIORITIES = ['low', 'medium', 'high'] -async function getStatus(value) { +// 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 } @@ -20,8 +33,31 @@ async function loadOwned(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 +// 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) { @@ -29,7 +65,9 @@ async function loadVisible(id, email) { `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 @@ -69,8 +107,11 @@ async function spawnRecurrence(task) { 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. + // 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/ @@ -104,25 +145,23 @@ export async function taskRoutes(app) { 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`) - 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, + 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 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 + 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.position, t.sort_order, t.due_at NULLS LAST, t.id`, + 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) })) @@ -177,24 +216,38 @@ export async function taskRoutes(app) { : '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, sort_order) - VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11) + `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, Number(max.m) + 1] + 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) => { - const task = await loadOwned(req.params.id, req.user.email) + // 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) + 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' }) @@ -221,7 +274,7 @@ export async function taskRoutes(app) { 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']) { + 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. @@ -243,7 +296,7 @@ export async function taskRoutes(app) { } } - const oldStatus = newStatus ? await getStatus(task.status) : 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) @@ -252,9 +305,9 @@ export async function taskRoutes(app) { } fields.push('updated_at = NOW()') - vals.push(task.id, req.user.email) + vals.push(task.id) const { rows: [updated] } = await pool.query( - `UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`, + `UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`, vals ) if (newStatus?.is_done && !wasDone) await spawnRecurrence(task)