Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions backend/presets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"os"
"regexp"
"strings"
"testing"
)

// Every SQL preset shipped to users (the frontend's "insert a preset" list) and
// to agents (the paca-dashboard-builder skill) must pass the same guard a saved
// panel goes through, or it fails the moment someone uses it.

var (
presetBlock = regexp.MustCompile("(?s)\\{\\s*id: \"([^\"]+)\".*?scopes: \\[([^\\]]*)\\].*?query: `(.*?)`")
presetConst = regexp.MustCompile("(?s)const ([A-Z_]+) = `(.*?)`;")
skillSQLBlock = regexp.MustCompile("(?s)```sql\\n(.*?)```")
placeholderUsed = "{{project_id}}"
)

func TestFrontendPresetsPassTheQueryGuard(t *testing.T) {
src, err := os.ReadFile("../frontend/src/presets.ts")
if err != nil {
t.Fatal(err)
}
text := string(src)
for _, m := range presetConst.FindAllStringSubmatch(text, -1) {
text = strings.ReplaceAll(text, "${"+m[1]+"}", m[2])
}
presets := presetBlock.FindAllStringSubmatch(text, -1)
if len(presets) == 0 {
t.Fatal("found no presets in presets.ts; the parser is out of date")
}
for _, m := range presets {
id, scopes, query := m[1], m[2], m[3]
projectScoped := strings.Contains(scopes, "project") || strings.Contains(scopes, "integration")
if _, err := validateQuery(query, projectScoped); err != nil {
t.Errorf("preset %q is rejected by the query guard: %v", id, err)
}
}
}

func TestSkillPresetsPassTheQueryGuard(t *testing.T) {
src, err := os.ReadFile("../skills/paca-dashboard-builder/SKILL.md")
if err != nil {
t.Fatal(err)
}
blocks := skillSQLBlock.FindAllStringSubmatch(string(src), -1)
if len(blocks) == 0 {
t.Fatal("found no ```sql blocks in SKILL.md; the parser is out of date")
}
for _, m := range blocks {
query := strings.TrimSpace(m[1])
// Admin presets are the ones written without the placeholder; the guard
// rejects the placeholder in admin scope and requires it in project scope.
projectScoped := strings.Contains(query, placeholderUsed)
if _, err := validateQuery(query, projectScoped); err != nil {
first := strings.SplitN(query, "\n", 2)[0]
t.Errorf("skill preset starting %q is rejected by the query guard: %v", first, err)
}
}
}
62 changes: 25 additions & 37 deletions frontend/src/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
* forbidden keywords) — see the dev notes in that file before adding more.
*
* Burndown/burnup scoping: rather than making the user hand-edit a sprint
* UUID into the query text, both sprint-based presets resolve "the active
* sprint" via a correlated subquery on sprints.status = 'active'. If a
* project runs multiple concurrent active sprints (schema allows it, see
* database-schema.md), the subquery's own filter still yields a single
* chart per query invocation — the query returns one row per day across
* whichever active sprint(s) match; if that's ever ambiguous for a given
* project, the user can copy the preset text and hand-tune the WHERE
* clause (e.g. add `AND sprint_id = '<uuid>'` for a specific sprint).
* UUID into the query text, both sprint-based presets pick the project's most
* recently started active sprint and produce one row per day from its start
* date to today (generate_series), counting tasks with correlated subqueries.
* That shape is forced by the guard: compound SELECTs (UNION/INTERSECT/EXCEPT)
* are forbidden, and {{project_id}} must appear exactly once, as a top-level
* equality filter — so the sprint is selected in the outer query, not in a
* nested one. To chart a specific sprint instead, replace the start_date
* condition with `AND s.id = '<uuid>'`.
*/

import type { ChartType, DashboardScopeKind, PanelType } from "./types";
Expand All @@ -30,7 +30,15 @@ export interface QueryPreset {
query: string;
}

const ACTIVE_SPRINT = `(SELECT id FROM sprints WHERE project_id = {{project_id}} AND status = 'active' ORDER BY start_date DESC LIMIT 1)`;
// One row per day of the project's most recently started active sprint.
const ACTIVE_SPRINT_DAYS = `FROM sprints s
CROSS JOIN generate_series(s.start_date::date, CURRENT_DATE, INTERVAL '1 day') AS d
WHERE s.project_id = {{project_id}} AND s.status = 'active'
AND s.start_date = (SELECT MAX(s2.start_date) FROM sprints s2 WHERE s2.project_id = s.project_id AND s2.status = 'active')
ORDER BY d`;
const SCOPE_BY_DAY = `(SELECT COUNT(*) FROM tasks t WHERE t.sprint_id = s.id AND t.deleted_at IS NULL AND t.created_at::date <= d::date)`;
const DONE_BY_DAY = `(SELECT COUNT(*) FROM tasks t JOIN task_statuses ts ON ts.id = t.status_id
WHERE t.sprint_id = s.id AND ts.category = 'done' AND t.deleted_at IS NULL AND t.updated_at::date <= d::date)`;

export const QUERY_PRESETS: QueryPreset[] = [
{
Expand All @@ -40,19 +48,10 @@ export const QUERY_PRESETS: QueryPreset[] = [
panelType: "chart",
chartType: "line",
scopes: ["project", "integration"],
query: `SELECT to_char(day, 'YYYY-MM-DD') AS day, SUM(delta) OVER (ORDER BY day::date) AS remaining_tasks
FROM (
SELECT created_at::date AS day, 1 AS delta
FROM tasks
WHERE project_id = {{project_id}} AND sprint_id = ${ACTIVE_SPRINT} AND deleted_at IS NULL
UNION ALL
SELECT t.updated_at::date AS day, -1 AS delta
FROM tasks t
JOIN task_statuses ts ON ts.id = t.status_id
WHERE t.project_id = {{project_id}} AND t.sprint_id = ${ACTIVE_SPRINT}
AND ts.category = 'done' AND t.deleted_at IS NULL
) events
ORDER BY day`,
query: `SELECT to_char(d::date, 'YYYY-MM-DD') AS day,
${SCOPE_BY_DAY}
- ${DONE_BY_DAY} AS remaining_tasks
${ACTIVE_SPRINT_DAYS}`,
},
{
id: "burnup-active-sprint",
Expand All @@ -61,21 +60,10 @@ ORDER BY day`,
panelType: "chart",
chartType: "line",
scopes: ["project", "integration"],
query: `SELECT to_char(day, 'YYYY-MM-DD') AS day,
SUM(scope_delta) OVER (ORDER BY day::date) AS total_scope,
SUM(done_delta) OVER (ORDER BY day::date) AS completed
FROM (
SELECT created_at::date AS day, 1 AS scope_delta, 0 AS done_delta
FROM tasks
WHERE project_id = {{project_id}} AND sprint_id = ${ACTIVE_SPRINT} AND deleted_at IS NULL
UNION ALL
SELECT t.updated_at::date AS day, 0 AS scope_delta, 1 AS done_delta
FROM tasks t
JOIN task_statuses ts ON ts.id = t.status_id
WHERE t.project_id = {{project_id}} AND t.sprint_id = ${ACTIVE_SPRINT}
AND ts.category = 'done' AND t.deleted_at IS NULL
) events
ORDER BY day`,
query: `SELECT to_char(d::date, 'YYYY-MM-DD') AS day,
${SCOPE_BY_DAY} AS total_scope,
${DONE_BY_DAY} AS completed
${ACTIVE_SPRINT_DAYS}`,
},
{
id: "tasks-by-status",
Expand Down
31 changes: 13 additions & 18 deletions skills/paca-dashboard-builder/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ Every panel tool needs a `viewId`. Get one first:

- Exactly one statement, and it must start with `SELECT` or `WITH`. No `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/`CREATE`/`GRANT`/`TRUNCATE`/`COPY`/`CALL`/`EXPLAIN`/`VACUUM`, and nothing after a trailing `;`.
- No `information_schema`/`pg_catalog`/`pg_*` introspection, `dblink`, `lo_*`.
- **Project and integration-scope queries must contain the literal placeholder `{{project_id}}`** somewhere in a `WHERE`/`ON`/`HAVING` clause (e.g. `WHERE t.project_id = {{project_id}}`) — this is what scopes the query to the caller's own project; the guard substitutes it with a bound `$1` server-side. Never write `$1` yourself — it's reserved and will be rejected.
- **Project and integration-scope queries must contain the literal placeholder `{{project_id}}` exactly once, as a top-level equality filter against a `project_id` column** (e.g. `WHERE t.project_id = {{project_id}}`), never inside a subquery and never next to a bare `OR` — this is what scopes the query to the caller's own project; the guard substitutes it with a bound `$1` server-side. Select per-project rows in the outer query and keep subqueries correlated to it instead of repeating the placeholder. Never write `$1` yourself — it's reserved and will be rejected.
- No compound SELECTs: `UNION`, `INTERSECT` and `EXCEPT` are rejected (a second branch would escape the project filter). Build time series with `generate_series` and correlated `COUNT(*)` subqueries instead — see the burndown preset.
- A query that reads a table with sensitive columns (e.g. `agents`, `users`) may use subqueries only as `IN`/`EXISTS`/`ANY`/`ALL`/`SOME` operands; join those tables at the top level instead.
- **Admin-scope queries must NOT contain `{{project_id}}`** — they're intentionally cross-project.
- A `LIMIT 500` is appended automatically if the query doesn't already declare one — don't rely on getting more than 500 rows back.
- This is a pattern-based guard, not a full SQL parser: prefer straightforward SELECTs over cleverness that might trip a keyword match.
Expand Down Expand Up @@ -147,24 +149,17 @@ GROUP BY s.name, s.start_date
ORDER BY s.start_date
```

**Burndown (active sprint)** (line chart) — remaining open tasks per day:
**Burndown (active sprint)** (line chart) — remaining open tasks per day of the most recently started active sprint:
```sql
SELECT to_char(day, 'YYYY-MM-DD') AS day, SUM(delta) OVER (ORDER BY day::date) AS remaining_tasks
FROM (
SELECT created_at::date AS day, 1 AS delta
FROM tasks
WHERE project_id = {{project_id}}
AND sprint_id = (SELECT id FROM sprints WHERE project_id = {{project_id}} AND status = 'active' ORDER BY start_date DESC LIMIT 1)
AND deleted_at IS NULL
UNION ALL
SELECT t.updated_at::date AS day, -1 AS delta
FROM tasks t
JOIN task_statuses ts ON ts.id = t.status_id
WHERE t.project_id = {{project_id}}
AND t.sprint_id = (SELECT id FROM sprints WHERE project_id = {{project_id}} AND status = 'active' ORDER BY start_date DESC LIMIT 1)
AND ts.category = 'done' AND t.deleted_at IS NULL
) events
ORDER BY day
SELECT to_char(d::date, 'YYYY-MM-DD') AS day,
(SELECT COUNT(*) FROM tasks t WHERE t.sprint_id = s.id AND t.deleted_at IS NULL AND t.created_at::date <= d::date)
- (SELECT COUNT(*) FROM tasks t JOIN task_statuses ts ON ts.id = t.status_id
WHERE t.sprint_id = s.id AND ts.category = 'done' AND t.deleted_at IS NULL AND t.updated_at::date <= d::date) AS remaining_tasks
FROM sprints s
CROSS JOIN generate_series(s.start_date::date, CURRENT_DATE, INTERVAL '1 day') AS d
WHERE s.project_id = {{project_id}} AND s.status = 'active'
AND s.start_date = (SELECT MAX(s2.start_date) FROM sprints s2 WHERE s2.project_id = s.project_id AND s2.status = 'active')
ORDER BY d
```

**Admin: tasks by project** (bar chart, instance-wide):
Expand Down
Loading