diff --git a/backend/src/db.js b/backend/src/db.js index f4d425c..6127840 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. + -- child tasks' statuses — projects don't get their own status pipeline. CREATE TABLE IF NOT EXISTS projects ( id SERIAL PRIMARY KEY, created_by TEXT NOT NULL, @@ -62,35 +62,6 @@ 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 @@ -114,7 +85,6 @@ 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() @@ -177,33 +147,6 @@ 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() } @@ -243,21 +186,6 @@ 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 79f9887..d3be83f 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -2,16 +2,12 @@ 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, 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. +// 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. 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 @@ -19,15 +15,6 @@ 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) @@ -35,12 +22,12 @@ export async function projectRoutes(app) { const { rows } = await pool.query( `SELECT p.*, COUNT(t.id) AS task_count, - COUNT(t.id) FILTER (WHERE ts.is_done) AS done_count + COUNT(t.id) FILTER (WHERE s.is_done) AS done_count FROM projects p LEFT JOIN tasks t ON t.project_id = p.id AND ${visibleTask('t')} - LEFT JOIN task_statuses ts ON ts.task_id = t.id + LEFT JOIN statuses s ON s.value = t.status WHERE p.is_archived = FALSE - AND (p.created_by = $1 OR ${isMember('p')} OR EXISTS ( + AND (p.created_by = $1 OR EXISTS ( SELECT 1 FROM tasks t2 WHERE t2.project_id = p.id AND ${visibleTask('t2')} )) GROUP BY p.id @@ -53,26 +40,12 @@ 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.*, ts.status_label, ts.is_done, ts.is_hold + `SELECT t.*, s.label AS status_label, s.is_done, s.is_hold FROM tasks t - 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`, + 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`, [req.user.email, req.params.id] ) return { ...project, tasks } @@ -116,104 +89,4 @@ 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 34daf17..bdd44d4 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -1,23 +1,10 @@ import { requireAuth, requireCap, hasCap } from '../auth.js' -import { pool, getEffectiveStatuses } from '../db.js' +import { pool } 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 - } +async function getStatus(value) { const { rows: [s] } = await pool.query('SELECT * FROM statuses WHERE value = $1', [value]) return s || null } @@ -33,31 +20,8 @@ async function loadOwned(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 +// 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) { @@ -65,9 +29,7 @@ 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 @@ -107,11 +69,8 @@ async function spawnRecurrence(task) { 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. + // 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/ @@ -145,23 +104,25 @@ 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.status_label AS status_label, s.is_done, s.is_hold, + 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 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 + 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.status_position, t.sort_order, t.due_at NULLS LAST, t.id`, + 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) })) @@ -188,18 +149,12 @@ 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' }) - // 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' }) - } + // 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) { @@ -211,43 +166,27 @@ export async function taskRoutes(app) { 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] : [] + 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1', + [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) + `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 || 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] + [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) => { - // 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) + const task = await loadOwned(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) + 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' }) @@ -274,7 +213,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', 'pinned_today']) { + 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. @@ -296,7 +235,7 @@ export async function taskRoutes(app) { } } - const oldStatus = newStatus ? await getStatus(task.status, task.project_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) @@ -305,9 +244,9 @@ export async function taskRoutes(app) { } fields.push('updated_at = NOW()') - vals.push(task.id) + vals.push(task.id, req.user.email) const { rows: [updated] } = await pool.query( - `UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`, + `UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_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 af97530..57c0da1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ 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' @@ -31,29 +30,27 @@ export default function App() { - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 079ff4c..4f6e943 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, ProjectMember, ProjectRole, AuthUser } from './types' +import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types' const BASE = '/tasks/api' @@ -121,32 +121,6 @@ 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 }) }) @@ -163,11 +137,6 @@ 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 8aba030..abd470b 100644 --- a/frontend/src/components/ProjectEditor.tsx +++ b/frontend/src/components/ProjectEditor.tsx @@ -1,15 +1,10 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { X, Trash2, ChevronDown, PauseCircle, Plus } from 'lucide-react' -import type { Project, ProjectMember, ProjectRole, AppStatus } from '../types' +import { X, Trash2 } from 'lucide-react' +import type { Project } from '../types' import { LIST_COLOURS } from '../types' -import { - createProject, updateProject, deleteProject, - fetchProjectMembers, addProjectMember, removeProjectMember, - fetchProjectStatuses, createProjectStatus, updateProjectStatus, deleteProjectStatus, -} from '../api' +import { createProject, updateProject, deleteProject } 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() @@ -92,9 +87,6 @@ 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 deleted file mode 100644 index 7458506..0000000 --- a/frontend/src/components/TaskBoard.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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 6d094f6..3e8cf23 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, ChevronDown, Pin } from 'lucide-react' +import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send } 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, fetchProjectMembers, + tagTask, untagTask, reallocateTask, } from '../api' import { useLists } from '../context/ListsContext' import { useCategories } from '../context/CategoriesContext' import { useProjects } from '../context/ProjectsContext' -import { useProjectStatuses } from '../hooks/useProjectStatuses' +import { useStatuses } from '../context/StatusesContext' import { useAuth } from './AuthGate' import { can } from '../types' -import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared' +import { Check_, formatDateTime, toLocalInput } 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,33 +31,18 @@ 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) { @@ -140,7 +125,7 @@ export default function TaskDetail({ taskId, onChange, onClose }: