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 bdd44d4..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) })) @@ -149,12 +188,18 @@ export async function taskRoutes(app) { 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' }) + // 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) { @@ -166,27 +211,43 @@ export async function taskRoutes(app) { 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] + 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, 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, 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] + [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) => { - 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' }) @@ -213,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. @@ -235,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) @@ -244,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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57c0da1..af97530 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { ListsProvider } from './context/ListsContext' import { StatusesProvider } from './context/StatusesContext' import { CategoriesProvider } from './context/CategoriesContext' import { ProjectsProvider } from './context/ProjectsContext' +import { UsersProvider } from './context/UsersContext' import { SearchProvider } from './context/SearchContext' import { TaskFiltersProvider } from './context/TaskFiltersContext' import { TaskDrawerProvider } from './components/TaskDrawer' @@ -30,27 +31,29 @@ export default function App() { - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4f6e943..079ff4c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types' +import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, ProjectMember, ProjectRole, AuthUser } from './types' const BASE = '/tasks/api' @@ -121,6 +121,32 @@ export function deleteProject(id: number): Promise<{ ok: boolean }> { return request(`/projects/${id}`, { method: 'DELETE' }) } +// ── Project members (viewer/editor) ────────────────────────── +export function fetchProjectMembers(projectId: number): Promise { + return request(`/projects/${projectId}/members`) +} +export function addProjectMember(projectId: number, email: string, role: ProjectRole): Promise { + return request(`/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify({ email, role }) }) +} +export function removeProjectMember(projectId: number, email: string): Promise<{ ok: boolean }> { + return request(`/projects/${projectId}/members/${encodeURIComponent(email)}`, { method: 'DELETE' }) +} + +// ── Per-project status pipeline (falls back to the global one if a project +// has none of its own — see task_statuses view on the backend) ────────── +export function fetchProjectStatuses(projectId: number): Promise { + return request(`/projects/${projectId}/statuses`) +} +export function createProjectStatus(projectId: number, body: { label: string; value: string; is_hold?: boolean }): Promise { + return request(`/projects/${projectId}/statuses`, { method: 'POST', body: JSON.stringify(body) }) +} +export function updateProjectStatus(projectId: number, statusId: number, body: Partial): Promise { + return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function deleteProjectStatus(projectId: number, statusId: number): Promise<{ ok: boolean }> { + return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'DELETE' }) +} + // ── Checklist items ────────────────────────────────────────── export function addItem(taskId: number, label: string): Promise { return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) }) @@ -137,6 +163,11 @@ export function addComment(taskId: number, body: string): Promise { return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) }) } +// ── Users (for tag/assign/reallocate pickers) ─────────────────── +export function fetchAssignableUsers(): Promise { + return request('/auth/users?app=tasks') +} + // ── Config ─────────────────────────────────────────────────── export function fetchConfig(): Promise { return request('/config') diff --git a/frontend/src/components/ProjectEditor.tsx b/frontend/src/components/ProjectEditor.tsx index abd470b..8aba030 100644 --- a/frontend/src/components/ProjectEditor.tsx +++ b/frontend/src/components/ProjectEditor.tsx @@ -1,10 +1,15 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { X, Trash2 } from 'lucide-react' -import type { Project } from '../types' +import { X, Trash2, ChevronDown, PauseCircle, Plus } from 'lucide-react' +import type { Project, ProjectMember, ProjectRole, AppStatus } from '../types' import { LIST_COLOURS } from '../types' -import { createProject, updateProject, deleteProject } from '../api' +import { + createProject, updateProject, deleteProject, + fetchProjectMembers, addProjectMember, removeProjectMember, + fetchProjectStatuses, createProjectStatus, updateProjectStatus, deleteProjectStatus, +} from '../api' import { useProjects } from '../context/ProjectsContext' +import { UserPicker } from './shared' export default function ProjectEditor({ project, onClose }: { project: Project | null; onClose: () => void }) { const { reload } = useProjects() @@ -87,6 +92,9 @@ export default function ProjectEditor({ project, onClose }: { project: Project | + {project && } + {project && } +
{project && (
) } + +// Collapsed by default — membership is a lesser-used, occasional action +// compared to the name/description/colour fields above it. +function MembersManager({ projectId }: { projectId: number }) { + const [open, setOpen] = useState(false) + const [members, setMembers] = useState([]) + const [email, setEmail] = useState('') + const [role, setRole] = useState('editor') + const [error, setError] = useState(null) + + useEffect(() => { if (open) fetchProjectMembers(projectId).then(setMembers).catch(e => setError(e.message)) }, [open, projectId]) + + async function add() { + if (!email) return + try { + const m = await addProjectMember(projectId, email, role) + setMembers(ms => [...ms.filter(x => x.user_email !== m.user_email), m]) + setEmail('') + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') } + } + async function remove(userEmail: string) { + await removeProjectMember(projectId, userEmail).catch(e => setError(e.message)) + setMembers(ms => ms.filter(m => m.user_email !== userEmail)) + } + + return ( + <> +
setOpen(o => !o)}> + Members {members.length > 0 && `(${members.length})`} + +
+ {open && ( +
+ {error &&
{error}
} +
+ Viewers can see this project and everything in it. Editors can also edit any task inside it — not just + their own — but still can't move a task out of the project or reassign it; that stays with the task's owner. +
+ {members.length > 0 && ( +
+ {members.map(m => ( + + {m.user_email} · {m.role} + remove(m.user_email)} /> + + ))} +
+ )} +
+ + m.user_email)} /> +
+
+ )} + + ) +} + +// Collapsed by default — most projects never need their own pipeline and +// just use the shared global one (see the task_statuses view on the backend). +function ProjectStatusManager({ projectId }: { projectId: number }) { + const [open, setOpen] = useState(false) + const [statuses, setStatuses] = useState([]) + const [isCustom, setIsCustom] = useState(false) + const [newLabel, setNewLabel] = useState('') + const [error, setError] = useState(null) + + async function reload() { + const data = await fetchProjectStatuses(projectId) + setStatuses(data) + // The endpoint returns global statuses verbatim (no project_id field) as + // a fallback when the project has none of its own — that's the cheapest + // way to tell "this project has its own pipeline" from "borrowing the + // shared one" without a second request. + setIsCustom(data.length > 0 && 'project_id' in data[0]) + } + useEffect(() => { if (open) reload().catch(e => setError(e.message)) }, [open, projectId]) + + async function add() { + if (!newLabel.trim()) return + const value = newLabel.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + if (!value) return + try { + await createProjectStatus(projectId, { label: newLabel.trim(), value }) + setNewLabel('') + await reload() + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') } + } + async function rename(id: number, label: string) { + await updateProjectStatus(projectId, id, { label }).catch(e => setError(e.message)) + await reload() + } + async function toggleHold(id: number, is_hold: boolean) { + await updateProjectStatus(projectId, id, { is_hold }).catch(e => setError(e.message)) + await reload() + } + async function toggleDone(id: number, is_done: boolean) { + await updateProjectStatus(projectId, id, { is_done }).catch(e => setError(e.message)) + await reload() + } + async function remove(id: number) { + if (!confirm('Delete this status? Only possible if no tasks in this project currently use it.')) return + try { await deleteProjectStatus(projectId, id); await reload() } + catch (e) { setError(e instanceof Error ? e.message : 'Could not delete — still in use by tasks') } + } + + return ( + <> +
setOpen(o => !o)}> + Status pipeline + +
+ {open && ( +
+ {error &&
{error}
} +
+ {isCustom + ? "This project has its own pipeline, separate from the shared one — tasks here only use these statuses." + : 'Currently using the shared pipeline (Settings → Status pipeline). Add a status below to give this project its own.'} +
+ {statuses.map(s => ( +
+ isCustom && e.target.value.trim() && e.target.value !== s.label && rename(s.id, e.target.value.trim())} /> + {isCustom && ( + <> + + + + + )} +
+ ))} +
+ setNewLabel(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') add() }} /> + +
+
+ )} + + ) +} diff --git a/frontend/src/components/TaskBoard.tsx b/frontend/src/components/TaskBoard.tsx new file mode 100644 index 0000000..7458506 --- /dev/null +++ b/frontend/src/components/TaskBoard.tsx @@ -0,0 +1,97 @@ +import { useState, useRef } from 'react' +import { PauseCircle } from 'lucide-react' +import type { Task, AppStatus } from '../types' +import { PriorityBadge, dueInfo } from './shared' + +// Kanban columns over a given task list + status pipeline — used by the +// global Board page, and as a per-list/per-project view-mode toggle (see +// ListView/ProjectDetailView). Parameterized on `statuses` so callers can +// pass either the app-wide pipeline or a project's own (useProjectStatuses). +export default function TaskBoard({ tasks, statuses, onOpen, onDrop, showList = true }: { + tasks: Task[] + statuses: AppStatus[] + onOpen: (id: number) => void + onDrop: (id: number, status: string) => void + showList?: boolean +}) { + const [over, setOver] = useState(null) + // Done columns collapse by default — otherwise the board fills up with + // finished cards the longer a list/project stays in use. Anything a user + // has explicitly expanded this session stays expanded. + const [openDone, setOpenDone] = useState>(new Set()) + const dragId = useRef(null) + + function drop(status: string) { + const id = dragId.current + dragId.current = null + setOver(null) + if (id !== null) onDrop(id, status) + } + + function toggleDoneColumn(value: string) { + setOpenDone(s => { + const next = new Set(s) + if (next.has(value)) next.delete(value); else next.add(value) + return next + }) + } + + return ( +
+ {statuses.map(status => { + const items = tasks.filter(t => t.status === status.value) + const collapsed = status.is_done && !openDone.has(status.value) + return ( +
{ e.preventDefault(); setOver(status.value) }} + onDragLeave={() => setOver(o => o === status.value ? null : o)} + onDrop={() => drop(status.value)}> +
status.is_done && toggleDoneColumn(status.value)}> + {status.is_hold && } + {status.label} + {items.length} +
+ {collapsed ? ( + items.length > 0 && ( +
toggleDoneColumn(status.value)}> + Show {items.length} done +
+ ) + ) : ( + <> + {items.map(t => ( +
{ dragId.current = t.id }} + onClick={() => onOpen(t.id)}> +
{t.title}
+
+ {showList && t.list_name && ( + + + {t.list_name} + + )} + {t.category_name && ( + + + {t.category_name} + + )} + {(() => { const d = dueInfo(t.due_at); return d && {d.label} })()} + +
+
+ ))} + {items.length === 0 &&
—
} + + )} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/TaskDetail.tsx b/frontend/src/components/TaskDetail.tsx index 3e8cf23..6d094f6 100644 --- a/frontend/src/components/TaskDetail.tsx +++ b/frontend/src/components/TaskDetail.tsx @@ -1,19 +1,19 @@ import { useEffect, useState } from 'react' -import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send } from 'lucide-react' +import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown, Pin } from 'lucide-react' import type { TaskDetail as TDetail, Priority, RecurRule } from '../types' import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types' import { fetchTask, updateTask, deleteTask, addItem, updateItem, deleteItem, addComment, - tagTask, untagTask, reallocateTask, + tagTask, untagTask, reallocateTask, fetchProjectMembers, } from '../api' import { useLists } from '../context/ListsContext' import { useCategories } from '../context/CategoriesContext' import { useProjects } from '../context/ProjectsContext' -import { useStatuses } from '../context/StatusesContext' +import { useProjectStatuses } from '../hooks/useProjectStatuses' import { useAuth } from './AuthGate' import { can } from '../types' -import { Check_, formatDateTime, toLocalInput } from './shared' +import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared' export default function TaskDetail({ taskId, onChange, onClose }: { taskId: number; onChange: () => void; onClose: () => void }) { @@ -21,8 +21,8 @@ export default function TaskDetail({ taskId, onChange, onClose }: const { lists } = useLists() const { categories } = useCategories() const { projects } = useProjects() - const { statuses } = useStatuses() const [task, setTask] = useState(null) + const { statuses } = useProjectStatuses(task?.project_id ?? null) const [error, setError] = useState(null) const [title, setTitle] = useState('') const [notes, setNotes] = useState('') @@ -31,18 +31,33 @@ export default function TaskDetail({ taskId, onChange, onClose }: const [tagEmail, setTagEmail] = useState('') const [assignEmail, setAssignEmail] = useState('') const [reallocateEmail, setReallocateEmail] = useState('') + const [sharingOpen, setSharingOpen] = useState(false) + // Project 'editor' members can edit a task's content/status without + // owning it (see loadEditable on the backend) — not resolvable from the + // task payload alone, so check membership once the task's project is known. + const [editorViaProject, setEditorViaProject] = useState(false) useEffect(() => { fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') }) .catch(e => setError(e.message)) }, [taskId]) + useEffect(() => { + if (!task?.project_id || task.owner_id === user.email) { setEditorViaProject(false); return } + fetchProjectMembers(task.project_id) + .then(members => setEditorViaProject(members.some(m => m.user_email === user.email && m.role === 'editor'))) + .catch(() => setEditorViaProject(false)) + }, [task?.project_id, task?.owner_id, user.email]) + if (error) return (
{error}
) if (!task) return
Loading…
const isOwner = task.owner_id === user.email + // Content/status fields; list/project/assignment stay owner-only (see the + // matching 403 on the backend). + const canEdit = isOwner || editorViaProject // Patch task fields, merge the result locally, and notify the parent view. async function patch(fields: Record) { @@ -125,7 +140,7 @@ export default function TaskDetail({ taskId, onChange, onClose }: