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:
parent
4d19b37b8a
commit
2a75c7060e
3 changed files with 287 additions and 35 deletions
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 +145,11 @@ 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,
|
||||
|
|
@ -118,11 +157,11 @@ export async function taskRoutes(app) {
|
|||
FROM tasks t
|
||||
${joinTags}
|
||||
LEFT JOIN lists l ON l.id = t.list_id
|
||||
JOIN statuses s ON s.value = t.status
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue