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 && }
-
- 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.
-
- )}
- >
- )
-}
-
-// 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.'}
-