Backend: project membership, per-project status pipelines, pin-to-today

- project_members (viewer/editor) makes a project + everything in it visible
  to someone before they own anything there; editors can edit any task's
  content/status in the project (not its list/project/assignment, which stay
  owner/cap-gated) via a new loadEditable() alongside the existing
  loadOwned/loadVisible.
- project_statuses lets a project define its own status pipeline instead of
  the shared global one. Dropped the FK tasks.status had to the global
  statuses table (a project-only value would violate it) and added a
  task_statuses view that resolves each task's status against its project's
  pipeline if it has one, else the global one -- this is also why new tasks
  in a custom-pipeline project now default to that project's own first
  non-done status instead of the hardcoded 'todo'.
- pinned_today column + GET /api/tasks?view=today no longer requires a due
  date, so the frontend can show pinned/undated tasks there too.

Verified against a throwaway Postgres: 20 assertions covering nullable
list_id, pin, broadened today view, custom status default/resolution/
cross-project leakage, and the full viewer/editor/owner/stranger permission
matrix on a shared project.
This commit is contained in:
jtricerolph 2026-10-12 14:47:36 +00:00
parent 4d19b37b8a
commit 2a75c7060e
3 changed files with 287 additions and 35 deletions

View file

@ -1,10 +1,23 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { pool, getEffectiveStatuses } from '../db.js'
import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js'
const PRIORITIES = ['low', 'medium', 'high']
async function getStatus(value) {
// Resolves a status value against a project's own pipeline if it has one
// defined, else the shared global pipeline — same rule as the task_statuses
// view, for the one-off lookups (old/new status on a PATCH) that don't want
// a full join.
async function getStatus(value, projectId) {
if (projectId) {
const { rows: [ps] } = await pool.query(
'SELECT * FROM project_statuses WHERE project_id = $1 AND value = $2',
[projectId, value]
)
if (ps) return ps
const { rows: [any] } = await pool.query('SELECT 1 FROM project_statuses WHERE project_id = $1', [projectId])
if (any) return null // project has its own pipeline and this value isn't in it
}
const { rows: [s] } = await pool.query('SELECT * FROM statuses WHERE value = $1', [value])
return s || null
}
@ -20,8 +33,31 @@ async function loadOwned(id, email) {
return rows[0] || null
}
// Load a task the caller can see: they own it, it was assigned to them, or
// they've been tagged in. Used for read access and collaborative actions
// Load a task the caller can edit: they own it, or the task sits in a
// project they created or are an 'editor' member of. Narrower than it sounds
// — see the PATCH route, which still blocks a non-owner editor from touching
// list_id/project_id/assigned_to (those stay reserved for the owner or the
// assign/reallocate caps).
async function loadEditable(id, email) {
const { rows } = await pool.query(
`SELECT t.* FROM tasks t
WHERE t.id = $1 AND (
t.owner_id = $2
OR (t.project_id IS NOT NULL AND EXISTS (
SELECT 1 FROM projects p WHERE p.id = t.project_id AND p.created_by = $2
))
OR (t.project_id IS NOT NULL AND EXISTS (
SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2 AND pm.role = 'editor'
))
)`,
[id, email]
)
return rows[0] || null
}
// Load a task the caller can see: they own it, it was assigned to them,
// they've been tagged in, or it sits in a project they're a member of
// (viewer or editor). Used for read access and collaborative actions
// (comments, checklist items) that a tagged-in collaborator should be able
// to do without full edit rights.
async function loadVisible(id, email) {
@ -29,7 +65,9 @@ async function loadVisible(id, email) {
`SELECT t.* FROM tasks t
WHERE t.id = $1 AND (t.owner_id = $2 OR t.assigned_to = $2 OR EXISTS (
SELECT 1 FROM task_tags tt WHERE tt.task_id = t.id AND tt.user_email = $2
))`,
) OR (t.project_id IS NOT NULL AND EXISTS (
SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2
)))`,
[id, email]
)
return rows[0] || null
@ -69,8 +107,11 @@ async function spawnRecurrence(task) {
export async function taskRoutes(app) {
app.addHook('preHandler', requireAuth)
// List tasks. ?view=today = cross-list agenda (non-done, due within 8 days
// or overdue). ?view=assigned = tasks handed to me via the 'assign' cap.
// List tasks. ?view=today = every open task I own (the frontend buckets
// these into overdue/today/next-7-days/pinned/other — the backend only
// excludes done ones, so the view can show an "other open tasks" section
// instead of looking empty whenever nothing happens to be due).
// ?view=assigned = tasks handed to me via the 'assign' cap.
// ?view=shared = tasks I've been tagged into (not mine, not assigned to me).
// ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap.
// Otherwise defaults to everything I own. ?list_id/?status/?category_id/
@ -104,25 +145,23 @@ export async function taskRoutes(app) {
if (search) { const idx = i++; clauses.push(`(t.title ILIKE $${idx} OR t.notes ILIKE $${idx})`); vals.push(`%${search}%`) }
if (view === 'today') {
clauses.push(`NOT s.is_done`)
clauses.push(`t.due_at IS NOT NULL`)
clauses.push(`t.due_at < (NOW() + INTERVAL '8 days')`)
}
const { rows } = await pool.query(
`SELECT t.*, l.name AS list_name, l.colour AS list_colour,
s.label AS status_label, s.is_done, s.is_hold,
s.status_label AS status_label, s.is_done, s.is_hold,
c.name AS category_name, c.colour AS category_colour,
p.name AS project_name, p.colour AS project_colour,
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id) AS item_count,
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id AND ti.is_done) AS item_done
FROM tasks t
${joinTags}
LEFT JOIN lists l ON l.id = t.list_id
JOIN statuses s ON s.value = t.status
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN projects p ON p.id = t.project_id
LEFT JOIN lists l ON l.id = t.list_id
JOIN task_statuses s ON s.task_id = t.id
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN projects p ON p.id = t.project_id
WHERE ${clauses.join(' AND ')}
ORDER BY s.is_done, s.position, t.sort_order, t.due_at NULLS LAST, t.id`,
ORDER BY s.is_done, s.status_position, t.sort_order, t.due_at NULLS LAST, t.id`,
vals
)
return rows.map(r => ({ ...r, item_count: Number(r.item_count), item_done: Number(r.item_done) }))
@ -177,24 +216,38 @@ export async function taskRoutes(app) {
: 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id IS NULL',
list_id ? [list_id] : []
)
// A project with its own custom pipeline won't necessarily have a 'todo'
// value in it — default to its own lowest-position non-done status instead.
const effectiveStatuses = await getEffectiveStatuses(project_id || null)
const defaultStatus = effectiveStatuses.find(s => !s.is_done)?.value ?? effectiveStatuses[0]?.value ?? 'todo'
const { rows: [task] } = await pool.query(
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order)
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11)
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, status, sort_order)
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[list_id || null, req.user.email, title.trim(), notes || null, priority || null,
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1]
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null,
defaultStatus, Number(max.m) + 1]
)
return task
})
app.patch('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
const task = await loadOwned(req.params.id, req.user.email)
// A non-owner can only get here via a project 'editor' membership — see
// loadEditable. They can change a task's content/status, but not its
// container identity (list/project) or who owns/is assigned to it; those
// stay reserved for the owner or the assign/reallocate caps below.
const task = await loadEditable(req.params.id, req.user.email)
if (!task) return reply.status(404).send({ error: 'task not found' })
const isOwner = task.owner_id === req.user.email
const b = req.body || {}
if (!isOwner && (b.list_id !== undefined || b.project_id !== undefined || b.assigned_to !== undefined)) {
return reply.status(403).send({ error: 'Only the owner can change list, project, or assignment' })
}
let newStatus = null
if (b.status !== undefined) {
newStatus = await getStatus(b.status)
newStatus = await getStatus(b.status, task.project_id)
if (!newStatus) return reply.status(400).send({ error: 'bad status' })
}
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'bad priority' })
@ -221,7 +274,7 @@ export async function taskRoutes(app) {
const fields = []
const vals = []
let i = 1
for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id']) {
for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id', 'pinned_today']) {
if (b[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(b[key] === '' ? null : b[key]) }
}
// list_id can be explicitly nulled (unfiled) as well as changed.
@ -243,7 +296,7 @@ export async function taskRoutes(app) {
}
}
const oldStatus = newStatus ? await getStatus(task.status) : null
const oldStatus = newStatus ? await getStatus(task.status, task.project_id) : null
const wasDone = !!oldStatus?.is_done
if (newStatus) {
fields.push(`status = $${i++}`); vals.push(b.status)
@ -252,9 +305,9 @@ export async function taskRoutes(app) {
}
fields.push('updated_at = NOW()')
vals.push(task.id, req.user.email)
vals.push(task.id)
const { rows: [updated] } = await pool.query(
`UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`,
`UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
vals
)
if (newStatus?.is_done && !wasDone) await spawnRecurrence(task)