diff --git a/.agents/skills/planner-agent/SKILL.md b/.agents/skills/planner-agent/SKILL.md index be63aca6..60b8fae6 100644 --- a/.agents/skills/planner-agent/SKILL.md +++ b/.agents/skills/planner-agent/SKILL.md @@ -1,82 +1,128 @@ -# Planner Agent Skill - -**Role**: One-shot decomposition of SPEC into a detailed PLAN. Phases work, assigns files to tasks, identifies parallel sequences that avoid file conflicts. Does not participate in execution. - -**Agents Using This Skill**: Planner (invoked when Owner requests a plan) - -**TDD emphasis**: Every task must have explicit test requirements—what tests to create, what tests to run. Map SPEC AC and test requirements to each task. - --- - -## System Prompt - -You are the Planner Agent for the Zazz multi-agent deliverable framework. Your role is to perform a **one-shot, full decomposition** of an approved SPEC into a detailed Implementation Plan (PLAN). You do not participate in execution—the Coordinator takes over once the plan is approved. - -You must: - -1. **Decompose into manageable chunks** — Break the SPEC into phases and steps with clear task boundaries -2. **Phase the work** — Organize tasks into logical phases (e.g., setup, core feature, tests, integration) -3. **Assign files to tasks** — Use file names, conventions, and project structure to assign specific files to each task. This is critical for parallelization. -4. **Identify parallel sequences** — Find sequences where tasks can run in parallel without impacting the same files. Tasks that touch different files can proceed concurrently; tasks that share files must use DEPENDS_ON. -5. **Minimize file conflicts** — Design the plan so that when work is combined in the shared worktree, conflicts are rare. All agents work in the same worktree. -6. **Map AC and test requirements** — Each task gets derived acceptance criteria and explicit test requirements (what tests to create, what tests to run) from the SPEC -7. **Define dependencies** — Use DEPENDS_ON when a task requires output from another; use COORDINATES_WITH when tasks must complete before a dependent can start (merge points) - ---- - -## Output - -**Output**: `.zazz/deliverables/{deliverable-name}-PLAN.md` - -The PLAN document must include: -- Phases and steps -- Per-task: description, objectives, acceptance criteria, test requirements, file assignments -- Dependencies (DEPENDS_ON, COORDINATES_WITH) -- Parallelization notes: which task groups can run concurrently - +name: planner-agent +description: Creates a generic, execution-ready implementation PLAN from an approved SPEC for any deliverable. Use when an Owner asks to generate or update a phased plan with dependencies, file assignments, testing, and parallelization. --- -## Trigger - -**When invoked**: The Owner requests a plan (e.g., after SPEC approval, during the Planning phase). You run once, produce the PLAN, and exit. The Owner reviews and approves; the deliverable moves to Ready. The Coordinator then takes over to create tasks and begin execution. - -**Vendor-native planning**: When available, leverage vendor-native planning features (e.g., Warp Plan, Claude Code plan mode) to enhance decomposition—planning is a natural fit for these tools. +# Planner Agent Skill ---- +## Role +Perform a one-shot decomposition of an approved SPEC into an execution-ready PLAN document. The PLAN is for Coordinator/Worker/QA execution in a shared worktree and must minimize file conflicts. + +You are a planner only. Do not implement code in this step. + +## Framework Context +- Zazz is spec-driven and test-driven. +- The SPEC defines intent (`what`); the PLAN defines execution (`how work is broken down`). +- The SPEC is read-only during planning. +- The Coordinator executes and maintains the PLAN during implementation. + +## Companion Skill Requirement +- Load and follow `.agents/skills/zazz-board-api/SKILL.md` when planning API work. +- Treat live OpenAPI as route truth when available; do not rely on stale hardcoded route assumptions. + +## Required Inputs +Before generating a PLAN, confirm these values are known: +- Project code (e.g. `ZAZZ`) +- Deliverable code (e.g. `ZAZZ-5`) +- Deliverable numeric ID (integer, e.g. `8`) +- SPEC file path + +If any are missing, ask the Owner. + +## PLAN Naming + Location (Generic Rule) +- Store all plans in `.zazz/deliverables/`. +- Derive PLAN name from SPEC name by replacing `-SPEC.md` with `-PLAN.md`. +- Enforce hyphen-delimited filenames. +- Update `.zazz/deliverables/index.yaml` when generating/updating the canonical PLAN: + - if deliverable entry exists, add or update its `plan` field + - if entry does not exist, add a new deliverable record with `id`, `name`, `spec`, and `plan` +- If the Owner explicitly asks for an alternate planning draft (for example `-CODEX-PLAN.md`), create it without replacing canonical `-PLAN.md` unless asked. + +Example: +- SPEC: `.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md` +- PLAN: `.zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md` + +## Output Requirements +Write one markdown PLAN file that includes: +1. Header metadata: + - Project Code + - Deliverable Code + - Deliverable ID (integer) + - SPEC Reference +2. Scope guardrails (in-scope, out-of-scope, explicit non-goals from SPEC) +3. Current-state summary from repository reality (not guesses), with evidence references +4. Impacted files by subsystem (API, DB, client, tests, docs/skills) +5. API/contract delta table for changed and removed endpoints (if applicable) +6. AC traceability matrix (`AC -> implementation steps -> tests`) +7. Phased decomposition with numbered phases (`1`, `2`, `3`, ...) +8. Numbered tasks/steps within each phase (`1.1`, `1.2`, `1.3`, ...) +9. Explicit dependencies (`DEPENDS_ON`, optional `COORDINATES_WITH`) +10. Parallelization notes driven by file overlap +11. Testing and validation tasks (unit/API/E2E/manual as applicable) +12. Final verification command set and owner-signoff checkpoints where applicable + +## Planning Workflow +1. Read SPEC completely and extract AC + test requirements. +2. Read relevant standards (`testing.md`, `coding-styles.md`, architecture/data docs). +3. Inspect repository structure and identify actual files likely to change. +4. For API work, resolve target routes/capabilities from OpenAPI (or document if unavailable). +5. Group work into dependency-safe phases and explicit parallel streams. +6. Split phases into concrete steps with file ownership. +7. Add validation steps (tests, lint/type checks, OpenAPI/doc checks, manual sign-off where needed). +8. Write PLAN file to `.zazz/deliverables/`. +9. Update `.zazz/deliverables/index.yaml` only when canonical plan target changes. ## Decomposition Rules - -1. **File-first thinking** — Before defining tasks, map SPEC requirements to files. Group tasks by file ownership to maximize parallelization. -2. **No same-file parallelism** — Tasks that modify the same file(s) must be sequential (DEPENDS_ON). Tasks that touch disjoint file sets can run in parallel. -3. **Convention awareness** — Use .zazz/standards/, .zazz/project.md, project structure, and naming conventions to infer file locations and task boundaries. -4. **Test tasks** — Plan test creation tasks (unit, API, E2E) per SPEC. These may precede or accompany feature tasks. -5. **Task sizing** — Each task should be self-contained and completable within a reasonable context window. Avoid monolithic tasks. - ---- - -## Key Responsibilities - -- [ ] Read SPEC completely -- [ ] Extract AC and test requirements -- [ ] Identify phases and natural task boundaries -- [ ] Map files to tasks using project structure -- [ ] Identify parallel sequences (disjoint file sets) -- [ ] Define DEPENDS_ON and COORDINATES_WITH -- [ ] Produce .zazz/deliverables/{deliverable-name}-PLAN.md with all task definitions - ---- - -## Best Practices - -1. **Maximize parallelism** — The more tasks that can run without file overlap, the faster the deliverable completes -2. **Explicit file assignments** — Every task should list the files it will create or modify -3. **Clear dependencies** — No circular dependencies; all relations declared upfront -4. **TDD per task** — Each task knows exactly what tests to create and run - ---- - -## Environment Variables Required - +1. **File-first decomposition**: every step lists affected files. +2. **No same-file parallelism**: if steps touch same file(s), they must be sequential via `DEPENDS_ON`. +3. **Test-first planning**: every AC must map to one or more test activities. +4. **TDD gates per step**: include both tests-to-write-first and tests-to-run-to-complete. +5. **Small, finishable steps**: avoid oversized tasks; each step has a clear completion signal. +6. **No circular dependencies**. +7. **Reality over assumptions**: do not cite files/tests that do not exist without marking them as new files. + +## Step Format (Use for every step) +For each numbered step (`1.1`, `1.2`, ...), include: +- Objective +- Files affected +- Deliverables/output +- DEPENDS_ON +- COORDINATES_WITH (optional) +- Parallelizable with +- TDD: tests to write first +- TDD: tests to run for completion +- Acceptance criteria mapped +- Completion signal + +## Parallelization Guidance +- Maximize concurrency across disjoint files/subsystems. +- Call out merge points where parallel streams converge. +- Serialize around high-conflict files (route registries, schema barrels, shared configs). +- Prefer planning streams such as: + - API route stream + - data/schema stream + - client/UI stream + - tests/docs stream + +## Warp-Specific Planning Capabilities (When Available) +Use Warp capabilities to improve decomposition quality: +- Semantic code search for likely impact areas +- Exact symbol search for routes/functions +- TODO decomposition for task sequencing +- Native planning/doc tools for structured phase generation + +## Quality Bar +A PLAN is complete only if it: +- Uses correct `-PLAN.md` naming derived from SPEC +- Includes project/deliverable identifiers (including numeric deliverable ID) +- Uses phased numbering (`1`, `2`, `3`) and step numbering (`1.1`, `1.2`) +- Includes development + testing + validation work +- Includes AC traceability and test traceability +- Explicitly documents dependencies and parallelizable groups +- Includes concrete commands for required verification runs +- Avoids speculative routes/files and aligns to repository reality + +## Environment Variables ```bash export ZAZZ_API_BASE_URL="http://localhost:3000" export ZAZZ_API_TOKEN="your-api-token" @@ -84,5 +130,3 @@ export AGENT_ID="planner" export ZAZZ_WORKSPACE="/path/to/project" export ZAZZ_STATE_DIR="${ZAZZ_WORKSPACE}/.zazz" ``` - -Notes - in the top of the plan we should call out the Project Code and the Deliverable code and the deliverable Id (integer) so the planner agent does not need to look up these values \ No newline at end of file diff --git a/.agents/skills/zazz-board-api/SKILL.md b/.agents/skills/zazz-board-api/SKILL.md index fe5f0aea..ea8245db 100644 --- a/.agents/skills/zazz-board-api/SKILL.md +++ b/.agents/skills/zazz-board-api/SKILL.md @@ -1,123 +1,163 @@ --- name: "Zazz Board API" type: "rule" -description: "Required API skill for agents to create and manage deliverables and tasks. Uses live OpenAPI spec; only agent-relevant routes are needed." +description: "Required API skill for agents to create and manage deliverables/tasks using live OpenAPI. OpenAPI is source of truth; resolve routes by capability instead of brittle hardcoded full path lists." required_for: ["planner", "coordinator", "worker", "qa", "spec-builder"] --- # Zazz Board API (Agent Routes) - -**Purpose**: Agents use this API to create deliverables, create tasks, update content, and change statuses. Projects and users are pre-configured; agents do not create them. +## Purpose +Agents use this API to create/manage deliverables and tasks, update statuses, append notes, and inspect task graph/readiness. Projects and users are pre-configured; agents do not create them. --- ## Authentication - All API requests (except `/openapi.json`, `/health`, `/`, `/db-test`, `/token-info`) require: -- **Header**: `TB_TOKEN: ` or `Authorization: Bearer ` -- **Token**: `ZAZZ_API_TOKEN` or fallback `550e8400-e29b-41d4-a716-446655440000` +- Header: `TB_TOKEN: ` or `Authorization: Bearer ` +- Token: `ZAZZ_API_TOKEN` or fallback `550e8400-e29b-41d4-a716-446655440000` --- ## Environment variables - -| Variable | Fallback | Purpose | -|----------|----------|---------| -| `ZAZZ_API_BASE_URL` | `http://localhost:3030` | API base; spec at `{base}/openapi.json` | -| `ZAZZ_API_TOKEN` | `550e8400-e29b-41d4-a716-446655440000` | Auth token | -| `ZAZZ_PROJECT_CODE` | `ZAZZ` | Default project code | +- `ZAZZ_API_BASE_URL` (fallback: `http://localhost:3030`) +- `ZAZZ_API_TOKEN` (fallback: `550e8400-e29b-41d4-a716-446655440000`) +- `ZAZZ_PROJECT_CODE` (fallback: `ZAZZ`) --- -## Source of truth: OpenAPI spec - -**Fetch the live spec for request/response schemas.** The spec is at `{ZAZZ_API_BASE_URL}/openapi.json`. No auth required for the spec. Parse as JSON; use `paths` for routes. - -**Agent routes only** — When using the spec, extract only these paths. Ignore projects, users, tags, and other non-agent routes. - -| Capability | Method | Path | -|------------|--------|------| -| Create deliverable | POST | `/projects/{projectCode}/deliverables` | -| Get deliverable | GET | `/projects/{projectCode}/deliverables/{id}` | -| Update deliverable (add spec path, plan path, git worktree, etc.) | PUT | `/projects/{projectCode}/deliverables/{id}` | -| Change deliverable status | PATCH | `/projects/{projectCode}/deliverables/{id}/status` | -| Approve deliverable (required before creating tasks) | PATCH | `/projects/{projectCode}/deliverables/{id}/approve` | -| List deliverables | GET | `/projects/{projectCode}/deliverables` | -| Create task | POST | `/projects/{code}/deliverables/{delivId}/tasks` | -| Get task | GET | `/projects/{code}/deliverables/{delivId}/tasks/{taskId}` | -| Update task | PUT | `/projects/{code}/deliverables/{delivId}/tasks/{taskId}` | -| Change task status | PATCH | `/projects/{code}/deliverables/{delivId}/tasks/{taskId}/status` | -| Append note to task | PATCH | `/projects/{code}/deliverables/{delivId}/tasks/{taskId}/notes` | -| List deliverable tasks | GET | `/projects/{projectCode}/deliverables/{id}/tasks` | -| Get deliverable graph | GET | `/projects/{code}/deliverables/{delivId}/graph` | -| Check task readiness | GET | `/projects/{code}/tasks/{taskId}/readiness` | -| Get deliverable statuses | GET | `/projects/{code}/deliverable-statuses` | -| List task images | GET | `/tasks/{taskId}/images` | -| Upload task images | POST | `/tasks/{taskId}/images/upload` | -| Get image binary | GET | `/images/{id}` | -| Get image metadata | GET | `/images/{id}/metadata` | +## Source of truth: OpenAPI +Always fetch the live spec from: +`{ZAZZ_API_BASE_URL}/openapi.json` ---- +Rules: +- Parse `paths` + operation metadata (`tags`, `summary`, `description`, params, requestBody, responses). +- Do not trust stale hardcoded route lists when OpenAPI differs. +- Do not invent routes; derive from live spec. -## Fetch spec +--- -**URL**: `{ZAZZ_API_BASE_URL}/openapi.json` (see Environment variables for base URL) +## Capability-first routing model (hybrid) +Use capability names as the stable contract, then resolve concrete routes from OpenAPI. -Filter `spec.paths` to the agent paths in the table above before reading schemas. +Core capabilities: +- Create/list/get/update/approve/status-change deliverable +- Create/list/get/update/delete/status-change task (deliverable-scoped) +- Append notes to task +- Get deliverable graph +- Create task relations (`DEPENDS_ON`, `COORDINATES_WITH`) +- Check task readiness +- Get deliverable status workflow +- Image operations (list/upload/delete/fetch/metadata) using project-scoped routes --- -## Create deliverable - -**POST** `/projects/{projectCode}/deliverables` +## Deterministic route resolution rules +For each capability: +1. Filter operations by tags relevant to agent workflows: `deliverables`, `projects`, `task-graph`, `images`. +2. Match method + intent keywords in `summary`/`description`. +3. Prefer project/deliverable-scoped routes over global/legacy routes. +4. If multiple matches remain, choose the most specific path (more scoped params). +5. Read request/response schemas from OpenAPI before constructing requests. +6. If no match is found, stop and report missing capability + method + candidates. -- **Required body**: `name` (string, 1–30 chars), `type` (enum: `FEATURE`, `BUG_FIX`, `REFACTOR`, `ENHANCEMENT`, `CHORE`, `DOCUMENTATION`). -- **Optional body**: `description`, `dedFilePath`, `planFilePath`, `prdFilePath`, `gitWorktree`, `gitBranch`, `pullRequestUrl`. -- **Response (201)**: `id` (numeric, for API paths), `deliverableId` (e.g. `ZAZZ-4`, for display). Return `deliverableId` to the user. - -**Before creating:** Ensure you have `name`, `type`, and `projectCode` (path; from user or `PROJECT_CODE` env). If any are missing, ask the human. Do not infer or invent. +Image/graph routing policy: +- Use deliverable graph route (`/projects/{code}/deliverables/{delivId}/graph`). +- Do not use project-wide graph route (`/projects/{code}/graph`) if absent in OpenAPI. +- Use only project-scoped image routes; do not fallback to legacy global/task-only image routes. --- -## Create task +## Minimal critical assertions (guardrails) +These capabilities must resolve for normal agent workflows: +- Create deliverable +- Update deliverable +- Change deliverable status +- Approve deliverable +- Create task in deliverable +- Change task status in deliverable +- Get deliverable graph +- Check task readiness -**POST** `/projects/{code}/deliverables/{delivId}/tasks` +If a critical capability cannot be resolved, stop and surface the mismatch. -- **Path params**: `code` = project code, `delivId` = numeric deliverable id from create deliverable response. -- **Required body**: `title` (string, 1–255 chars). -- **Optional body**: `description`, `status`, `priority`, `agentName`, `storyPoints`, `position`, `phase`, `phaseTaskId`, `prompt`, `dependencies`, `gitWorktree`. -- **Prerequisite**: Deliverable must be approved (PATCH `.../approve`) before creating tasks. -- **Response (201)**: `id` (numeric task id). Return `id` to the user. +--- -**Before creating:** Ensure you have `code`, `delivId`, and `title`. If any are missing, ask the human. Do not infer or invent. +## Request construction rules +- Never infer body fields from memory; derive from OpenAPI schema. +- Never invent required user inputs; ask the human for missing data. +- Use numeric IDs where path schema expects numeric IDs (`id`, `delivId`, `taskId`). +- Treat `deliverableId` (e.g., `ZAZZ-4`) as display-only unless schema says otherwise. --- -## Missing data — do not invent - -If required fields are missing, **do not make up values**. Ask the human (or surface through the agent so the human can provide them). Example: "To create the deliverable, I need the type. Please choose: FEATURE, BUG_FIX, REFACTOR, ENHANCEMENT, CHORE, DOCUMENTATION." +## Mandatory execution contract +For coordinator/worker/qa agent runs, these behaviors are required: +- Use live API for all task/deliverable lifecycle updates. +- Do not leave created tasks in ambiguous state. +- Keep task graph relations explicit and verifiable. + +Task lifecycle (required): +1. Create task in deliverable (`POST /projects/{code}/deliverables/{delivId}/tasks`) with: + - `title` + - `phase` + - `phaseTaskId` + - `prompt` +2. If task begins execution, set status to `IN_PROGRESS` immediately (`PATCH .../tasks/{taskId}/status`). +3. On implementation completion, set status to `QA`. +4. After QA passes, set status to `COMPLETED`. + +Deliverable lifecycle (required): +- Resolve project deliverable workflow from API/OpenAPI-capable endpoints. +- Update deliverable status explicitly with status endpoints; do not assume implicit transitions. +- Approve deliverable explicitly with approve endpoint when workflow requires it. + +Dependency lifecycle (required): +- Treat `DEPENDS_ON` in PLAN as required `TASK_RELATIONS` rows. +- Do not assume task create `dependencies` field is sufficient for graph lines. +- After task creation, create each dependency edge explicitly via relation endpoint. +- Solo tasks are valid and visible without dependencies. + +Verification lifecycle (required): +- After creating/updating tasks, re-fetch deliverable task list and confirm task `id`, `phaseTaskId`, and `status`. +- Re-fetch deliverable graph and confirm task presence and relation edges. +- If mismatch appears, report exact endpoint + payload + response. --- -## Key conventions - -- **`id` / `delivId`**: Numeric ids for API paths. `deliverableId` (e.g. ZAZZ-4) is display-only. -- **Update deliverable**: PUT to add `dedFilePath`, `planFilePath`, `gitWorktree`, `gitBranch`, `pullRequestUrl` after creation. -- **Append note**: PATCH `.../tasks/{taskId}/notes` — body `{ "note": "...", "agentName": "..." }`. -- **Claim task**: Include `agentName` when PATCHing status to IN_PROGRESS. -- **Upload images**: POST `/tasks/{taskId}/images/upload` — body `{ images: [{ originalName, contentType, fileSize, base64Data }] }`, `contentType` must be `image/*`. +## Practical workflow +1. Fetch OpenAPI spec. +2. Resolve routes for required capabilities using deterministic rules. +3. Validate required path/query/body schema for each operation. +4. Execute request with `TB_TOKEN`. +5. Validate post-conditions (task list + graph + statuses). +6. On errors, report capability + path + status + API error payload. --- -## Workflow - -1. Fetch spec from `/openapi.json`. -2. Extract only the agent paths listed above from `spec.paths`. -3. For each operation, read `requestBody.content.application/json.schema` and `responses.201` (or 200) from the spec. -4. Make requests to `{ZAZZ_API_BASE_URL}{path}` with `TB_TOKEN` header and JSON body per spec. +## Capability-specific guidance +- Create deliverable: + - Required inputs: `projectCode`, `name`, `type` + - Return both numeric `id` and display `deliverableId` +- Create task: + - Required inputs: `code`, `delivId`, `title` + - Required operational fields for planning execution: `phase`, `phaseTaskId`, `prompt` + - Respect deliverable approval prerequisites + - For each planned dependency, create explicit relation (`DEPENDS_ON`) after task creation +- Update task status: + - Use explicit transitions: `READY` -> `IN_PROGRESS` -> `QA` -> `COMPLETED` + - Include `agentName` when moving to `IN_PROGRESS` to claim work +- Update deliverable status: + - Use deliverable status endpoint, validate allowed values from workflow +- Append note: + - Include `note` and optional `agentName` +- Images: + - Use project-scoped routes only + - Validate upload payload schema + content type from OpenAPI --- ## Error handling - -200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 404 Not Found, 409 Conflict, 500 Internal Server Error. Response bodies may include `error` field. +Expected statuses: `200`, `201`, `400`, `401`, `403`, `404`, `409`, `500`. +- Include API `error` payload when present. +- Do not retry with guessed alternate routes; re-resolve from OpenAPI first. +- If status update response conflicts with subsequent list/graph reads, report eventual-consistency mismatch and re-check once before escalating. diff --git a/.gitignore b/.gitignore index 0d240a8e..292ebe98 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ api/.env.local !.vscode/extensions.json .idea .DS_Store +.codex/ *.suo *.ntvs* *.njsproj diff --git a/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-CODEX-PLAN.md b/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-CODEX-PLAN.md new file mode 100644 index 00000000..6faa5658 --- /dev/null +++ b/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-CODEX-PLAN.md @@ -0,0 +1,640 @@ +# CODEX Implementation Plan: Fix Routes No Project +Project Code: `ZAZZ` +Deliverable Code: `ZAZZ-5` +Deliverable ID (integer): `8` +SPEC Reference: `.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md` +Status: `IMPLEMENTED_COMPLETED` +Planning basis: repository audit + standards (`testing.md`, `coding-styles.md`, `system-architecture.md`, `data-architecture.md`) + API skill constraints from `.agents/skills/zazz-board-api/SKILL.md` + +## 1. Scope Guardrails +In scope: +- Remove project-wide graph route and project-wide graph UI mode. +- Replace all legacy image routes with project-code-scoped routes supporting task and deliverable images. +- Introduce single-owner image model (`task_id XOR deliverable_id`) in `IMAGE_METADATA`. +- Add TDD-first API/OpenAPI regression coverage for all route contract changes. +- Update API skill/docs to reflect the final route contract. + +Out of scope (must not be pulled into this plan): +- SPEC changes. +- Project membership/access-control redesign (`USER_PROJECTS` style token-project mapping). +- Global normalization of every `:id` vs `:code` route. +- Building card-image UI components. + +## 2. Verified Current State (Repository Reality) +- `api/src/routes/taskGraph.js` currently exposes `GET /projects/:code/graph` and `GET /projects/:code/deliverables/:delivId/graph`. +- `client/src/App.jsx` currently renders `All tasks (project-wide)` in Task Graph center selector and passes `null` deliverable. +- `client/src/hooks/useTaskGraph.js` currently falls back to `/projects/{code}/graph` when `deliverableId` is null. +- `api/src/routes/images.js` currently exposes only legacy non-project-scoped routes: + - `/tasks/:taskId/images` + - `/tasks/:taskId/images/upload` + - `/tasks/:taskId/images/:imageId` + - `/images/:id` + - `/images/:id/metadata` +- `api/lib/db/schema.js` currently enforces task-only image ownership (`IMAGE_METADATA.task_id` is `NOT NULL`; no `deliverable_id`). +- `api/src/services/databaseService.js` currently has task/global image methods only (`getTaskImages`, `storeTaskImage`, `getImageWithData`, `getImageMetadata`, `deleteImage`) and URL generation tied to `/images/{id}`. +- Route-test reality: there are no dedicated image route tests in `api/__tests__/routes/`; graph behavior is covered indirectly in `agent-workflow.test.mjs`. +- `api/__tests__/helpers/testDatabase.js` does not clear image tables; image tests would currently leak state across tests. +- `api/__tests__/routes/openapi.test.mjs` does not assert removal of `/projects/{code}/graph` or removal/addition of legacy/new image paths. +- `.agents/skills/zazz-board-api/SKILL.md` still allows legacy image fallback if project-scoped routes are absent. + +## 3. Contract Delta (Target API) +| Capability | Current | Target | +| --- | --- | --- | +| Project-wide graph | `GET /projects/{code}/graph` | Removed (404) | +| Deliverable graph | `GET /projects/{code}/deliverables/{delivId}/graph` | Unchanged | +| Task image list/upload/delete | `/tasks/{taskId}/images...` | `/projects/{code}/deliverables/{delivId}/tasks/{taskId}/images...` | +| Deliverable image list/upload/delete | Not available | `/projects/{code}/deliverables/{delivId}/images...` | +| Image binary + metadata | `/images/{id}`, `/images/{id}/metadata` | `/projects/{code}/images/{id}`, `/projects/{code}/images/{id}/metadata` | +| Legacy image routes | Active | Removed (404) | + +Behavior requirements: +- Cross-project resource access for image routes returns `403`. +- Missing project/resource returns `404`. +- Missing/invalid token returns `401`. + +## 4. Parallelization Strategy (Shared Worktree Safe) +Parallel streams: +- Stream A: Graph API + Graph UI (`taskGraph.js`, client graph files, graph tests). +- Stream B: Image schema + service + routes (`schema.js`, `databaseService.js`, `images.js`, `schemas/images.js`). +- Stream C: Test harness + regression tests + OpenAPI assertions (new route test files + `openapi.test.mjs`). +- Stream D: Skill/docs updates (`.agents/skills/zazz-board-api/SKILL.md`, optional docs note). + +Serialization hotspots: +- `api/lib/db/schema.js` +- `api/src/services/databaseService.js` +- `api/src/routes/images.js` +- `api/src/schemas/images.js` +- `api/__tests__/routes/openapi.test.mjs` + +Merge points: +- Stream C depends on final contracts from Stream A + Stream B. +- Stream D should run after Stream A/B contracts stabilize and OpenAPI assertions are green. + +### Mandatory Dependency Edge Sync (Live Task Graph) +- `DEPENDS_ON` in this plan must be reflected as explicit `TASK_RELATIONS` rows (`relation_type = DEPENDS_ON`). +- Do not rely on task-create payload `dependencies` to draw graph lines; create each edge explicitly via: + - `POST /projects/{code}/tasks/{taskId}/relations` + - body: `{ "relatedTaskId": , "relationType": "DEPENDS_ON" }` +- Verification gate for every phase transition: + - Query DB directly with `psql`: + - `SELECT task_id, related_task_id, relation_type FROM "TASK_RELATIONS" WHERE task_id BETWEEN AND ORDER BY task_id, related_task_id;` + - Ensure every non-`none` `DEPENDS_ON` line in this plan has a matching DB row. + +## 5. AC Traceability Matrix +| AC | Implementation steps | Tests/evidence | +| --- | --- | --- | +| AC1 | 1.2, 3.4 | `task-graph-scoping.test.mjs` + `openapi.test.mjs` path absence assertion | +| AC2 | 1.3, 3.5 | Manual owner sign-off checklist + client behavior verification (no fetch on null selection) | +| AC3 | 2.3, 3.1 | `image-scoping.test.mjs` task route happy path | +| AC4 | 2.3, 3.1 | `image-scoping.test.mjs` deliverable route happy path | +| AC5 | 2.2, 2.3, 3.1 | `image-scoping.test.mjs` binary/metadata cross-project 403 assertions | +| AC6 | 2.2, 2.3, 3.1 | `image-scoping.test.mjs` mutation route 403 assertions | +| AC7 | 2.3, 3.1, 3.4 | Legacy route 404 tests + grep/audit + skill/doc updates | +| AC8 | 3.2 | `project-id-routes-regression.test.mjs` | +| AC9 | 2.1, 3.1 | DB constraint insert-fail tests (both-set and neither-set) | +| AC10 | 2.3, 3.4 | OpenAPI path add/remove + schema assertions | + +## 6. Phased Execution Plan +### Phase 1 - Graph De-Scope + Test Harness Foundation +#### Step 1.1 +Objective: Prepare test harness for image-route work and deterministic cleanup. + +Files affected: +- `api/__tests__/helpers/testDatabase.js` +- `api/lib/db/schema.js` (imports used by helper only, if needed) + +Deliverables/output: +- `clearTaskData()` (or new helper) also clears `IMAGE_DATA` and `IMAGE_METADATA` to prevent image-state bleed. +- Add helper utilities for image test setup (task image + deliverable image seed helpers) if needed. + +DEPENDS_ON: none +COORDINATES_WITH: none +Parallelizable with: Step `1.2` + +TDD: tests to write first: +- Add a basic image fixture cleanup assertion in new `image-scoping.test.mjs` setup section (failing until helper is updated). + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- image-scoping.test.mjs` + +Acceptance criteria mapped: +- AC3, AC4, AC5, AC6, AC9 (test infrastructure prerequisite) + +Completion signal: +- Image test suite can create/delete fixture images across tests without cross-test contamination. + +#### Step 1.2 +Objective: Remove project-wide graph endpoint and enforce API contract at test level. + +Files affected: +- `api/src/routes/taskGraph.js` +- `api/src/schemas/taskGraph.js` +- `api/__tests__/routes/task-graph-scoping.test.mjs` (new) + +Deliverables/output: +- Remove `GET /projects/:code/graph` route and schema export usage. +- Add focused tests: + - removed route returns `404` + - `GET /projects/:code/deliverables/:delivId/graph` remains `200` + - invalid deliverable/project combinations return expected `404` + +DEPENDS_ON: none +COORDINATES_WITH: Step `1.3` +Parallelizable with: Step `1.1` + +TDD: tests to write first: +- Create `task-graph-scoping.test.mjs` with failing assertions for removed route and preserved deliverable route. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- task-graph-scoping.test.mjs` + +Acceptance criteria mapped: +- AC1 + +Completion signal: +- Removed graph route is unreachable, and deliverable route remains stable under tests. + +#### Step 1.3 +Objective: Make Task Graph UI strictly deliverable-driven (no project-wide fallback call path). + +Files affected: +- `client/src/App.jsx` +- `client/src/hooks/useTaskGraph.js` +- `client/src/pages/TaskGraphPage.jsx` (prompt behavior if no deliverable selected) + +Deliverables/output: +- Remove `All tasks (project-wide)` option from selector. +- Selector contains only deliverables. +- `useTaskGraph` does not fetch when `deliverableId` is `null`. +- Task Graph page shows explicit prompt when no deliverable is selected. + +DEPENDS_ON: Step `1.2` +COORDINATES_WITH: none +Parallelizable with: Step `2.1` + +TDD: tests to write first: +- If client test harness is available, add hook/component tests for null-selection no-fetch behavior. +- If no client test harness exists, add a manual verification checklist artifact in this step. + +TDD: tests to run for completion: +- `cd client && npm run lint` +- Manual check in running UI: + - no project-wide selector option + - no graph API call before deliverable selection + - clear guidance text shown + +Acceptance criteria mapped: +- AC2 + +Completion signal: +- UI cannot trigger removed project-wide graph API path. + +### Phase 2 - Image Ownership Model + Scoped Route Migration +#### Step 2.1 +Objective: Implement `IMAGE_METADATA` single-owner model (`task_id XOR deliverable_id`) with DB enforcement. + +Files affected: +- `api/lib/db/schema.js` + +Deliverables/output: +- Add nullable `deliverable_id` FK to `DELIVERABLES.id`. +- Make `task_id` nullable. +- Add DB check constraint enforcing exactly one owner (`task_id IS NOT NULL` xor `deliverable_id IS NOT NULL`). +- Keep `IMAGE_DATA` unchanged. + +DEPENDS_ON: Step `1.1` +COORDINATES_WITH: Step `2.2` +Parallelizable with: Step `1.3` + +TDD: tests to write first: +- In `image-scoping.test.mjs`, add failing direct DB write cases for: + - both owner columns set + - neither owner column set + +TDD: tests to run for completion: +- `cd api && npm run db:push` +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- image-scoping.test.mjs` + +Acceptance criteria mapped: +- AC9 + +Completion signal: +- Constraint violations are test-proven; valid task/deliverable owner rows insert successfully. + +#### Step 2.2 +Objective: Refactor image service layer to explicit project-scoped resolution and ownership checks. + +Files affected: +- `api/src/services/databaseService.js` + +Deliverables/output: +- Replace legacy generic methods with scoped methods for: + - task image list/upload/delete under project+deliverable+task context + - deliverable image list/upload/delete under project+deliverable context + - project-scoped image binary + metadata fetch +- Add project ownership resolution for each operation. +- Standardize error semantics for service consumers (`not found` vs `forbidden` conditions). + +DEPENDS_ON: Step `2.1` +COORDINATES_WITH: Step `2.3` +Parallelizable with: none (high-conflict file) + +TDD: tests to write first: +- Add failing assertions in `image-scoping.test.mjs` for cross-project `403` and missing-resource `404`. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- image-scoping.test.mjs` + +Acceptance criteria mapped: +- AC5, AC6, AC9 + +Completion signal: +- Service APIs provide all scoped operations needed by routes with correct auth/error behavior. + +#### Step 2.3 +Objective: Replace legacy image routes/schemas with project-code-scoped contracts and deliverable image support. + +Files affected: +- `api/src/routes/images.js` +- `api/src/schemas/images.js` +- `api/src/schemas/validation.js` (if export surface changes) + +Deliverables/output: +- Remove: + - `GET /tasks/:taskId/images` + - `POST /tasks/:taskId/images/upload` + - `DELETE /tasks/:taskId/images/:imageId` + - `GET /images/:id` + - `GET /images/:id/metadata` +- Add: + - `GET /projects/:code/deliverables/:delivId/tasks/:taskId/images` + - `POST /projects/:code/deliverables/:delivId/tasks/:taskId/images/upload` + - `DELETE /projects/:code/deliverables/:delivId/tasks/:taskId/images/:imageId` + - `GET /projects/:code/deliverables/:delivId/images` + - `POST /projects/:code/deliverables/:delivId/images/upload` + - `DELETE /projects/:code/deliverables/:delivId/images/:imageId` + - `GET /projects/:code/images/:id` + - `GET /projects/:code/images/:id/metadata` +- Route handlers enforce project-resource ownership (`403` on cross-project). + +DEPENDS_ON: Step `2.2` +COORDINATES_WITH: none +Parallelizable with: none (shared route/schema files) + +TDD: tests to write first: +- Build full failing endpoint matrix in `image-scoping.test.mjs` for `200/201`, `401`, `403`, `404`, and legacy-route `404`. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- image-scoping.test.mjs` + +Acceptance criteria mapped: +- AC3, AC4, AC5, AC6, AC7 + +Completion signal: +- Only scoped image routes remain exposed; legacy routes are absent. + +#### Step 2.4 +Objective: Align core API metadata text with new image contract language. + +Files affected: +- `api/src/routes/index.js` +- `api/src/server.js` (tag/description text only, if needed) + +Deliverables/output: +- Root endpoint docs/list no longer imply legacy `/images` global usage. +- Tag descriptions reflect scoped task/deliverable image operations. + +DEPENDS_ON: Step `2.3` +COORDINATES_WITH: Step `3.4` +Parallelizable with: Step `3.2` + +TDD: tests to write first: +- Add/adjust OpenAPI assertions first (Step `3.4`) for updated descriptions when practical. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- openapi.test.mjs` + +Acceptance criteria mapped: +- AC10 + +Completion signal: +- Generated OpenAPI/service metadata no longer references legacy image behavior. + +### Phase 3 - Regression Net, OpenAPI Contract, and Skill/Docs +#### Step 3.1 +Objective: Add comprehensive image scoping integration tests. + +Files affected: +- `api/__tests__/routes/image-scoping.test.mjs` (new) +- `api/__tests__/helpers/testDatabase.js` (if additional helpers are needed) + +Deliverables/output: +- Pactum tests cover: + - task image routes (`200/201`, `401`, `403`, `404`) + - deliverable image routes (`200/201`, `401`, `403`, `404`) + - project-scoped image fetch + metadata (`200`, `403`, `404`) + - legacy route removal (`404`) + - DB ownership constraint behavior + +DEPENDS_ON: Steps `2.1`, `2.3` +COORDINATES_WITH: Step `3.4` +Parallelizable with: Step `3.2` + +TDD: tests to write first: +- This step is test-authoring itself; create exhaustive failing suite before final route/service edits close. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- image-scoping.test.mjs` + +Acceptance criteria mapped: +- AC3, AC4, AC5, AC6, AC7, AC9 + +Completion signal: +- Image route behavior and ownership invariants are fully test-enforced. + +#### Step 3.2 +Objective: Lock regression behavior for accepted unchanged project-id routes. + +Files affected: +- `api/__tests__/routes/project-id-routes-regression.test.mjs` (new) + +Deliverables/output: +- Regression tests for: + - `GET /projects/:id` + - `GET /projects/:id/kanban/tasks/column/:status` + - `GET /projects/:id/tasks` + +DEPENDS_ON: none +COORDINATES_WITH: none +Parallelizable with: Step `3.1` + +TDD: tests to write first: +- Add failing assertions (if route behavior drifted during refactor). + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- project-id-routes-regression.test.mjs` + +Acceptance criteria mapped: +- AC8 + +Completion signal: +- These three routes are protected against accidental refactor regression. + +#### Step 3.3 +Objective: Finalize graph removal regression tests. + +Files affected: +- `api/__tests__/routes/task-graph-scoping.test.mjs` + +Deliverables/output: +- Confirm removed project graph route remains absent while deliverable graph path remains healthy. + +DEPENDS_ON: Step `1.2` +COORDINATES_WITH: Step `3.4` +Parallelizable with: Step `3.2` + +TDD: tests to write first: +- Done in Step `1.2`; extend edge-case matrix here if needed. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- task-graph-scoping.test.mjs` + +Acceptance criteria mapped: +- AC1 + +Completion signal: +- Graph contract regression is permanently enforced. + +#### Step 3.4 +Objective: Harden OpenAPI tests for all route removals/additions and schema shape. + +Files affected: +- `api/__tests__/routes/openapi.test.mjs` + +Deliverables/output: +- Add assertions that OpenAPI: + - omits `/projects/{code}/graph` + - omits all legacy image paths + - includes all new scoped task/deliverable/project image paths + - includes expected params/body/response schema references for new routes + +DEPENDS_ON: Steps `1.2`, `2.3` +COORDINATES_WITH: Step `2.4` +Parallelizable with: Step `3.3` + +TDD: tests to write first: +- Add failing path presence/absence assertions before route/schema edits finalize. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- openapi.test.mjs` + +Acceptance criteria mapped: +- AC1, AC7, AC10 + +Completion signal: +- OpenAPI contract and docs are test-locked to final intended route set. + +#### Step 3.5 +Objective: Update API skill/docs and perform final verification gate. + +Files affected: +- `.agents/skills/zazz-board-api/SKILL.md` +- `docs/swagger-for-agent-enhancement.md` (if examples reference removed routes) + +Deliverables/output: +- Remove/replace legacy image route guidance and project-wide graph references. +- Route resolution guidance prefers only scoped image routes for this repo contract. +- Final verification record with command outputs and manual UI sign-off notes. + +DEPENDS_ON: Steps `1.3`, `2.4`, `3.1`, `3.2`, `3.3`, `3.4` +COORDINATES_WITH: none +Parallelizable with: none + +TDD: tests to write first: +- N/A (docs step), but verify against generated OpenAPI. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test` +- `cd client && npm run lint` +- Manual owner sign-off checklist for AC2 + +Acceptance criteria mapped: +- AC2, AC7, AC10 + +Completion signal: +- Automated checks pass, docs/skills match implementation, and owner confirms graph UX behavior. + +### Phase 4 - Worktree Dependency Hardening (Post-Plan Improvement) +#### Step 4.1 +Objective: Remove manual `drizzle-orm` symlink workaround and harden worktree dependency setup. + +Files affected: +- `package.json` +- `package-lock.json` +- `AGENTS.md` +- `CONTRIBUTOR_SETUP.md` + +Deliverables/output: +- Root dependency install path supports `drizzle-kit` execution without manual symlinks. +- Troubleshooting/setup docs explicitly direct install workflow and prohibit manual `node_modules` symlink hacks. +- DB reset/push commands succeed without symlink dependency. + +DEPENDS_ON: Step `3.5` +COORDINATES_WITH: none +Parallelizable with: none + +TDD: tests to write first: +- N/A (operational hardening/documentation step). + +TDD: tests to run for completion: +- `cd api && DATABASE_URL=postgres://postgres:password@localhost:5433/zazz_board_test npm run db:push` +- `cd api && DATABASE_URL=postgres://postgres:password@localhost:5433/zazz_board_test npm run db:reset` + +Acceptance criteria mapped: +- Operational hardening (worktree setup reliability) + +Completion signal: +- Database lifecycle commands run successfully without symlink creation. + +### Phase 5 - Realtime Multi-Client Status Sync (SSE Follow-up) +#### Step 5.1 +Objective: Add project-scoped SSE stream and emit events on task/deliverable/relation mutations. + +Files affected: +- `api/src/services/realtimeService.js` (new) +- `api/src/routes/index.js` +- `api/src/routes/projects.js` +- `api/src/routes/deliverables.js` +- `api/src/routes/taskGraph.js` + +Deliverables/output: +- New `GET /projects/:code/events` SSE endpoint (authenticated). +- Realtime emits for: + - task status/position/create/update/delete events + - deliverable status updates + - relation create/delete (including `DEPENDS_ON`) + +DEPENDS_ON: Step `3.5` +COORDINATES_WITH: Step `5.2` +Parallelizable with: none (shared route files) + +TDD: tests to write first: +- Add failing API integration test(s) asserting status and relation events are emitted. + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/realtime-events.test.mjs` + +Acceptance criteria mapped: +- AC2 (live UX consistency), plus multi-client follow-up hardening + +Completion signal: +- API publishes SSE payloads on every status/graph mutation path. + +#### Step 5.2 +Objective: Subscribe UI views to SSE and refresh board/graph state immediately. + +Files affected: +- `client/src/hooks/useProjectEvents.js` (new) +- `client/src/pages/KanbanPage.jsx` +- `client/src/pages/TaskGraphPage.jsx` +- `client/src/hooks/useDeliverables.js` +- `client/src/pages/DeliverableKanbanPage.jsx` + +Deliverables/output: +- Task Kanban auto-refreshes on task/relation events. +- Task Graph auto-refreshes on task/relation events scoped to selected deliverable. +- Deliverable Kanban auto-refreshes on deliverable status events. + +DEPENDS_ON: Step `5.1` +COORDINATES_WITH: Step `5.3` +Parallelizable with: none (shared UI pages/hooks) + +TDD: tests to write first: +- Manual verification checklist for cross-client immediate updates. + +TDD: tests to run for completion: +- `cd client && npm run build` + +Acceptance criteria mapped: +- AC2 UX stability + live board synchronization follow-up + +Completion signal: +- Status color/column/graph updates appear without manual page refresh. + +#### Step 5.3 +Objective: Add SSE integration regression tests and validate targeted suite. + +Files affected: +- `api/__tests__/routes/realtime-events.test.mjs` (new) + +Deliverables/output: +- New tests covering: + - auth required for SSE stream + - task status change events + - deliverable status change events + - `DEPENDS_ON` relation events + +DEPENDS_ON: Steps `5.1`, `5.2` +COORDINATES_WITH: none +Parallelizable with: none + +TDD: tests to write first: +- Test file itself (failing first before route/event implementation). + +TDD: tests to run for completion: +- `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/realtime-events.test.mjs __tests__/routes/task-graph-scoping.test.mjs __tests__/routes/project-id-routes-regression.test.mjs __tests__/routes/openapi.test.mjs` + +Acceptance criteria mapped: +- Regression guard for realtime follow-up work + +Completion signal: +- SSE regression suite passes and protects status/graph event behavior. + +## Implementation Status Snapshot (2026-03-07) +| Step | Live Task ID | Status | DEPENDS_ON plan | DB relation check | +| --- | --- | --- | --- | --- | +| 1.1 | 13 | COMPLETED | none | N/A | +| 1.2 | 14 | COMPLETED | none | N/A | +| 1.3 | 15 | COMPLETED | 1.2 | `15 -> 14` | +| 2.1 | 16 | COMPLETED | 1.1 | `16 -> 13` | +| 2.2 | 17 | COMPLETED | 2.1 | `17 -> 16` | +| 2.3 | 18 | COMPLETED | 2.2 | `18 -> 17` | +| 2.4 | 20 | COMPLETED | 2.3 | `20 -> 18` | +| 3.1 | 21 | COMPLETED | 2.1, 2.3 | `21 -> 16`, `21 -> 18` | +| 3.2 | 19 | COMPLETED | none | N/A | +| 3.3 | 22 | COMPLETED | 1.2 | `22 -> 14` | +| 3.4 | 23 | COMPLETED | 1.2, 2.3 | `23 -> 14`, `23 -> 18` | +| 3.5 | 24 | COMPLETED | 1.3, 2.4, 3.1, 3.2, 3.3, 3.4 | `24 -> 15/20/21/19/22/23` | +| 4.1 | 25 | COMPLETED | 3.5 | `25 -> 24` | +| 4.2 | 26 | COMPLETED | 1.3 | `26 -> 15` | +| 4.3 | 27 | COMPLETED | 4.2 | `27 -> 26` | +| 4.4 | 28 | COMPLETED | 4.3 | `28 -> 27` | +| 4.5 | 29 | COMPLETED | 4.4 | `29 -> 28` | +| 5.1 | 30 | COMPLETED | 3.5 | N/A | +| 5.2 | 31 | COMPLETED | 5.1 | `31 -> 30` | +| 5.3 | 32 | COMPLETED | 5.1, 5.2 | `32 -> 30`, `32 -> 31` | + +DB verification command used: +- `docker exec zazz_board_postgres psql -U postgres -d zazz_board_db -c "SELECT task_id, related_task_id, relation_type, updated_at FROM \"TASK_RELATIONS\" WHERE task_id BETWEEN 13 AND 32 ORDER BY task_id, related_task_id;"` + +## 7. Test Command Matrix (Execution Order) +1. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- task-graph-scoping.test.mjs` +2. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- image-scoping.test.mjs` +3. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- project-id-routes-regression.test.mjs` +4. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- openapi.test.mjs` +5. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test -- __tests__/routes/realtime-events.test.mjs __tests__/routes/task-graph-scoping.test.mjs __tests__/routes/project-id-routes-regression.test.mjs __tests__/routes/openapi.test.mjs` +6. `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test` +7. `cd client && npm run build` + +## 8. Risks and Mitigations +- Risk: Existing tests do not currently cover image routes. + - Mitigation: Add dedicated `image-scoping.test.mjs` before finalizing route changes. +- Risk: No token-to-project mapping exists today, while SPEC asks for cross-project denial. + - Mitigation: Enforce project ownership via route path/resource relationship and assert `403` for cross-project resource access attempts. +- Risk: Schema changes can drift from OpenAPI docs. + - Mitigation: add explicit OpenAPI path-add/path-remove assertions and keep schema-first edits in `api/src/schemas/images.js`. + +## 9. Approval Checklist +- [ ] Owner accepts the AC traceability matrix and phase structure. +- [ ] Owner accepts assumption that cross-project checks are route/resource-ownership based (not membership system redesign). +- [ ] Owner approves proceeding with this CODEX plan as implementation source. diff --git a/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md b/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md new file mode 100644 index 00000000..03f66d64 --- /dev/null +++ b/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md @@ -0,0 +1,265 @@ +# Implementation Plan: Fix Routes No Project +Project Code: `ZAZZ` +Deliverable Code: `ZAZZ-5` +Deliverable ID (integer): `8` +SPEC Reference: `.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md` +Status: `IMPLEMENTED_COMPLETED` + +## 1. Current State and Repository Impact +The current codebase still supports project-wide graph access and legacy non-project-scoped image routes. +- `api/src/routes/taskGraph.js` still exposes `GET /projects/:code/graph`. +- `client/src/App.jsx` and `client/src/hooks/useTaskGraph.js` still allow an “All tasks (project-wide)” graph path. +- `api/src/routes/images.js`, `api/src/schemas/images.js`, and image methods in `api/src/services/databaseService.js` are task/global-id scoped, not project-code scoped. +- `api/lib/db/schema.js` models images as task-only ownership (`IMAGE_METADATA.task_id` required). +- `.agents/skills/zazz-board-api/SKILL.md` still documents legacy image routes. +- OpenAPI coverage in `api/__tests__/routes/openapi.test.mjs` does not yet assert this deliverable’s graph/image route removals/additions. + +Primary files likely affected: +- API/data model: + - `api/lib/db/schema.js` + - `api/src/services/databaseService.js` + - `api/src/routes/images.js` + - `api/src/routes/taskGraph.js` + - `api/src/schemas/images.js` + - `api/src/schemas/taskGraph.js` +- Client: + - `client/src/App.jsx` + - `client/src/hooks/useTaskGraph.js` + - `client/src/pages/TaskGraphPage.jsx` +- Tests: + - `api/__tests__/helpers/testDatabase.js` + - `api/__tests__/routes/openapi.test.mjs` + - `api/__tests__/routes/task-graph-scoping.test.mjs` (new) + - `api/__tests__/routes/images-scoping.test.mjs` (new) +- Skill/docs: + - `.agents/skills/zazz-board-api/SKILL.md` + +## 2. Dependency and Parallelization Strategy +Critical path: +1. Image ownership schema change +2. Database service ownership/scoping logic +3. Route/schema refactor to new image endpoints +4. Route-level tests + OpenAPI assertions + +Parallelization opportunities: +- Graph cleanup and client graph UX work can proceed in parallel with image ownership/service refactor because they touch mostly different files. +- OpenAPI assertion updates can proceed in parallel with route tests once route contracts stabilize. +- API skill documentation update can run in parallel with tests after final endpoint naming is confirmed. + +Conflict-prone files (serialize changes): +- `api/src/services/databaseService.js` +- `api/lib/db/schema.js` +- `api/src/routes/images.js` +- `api/src/schemas/images.js` +- `client/src/App.jsx` + +## 3. Phased Plan and Task List +### Phase 1 — Remove project-wide graph scope and enforce deliverable-first graph UX +#### Step 1.1 +Objective: Remove project-wide graph endpoint and keep only deliverable-scoped graph retrieval. +Files affected: +- `api/src/routes/taskGraph.js` +- `api/src/schemas/taskGraph.js` +Deliverables/output: +- `GET /projects/:code/graph` removed +- deliverable graph endpoint remains and is documented +DEPENDS_ON: none +Parallelizable with: Step `1.2` (different files) +Test requirements: +- Add route test for removed endpoint returning 404 +- Add route test for existing deliverable graph endpoint success path +Completion signal: +- Project-wide graph endpoint unreachable and deliverable endpoint intact + +#### Step 1.2 +Objective: Remove “All tasks (project-wide)” graph mode and require explicit deliverable selection. +Files affected: +- `client/src/App.jsx` +- `client/src/hooks/useTaskGraph.js` +- `client/src/pages/TaskGraphPage.jsx` +Deliverables/output: +- No “All tasks (project-wide)” selector option +- Graph fetch is disabled when no deliverable is selected +- Clear prompt shown when deliverable is not selected +DEPENDS_ON: Step `1.1` +Parallelizable with: Step `2.1` +Test requirements: +- Manual validation of graph page behavior +- Verify no API call to removed project-wide endpoint in this state +Completion signal: +- UI cannot trigger project-wide graph fetch path + +#### Step 1.3 +Objective: Add/adjust graph scope regression tests and OpenAPI assertions. +Files affected: +- `api/__tests__/routes/openapi.test.mjs` +- `api/__tests__/routes/task-graph-scoping.test.mjs` (new) +Deliverables/output: +- OpenAPI asserts removed project-wide graph path +- Route tests cover 404 removed path + success on deliverable-scoped path +DEPENDS_ON: Step `1.1` +Parallelizable with: Step `2.4` +Test requirements: +- Run targeted route tests and openapi tests +Completion signal: +- Graph scoping behavior is test-enforced + +### Phase 2 — Migrate image model and endpoints to project-scoped ownership-aware routes +#### Step 2.1 +Objective: Introduce single-owner image model (`task_id XOR deliverable_id`) in image metadata. +Files affected: +- `api/lib/db/schema.js` +- `api/__tests__/helpers/testDatabase.js` (if helper cleanup/setup requires updates) +Deliverables/output: +- `IMAGE_METADATA.task_id` nullable +- `IMAGE_METADATA.deliverable_id` added +- DB-level XOR constraint enforces exactly one owner +DEPENDS_ON: none +Parallelizable with: Step `1.2` +Test requirements: +- Constraint-level validation via integration tests (both-set and neither-set failure) +Completion signal: +- Schema supports task and deliverable image ownership with invariant enforced + +#### Step 2.2 +Objective: Refactor image service methods for project ownership validation and scoped URL semantics. +Files affected: +- `api/src/services/databaseService.js` +Deliverables/output: +- New/updated service methods for: + - scoped task image operations + - scoped deliverable image operations + - project-scoped image fetch/metadata +- ownership checks resolve project via task/deliverable relationship before read/delete +DEPENDS_ON: Step `2.1` +Parallelizable with: none (same high-conflict service file) +Test requirements: +- Integration tests assert 403 on cross-project resources and 404 on missing entities +Completion signal: +- Service layer can back all new project-scoped image endpoints + +#### Step 2.3 +Objective: Replace legacy image routes with project/deliverable/task-scoped routes and update schemas. +Files affected: +- `api/src/routes/images.js` +- `api/src/schemas/images.js` +- `api/src/schemas/validation.js` (if exports need adjustment) +Deliverables/output: +- Legacy routes removed: + - `GET /tasks/:taskId/images` + - `POST /tasks/:taskId/images/upload` + - `DELETE /tasks/:taskId/images/:imageId` + - `GET /images/:id` + - `GET /images/:id/metadata` +- New routes added: + - task scoped: `/projects/:code/deliverables/:delivId/tasks/:taskId/images...` + - deliverable scoped: `/projects/:code/deliverables/:delivId/images...` + - project image fetch: `/projects/:code/images/:id` and `/metadata` +DEPENDS_ON: Step `2.2` +Parallelizable with: none (shared route/schema files) +Test requirements: +- Route tests for happy path, 401, 403, 404 +Completion signal: +- API exposes only project-scoped image routes + +#### Step 2.4 +Objective: Update skill/docs references from legacy image routes to new project-scoped routes. +Files affected: +- `.agents/skills/zazz-board-api/SKILL.md` +- `docs/swagger-for-agent-enhancement.md` (if route examples must match current API) +Deliverables/output: +- Agent-facing route table aligns with new image + graph contracts +DEPENDS_ON: Steps `1.1`, `2.3` +Parallelizable with: Step `3.1` (different files) +Test requirements: +- Documentation review against generated OpenAPI +Completion signal: +- No legacy image or project-wide graph route references remain in API skill docs + +### Phase 3 — Verification, regression safety, and readiness for execution +#### Step 3.1 +Objective: Add integration tests for image route scoping and ownership model constraints. +Files affected: +- `api/__tests__/routes/images-scoping.test.mjs` (new) +- `api/__tests__/helpers/testDatabase.js` (if helper methods needed for image setup) +Deliverables/output: +- Coverage for: + - same-project success paths + - cross-project 403 paths + - 401 unauthorized + - 404 not found + - single-owner DB invariant behavior +DEPENDS_ON: Steps `2.1`, `2.3` +Parallelizable with: Step `3.2` +Test requirements: +- PactumJS integration tests with seeded projects/tokens +Completion signal: +- Image scope + ownership behavior is enforced by tests + +#### Step 3.2 +Objective: Strengthen OpenAPI tests for route removals/additions and schema correctness. +Files affected: +- `api/__tests__/routes/openapi.test.mjs` +Deliverables/output: +- Assertions verify: + - removed `GET /projects/{code}/graph` + - removed legacy image paths + - added project-scoped image paths + - expected summaries/request schemas for new routes +DEPENDS_ON: Steps `1.1`, `2.3` +Parallelizable with: Step `3.1` +Test requirements: +- Run OpenAPI schema validator test suite +Completion signal: +- API contract changes are protected by documentation tests + +#### Step 3.3 +Objective: Run full validation commands and confirm release readiness for this deliverable. +Files affected: +- No code files; execution/verification commands +Deliverables/output: +- Green test and quality signals for API and client behavior +DEPENDS_ON: Steps `1.3`, `2.4`, `3.1`, `3.2` +Parallelizable with: none (final convergence) +Test requirements: +- API tests: + - `cd api && set -a && source .env && set +a && NODE_ENV=test npm run test` +- OpenAPI tests included in API test run +- Client lint: + - `cd client && npm run lint` +- Manual graph UI smoke check: + - no project-wide option + - deliverable selection required before graph fetch +Completion signal: +- All required automated checks pass and manual graph behavior matches SPEC + +## 4. AC-to-Phase Coverage Mapping +- AC1/AC2 covered by Phase 1 and Phase 3. +- AC3/AC4/AC5/AC6/AC7 covered by Phase 2 and Phase 3. +- AC8 regression covered in Phase 3 integration tests. +- AC9 covered by Step `2.1` and Step `3.1`. +- AC10 covered by Step `3.2`. + +## 5. Execution Notes for Coordinator +- Create tasks using `phase.step` IDs aligned to this PLAN (`1.1`, `1.2`, ...). +- For every non-`none` `DEPENDS_ON` entry, create explicit `DEPENDS_ON` relations via `POST /projects/{code}/tasks/{taskId}/relations`; do not rely on task-create `dependencies` payload to render graph edges. +- Validate dependency edges directly in DB with `psql` against `"TASK_RELATIONS"` before final QA closure. +- Run Phase 1 and Phase 2 streams with parallelization noted above, but serialize high-conflict files. +- Preserve SPEC as read-only during execution unless Owner-approved change mechanism is invoked. + +## 6. Execution Update (2026-03-07) +- Additional completed post-plan hardening step was executed: + - Step `4.1` (task `25`): replace manual `drizzle-orm` symlink workaround with worktree-safe dependency setup and documentation updates. +- Additional graph UX follow-up was completed: + - Step `4.2` (task `26`): persist Task Graph deliverable selection on reload. + - Step `4.3` (task `27`): harden selection restore timing against project/deliverable hydration order. + - Step `4.4` (task `28`): keep completed tasks visible with green outline in Task Graph. + - Step `4.5` (task `29`): make API skill lifecycle + dependency relation workflow explicit. +- Realtime multi-client follow-up completed: + - Step `5.1` (task `30`): add SSE stream endpoint and API event emits for task/deliverable/relation changes. + - Step `5.2` (task `31`): wire UI SSE subscriptions for Task Kanban, Task Graph, and Deliverable Kanban. + - Step `5.3` (task `32`): add SSE integration tests and targeted regression verification. +- Live task statuses: + - Completed: `13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32` +- Dependency relations verified in DB (`TASK_RELATIONS`): + - `15->14`, `16->13`, `17->16`, `18->17`, `20->18`, `21->16`, `21->18`, `22->14`, `23->14`, `23->18`, `24->15`, `24->19`, `24->20`, `24->21`, `24->22`, `24->23`, `25->24`, `26->15`, `27->26`, `28->27`, `29->28`, `31->30`, `32->30`, `32->31` diff --git a/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md b/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md index 6062e3e6..b0959e68 100644 --- a/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md +++ b/.zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md @@ -2,23 +2,36 @@ ## 1. Problem Statement -**What**: Several API routes operate on project-scoped data (tasks, images) but do not include project in the path or verify that the requested resource belongs to a project the caller is authorized to access. +**What**: Several API routes operate on project-scoped data (task graph, images) without consistent project-code scoping. The Task Graph UI also includes a project-wide graph mode that is not desired. -**Why**: An agent token scoped to project A could access or modify data belonging to project B (e.g. task images, task metadata). This is a data isolation and authorization gap. +**Why**: Authorization should be constrained to a specific project. Inconsistent route scoping and project-wide graph behavior create ambiguous access patterns and increase risk of cross-project data exposure. **Who**: Agents using the zazz-board-api skill; any client with a valid token. -**Current state**: Routes such as `GET /tasks/:taskId/images`, `POST /tasks/:taskId/images/upload`, `GET /images/:id`, `GET /images/:id/metadata` accept a taskId or imageId without project context. The auth middleware validates the token but does not verify the resource belongs to the token's project. - -**Desired state**: All routes that operate on project-scoped resources must either (a) include project in the path and verify access, or (b) resolve the resource's project and verify the token has access before returning data. +**Current state**: +- The UI Task Graph page includes an `All tasks (project-wide)` option that calls `GET /projects/{code}/graph`. +- Image routes are not project-code-scoped: + - `GET /tasks/{taskId}/images` + - `POST /tasks/{taskId}/images/upload` + - `DELETE /tasks/{taskId}/images/{imageId}` + - `GET /images/{id}` + - `GET /images/{id}/metadata` +- Images are currently task-only. Deliverable-card images are planned but not yet supported by dedicated routes. + +**Desired state**: +- Remove `GET /projects/{code}/graph` and remove the `All tasks (project-wide)` UI option. +- Task graph UI must be deliverable-driven: user explicitly selects a deliverable. +- Image APIs must be project-code-scoped and support both task images and deliverable images, with strict project ownership validation. +- Existing internal project-id routes not exposed in normal browser navigation may remain unchanged in this deliverable. --- ## 2. Standards Applied -- **testing.md** — PactumJS for API tests; every route needs happy path, edge cases, 401/403/404 +- **testing.md** — PactumJS for API tests; every route needs happy path, edge cases, 401/403/404; tests must be written before or alongside implementation - **system-architecture.md** — API layer, auth middleware -- **data-architecture.md** — Tasks belong to deliverables; deliverables belong to projects; images belong to tasks +- **data-architecture.md** — Tasks belong to deliverables; deliverables belong to projects; images currently belong to tasks +- **coding-styles.md** — Project-scoped handler pattern and business logic validation rules --- @@ -26,37 +39,89 @@ ### In Scope -- Audit all API routes to identify which operate on project-scoped data without project filtering -- Fix image routes: `/tasks/:taskId/images`, `/tasks/:taskId/images/upload`, `/images/:id`, `/images/:id/metadata`, `DELETE /tasks/:taskId/images/:imageId` — add project verification -- Add PactumJS tests for cross-project access (403 when token for project A accesses project B's data) -- Update zazz-board-api skill if route paths change (e.g. images under `/projects/:code/...`) +- Remove project-wide graph route and behavior: + - Remove API route `GET /projects/{code}/graph` + - Remove `All tasks (project-wide)` UI option + - Require explicit deliverable selection for graph rendering +- Keep these project-id routes unchanged for now (explicitly accepted in this deliverable): + - `/projects/{id}` + - `/projects/{id}/kanban/tasks/column/{status}` + - `/projects/{id}/tasks` +- **Remove legacy image routes and all API usage**: Delete the following routes from the API and remove any code that calls them (client, databaseService, zazz-board-api skill, etc.): + - `GET /tasks/{taskId}/images` + - `POST /tasks/{taskId}/images/upload` + - `DELETE /tasks/{taskId}/images/{imageId}` + - `GET /images/{id}` + - `GET /images/{id}/metadata` +- Introduce project-scoped image route design for task and deliverable images: + - Task image routes under `/projects/{code}/deliverables/{delivId}/tasks/{taskId}/images...` + - Deliverable image routes under `/projects/{code}/deliverables/{delivId}/images...` + - Image fetch/metadata routes scoped under `/projects/{code}/images/{id}...` +- Update image data model to single-owner ownership: + - `IMAGE_METADATA.task_id` becomes nullable + - Add `IMAGE_METADATA.deliverable_id` (nullable FK to `DELIVERABLES.id`) + - Enforce exactly one owner (`task_id` XOR `deliverable_id`) with DB constraint + - Keep `IMAGE_DATA` as image binary payload store keyed by image metadata id + - **Schema change scope**: Modify only the `IMAGE_METADATA` table (and rebuild it). There is no image data in seed data. Do **not** perform a full database reset or replace other tables—only alter/rebuild `IMAGE_METADATA`. +- Add authorization checks so tokens cannot access images from other projects +- Add PactumJS tests for route behavior and cross-project authorization +- Update zazz-board-api skill/docs for new image and graph route expectations +- Update Swagger/OpenAPI route documentation and schemas for all changed/added image and graph endpoints ### Out of Scope - Tags (`/tags`) — design decision: tags may be global or project-scoped; defer to separate deliverable - Project-level access control (USER_PROJECTS, membership) — see future-fixes.md #5 -- Standardizing `:id` vs `:code` for project params — see future-fixes.md #1 +- Standardizing all `:id` vs `:code` project params across every route +- UI image components on cards (actual card feature work comes after route/API capability is in place) --- ## 4. Features & Requirements -- **F1**: Identify all routes that operate on project-scoped data (tasks, deliverables, images) but lack project in path or project verification -- **F2**: For image routes: verify the task (and thus its project) belongs to a project the token can access before returning data or performing mutations -- **F3**: Return 403 Forbidden when a valid token attempts to access another project's data -- **F4**: Add PactumJS tests: happy path (same project), 403 (cross-project), 401 (no/invalid token), 404 (resource not found) +- **F1 (Graph route removal)**: Remove `GET /projects/{code}/graph` from API and OpenAPI output. +- **F2 (Graph UI behavior)**: On the Task Graph page, remove the `All tasks (project-wide)` dropdown option; the dropdown must list only deliverables; require explicit deliverable selection before fetching graph data; when none selected, show a prompt and do not call the graph API. +- **F3 (Task image scoping)**: Remove legacy task image routes and add project+deliverable+task scoped routes: + - `GET /projects/{code}/deliverables/{delivId}/tasks/{taskId}/images` + - `POST /projects/{code}/deliverables/{delivId}/tasks/{taskId}/images/upload` + - `DELETE /projects/{code}/deliverables/{delivId}/tasks/{taskId}/images/{imageId}` +- **F4 (Deliverable image capability)**: Add deliverable image routes: + - `GET /projects/{code}/deliverables/{delivId}/images` + - `POST /projects/{code}/deliverables/{delivId}/images/upload` + - `DELETE /projects/{code}/deliverables/{delivId}/images/{imageId}` +- **F5 (Project-scoped image fetch by id)**: Remove global image fetch routes and add project-scoped equivalents: + - `GET /projects/{code}/images/{id}` + - `GET /projects/{code}/images/{id}/metadata` +- **F6 (Authorization guarantees)**: For all image endpoints, verify resource belongs to the specified project; return 403 for cross-project access. +- **F7 (Cloud-ready behavior continuity)**: Keep storage abstraction behavior compatible with local DB now and object-storage backends (e.g., S3/GCS) later. +- **F8 (Single-owner image model)**: Implement a single-owner model in `IMAGE_METADATA` so an image belongs to exactly one owner type: + - owner is either task (`task_id`) or deliverable (`deliverable_id`) + - never both and never neither (DB-enforced) + - `IMAGE_DATA` remains linked 1:1 to image metadata +- **F9 (Swagger/OpenAPI updates)**: Update Fastify schemas/OpenAPI generation to document: + - removed `GET /projects/{code}/graph` + - new/updated project-scoped task image routes + - new deliverable image routes + - project-scoped image fetch/metadata routes --- ## 5. Acceptance Criteria -- **AC1**: `GET /tasks/:taskId/images` returns 403 when the task belongs to a project different from the token's project — Verified by: API test -- **AC2**: `POST /tasks/:taskId/images/upload` returns 403 when the task belongs to a different project — Verified by: API test -- **AC3**: `GET /images/:id` returns 403 when the image's task belongs to a different project — Verified by: API test -- **AC4**: `GET /images/:id/metadata` returns 403 when the image's task belongs to a different project — Verified by: API test -- **AC5**: `DELETE /tasks/:taskId/images/:imageId` returns 403 when the task belongs to a different project — Verified by: API test -- **AC6**: All image routes return 200/201 as before when the task belongs to the token's project — Verified by: API test -- **AC7**: Document the list of routes that were audited and which were fixed vs deferred — Verified by: Owner sign-off +- **AC1**: `GET /projects/{code}/graph` is removed from API and OpenAPI spec — Verified by: API/OpenAPI test +- **AC2**: Task Graph UI is deliverable-only: the center dropdown must not offer `All tasks (project-wide)`; it must list only deliverables; user must select a deliverable before any graph data is fetched; when no deliverable is selected, show a prompt (e.g. "Select a deliverable to view the task graph") and do not call any graph API — Verified by: Owner sign-off + UI behavior test/manual verification +- **AC3**: New task image routes under `/projects/{code}/deliverables/{delivId}/tasks/{taskId}/images...` support happy-path access for same-project token — Verified by: API test +- **AC4**: New deliverable image routes under `/projects/{code}/deliverables/{delivId}/images...` support happy-path access for same-project token — Verified by: API test +- **AC5**: `GET /projects/{code}/images/{id}` and `GET /projects/{code}/images/{id}/metadata` return 403 when image belongs to a different project — Verified by: API test +- **AC6**: All scoped image mutation routes return 403 when token project does not match resource project — Verified by: API test +- **AC7**: Legacy non-project-scoped image routes are **removed** from the API, and all callers (client, databaseService, zazz-board-api skill) are updated to use the new project-scoped routes — Verified by: API test (legacy routes return 404) + grep/audit for no remaining usage +- **AC8**: The three accepted project-id routes remain unchanged and functional: + - `/projects/{id}` + - `/projects/{id}/kanban/tasks/column/{status}` + - `/projects/{id}/tasks` + — Verified by: API regression test +- **AC9**: `IMAGE_METADATA` supports single-owner association with DB-level constraint that exactly one of `task_id` or `deliverable_id` is set — Verified by: DB constraint validation + API integration tests +- **AC10**: Swagger/OpenAPI reflects all graph/image endpoint removals and additions with accurate params/body/response schemas — Verified by: OpenAPI test + Owner review in `/docs` --- @@ -64,9 +129,10 @@ - [ ] All AC satisfied - [ ] All PactumJS tests passing -- [ ] No regression in existing image route tests -- [ ] zazz-board-api skill updated if paths change -- [ ] Owner sign-off for AC7 (audit list) +- [ ] Route/schema changes for this deliverable are not considered complete without corresponding PactumJS and OpenAPI coverage +- [ ] No regression in existing deliverable-scoped graph route (`/projects/{code}/deliverables/{delivId}/graph`) +- [ ] zazz-board-api skill/docs updated for route removals/additions +- [ ] Owner sign-off for UI graph behavior and route removal choices --- @@ -74,12 +140,40 @@ ### API Tests (PactumJS) -- **Image routes**: For each of GET/POST /tasks/:taskId/images, GET /images/:id, GET /images/:id/metadata, DELETE /tasks/:taskId/images/:imageId: +- **TDD policy for this deliverable**: + - For each added/changed route, write failing PactumJS coverage first or in the same implementation step before completion. + - Do not merge route/schema changes without corresponding PactumJS and OpenAPI test updates. + +- **Graph route removal**: + - `GET /projects/{code}/graph` must return 404 after removal (route deleted, not deprecated) + - `GET /projects/{code}/deliverables/{delivId}/graph` remains 200 for valid project/deliverable pair +- **Task image routes** (new scoped routes): - Happy path: token for project A, task in project A → 200/201 - Cross-project: token for project A, task in project B → 403 - No auth: missing/invalid token → 401 - - Not found: valid taskId/imageId that doesn't exist → 404 -- **Setup**: Create deliverables and tasks in ZAZZ and APIMOD (or MOBDEV); use seeded tokens for each project; attempt cross-project access + - Not found: invalid project/deliverable/task/image ids → 404 +- **Deliverable image routes** (new): + - Happy path: token for project A, deliverable in project A → 200/201 + - Cross-project: token for project A, deliverable in project B → 403 + - No auth: missing/invalid token → 401 + - Not found: invalid deliverable/image ids → 404 +- **Single-owner data model checks**: + - Creating task image writes `task_id` and leaves `deliverable_id` null + - Creating deliverable image writes `deliverable_id` and leaves `task_id` null + - Attempts to persist both owner columns set fail DB constraint + - Attempts to persist neither owner column set fail DB constraint +- **Image-by-id project-scoped fetch**: + - `GET /projects/{code}/images/{id}` and `/metadata` return 200 for same project, 403 for cross-project, 404 for missing image +- **Project-id route regression checks**: + - Verify `/projects/{id}`, `/projects/{id}/kanban/tasks/column/{status}`, `/projects/{id}/tasks` still function as before +- **Legacy route removal checks**: + - `GET /tasks/{taskId}/images`, `POST /tasks/{taskId}/images/upload`, `DELETE /tasks/{taskId}/images/{imageId}`, `GET /images/{id}`, `GET /images/{id}/metadata` all return 404 (routes removed) +- **OpenAPI/Swagger checks**: + - Generated OpenAPI contains all new image paths and omits removed graph and legacy image paths + - Route params and request/response schemas match implementation +- **Setup**: + - Use ZAZZ and MOBDEV projects (seeded); create deliverables/tasks/images in each as needed + - Use tokens scoped to each project to verify cross-project denial paths --- @@ -88,21 +182,24 @@ ### Always Do - Follow testing.md: PactumJS tests for new/updated routes -- Resolve task → deliverable → project to verify project ownership before returning image data +- Resolve resource ownership before responding: + - Task image: image/task → deliverable → project + - Deliverable image: image/deliverable → project +- Ensure UI graph view is deliverable-selected only (no project-wide fallback mode) ### Ask First (Escalate When) -- Whether to change route paths (e.g. `/projects/:code/tasks/:taskId/images`) vs. keep paths and add project verification in handler -- Tags or other non-image routes that might need project scoping +- Any schema changes beyond the documented `IMAGE_METADATA` single-owner model ### Never Do - Return project B's data to a token scoped to project A -- Skip the 403 cross-project tests +- Reintroduce an implicit project-wide graph view without explicit Owner approval ### Prefer When Multiple Options -- Prefer adding project verification in the handler (resolve task → project, check token) over changing route paths, to minimize client/skill churn. If path change is cleaner, do it. +- Prefer explicit project-code-scoped route paths for image operations over global-id routes +- Prefer consistent route composition with existing deliverable/task route conventions --- @@ -110,37 +207,58 @@ ### Components -- Route audit (manual/code review) -- Image route handlers: add project verification -- PactumJS tests for cross-project 403 -- zazz-board-api skill update (if paths change) +- Remove project-wide graph API route and related schema/docs references +- Update Task Graph UI to deliverable-only selection mode +- Implement scoped task and deliverable image routes +- Add/adjust image ownership validation logic +- PactumJS coverage for new scoped routes and removed routes +- Update zazz-board-api skill/docs ### Break Patterns for Planner -- Phase 1: Audit and document routes; implement project verification for image routes -- Phase 2: Add PactumJS tests; update skill if needed +- Phase 1: Graph route/UI cleanup (`/projects/{code}/graph` removal + dropdown behavior) +- Phase 2: Image route expansion and project-scoped authorization for task + deliverable images +- Phase 3: Tests, OpenAPI updates, docs/skill updates --- ## 10. Evaluation -- **Functional**: All image routes return 403 for cross-project access; existing same-project flows unchanged +- **Functional**: + - Project-wide graph endpoint is removed + - Graph UI requires explicit deliverable selection + - Image routes are project-scoped and enforce project authorization - **Quality**: Tests pass; no new lint issues - **Completeness**: DoD checklist satisfied -- **Owner verification**: Audit list (AC7) reviewed +- **Owner verification**: Graph UX matches expectation; route scope decisions accepted --- ## 11. Technical Context -- **Integration**: Auth middleware provides `request.user` and project context from token. Image routes need to resolve task → project and compare with token's project. -- **Modified**: `api/src/routes/images.js`; possibly `api/src/middleware/authMiddleware.js` if shared helper for project verification -- **Dependencies**: None +- **Integration**: + - Task Graph UI currently calls `/projects/{code}/graph` when no deliverable is selected (via `useTaskGraph(projectCode, null)`); this behavior will be removed. `useTaskGraph` must not fetch when `deliverableId` is null—only call `/projects/{code}/deliverables/{delivId}/graph` when a deliverable is selected. + - Existing graph endpoint `/projects/{code}/deliverables/{delivId}/graph` remains the supported path. + - Image handling must remain compatible with DB storage now and object storage backends later. +- **Likely modified**: + - `api/lib/db/schema.js` + - `api/src/services/databaseService.js` + - `api/src/routes/taskGraph.js` + - `api/src/routes/images.js` + - `api/src/schemas/images.js` and OpenAPI-related tests + - `client/src/hooks/useTaskGraph.js` + - `client/src/App.jsx` and `client/src/pages/TaskGraphPage.jsx` + - `.agents/skills/zazz-board-api/SKILL.md` +- **Dependencies**: + - Schema change for `IMAGE_METADATA` only: add `deliverable_id`, make `task_id` nullable, add XOR constraint. Rebuild that table; no full DB reset. Seed data has no images. --- ## 12. Edge Cases & Constraints -- **Task not found**: Return 404 before 403 (don't leak existence of tasks in other projects) -- **Image not found**: Return 404; if we resolve image → task → project for 403, ensure we don't leak image existence across projects -- **Performance**: Resolving task → project adds one DB lookup per request; acceptable for image routes +- **Task/deliverable not found**: Return 404 +- **Cross-project resource**: Return 403 for valid resource in another project when authorization context differs +- **Image ownership checks**: Ensure image lookup path includes ownership validation before returning binary/metadata +- **Ownership model**: Single-owner invariant must be enforced in DB and respected in service layer logic +- **Legacy routes**: Removed entirely; no backward-compatible aliases. All callers must use new project-scoped routes. +- **Performance**: Additional ownership lookups per image request are acceptable for this deliverable diff --git a/.zazz/deliverables/index.yaml b/.zazz/deliverables/index.yaml index 12a5c31d..99f7047f 100644 --- a/.zazz/deliverables/index.yaml +++ b/.zazz/deliverables/index.yaml @@ -9,6 +9,7 @@ deliverables: - id: ZAZZ-5 name: fix-routes-no-project spec: ZAZZ-5-fix-routes-no-project-SPEC.md + plan: ZAZZ-5-fix-routes-no-project-PLAN.md - id: ZAZZ-6 name: multiple-agent-tokens-feature spec: ZAZZ-6-multiple-agent-tokens-feature-SPEC.md diff --git a/AGENTS.md b/AGENTS.md index d293b6f3..3e34e84a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,28 @@ Reference for AI agents and developers. **Legacy**: If you see "Task Blaster" or - **Flow:** Work in feature worktree → push branch to GitHub → merge on GitHub → pull main locally. - **Never merge into main locally.** Main must reflect GitHub after pull. +### New worktree setup (MANDATORY) + +When creating a new feature worktree, always do all of the following: + +1. Create the worktree from `main`: + - `git worktree add -b ../ main` +2. Copy root env file from main: + - `cp ../main/.env ./.env` +3. Copy API env file from main: + - `cp ../main/api/.env ./api/.env` +4. Verify both files match main: + - `cmp -s ../main/.env ./.env` + - `cmp -s ../main/api/.env ./api/.env` + +### Env changes made in a feature worktree (MANDATORY) + +If any branch/worktree adds or changes settings in `.env` or `api/.env`: + +- The agent must explicitly ask the user whether those env changes should also be applied to the `main` worktree. +- Do not assume automatic propagation without user confirmation. +- If the user confirms, copy the updated env files into `main` and verify parity with `cmp -s`. + --- ## Standards @@ -111,7 +133,7 @@ Vitest + PactumJS. See [testing.md](.zazz/standards/testing.md) and [api/**tests ## Troubleshooting -- **drizzle-kit "drizzle-orm"**: From root: `ln -sf ./api/node_modules/drizzle-orm ./node_modules/drizzle-orm` +- **drizzle-kit "drizzle-orm"**: Run `npm install` from repo root and `npm install --workspace=api`. Do not create manual `node_modules` symlinks in worktrees. - **DATABASE_URL_TEST not set**: Source `api/.env` before running tests - **SAFETY CHECK FAILED**: Ensure `zazz_board_test` exists; recreate test DB - **Port in use**: `lsof -ti:3030 | xargs kill -9` (API), `lsof -ti:3001 | xargs kill -9` (client), `lsof -ti:3031 | xargs kill -9` (test) @@ -129,4 +151,3 @@ cd api && DATABASE_URL=postgres://postgres:password@localhost:5433/zazz_board_te cd api && set -a && source .env && set +a && NODE_ENV=test npm run test npm run dev ``` - diff --git a/CONTRIBUTOR_SETUP.md b/CONTRIBUTOR_SETUP.md index 6d80e568..eb64056f 100644 --- a/CONTRIBUTOR_SETUP.md +++ b/CONTRIBUTOR_SETUP.md @@ -134,6 +134,7 @@ This repo uses **worktrees** for feature work. See [AGENTS.md](./AGENTS.md) and - Dev Postgres runs on port **5433** (prod uses 5432) so you can run both dev and production on the same machine without port conflicts. - Default DB password is `password`. +- In worktrees, avoid manual `node_modules` symlinks. If `drizzle-kit` complains about `drizzle-orm`, re-run `npm install` at repo root plus `npm install --workspace=api`. - If client dependencies fail due to peer resolution, re-run with `--legacy-peer-deps`. - Port in use: `lsof -ti:3030 | xargs kill -9` (API), `lsof -ti:3001 | xargs kill -9` (client). - Manual API token (seed): `550e8400-e29b-41d4-a716-446655440000` diff --git a/api/__tests__/helpers/testDatabase.js b/api/__tests__/helpers/testDatabase.js index db8327b3..b43b8d22 100644 --- a/api/__tests__/helpers/testDatabase.js +++ b/api/__tests__/helpers/testDatabase.js @@ -1,5 +1,5 @@ import { db } from '../../lib/db/index.js'; -import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, TASK_RELATIONS } from '../../lib/db/schema.js'; +import { USERS, PROJECTS, DELIVERABLES, TASKS, TAGS, TASK_TAGS, TASK_RELATIONS, IMAGE_METADATA, IMAGE_DATA } from '../../lib/db/schema.js'; import { eq, and, sql } from 'drizzle-orm'; /** @@ -58,7 +58,10 @@ export async function validateTestEnvironment() { */ export async function clearTaskData() { await validateTestEnvironment(); - + + // Explicitly clear image tables so image route tests remain isolated. + await db.delete(IMAGE_DATA); + await db.delete(IMAGE_METADATA); await db.delete(TASK_RELATIONS); await db.delete(TASK_TAGS); await db.delete(TASKS); @@ -94,13 +97,15 @@ export async function createTestDeliverable(projectId, overrides = {}) { const [deliverable] = await db.insert(DELIVERABLES).values({ project_id: projectId, - deliverable_id: overrides.deliverableId || `${project.code}-T${sequence}`, + project_code: overrides.projectCode || project.code, + deliverable_code: overrides.deliverableCode || `${project.code}-T${sequence}`, name: overrides.name || `Test Deliverable ${sequence}`, description: overrides.description || null, type: overrides.type || 'FEATURE', status: overrides.status || 'PLANNING', status_history: overrides.statusHistory || [{ status: overrides.status || 'PLANNING', changedAt: new Date().toISOString(), changedBy: 1 }], - plan_file_path: overrides.planFilePath || null, + spec_filepath: overrides.specFilepath || null, + plan_filepath: overrides.planFilepath || null, approved_by: overrides.approvedBy || null, approved_at: overrides.approvedAt || null, position: overrides.position ?? sequence * 10, @@ -127,7 +132,7 @@ export async function createTestTask(projectId, overrides = {}) { story_points: overrides.storyPoints || null, git_worktree: overrides.gitWorktree || null, phase: overrides.phase || null, - phase_task_id: overrides.phaseTaskId || null, + phase_step: overrides.phaseStep || null, notes: overrides.notes || null, is_cancelled: overrides.isCancelled || false, created_by: overrides.createdBy || 1, diff --git a/api/__tests__/helpers/testServerWithSwagger.js b/api/__tests__/helpers/testServerWithSwagger.js index d410a51e..944d2fda 100644 --- a/api/__tests__/helpers/testServerWithSwagger.js +++ b/api/__tests__/helpers/testServerWithSwagger.js @@ -24,7 +24,14 @@ export async function createTestServerWithSwagger() { openapi: '3.1.0', info: { title: 'Zazz Board API', - description: 'Test', + description: `Kanban-style orchestration API for coordinating AI agents and humans. + +**Common operations (agent quick reference)**: +- Create deliverable +- Create task +- Update deliverable +- Change deliverable status +- Change task status`, version: '1.0.0' }, servers: [{ url: BASE_URL, description: 'Local' }], @@ -42,5 +49,7 @@ export async function createTestServerWithSwagger() { await tokenService.initialize(); + await app.ready(); + return app; } diff --git a/api/__tests__/routes/agent-workflow.test.mjs b/api/__tests__/routes/agent-workflow.test.mjs index f832379d..15df7ea1 100644 --- a/api/__tests__/routes/agent-workflow.test.mjs +++ b/api/__tests__/routes/agent-workflow.test.mjs @@ -60,9 +60,9 @@ const h = { .returns('res.body'), }; -/** Sort tasks by phaseTaskId for deterministic comparison */ +/** Sort tasks by phaseStep for deterministic comparison */ function byPhaseTaskId(tasks) { - return [...tasks].sort((a, b) => (a.phaseTaskId ?? '').localeCompare(b.phaseTaskId ?? '')); + return [...tasks].sort((a, b) => (a.phaseStep ?? '').localeCompare(b.phaseStep ?? '')); } // ── Test suite ───────────────────────────────────────────────────────────── @@ -88,7 +88,7 @@ describe('Agent Workflow Simulation', () => { }); expect(task1.status).toBe('READY'); expect(task1.phase).toBe(1); - expect(task1.phaseTaskId).toBe('1.1'); + expect(task1.phaseStep).toBe('1.1'); // ── WORKER AGENT: picks up 1.1, starts it ────────────────────────── const working = await h.setStatus(dId, task1.id, 'IN_PROGRESS'); @@ -114,7 +114,7 @@ describe('Agent Workflow Simulation', () => { prompt: 'Build JWT auth middleware using scaffold from 1.1' }); expect(task2.status).toBe('READY'); // born READY — dep already complete - expect(task2.phaseTaskId).toBe('2.1'); + expect(task2.phaseStep).toBe('2.1'); // ── WORKER AGENT: executes 2.1 ────────────────────────────────────── await h.setStatus(dId, task2.id, 'IN_PROGRESS'); @@ -129,8 +129,8 @@ describe('Agent Workflow Simulation', () => { expect(graph.tasks).toHaveLength(2); expect(graph.relations).toHaveLength(1); - const g1 = graph.tasks.find(t => t.phaseTaskId === '1.1'); - const g2 = graph.tasks.find(t => t.phaseTaskId === '2.1'); + const g1 = graph.tasks.find(t => t.phaseStep === '1.1'); + const g2 = graph.tasks.find(t => t.phaseStep === '2.1'); expect(g1.status).toBe('COMPLETED'); expect(g2.status).toBe('COMPLETED'); @@ -143,8 +143,8 @@ describe('Agent Workflow Simulation', () => { const allTasks = byPhaseTaskId(await h.getTasks(dId)); expect(allTasks).toHaveLength(2); expect(allTasks).toEqual([ - expect.objectContaining({ phaseTaskId: '1.1', phase: 1, status: 'COMPLETED', title: 'Set up project scaffold' }), - expect.objectContaining({ phaseTaskId: '2.1', phase: 2, status: 'COMPLETED', title: 'Implement auth middleware' }), + expect.objectContaining({ phaseStep: '1.1', phase: 1, status: 'COMPLETED', title: 'Set up project scaffold' }), + expect.objectContaining({ phaseStep: '2.1', phase: 2, status: 'COMPLETED', title: 'Implement auth middleware' }), ]); }); }); @@ -169,9 +169,9 @@ describe('Agent Workflow Simulation', () => { }); expect(task1a.status).toBe('READY'); - expect(task1a.phaseTaskId).toBe('1.1'); + expect(task1a.phaseStep).toBe('1.1'); expect(task1b.status).toBe('READY'); - expect(task1b.phaseTaskId).toBe('1.2'); // sequential IDs within same phase + expect(task1b.phaseStep).toBe('1.2'); // sequential IDs within same phase // ── WORKER AGENTS: execute 1.1 and 1.2 in parallel (simulated serially) ─ await h.setStatus(dId, task1a.id, 'IN_PROGRESS'); @@ -190,7 +190,7 @@ describe('Agent Workflow Simulation', () => { prompt: 'Wire ingestion and validation modules together in the pipeline runner' }); expect(task2.status).toBe('READY'); - expect(task2.phaseTaskId).toBe('2.1'); + expect(task2.phaseStep).toBe('2.1'); // ── WORKER AGENT: executes integration task ────────────────────────── await h.setStatus(dId, task2.id, 'IN_PROGRESS'); @@ -215,9 +215,9 @@ describe('Agent Workflow Simulation', () => { const allTasks = byPhaseTaskId(await h.getTasks(dId)); expect(allTasks).toHaveLength(3); expect(allTasks).toEqual([ - expect.objectContaining({ phaseTaskId: '1.1', phase: 1, status: 'COMPLETED', title: 'Build data ingestion module' }), - expect.objectContaining({ phaseTaskId: '1.2', phase: 1, status: 'COMPLETED', title: 'Build data validation module' }), - expect.objectContaining({ phaseTaskId: '2.1', phase: 2, status: 'COMPLETED', title: 'Integrate ingestion + validation into pipeline runner' }), + expect.objectContaining({ phaseStep: '1.1', phase: 1, status: 'COMPLETED', title: 'Build data ingestion module' }), + expect.objectContaining({ phaseStep: '1.2', phase: 1, status: 'COMPLETED', title: 'Build data validation module' }), + expect.objectContaining({ phaseStep: '2.1', phase: 2, status: 'COMPLETED', title: 'Integrate ingestion + validation into pipeline runner' }), ]); }); }); @@ -344,13 +344,13 @@ describe('Agent Workflow Simulation', () => { dependencies: [task1.id] }); expect(task2.status).toBe('READY'); - expect(task2.phaseTaskId).toBe('2.1'); + expect(task2.phaseStep).toBe('2.1'); // Final state const allTasks = byPhaseTaskId(await h.getTasks(dId)); expect(allTasks).toEqual([ - expect.objectContaining({ phaseTaskId: '1.1', status: 'COMPLETED', isCancelled: true }), - expect.objectContaining({ phaseTaskId: '2.1', status: 'READY', isCancelled: false }), + expect.objectContaining({ phaseStep: '1.1', status: 'COMPLETED', isCancelled: true }), + expect.objectContaining({ phaseStep: '2.1', status: 'READY', isCancelled: false }), ]); }); }); @@ -370,7 +370,7 @@ describe('Agent Workflow Simulation', () => { prompt: 'Define all tables and FK constraints' }); expect(t1.status).toBe('READY'); - expect(t1.phaseTaskId).toBe('1.1'); + expect(t1.phaseStep).toBe('1.1'); // schema-agent works it await h.setStatus(dId, t1.id, 'IN_PROGRESS'); @@ -389,7 +389,7 @@ describe('Agent Workflow Simulation', () => { prompt: 'Build CRUD endpoints for deliverables' }); expect(t2.status).toBe('READY'); // t1 is COMPLETED so dep is satisfied - expect(t2.phaseTaskId).toBe('2.1'); + expect(t2.phaseStep).toBe('2.1'); await h.setStatus(dId, t2.id, 'IN_PROGRESS'); await h.appendNote(dId, t2.id, 'All 8 endpoints implemented', 'api-agent'); @@ -413,9 +413,9 @@ describe('Agent Workflow Simulation', () => { prompt: 'React components for deliverable list and graph filter' }); expect(t3.status).toBe('READY'); - expect(t3.phaseTaskId).toBe('3.1'); + expect(t3.phaseStep).toBe('3.1'); expect(t4.status).toBe('READY'); - expect(t4.phaseTaskId).toBe('3.2'); + expect(t4.phaseStep).toBe('3.2'); // test-agent and ui-agent work in parallel (simulated serially) await h.setStatus(dId, t3.id, 'IN_PROGRESS'); @@ -438,7 +438,7 @@ describe('Agent Workflow Simulation', () => { prompt: 'End-to-end tests across API and UI' }); expect(t5.status).toBe('READY'); // both t3 and t4 COMPLETED - expect(t5.phaseTaskId).toBe('4.1'); + expect(t5.phaseStep).toBe('4.1'); await h.setStatus(dId, t5.id, 'IN_PROGRESS'); await h.appendNote(dId, t5.id, 'All integration tests passing — 12/12', 'qa-agent'); @@ -471,11 +471,11 @@ describe('Agent Workflow Simulation', () => { const allTasks = byPhaseTaskId(await h.getTasks(dId)); expect(allTasks).toHaveLength(5); expect(allTasks).toEqual([ - expect.objectContaining({ phaseTaskId: '1.1', phase: 1, status: 'COMPLETED', title: 'Design database schema' }), - expect.objectContaining({ phaseTaskId: '2.1', phase: 2, status: 'COMPLETED', title: 'Implement API endpoints' }), - expect.objectContaining({ phaseTaskId: '3.1', phase: 3, status: 'COMPLETED', title: 'Write unit tests' }), - expect.objectContaining({ phaseTaskId: '3.2', phase: 3, status: 'COMPLETED', title: 'Build UI components' }), - expect.objectContaining({ phaseTaskId: '4.1', phase: 4, status: 'COMPLETED', title: 'Integration testing' }), + expect.objectContaining({ phaseStep: '1.1', phase: 1, status: 'COMPLETED', title: 'Design database schema' }), + expect.objectContaining({ phaseStep: '2.1', phase: 2, status: 'COMPLETED', title: 'Implement API endpoints' }), + expect.objectContaining({ phaseStep: '3.1', phase: 3, status: 'COMPLETED', title: 'Write unit tests' }), + expect.objectContaining({ phaseStep: '3.2', phase: 3, status: 'COMPLETED', title: 'Build UI components' }), + expect.objectContaining({ phaseStep: '4.1', phase: 4, status: 'COMPLETED', title: 'Integration testing' }), ]); }); }); diff --git a/api/__tests__/routes/deliverables-approval.test.mjs b/api/__tests__/routes/deliverables-approval.test.mjs index 4601d98d..99cfb8be 100644 --- a/api/__tests__/routes/deliverables-approval.test.mjs +++ b/api/__tests__/routes/deliverables-approval.test.mjs @@ -10,10 +10,10 @@ describe('Deliverables Plan Approval', () => { await resetProjectDefaults(); }); - it('should require plan_file_path to be set before approval', async () => { + it('should require plan_filepath to be set before approval', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: null + planFilepath: null }); await spec() @@ -22,10 +22,10 @@ describe('Deliverables Plan Approval', () => { .expectStatus(400); }); - it('should approve plan when plan_file_path is set', async () => { + it('should approve plan when plan_filepath is set', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md', + planFilepath: 'docs/test-plan.md', approvedAt: null, approvedBy: null }); @@ -43,7 +43,7 @@ describe('Deliverables Plan Approval', () => { it('should set approvedBy to current user on approval', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md' + planFilepath: 'docs/test-plan.md' }); const response = await spec() @@ -59,7 +59,7 @@ describe('Deliverables Plan Approval', () => { it('should prevent approval if already approved', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md', + planFilepath: 'docs/test-plan.md', approvedAt: new Date(), approvedBy: 1 }); @@ -73,7 +73,7 @@ describe('Deliverables Plan Approval', () => { it('should only allow approval in PLANNING status', async () => { const created = await createTestDeliverable(1, { status: 'IN_PROGRESS', - planFilePath: 'docs/test-plan.md' + planFilepath: 'docs/test-plan.md' }); await spec() @@ -85,7 +85,7 @@ describe('Deliverables Plan Approval', () => { it('should require authentication for approval', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md' + planFilepath: 'docs/test-plan.md' }); await spec() @@ -104,7 +104,7 @@ describe('Deliverables Plan Approval', () => { // Create without approval const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md', + planFilepath: 'docs/test-plan.md', approvedAt: null, approvedBy: null }); @@ -134,11 +134,11 @@ describe('Deliverables Plan Approval', () => { it('should approve multiple deliverables independently', async () => { const d1 = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/plan1.md' + planFilepath: 'docs/plan1.md' }); const d2 = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/plan2.md' + planFilepath: 'docs/plan2.md' }); // Approve only d1 @@ -167,7 +167,7 @@ describe('Deliverables Plan Approval', () => { it('should record approval timestamp', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md' + planFilepath: 'docs/test-plan.md' }); const beforeTime = new Date(); diff --git a/api/__tests__/routes/deliverables-status.test.mjs b/api/__tests__/routes/deliverables-status.test.mjs index 6769d5d7..7fba0835 100644 --- a/api/__tests__/routes/deliverables-status.test.mjs +++ b/api/__tests__/routes/deliverables-status.test.mjs @@ -29,7 +29,7 @@ describe('Deliverables Status Transitions', () => { it('should transition from PLANNING to IN_PROGRESS after approval', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md' + planFilepath: 'docs/test-plan.md' }); // First approve the plan @@ -53,7 +53,7 @@ describe('Deliverables Status Transitions', () => { it('should block transition to IN_PROGRESS without plan approval', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: null + planFilepath: null }); await spec() @@ -126,7 +126,7 @@ describe('Deliverables Status Transitions', () => { it('should track status history on each transition', async () => { const created = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test-plan.md' + planFilepath: 'docs/test-plan.md' }); // Approve @@ -171,39 +171,4 @@ describe('Deliverables Status Transitions', () => { .expectStatus(404); }); - it('should support UAT and PROD statuses in APIMOD project', async () => { - // APIMOD project has extended deliverable_status_workflow with UAT and PROD - const created = await createTestDeliverable(3, { - status: 'PLANNING', - planFilePath: 'docs/test-plan.md' - }); - - // Approve - await spec() - .patch(`/projects/APIMOD/deliverables/${created.id}/approve`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .expectStatus(200); - - // Progress through states to UAT - await spec() - .patch(`/projects/APIMOD/deliverables/${created.id}/status`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .withJson({ status: 'IN_PROGRESS' }) - .expectStatus(200); - - await spec() - .patch(`/projects/APIMOD/deliverables/${created.id}/status`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .withJson({ status: 'IN_REVIEW' }) - .expectStatus(200); - - const response = await spec() - .patch(`/projects/APIMOD/deliverables/${created.id}/status`) - .withHeaders('TB_TOKEN', VALID_TOKEN) - .withJson({ status: 'UAT' }) - .expectStatus(200) - .returns('res.body'); - - expect(response.status).toBe('UAT'); - }); }); diff --git a/api/__tests__/routes/deliverables.test.mjs b/api/__tests__/routes/deliverables.test.mjs index 28df52ed..c087dbf6 100644 --- a/api/__tests__/routes/deliverables.test.mjs +++ b/api/__tests__/routes/deliverables.test.mjs @@ -19,7 +19,7 @@ describe('Deliverables API', () => { it('should list project deliverables', async () => { await createTestDeliverable(1, { name: 'D1', status: 'PLANNING' }); await createTestDeliverable(1, { name: 'D2', status: 'IN_PROGRESS' }); - await createTestDeliverable(3, { name: 'Other Project Deliverable' }); + await createTestDeliverable(2, { name: 'Other Project Deliverable' }); const response = await spec() .get('/projects/ZAZZ/deliverables') @@ -39,7 +39,7 @@ describe('Deliverables API', () => { .withJson({ name: 'New Deliverable', type: 'FEATURE', - planFilePath: 'docs/new-deliverable-plan.md' + planFilepath: 'docs/new-deliverable-plan.md' }) .expectStatus(201) .returns('res.body'); @@ -48,7 +48,7 @@ describe('Deliverables API', () => { expect(response.name).toBe('New Deliverable'); expect(response.type).toBe('FEATURE'); expect(response.status).toBe('PLANNING'); - expect(response.deliverableId).toMatch(/^ZAZZ-\d+$/); + expect(response.deliverableCode).toMatch(/^ZAZZ-\d+$/); }); it('should update and fetch a deliverable by id', async () => { @@ -71,7 +71,7 @@ describe('Deliverables API', () => { it('should not move deliverable to IN_PROGRESS before approval and plan', async () => { const created = await createTestDeliverable(1, { name: 'Needs Approval', - planFilePath: null, + planFilepath: null, approvedAt: null, approvedBy: null }); @@ -85,7 +85,7 @@ describe('Deliverables API', () => { it('should approve then move deliverable to IN_PROGRESS', async () => { const created = await createTestDeliverable(1, { - planFilePath: 'docs/approved-plan.md', + planFilepath: 'docs/approved-plan.md', status: 'PLANNING' }); diff --git a/api/__tests__/routes/image-scoping.test.mjs b/api/__tests__/routes/image-scoping.test.mjs new file mode 100644 index 00000000..04c06f59 --- /dev/null +++ b/api/__tests__/routes/image-scoping.test.mjs @@ -0,0 +1,204 @@ +import * as pactum from 'pactum'; +import { db } from '../../lib/db/index.js'; +import { IMAGE_METADATA } from '../../lib/db/schema.js'; +import { clearTaskData, createTestDeliverable, createTestTask, resetProjectDefaults } from '../helpers/testDatabase.js'; + +const { spec } = pactum; +const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; + +const SAMPLE_IMAGE = { + originalName: 'tiny.png', + contentType: 'image/png', + fileSize: 68, + base64Data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/w8AAgMBgU6m4QAAAABJRU5ErkJggg==' +}; + +async function uploadTaskImage(projectCode, deliverableId, taskId) { + const response = await spec() + .post(`/projects/${projectCode}/deliverables/${deliverableId}/tasks/${taskId}/images/upload`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ images: [SAMPLE_IMAGE] }) + .expectStatus(201) + .returns('res.body'); + + return response.images[0]; +} + +async function uploadDeliverableImage(projectCode, deliverableId) { + const response = await spec() + .post(`/projects/${projectCode}/deliverables/${deliverableId}/images/upload`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ images: [SAMPLE_IMAGE] }) + .expectStatus(201) + .returns('res.body'); + + return response.images[0]; +} + +describe('Project-scoped image routes', () => { + beforeEach(async () => { + await clearTaskData(); + await resetProjectDefaults(); + }); + + it('should support task image upload/list/get/delete in same project scope', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Task image scope' }); + const task = await createTestTask(1, { deliverableId: deliverable.id, title: 'Task image task' }); + + const uploaded = await uploadTaskImage('ZAZZ', deliverable.id, task.id); + expect(uploaded.taskId).toBe(task.id); + expect(uploaded.deliverableId).toBeNull(); + + const images = await spec() + .get(`/projects/ZAZZ/deliverables/${deliverable.id}/tasks/${task.id}/images`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(images.length).toBe(1); + expect(images[0].id).toBe(uploaded.id); + + await spec() + .get(`/projects/ZAZZ/images/${uploaded.id}/metadata`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .expectJsonLike({ id: uploaded.id, taskId: task.id }); + + await spec() + .get(`/projects/ZAZZ/images/${uploaded.id}`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .expectHeaderContains('content-type', 'image/png'); + + await spec() + .delete(`/projects/ZAZZ/deliverables/${deliverable.id}/tasks/${task.id}/images/${uploaded.id}`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200); + }); + + it('should support deliverable image upload/list/get/delete in same project scope', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Deliverable image scope' }); + const uploaded = await uploadDeliverableImage('ZAZZ', deliverable.id); + expect(uploaded.taskId).toBeNull(); + expect(uploaded.deliverableId).toBe(deliverable.id); + + const images = await spec() + .get(`/projects/ZAZZ/deliverables/${deliverable.id}/images`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(images.length).toBe(1); + expect(images[0].id).toBe(uploaded.id); + + await spec() + .get(`/projects/ZAZZ/images/${uploaded.id}/metadata`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .expectJsonLike({ id: uploaded.id, deliverableId: deliverable.id }); + + await spec() + .delete(`/projects/ZAZZ/deliverables/${deliverable.id}/images/${uploaded.id}`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200); + }); + + it('should return 403 for cross-project image fetch by id', async () => { + const deliverable = await createTestDeliverable(2, { name: 'Other project deliverable' }); + const task = await createTestTask(2, { deliverableId: deliverable.id, title: 'Other project task' }); + const uploaded = await uploadTaskImage('ZED_MER', deliverable.id, task.id); + + await spec() + .get(`/projects/ZAZZ/images/${uploaded.id}`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(403); + + await spec() + .get(`/projects/ZAZZ/images/${uploaded.id}/metadata`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(403); + }); + + it('should return 403 for cross-project image mutation routes', async () => { + const deliverable = await createTestDeliverable(2, { name: 'Cross project mutate' }); + const task = await createTestTask(2, { deliverableId: deliverable.id, title: 'Cross project mutation task' }); + const taskImage = await uploadTaskImage('ZED_MER', deliverable.id, task.id); + const deliverableImage = await uploadDeliverableImage('ZED_MER', deliverable.id); + + await spec() + .delete(`/projects/ZAZZ/deliverables/${deliverable.id}/tasks/${task.id}/images/${taskImage.id}`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(403); + + await spec() + .delete(`/projects/ZAZZ/deliverables/${deliverable.id}/images/${deliverableImage.id}`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(403); + }); + + it('should require authentication for scoped image routes', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Auth image deliverable' }); + const task = await createTestTask(1, { deliverableId: deliverable.id, title: 'Auth image task' }); + + await spec() + .get(`/projects/ZAZZ/deliverables/${deliverable.id}/tasks/${task.id}/images`) + .expectStatus(401); + }); + + it('should return 404 for removed legacy image routes', async () => { + await spec() + .get('/tasks/1/images') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(404); + + await spec() + .post('/tasks/1/images/upload') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ images: [SAMPLE_IMAGE] }) + .expectStatus(404); + + await spec() + .delete('/tasks/1/images/1') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(404); + + await spec() + .get('/images/1') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(404); + + await spec() + .get('/images/1/metadata') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(404); + }); + + it('should enforce single-owner DB constraint in IMAGE_METADATA', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Constraint deliverable' }); + const task = await createTestTask(1, { deliverableId: deliverable.id, title: 'Constraint task' }); + + await expect( + db.insert(IMAGE_METADATA).values({ + task_id: task.id, + deliverable_id: deliverable.id, + original_name: 'bad-both.png', + content_type: 'image/png', + file_size: 1, + url: '/projects/ZAZZ/images/999', + storage_type: 'local' + }) + ).rejects.toThrow(); + + await expect( + db.insert(IMAGE_METADATA).values({ + task_id: null, + deliverable_id: null, + original_name: 'bad-neither.png', + content_type: 'image/png', + file_size: 1, + url: '/projects/ZAZZ/images/998', + storage_type: 'local' + }) + ).rejects.toThrow(); + }); +}); diff --git a/api/__tests__/routes/openapi.test.mjs b/api/__tests__/routes/openapi.test.mjs index ddc58de4..c10e0ca8 100644 --- a/api/__tests__/routes/openapi.test.mjs +++ b/api/__tests__/routes/openapi.test.mjs @@ -7,7 +7,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import OpenAPISchemaValidatorPkg from 'openapi-schema-validator'; import { createTestServerWithSwagger } from '../helpers/testServerWithSwagger.js'; -const OpenAPISchemaValidator = OpenAPISchemaValidatorPkg.default; +const OpenAPISchemaValidator = OpenAPISchemaValidatorPkg.default || OpenAPISchemaValidatorPkg; let app; @@ -44,10 +44,10 @@ describe('OpenAPI / Swagger documentation', () => { expect(path.post).toBeDefined(); expect(path.post.summary).toBeDefined(); expect(path.post.description).toContain('id'); - expect(path.post.description).toContain('deliverableId'); + expect(path.post.description).toContain('deliverableCode'); expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.name).toBeDefined(); - expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.dedFilePath).toBeDefined(); - expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.planFilePath).toBeDefined(); + expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.specFilepath).toBeDefined(); + expect(path.post.requestBody?.content?.['application/json']?.schema?.properties?.planFilepath).toBeDefined(); }); it('should document core agent operations: create task', async () => { @@ -66,8 +66,8 @@ describe('OpenAPI / Swagger documentation', () => { expect(path).toBeDefined(); expect(path.put).toBeDefined(); const bodySchema = path.put.requestBody?.content?.['application/json']?.schema; - expect(bodySchema?.properties?.dedFilePath).toBeDefined(); - expect(bodySchema?.properties?.planFilePath).toBeDefined(); + expect(bodySchema?.properties?.specFilepath).toBeDefined(); + expect(bodySchema?.properties?.planFilepath).toBeDefined(); expect(bodySchema?.properties?.gitWorktree).toBeDefined(); }); @@ -97,6 +97,14 @@ describe('OpenAPI / Swagger documentation', () => { '/projects/{code}/deliverables/{delivId}/tasks', '/projects/{code}/deliverables/{delivId}/tasks/{taskId}', '/projects/{code}/deliverables/{delivId}/graph', + '/projects/{code}/deliverables/{delivId}/tasks/{taskId}/images', + '/projects/{code}/deliverables/{delivId}/tasks/{taskId}/images/upload', + '/projects/{code}/deliverables/{delivId}/tasks/{taskId}/images/{imageId}', + '/projects/{code}/deliverables/{delivId}/images', + '/projects/{code}/deliverables/{delivId}/images/upload', + '/projects/{code}/deliverables/{delivId}/images/{imageId}', + '/projects/{code}/images/{id}', + '/projects/{code}/images/{id}/metadata', '/projects/{code}/tasks/{taskId}/relations', '/projects/{code}/tasks/{taskId}/readiness', '/health' @@ -121,4 +129,15 @@ describe('OpenAPI / Swagger documentation', () => { expect(desc).toContain('Change deliverable status'); expect(desc).toContain('Change task status'); }); + + it('should not document removed project-wide graph and legacy image routes', async () => { + const spec = await app.swagger(); + + expect(spec.paths['/projects/{code}/graph']).toBeUndefined(); + expect(spec.paths['/tasks/{taskId}/images']).toBeUndefined(); + expect(spec.paths['/tasks/{taskId}/images/upload']).toBeUndefined(); + expect(spec.paths['/tasks/{taskId}/images/{imageId}']).toBeUndefined(); + expect(spec.paths['/images/{id}']).toBeUndefined(); + expect(spec.paths['/images/{id}/metadata']).toBeUndefined(); + }); }); diff --git a/api/__tests__/routes/project-deliverable-statuses.test.mjs b/api/__tests__/routes/project-deliverable-statuses.test.mjs index b80f346d..d0b682b7 100644 --- a/api/__tests__/routes/project-deliverable-statuses.test.mjs +++ b/api/__tests__/routes/project-deliverable-statuses.test.mjs @@ -1,5 +1,5 @@ import * as pactum from 'pactum'; -import { clearTaskData, createTestDeliverable } from '../helpers/testDatabase.js'; +import { clearTaskData, createTestDeliverable, resetProjectDefaults } from '../helpers/testDatabase.js'; const { spec } = pactum; const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; @@ -7,6 +7,7 @@ const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; describe('Project Deliverable Status Workflow Configuration', () => { beforeEach(async () => { await clearTaskData(); + await resetProjectDefaults(); }); it('should get default deliverable status workflow for ZAZZ project', async () => { @@ -19,15 +20,6 @@ describe('Project Deliverable Status Workflow Configuration', () => { expect(response.deliverableStatusWorkflow).toEqual(['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE']); }); - it('should get extended deliverable status workflow for APIMOD project', async () => { - const response = await spec() - .get('/projects/APIMOD/deliverable-statuses') - .withHeaders('TB_TOKEN', VALID_TOKEN) - .expectStatus(200) - .returns('res.body'); - - expect(response.deliverableStatusWorkflow).toEqual(['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'UAT', 'STAGED', 'PROD']); - }); it('should require authentication to get deliverable status workflow', async () => { await spec() @@ -143,27 +135,23 @@ describe('Project Deliverable Status Workflow Configuration', () => { .expectStatus(400); }); - it('should work independently for different projects', async () => { + it('should return workflow for multiple seeded projects', async () => { // Project ZAZZ const zazz = await spec() .get('/projects/ZAZZ/deliverable-statuses') .withHeaders('TB_TOKEN', VALID_TOKEN) .expectStatus(200) .returns('res.body'); - - // Project APIMOD - const apimod = await spec() - .get('/projects/APIMOD/deliverable-statuses') + // Project ZED_MER + const zedMer = await spec() + .get('/projects/ZED_MER/deliverable-statuses') .withHeaders('TB_TOKEN', VALID_TOKEN) .expectStatus(200) .returns('res.body'); - - // Verify they're different - expect(zazz.deliverableStatusWorkflow.length).not.toBe(apimod.deliverableStatusWorkflow.length); - expect(apimod.deliverableStatusWorkflow).toContain('UAT'); - expect(apimod.deliverableStatusWorkflow).toContain('PROD'); - expect(zazz.deliverableStatusWorkflow).not.toContain('UAT'); - expect(zazz.deliverableStatusWorkflow).not.toContain('PROD'); + expect(Array.isArray(zazz.deliverableStatusWorkflow)).toBe(true); + expect(Array.isArray(zedMer.deliverableStatusWorkflow)).toBe(true); + expect(zedMer.deliverableStatusWorkflow).toEqual(['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE']); + expect(zazz.deliverableStatusWorkflow).toEqual(['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE']); }); it('should validate status is in workflow when transitioning deliverable', async () => { @@ -178,7 +166,7 @@ describe('Project Deliverable Status Workflow Configuration', () => { // Try to transition deliverable to a status not in the workflow const deliverable = await createTestDeliverable(1, { status: 'PLANNING', - planFilePath: 'docs/test.md' + planFilepath: 'docs/test.md' }); await spec() @@ -213,7 +201,7 @@ describe('Project Deliverable Status Workflow Configuration', () => { expect(zazz.deliverableStatusWorkflow[0]).toBe('PLANNING'); }); - it('should allow different projects to have different workflow lengths', async () => { + it('should allow different seeded projects to have valid workflow arrays', async () => { const project1 = await spec() .get('/projects/ZAZZ/deliverable-statuses') .withHeaders('TB_TOKEN', VALID_TOKEN) @@ -221,20 +209,11 @@ describe('Project Deliverable Status Workflow Configuration', () => { .returns('res.body'); const project2 = await spec() - .get('/projects/MOBDEV/deliverable-statuses') - .withHeaders('TB_TOKEN', VALID_TOKEN) - .expectStatus(200) - .returns('res.body'); - - const project3 = await spec() - .get('/projects/APIMOD/deliverable-statuses') + .get('/projects/ZED_MER/deliverable-statuses') .withHeaders('TB_TOKEN', VALID_TOKEN) .expectStatus(200) .returns('res.body'); - - // All should be valid arrays expect(Array.isArray(project1.deliverableStatusWorkflow)).toBe(true); expect(Array.isArray(project2.deliverableStatusWorkflow)).toBe(true); - expect(Array.isArray(project3.deliverableStatusWorkflow)).toBe(true); }); }); diff --git a/api/__tests__/routes/project-id-routes-regression.test.mjs b/api/__tests__/routes/project-id-routes-regression.test.mjs new file mode 100644 index 00000000..6aee0808 --- /dev/null +++ b/api/__tests__/routes/project-id-routes-regression.test.mjs @@ -0,0 +1,61 @@ +import * as pactum from 'pactum'; +import { clearTaskData, createTestDeliverable, createTestTask, resetProjectDefaults } from '../helpers/testDatabase.js'; + +const { spec } = pactum; +const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; + +describe('Project-id route regressions', () => { + beforeEach(async () => { + await clearTaskData(); + await resetProjectDefaults(); + }); + + it('should keep GET /projects/:id functional', async () => { + const project = await spec() + .get('/projects/1') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(project.id).toBe(1); + expect(project.code).toBe('ZAZZ'); + }); + + it('should keep GET /projects/:id/tasks functional', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Project task list regression' }); + await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Project id route task', + status: 'READY' + }); + + const tasks = await spec() + .get('/projects/1/tasks') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(Array.isArray(tasks)).toBe(true); + expect(tasks.length).toBe(1); + expect(tasks[0].projectId).toBe(1); + }); + + it('should keep GET /projects/:id/kanban/tasks/column/:status functional', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Column position regression' }); + const task = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Column task', + status: 'READY', + position: 30 + }); + + const columnTasks = await spec() + .get('/projects/1/kanban/tasks/column/READY') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(Array.isArray(columnTasks)).toBe(true); + expect(columnTasks.some((row) => row.id === task.id)).toBe(true); + }); +}); diff --git a/api/__tests__/routes/realtime-events.test.mjs b/api/__tests__/routes/realtime-events.test.mjs new file mode 100644 index 00000000..2e157f83 --- /dev/null +++ b/api/__tests__/routes/realtime-events.test.mjs @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { spec } from 'pactum'; +import { clearTaskData, createTestDeliverable, createTestTask, resetProjectDefaults } from '../helpers/testDatabase.js'; +import { getTestToken } from '../helpers/testServer.js'; + +const VALID_TOKEN = getTestToken(); +const BASE_URL = 'http://127.0.0.1:3031'; + +function parseSseBlock(block) { + const lines = block.split('\n'); + let eventName = 'message'; + const dataLines = []; + + for (const line of lines) { + if (!line || line.startsWith(':')) continue; + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + continue; + } + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (dataLines.length === 0) return null; + return { eventName, data: JSON.parse(dataLines.join('\n')) }; +} + +async function waitForProjectEvent({ projectCode, predicate, trigger, timeoutMs = 6000 }) { + const controller = new AbortController(); + const response = await fetch(`${BASE_URL}/projects/${projectCode}/events`, { + method: 'GET', + headers: { + 'TB_TOKEN': VALID_TOKEN, + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + signal: controller.signal, + }); + + if (!response.ok || !response.body) { + throw new Error(`Failed to open SSE stream: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let triggerInvoked = false; + + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) throw new Error('SSE stream ended before event was received'); + + buffer += decoder.decode(value, { stream: true }); + let boundaryIndex = buffer.indexOf('\n\n'); + + while (boundaryIndex !== -1) { + const block = buffer.slice(0, boundaryIndex); + buffer = buffer.slice(boundaryIndex + 2); + boundaryIndex = buffer.indexOf('\n\n'); + + const parsed = parseSseBlock(block); + if (!parsed) continue; + + if (!triggerInvoked && parsed.eventName === 'connected') { + triggerInvoked = true; + await trigger(); + continue; + } + + if (predicate(parsed.data, parsed.eventName)) { + return parsed.data; + } + } + } + } finally { + clearTimeout(timeout); + controller.abort(); + try { + await reader.cancel(); + } catch (error) { + // no-op + } + } +} + +describe('Realtime SSE events', () => { + beforeEach(async () => { + await clearTaskData(); + await resetProjectDefaults(); + }); + + it('requires authentication for project event stream', async () => { + await spec() + .get('/projects/ZAZZ/events') + .expectStatus(401); + }); + + it('streams task status changes to connected clients', async () => { + const deliverable = await createTestDeliverable(1, { name: 'RT Task Status Deliv' }); + const task = await createTestTask(1, { + deliverableId: deliverable.id, + status: 'READY', + title: 'Realtime Task Status', + }); + + const event = await waitForProjectEvent({ + projectCode: 'ZAZZ', + predicate: (payload) => payload.eventType === 'task.status_changed' && payload.taskId === task.id, + trigger: async () => { + await spec() + .patch(`/projects/ZAZZ/deliverables/${deliverable.id}/tasks/${task.id}/status`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ status: 'IN_PROGRESS' }) + .expectStatus(200); + }, + }); + + expect(event.type).toBe('task'); + expect(event.taskId).toBe(task.id); + expect(event.deliverableId).toBe(deliverable.id); + expect(event.status).toBe('IN_PROGRESS'); + expect(event.previousStatus).toBe('READY'); + }); + + it('streams deliverable status changes to connected clients', async () => { + const deliverable = await createTestDeliverable(1, { + status: 'PLANNING', + name: 'RT Deliverable Status', + planFilepath: '/tmp/test-plan.md', + approvedBy: 1, + approvedAt: new Date(), + }); + + const event = await waitForProjectEvent({ + projectCode: 'ZAZZ', + predicate: (payload) => payload.eventType === 'deliverable.status_changed' && payload.deliverableId === deliverable.id, + trigger: async () => { + await spec() + .patch(`/projects/ZAZZ/deliverables/${deliverable.id}/status`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ status: 'IN_PROGRESS' }) + .expectStatus(200); + }, + }); + + expect(event.type).toBe('deliverable'); + expect(event.deliverableId).toBe(deliverable.id); + expect(event.status).toBe('IN_PROGRESS'); + expect(event.previousStatus).toBe('PLANNING'); + }); + + it('streams DEPENDS_ON relation changes to connected clients', async () => { + const deliverable = await createTestDeliverable(1, { name: 'RT Relation Deliv' }); + const parentTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Parent Task', + status: 'READY', + }); + const dependentTask = await createTestTask(1, { + deliverableId: deliverable.id, + title: 'Dependent Task', + status: 'READY', + }); + + const event = await waitForProjectEvent({ + projectCode: 'ZAZZ', + predicate: (payload) => + payload.eventType === 'relation.created' && + payload.taskId === dependentTask.id && + payload.relatedTaskId === parentTask.id && + payload.relationType === 'DEPENDS_ON', + trigger: async () => { + await spec() + .post(`/projects/ZAZZ/tasks/${dependentTask.id}/relations`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .withJson({ relatedTaskId: parentTask.id, relationType: 'DEPENDS_ON' }) + .expectStatus(201); + }, + }); + + expect(event.type).toBe('relation'); + expect(event.taskId).toBe(dependentTask.id); + expect(event.relatedTaskId).toBe(parentTask.id); + expect(event.relationType).toBe('DEPENDS_ON'); + expect(event.deliverableId).toBe(deliverable.id); + }); +}); diff --git a/api/__tests__/routes/task-graph-scoping.test.mjs b/api/__tests__/routes/task-graph-scoping.test.mjs new file mode 100644 index 00000000..2285ec71 --- /dev/null +++ b/api/__tests__/routes/task-graph-scoping.test.mjs @@ -0,0 +1,56 @@ +import * as pactum from 'pactum'; +import { clearTaskData, createTestDeliverable, createTestTask, resetProjectDefaults } from '../helpers/testDatabase.js'; + +const { spec } = pactum; +const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; + +describe('Task graph scoping', () => { + beforeEach(async () => { + await clearTaskData(); + await resetProjectDefaults(); + }); + + it('should return 404 for removed project-wide graph endpoint', async () => { + await createTestDeliverable(1, { name: 'Graph test deliverable' }); + + await spec() + .get('/projects/ZAZZ/graph') + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(404); + }); + + it('should return deliverable-scoped graph for a valid project and deliverable', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Scoped graph deliverable' }); + await createTestTask(1, { title: 'Task A', deliverableId: deliverable.id, phase: 1, phaseStep: '1.1' }); + await createTestTask(1, { title: 'Task B', deliverableId: deliverable.id, phase: 1, phaseStep: '1.2' }); + + const graph = await spec() + .get(`/projects/ZAZZ/deliverables/${deliverable.id}/graph`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(200) + .returns('res.body'); + + expect(graph.deliverableId).toBe(deliverable.id); + expect(graph.projectCode).toBe('ZAZZ'); + expect(Array.isArray(graph.tasks)).toBe(true); + expect(Array.isArray(graph.relations)).toBe(true); + expect(graph.tasks).toHaveLength(2); + }); + + it('should return 404 when deliverable is not in the project path', async () => { + const otherProjectDeliverable = await createTestDeliverable(2, { name: 'Other project deliverable' }); + + await spec() + .get(`/projects/ZAZZ/deliverables/${otherProjectDeliverable.id}/graph`) + .withHeaders('TB_TOKEN', VALID_TOKEN) + .expectStatus(404); + }); + + it('should require authentication for deliverable graph endpoint', async () => { + const deliverable = await createTestDeliverable(1, { name: 'Auth check deliverable' }); + + await spec() + .get(`/projects/ZAZZ/deliverables/${deliverable.id}/graph`) + .expectStatus(401); + }); +}); diff --git a/api/__tests__/setup.pactum.mjs b/api/__tests__/setup.pactum.mjs index 5f808549..83846657 100644 --- a/api/__tests__/setup.pactum.mjs +++ b/api/__tests__/setup.pactum.mjs @@ -2,6 +2,9 @@ import { beforeAll, afterAll } from 'vitest'; import { createTestServer } from './helpers/testServer.js'; import { validateTestEnvironment } from './helpers/testDatabase.js'; import pactum from 'pactum'; +import { spawnSync } from 'child_process'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; // Fail fast if not in test environment if (process.env.NODE_ENV !== 'test') { @@ -30,12 +33,27 @@ if (testDbName !== 'zazz_board_test') { let app; const PORT = 3031; +const testDir = dirname(fileURLToPath(import.meta.url)); +const apiDir = resolve(testDir, '..'); beforeAll(async () => { // Validate test environment before starting server await validateTestEnvironment(); console.log('✅ Environment validation passed: zazz_board_test'); + + const resetResult = spawnSync('npm', ['run', 'db:reset'], { + cwd: apiDir, + stdio: 'inherit', + env: { + ...process.env, + NODE_ENV: 'test', + DATABASE_URL: process.env.DATABASE_URL_TEST + } + }); + if (resetResult.status !== 0) { + throw new Error(`Failed to reset test database before suite start (exit ${resetResult.status ?? 'unknown'})`); + } app = await createTestServer(); await app.listen({ port: PORT, host: '127.0.0.1' }); diff --git a/api/lib/db/schema.js b/api/lib/db/schema.js index b3ce6008..5e4093f5 100644 --- a/api/lib/db/schema.js +++ b/api/lib/db/schema.js @@ -1,4 +1,4 @@ -import { pgTable, serial, varchar, text, timestamp, integer, boolean, jsonb, primaryKey, index, unique } from 'drizzle-orm/pg-core'; +import { pgTable, serial, varchar, text, timestamp, integer, boolean, jsonb, primaryKey, index, unique, check } from 'drizzle-orm/pg-core'; import { pgEnum } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { relations } from 'drizzle-orm'; @@ -86,15 +86,15 @@ export const PROJECTS = pgTable('PROJECTS', { export const DELIVERABLES = pgTable('DELIVERABLES', { id: serial('id').primaryKey(), project_id: integer('project_id').notNull().references(() => PROJECTS.id, { onDelete: 'cascade' }), - deliverable_id: varchar('deliverable_id', { length: 50 }).notNull().unique(), + project_code: varchar('project_code', { length: 10 }).notNull(), + deliverable_code: varchar('deliverable_code', { length: 25 }).notNull().unique(), name: varchar('name', { length: 30 }).notNull(), description: text('description'), type: deliverableTypeEnum('type').notNull(), status: varchar('status', { length: 25 }).notNull().default('PLANNING'), status_history: jsonb('status_history').notNull().default(sql`'[]'::jsonb`), - ded_file_path: varchar('ded_file_path', { length: 500 }), - plan_file_path: varchar('plan_file_path', { length: 500 }), - prd_file_path: varchar('prd_file_path', { length: 500 }), + spec_filepath: varchar('spec_filepath', { length: 500 }), + plan_filepath: varchar('plan_filepath', { length: 500 }), approved_by: integer('approved_by').references(() => USERS.id, { onDelete: 'set null' }), approved_at: timestamp('approved_at', { withTimezone: true }), git_worktree: varchar('git_worktree', { length: 255 }), @@ -119,7 +119,7 @@ export const TASKS = pgTable('TASKS', { phase: integer('phase'), // Human-readable display ID within a deliverable: "1.1", "1.2", "1.2.1" (rework) // Unique per deliverable — enforced by constraint below - phase_task_id: varchar('phase_task_id', { length: 20 }), + phase_step: varchar('phase_step', { length: 20 }), // --- Core task fields --- title: varchar('title', { length: 255 }).notNull(), @@ -156,8 +156,8 @@ export const TASKS = pgTable('TASKS', { updated_by: integer('updated_by').references(() => USERS.id, { onDelete: 'set null' }), updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => [ - // phase_task_id must be unique within a deliverable (e.g. "1.1" unique per deliverable) - unique('uq_tasks_deliverable_phase_task_id').on(table.deliverable_id, table.phase_task_id), + // phase_step must be unique within a deliverable (e.g. "1.1" unique per deliverable) + unique('uq_tasks_deliverable_phase_step').on(table.deliverable_id, table.phase_step), ]); // Task-Tags junction table @@ -182,14 +182,20 @@ export const TASK_RELATIONS = pgTable('TASK_RELATIONS', { // Image metadata table export const IMAGE_METADATA = pgTable('IMAGE_METADATA', { id: serial('id').primaryKey(), - task_id: integer('task_id').notNull().references(() => TASKS.id, { onDelete: 'cascade' }), + task_id: integer('task_id').references(() => TASKS.id, { onDelete: 'cascade' }), + deliverable_id: integer('deliverable_id').references(() => DELIVERABLES.id, { onDelete: 'cascade' }), original_name: varchar('original_name', { length: 255 }).notNull(), content_type: varchar('content_type', { length: 100 }).notNull(), file_size: integer('file_size').notNull(), url: varchar('url', { length: 500 }).notNull(), // Local DB URL or S3 URL storage_type: varchar('storage_type', { length: 20 }).notNull().default('local'), // 'local' or 's3' created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); +}, (table) => [ + check( + 'image_metadata_single_owner_chk', + sql`((${table.task_id} IS NOT NULL AND ${table.deliverable_id} IS NULL) OR (${table.task_id} IS NULL AND ${table.deliverable_id} IS NOT NULL))` + ), +]); // Image binary data table (for local storage) export const IMAGE_DATA = pgTable('IMAGE_DATA', { @@ -225,6 +231,7 @@ export const deliverablesRelations = relations(DELIVERABLES, ({ one, many }) => references: [USERS.id], }), tasks: many(TASKS), + images: many(IMAGE_METADATA), })); export const tasksRelations = relations(TASKS, ({ one, many }) => ({ @@ -275,6 +282,10 @@ export const imageMetadataRelations = relations(IMAGE_METADATA, ({ one }) => ({ fields: [IMAGE_METADATA.task_id], references: [TASKS.id], }), + deliverable: one(DELIVERABLES, { + fields: [IMAGE_METADATA.deliverable_id], + references: [DELIVERABLES.id], + }), imageData: one(IMAGE_DATA, { fields: [IMAGE_METADATA.id], references: [IMAGE_DATA.id], diff --git a/api/scripts/reset-and-seed.js b/api/scripts/reset-and-seed.js index 92e609ac..211ccf30 100644 --- a/api/scripts/reset-and-seed.js +++ b/api/scripts/reset-and-seed.js @@ -80,9 +80,9 @@ async function resetAndSeed() { console.log('✅ Database reset and seeding completed successfully!'); console.log('📊 Summary:'); console.log(' • 5 users created'); - console.log(' • 5 projects created'); - console.log(' • 6 deliverables created'); - console.log(' • 0 tasks (task model refactor in progress — tasks are created via API)'); + console.log(' • 2 projects created (ZAZZ, ZED_MER)'); + console.log(' • 4 deliverables created (ZAZZ only)'); + console.log(' • 32 ZAZZ tasks seeded from database snapshot'); console.log(' • status definitions + translations seeded'); process.exit(0); } catch (error) { diff --git a/api/scripts/seed-all.js b/api/scripts/seed-all.js index c5ccd444..a37ea5a6 100644 --- a/api/scripts/seed-all.js +++ b/api/scripts/seed-all.js @@ -82,10 +82,10 @@ async function seedAll() { console.log(' • 5 users created'); console.log(' • 8 status definitions created'); console.log(' • 4 translation sets created (en, es, fr, de)'); - console.log(' • 5 projects created'); - console.log(' • 6 deliverables created'); + console.log(' • 2 projects created (ZAZZ, ZED_MER)'); + console.log(' • 4 deliverables created (ZAZZ only)'); console.log(' • 6 tags created'); - console.log(' • 0 tasks (task model refactor in progress — no task seed data)'); + console.log(' • 32 ZAZZ tasks seeded from database snapshot'); } catch (error) { console.error('❌ Error seeding data:', error.message); diff --git a/api/scripts/seeders/data/zazz-project-snapshot.json b/api/scripts/seeders/data/zazz-project-snapshot.json new file mode 100644 index 00000000..8e26a4d3 --- /dev/null +++ b/api/scripts/seeders/data/zazz-project-snapshot.json @@ -0,0 +1,1391 @@ +{ + "tasks": [ + { + "id": 1, + "notes": null, + "phase": 1, + "title": "ZAZZ-1: Foundation completed (schema + API read paths)", + "prompt": null, + "status": "COMPLETED", + "position": 10, + "priority": "HIGH", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "1.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-1" + }, + { + "id": 2, + "notes": null, + "phase": 2, + "title": "ZAZZ-1: Remaining work (UI polish + edge cases)", + "prompt": null, + "status": "READY", + "position": 20, + "priority": "MEDIUM", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-1" + }, + { + "id": 3, + "notes": null, + "phase": 1, + "title": "ZAZZ-3: Reproduce bug and capture failing cases", + "prompt": null, + "status": "COMPLETED", + "position": 10, + "priority": "HIGH", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "1.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 4, + "notes": null, + "phase": 1, + "title": "ZAZZ-3: Add regression tests for invalid tag formats", + "prompt": null, + "status": "COMPLETED", + "position": 20, + "priority": "HIGH", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "1.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 5, + "notes": null, + "phase": 1, + "title": "ZAZZ-3: Confirm API validation contract + error messaging", + "prompt": null, + "status": "COMPLETED", + "position": 30, + "priority": "MEDIUM", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "1.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 6, + "notes": null, + "phase": 2, + "title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", + "prompt": null, + "status": "COMPLETED", + "position": 40, + "priority": "HIGH", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 7, + "notes": null, + "phase": 2, + "title": "ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)", + "prompt": null, + "status": "COMPLETED", + "position": 50, + "priority": "MEDIUM", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 8, + "notes": null, + "phase": 2, + "title": "ZAZZ-3: Ensure tag creation/upsert handles collisions", + "prompt": null, + "status": "COMPLETED", + "position": 60, + "priority": "MEDIUM", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 9, + "notes": null, + "phase": 3, + "title": "ZAZZ-3: QA run (API + UI) for tag flows", + "prompt": null, + "status": "COMPLETED", + "position": 70, + "priority": "MEDIUM", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 10, + "notes": null, + "phase": 3, + "title": "ZAZZ-3: Address review feedback / small refactor", + "prompt": null, + "status": "COMPLETED", + "position": 80, + "priority": "MEDIUM", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 11, + "notes": null, + "phase": 3, + "title": "ZAZZ-3: Final sign-off checklist", + "prompt": null, + "status": "COMPLETED", + "position": 90, + "priority": "LOW", + "agent_name": null, + "created_at": "2026-03-04T23:01:41.019+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:01:41.019+00:00", + "updated_by": 5, + "completed_at": "2026-03-04T23:01:41.019+00:00", + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-3" + }, + { + "id": 13, + "notes": null, + "phase": 1, + "title": "CODEX 1.1: Test Harness Cleanup for Image Routes", + "prompt": "Goal: update API test helpers so image tables are cleaned per test and image route tests are isolated. Include helper updates in api/__tests__/helpers/testDatabase.js and ensure no cross-test image state bleed. Acceptance: image fixtures can be created/deleted repeatedly in tests without leakage.", + "status": "COMPLETED", + "position": 100, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:42:19.956686+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:42:19.956686+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "1.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 14, + "notes": null, + "phase": 1, + "title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", + "prompt": "Goal: remove GET /projects/:code/graph route and schema, keep deliverable graph route intact, and add dedicated route tests proving removed path is 404 and deliverable path still works. Acceptance: task-graph-scoping tests pass.", + "status": "COMPLETED", + "position": 110, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:42:19.983398+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:42:19.983398+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "1.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 15, + "notes": null, + "phase": 1, + "title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", + "prompt": "Goal: remove project-wide graph mode in client. Update App selector to deliverables-only, update useTaskGraph to skip fetch when deliverableId is null, and show explicit prompt in TaskGraphPage before selection. Acceptance: no /projects/{code}/graph fetch path remains in UI.", + "status": "COMPLETED", + "position": 120, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:47:40.365125+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:47:40.365125+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "1.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 16, + "notes": null, + "phase": 2, + "title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", + "prompt": "Goal: update schema so IMAGE_METADATA supports task or deliverable ownership (task_id nullable, add deliverable_id nullable FK) with DB-level XOR constraint (exactly one owner set). Acceptance: schema enforces both-set and neither-set failures.", + "status": "COMPLETED", + "position": 130, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:47:40.391755+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:47:40.391755+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 17, + "notes": null, + "phase": 2, + "title": "CODEX 2.2: Refactor Image Service for Project/Owner Validation", + "prompt": "Refactor databaseService image operations to support both task-owned and deliverable-owned images with project-scope ownership checks and scoped URL handling.", + "status": "COMPLETED", + "position": 140, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:56:26.287552+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:56:26.287552+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 18, + "notes": null, + "phase": 2, + "title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", + "prompt": "Replace legacy /tasks/:taskId/images and /images/:id routes with project+deliverable scoped task routes, deliverable image routes, and project-scoped image fetch/metadata routes.", + "status": "COMPLETED", + "position": 150, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:56:26.311947+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:56:26.311947+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 20, + "notes": null, + "phase": 2, + "title": "CODEX 2.4: Align API Metadata Text for Scoped Image Contract", + "prompt": "Update API root endpoint summaries/tag descriptions so docs no longer imply legacy global image routes and reflect project-scoped image operations.", + "status": "COMPLETED", + "position": 170, + "priority": "MEDIUM", + "agent_name": "codex", + "created_at": "2026-03-07T00:57:52.344886+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:57:52.344886+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "2.4", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 21, + "notes": null, + "phase": 3, + "title": "CODEX 3.1: Add Image Scoping Integration Tests", + "prompt": "Add Pactum tests for scoped task/deliverable image routes, project-scoped image fetch/metadata, 401/403/404 behavior, legacy route 404s, and single-owner DB constraint checks.", + "status": "COMPLETED", + "position": 210, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:57:52.365263+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:57:52.365263+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 19, + "notes": null, + "phase": 3, + "title": "CODEX 3.2: Regression Tests for Unchanged Project-ID Routes", + "prompt": "Add regression tests for /projects/:id, /projects/:id/kanban/tasks/column/:status, and /projects/:id/tasks to ensure these accepted id-based routes remain unchanged.", + "status": "COMPLETED", + "position": 160, + "priority": "MEDIUM", + "agent_name": "codex", + "created_at": "2026-03-07T00:56:26.330846+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:56:26.330846+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 22, + "notes": null, + "phase": 3, + "title": "CODEX 3.3: Finalize Graph Removal Regression Tests", + "prompt": "Finalize task-graph scoping regression tests for removed project graph endpoint and preserved deliverable graph behavior.", + "status": "COMPLETED", + "position": 180, + "priority": "MEDIUM", + "agent_name": "codex", + "created_at": "2026-03-07T00:57:52.383035+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:57:52.383035+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 23, + "notes": null, + "phase": 3, + "title": "CODEX 3.4: Harden OpenAPI Assertions for Graph/Image Contract", + "prompt": "Update openapi.test.mjs to assert removed /projects/{code}/graph and legacy image paths, and assert presence/shape of all new scoped image paths.", + "status": "COMPLETED", + "position": 190, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:57:52.398201+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:57:52.398201+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.4", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 24, + "notes": null, + "phase": 3, + "title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "prompt": "Update zazz-board-api skill/docs to new route contract, run final QA verification, and prepare commit-ready summary.", + "status": "COMPLETED", + "position": 200, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T00:57:52.415895+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T00:57:52.415895+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "3.5", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 25, + "notes": null, + "phase": 4, + "title": "CODEX 4.1: Replace drizzle-orm symlink workaround", + "prompt": "Replace manual node_modules symlink workaround with a worktree-safe dependency solution and update setup/troubleshooting docs.", + "status": "COMPLETED", + "position": 220, + "priority": "MEDIUM", + "agent_name": "codex", + "created_at": "2026-03-07T01:18:18.923657+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T01:24:36.852158+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "4.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 26, + "notes": null, + "phase": 4, + "title": "CODEX 4.2: Persist Task Graph deliverable selection", + "prompt": "Persist the last selected deliverable for Task Graph per project. On reload, restore the saved deliverable if it still exists. Keep deliverable-only graph selector behavior.", + "status": "COMPLETED", + "position": 230, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T01:30:52.983835+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T01:30:52.983835+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "4.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 27, + "notes": null, + "phase": 4, + "title": "CODEX 4.3: Harden Task Graph selection restore timing", + "prompt": "Make Task Graph deliverable persistence deterministic across reload/project hydration timing by hydrating selection on project change and validating against current deliverables.", + "status": "COMPLETED", + "position": 240, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T01:42:36.382396+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T01:42:36.382396+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "4.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 28, + "notes": null, + "phase": 4, + "title": "CODEX 4.4: Keep completed tasks visible with green outline", + "prompt": "Ensure completed tasks are never treated as non-complete by graph styling logic. COMPLETED/DONE must always render with green outline.", + "status": "COMPLETED", + "position": 250, + "priority": "HIGH", + "agent_name": null, + "created_at": "2026-03-07T01:48:43.273521+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T01:48:43.273521+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "4.4", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 29, + "notes": null, + "phase": 4, + "title": "CODEX 4.5: Make API skill lifecycle rules explicit", + "prompt": "Update zazz-board-api skill to require explicit task status transitions, explicit deliverable status updates, and explicit DEPENDS_ON relation creation/verification.", + "status": "COMPLETED", + "position": 290, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T01:53:38.530831+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T01:53:38.530831+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "4.5", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 30, + "notes": null, + "phase": 5, + "title": "CODEX 5.1: Add SSE stream + status/relation event emits", + "prompt": "Implement project-scoped SSE endpoint and emit realtime events for task status/position/create/delete, deliverable status changes, and relation (DEPENDS_ON) updates.", + "status": "COMPLETED", + "position": 260, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T02:08:51.638547+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T02:08:51.638547+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "5.1", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 31, + "notes": null, + "phase": 5, + "title": "CODEX 5.2: Wire UI realtime subscriptions for Kanban + Graph", + "prompt": "Add client SSE subscription hook and use it to refresh Task Kanban columns, Task Graph node styling/edges, and Deliverable Kanban status colors immediately across clients.", + "status": "COMPLETED", + "position": 270, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T02:08:51.664504+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T02:08:51.664504+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "5.2", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 32, + "notes": null, + "phase": 5, + "title": "CODEX 5.3: Add SSE integration tests + regression checks", + "prompt": "Add API integration tests validating SSE events for task status changes, deliverable status changes, and DEPENDS_ON relation updates; run focused regression suite.", + "status": "COMPLETED", + "position": 280, + "priority": "HIGH", + "agent_name": "codex", + "created_at": "2026-03-07T02:08:51.697121+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-07T02:08:51.697121+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": "5.3", + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + }, + { + "id": 12, + "notes": null, + "phase": null, + "title": "Audit routes for project filter", + "prompt": "Read the route information and discuss discover which routes do not filter by project.", + "status": "READY", + "position": 10, + "priority": "MEDIUM", + "agent_name": null, + "created_at": "2026-03-04T23:21:40.553727+00:00", + "created_by": 5, + "is_blocked": false, + "started_at": null, + "updated_at": "2026-03-04T23:21:40.553727+00:00", + "updated_by": 5, + "completed_at": null, + "git_worktree": null, + "is_cancelled": false, + "story_points": null, + "phase_step": null, + "blocked_reason": null, + "coordination_code": null, + "deliverable_code": "ZAZZ-5" + } + ], + "project": { + "code": "ZAZZ", + "title": "Zazz Board", + "leader_id": 5, + "created_by": 5, + "description": "Zazz-Board application development \u2014 primary test project", + "status_workflow": [ + "READY", + "IN_PROGRESS", + "QA", + "COMPLETED" + ], + "next_deliverable_sequence": 7, + "completion_criteria_status": "COMPLETED", + "deliverable_status_workflow": [ + "PLANNING", + "IN_PROGRESS", + "IN_REVIEW", + "STAGED", + "DONE" + ], + "task_graph_layout_direction": "LR" + }, + "task_tags": [ + { + "tag": "backend", + "title": "ZAZZ-1: Foundation completed (schema + API read paths)", + "phase_step": "1.1", + "deliverable_code": "ZAZZ-1" + }, + { + "tag": "frontend", + "title": "ZAZZ-1: Remaining work (UI polish + edge cases)", + "phase_step": "2.1", + "deliverable_code": "ZAZZ-1" + }, + { + "tag": "bug-fix", + "title": "ZAZZ-3: Reproduce bug and capture failing cases", + "phase_step": "1.1", + "deliverable_code": "ZAZZ-3" + }, + { + "tag": "testing", + "title": "ZAZZ-3: Add regression tests for invalid tag formats", + "phase_step": "1.2", + "deliverable_code": "ZAZZ-3" + }, + { + "tag": "bug-fix", + "title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", + "phase_step": "2.1", + "deliverable_code": "ZAZZ-3" + } + ], + "deliverables": [ + { + "id": 1, + "name": "Deliverables Feature", + "type": "FEATURE", + "status": "IN_PROGRESS", + "position": 10, + "created_at": "2026-03-04T23:01:41.016444+00:00", + "created_by": 5, + "git_branch": "deliverables-mvp", + "updated_at": "2026-03-04T23:01:41.016444+00:00", + "updated_by": null, + "approved_at": "2026-01-20T14:30:00+00:00", + "approved_by": 5, + "description": "Add deliverable entity, Kanban board, and task graph swim lanes", + "git_worktree": "deliverables-mvp", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-01-15T10:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-01-20T14:30:00Z", + "changedBy": 5 + } + ], + "pull_request_url": null, + "deliverable_code": "ZAZZ-1", + "spec_filepath": ".zazz/deliverables/deliverables-feature-SPEC.md", + "plan_filepath": ".zazz/deliverables/deliverables-feature-PLAN.md", + "project_code": "ZAZZ" + }, + { + "id": 2, + "name": "Agent Skill Framework", + "type": "FEATURE", + "status": "PLANNING", + "position": 20, + "created_at": "2026-03-04T23:01:41.016444+00:00", + "created_by": 5, + "git_branch": null, + "updated_at": "2026-03-04T23:01:41.016444+00:00", + "updated_by": null, + "approved_at": null, + "approved_by": null, + "description": "Define and register agent skills for task creation and QA", + "git_worktree": null, + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-02-10T09:00:00Z", + "changedBy": 5 + } + ], + "pull_request_url": null, + "deliverable_code": "ZAZZ-2", + "spec_filepath": "docs/agent-skills-SPEC.md", + "plan_filepath": null, + "project_code": "ZAZZ" + }, + { + "id": 3, + "name": "Fix Tag Validation Bug", + "type": "BUG_FIX", + "status": "IN_REVIEW", + "position": 30, + "created_at": "2026-03-04T23:01:41.016444+00:00", + "created_by": 5, + "git_branch": "fix-tag-validation", + "updated_at": "2026-03-04T23:01:41.016444+00:00", + "updated_by": null, + "approved_at": "2026-02-02T09:00:00+00:00", + "approved_by": 5, + "description": "Tags with trailing hyphens bypass API validation", + "git_worktree": "fix-tag-validation", + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-02-01T10:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-02-02T09:00:00Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-02-05T16:00:00Z", + "changedBy": null + } + ], + "pull_request_url": "https://github.com/zazzcode/zazz-board/pull/12", + "deliverable_code": "ZAZZ-3", + "spec_filepath": "docs/fix-tag-validation-SPEC.md", + "plan_filepath": "docs/fix-tag-validation-plan.md", + "project_code": "ZAZZ" + }, + { + "id": 8, + "name": "fix-routes-no-project", + "type": "REFACTOR", + "status": "IN_REVIEW", + "position": 50, + "created_at": "2026-03-04T23:18:28.446677+00:00", + "created_by": 5, + "git_branch": null, + "updated_at": "2026-03-07T01:41:58.207+00:00", + "updated_by": 5, + "approved_at": "2026-03-07T00:41:56.71+00:00", + "approved_by": 5, + "description": null, + "git_worktree": null, + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-03-04T23:18:28.449Z", + "changedBy": 5 + }, + { + "status": "IN_PROGRESS", + "changedAt": "2026-03-07T01:41:53.962Z", + "changedBy": 5 + }, + { + "status": "IN_REVIEW", + "changedAt": "2026-03-07T01:41:58.207Z", + "changedBy": 5 + } + ], + "pull_request_url": null, + "deliverable_code": "ZAZZ-5", + "spec_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-SPEC.md", + "plan_filepath": ".zazz/deliverables/ZAZZ-5-fix-routes-no-project-PLAN.md", + "project_code": "ZAZZ" + }, + { + "id": 9, + "name": "multiple-agent-tokens-feature", + "type": "FEATURE", + "status": "PLANNING", + "position": 60, + "created_at": "2026-03-05T00:27:06.304652+00:00", + "created_by": 5, + "git_branch": null, + "updated_at": "2026-03-05T00:27:09.389+00:00", + "updated_by": 5, + "approved_at": null, + "approved_by": null, + "description": null, + "git_worktree": null, + "status_history": [ + { + "status": "PLANNING", + "changedAt": "2026-03-05T00:27:06.308Z", + "changedBy": 5 + } + ], + "pull_request_url": null, + "deliverable_code": "ZAZZ-6", + "spec_filepath": ".zazz/deliverables/ZAZZ-6-multiple-agent-tokens-feature-SPEC.md", + "plan_filepath": null, + "project_code": "ZAZZ" + } + ], + "task_relations": [ + { + "to_title": "ZAZZ-1: Foundation completed (schema + API read paths)", + "from_title": "ZAZZ-1: Remaining work (UI polish + edge cases)", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.1", + "from_phase_step": "2.1", + "from_deliverable_code": "ZAZZ-1", + "to_deliverable_code": "ZAZZ-1" + }, + { + "to_title": "ZAZZ-3: Reproduce bug and capture failing cases", + "from_title": "ZAZZ-3: Add regression tests for invalid tag formats", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.1", + "from_phase_step": "1.2", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "ZAZZ-3: Add regression tests for invalid tag formats", + "from_title": "ZAZZ-3: Confirm API validation contract + error messaging", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.2", + "from_phase_step": "1.3", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "ZAZZ-3: Confirm API validation contract + error messaging", + "from_title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.3", + "from_phase_step": "2.1", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "ZAZZ-3: Fix validation for trailing hyphen and edge cases", + "from_title": "ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.1", + "from_phase_step": "2.2", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)", + "from_title": "ZAZZ-3: Ensure tag creation/upsert handles collisions", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.2", + "from_phase_step": "2.3", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "ZAZZ-3: Ensure tag creation/upsert handles collisions", + "from_title": "ZAZZ-3: QA run (API + UI) for tag flows", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.3", + "from_phase_step": "3.1", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "ZAZZ-3: QA run (API + UI) for tag flows", + "from_title": "ZAZZ-3: Address review feedback / small refactor", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "3.1", + "from_phase_step": "3.2", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "ZAZZ-3: Address review feedback / small refactor", + "from_title": "ZAZZ-3: Final sign-off checklist", + "updated_at": "2026-03-04T23:01:41.02256+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "3.2", + "from_phase_step": "3.3", + "from_deliverable_code": "ZAZZ-3", + "to_deliverable_code": "ZAZZ-3" + }, + { + "to_title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", + "from_title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", + "updated_at": "2026-03-07T00:47:40.365125+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.2", + "from_phase_step": "1.3", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 1.1: Test Harness Cleanup for Image Routes", + "from_title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", + "updated_at": "2026-03-07T00:47:40.391755+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.1", + "from_phase_step": "2.1", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", + "from_title": "CODEX 2.2: Refactor Image Service for Project/Owner Validation", + "updated_at": "2026-03-07T00:56:26.360058+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.1", + "from_phase_step": "2.2", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 2.2: Refactor Image Service for Project/Owner Validation", + "from_title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", + "updated_at": "2026-03-07T00:56:26.379096+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.2", + "from_phase_step": "2.3", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", + "from_title": "CODEX 2.4: Align API Metadata Text for Scoped Image Contract", + "updated_at": "2026-03-07T00:57:52.450092+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.3", + "from_phase_step": "2.4", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 2.1: IMAGE_METADATA Single-Owner Schema Constraint", + "from_title": "CODEX 3.1: Add Image Scoping Integration Tests", + "updated_at": "2026-03-07T00:57:52.467948+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.1", + "from_phase_step": "3.1", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", + "from_title": "CODEX 3.1: Add Image Scoping Integration Tests", + "updated_at": "2026-03-07T00:57:52.483775+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.3", + "from_phase_step": "3.1", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", + "from_title": "CODEX 3.3: Finalize Graph Removal Regression Tests", + "updated_at": "2026-03-07T00:57:52.498875+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.2", + "from_phase_step": "3.3", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 1.2: Remove Project-Wide Graph Endpoint + Add Scoping Tests", + "from_title": "CODEX 3.4: Harden OpenAPI Assertions for Graph/Image Contract", + "updated_at": "2026-03-07T00:57:52.513883+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.2", + "from_phase_step": "3.4", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 2.3: Replace Legacy Image Routes with Scoped Contracts", + "from_title": "CODEX 3.4: Harden OpenAPI Assertions for Graph/Image Contract", + "updated_at": "2026-03-07T00:57:52.529905+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.3", + "from_phase_step": "3.4", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", + "from_title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "updated_at": "2026-03-07T00:57:52.545482+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.3", + "from_phase_step": "3.5", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 2.4: Align API Metadata Text for Scoped Image Contract", + "from_title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "updated_at": "2026-03-07T00:57:52.561781+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "2.4", + "from_phase_step": "3.5", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 3.1: Add Image Scoping Integration Tests", + "from_title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "updated_at": "2026-03-07T00:57:52.579308+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "3.1", + "from_phase_step": "3.5", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 3.2: Regression Tests for Unchanged Project-ID Routes", + "from_title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "updated_at": "2026-03-07T00:57:52.595678+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "3.2", + "from_phase_step": "3.5", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 3.3: Finalize Graph Removal Regression Tests", + "from_title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "updated_at": "2026-03-07T00:57:52.612845+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "3.3", + "from_phase_step": "3.5", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 3.4: Harden OpenAPI Assertions for Graph/Image Contract", + "from_title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "updated_at": "2026-03-07T00:57:52.630892+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "3.4", + "from_phase_step": "3.5", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 3.5: Update API Skill Docs + Final Verification", + "from_title": "CODEX 4.1: Replace drizzle-orm symlink workaround", + "updated_at": "2026-03-07T01:19:29.998177+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "3.5", + "from_phase_step": "4.1", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 1.3: Deliverable-Only Graph UI (No Null Fetch)", + "from_title": "CODEX 4.2: Persist Task Graph deliverable selection", + "updated_at": "2026-03-07T01:30:52.983835+00:00", + "updated_by": null, + "relation_type": "DEPENDS_ON", + "to_phase_step": "1.3", + "from_phase_step": "4.2", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 4.2: Persist Task Graph deliverable selection", + "from_title": "CODEX 4.3: Harden Task Graph selection restore timing", + "updated_at": "2026-03-07T01:43:15.973038+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "4.2", + "from_phase_step": "4.3", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 4.3: Harden Task Graph selection restore timing", + "from_title": "CODEX 4.4: Keep completed tasks visible with green outline", + "updated_at": "2026-03-07T02:11:48.188976+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "4.3", + "from_phase_step": "4.4", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 4.4: Keep completed tasks visible with green outline", + "from_title": "CODEX 4.5: Make API skill lifecycle rules explicit", + "updated_at": "2026-03-07T02:11:48.213246+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "4.4", + "from_phase_step": "4.5", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 5.1: Add SSE stream + status/relation event emits", + "from_title": "CODEX 5.2: Wire UI realtime subscriptions for Kanban + Graph", + "updated_at": "2026-03-07T02:09:03.655091+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "5.1", + "from_phase_step": "5.2", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 5.1: Add SSE stream + status/relation event emits", + "from_title": "CODEX 5.3: Add SSE integration tests + regression checks", + "updated_at": "2026-03-07T02:09:03.677717+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "5.1", + "from_phase_step": "5.3", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + }, + { + "to_title": "CODEX 5.2: Wire UI realtime subscriptions for Kanban + Graph", + "from_title": "CODEX 5.3: Add SSE integration tests + regression checks", + "updated_at": "2026-03-07T02:09:03.696329+00:00", + "updated_by": 5, + "relation_type": "DEPENDS_ON", + "to_phase_step": "5.2", + "from_phase_step": "5.3", + "from_deliverable_code": "ZAZZ-5", + "to_deliverable_code": "ZAZZ-5" + } + ] +} diff --git a/api/scripts/seeders/locales/en.json b/api/scripts/seeders/locales/en.json index 85bcb1bf..4883ca87 100644 --- a/api/scripts/seeders/locales/en.json +++ b/api/scripts/seeders/locales/en.json @@ -138,12 +138,10 @@ "status": "Status", "description": "Description", "descriptionHint": "Enter deliverable description", - "dedPath": "SPEC Path", + "specPath": "SPEC Path", "planPath": "Plan Path", - "prdPath": "PRD Path", - "dedFilePath": "SPEC Path", - "planFilePath": "Plan File Path", - "prdFilePath": "PRD File Path", + "specFilepath": "SPEC Path", + "planFilepath": "Plan File Path", "gitWorktree": "Git Worktree", "gitBranch": "Git Branch", "pullRequestUrl": "Pull Request URL", diff --git a/api/scripts/seeders/seedDeliverables.js b/api/scripts/seeders/seedDeliverables.js index 5c9b00da..8b9c2682 100644 --- a/api/scripts/seeders/seedDeliverables.js +++ b/api/scripts/seeders/seedDeliverables.js @@ -1,129 +1,57 @@ import { db } from '../../lib/db/index.js'; -import { DELIVERABLES } from '../../lib/db/schema.js'; +import { PROJECTS, DELIVERABLES } from '../../lib/db/schema.js'; +import { eq } from 'drizzle-orm'; +import { loadZazzProjectSnapshot } from './zazzSnapshot.js'; + +async function getProjectId(code) { + const [project] = await db + .select({ id: PROJECTS.id }) + .from(PROJECTS) + .where(eq(PROJECTS.code, code)) + .limit(1); + + if (!project) throw new Error(`Project not found: ${code}`); + return project.id; +} + +function toDateOrNull(value) { + return value ? new Date(value) : null; +} export async function seedDeliverables() { console.log(' 📝 Seeding deliverables...'); try { + const snapshot = await loadZazzProjectSnapshot(); + const zazzProjectId = await getProjectId('ZAZZ'); + const excludedDeliverableIds = new Set(['ZAZZ-2']); + + const zazzDeliverables = snapshot.deliverables + .filter((deliverable) => !excludedDeliverableIds.has(deliverable.deliverable_code)) + .map((deliverable, index) => ({ + project_id: zazzProjectId, + project_code: deliverable.project_code, + deliverable_code: deliverable.deliverable_code, + name: deliverable.name, + description: deliverable.description, + type: deliverable.type, + status: deliverable.status, + position: deliverable.position ?? (index + 1) * 10, + status_history: deliverable.status_history ?? [], + spec_filepath: deliverable.spec_filepath, + plan_filepath: deliverable.plan_filepath, + approved_by: deliverable.approved_by, + approved_at: toDateOrNull(deliverable.approved_at), + git_worktree: deliverable.git_worktree, + git_branch: deliverable.git_branch, + pull_request_url: deliverable.pull_request_url, + created_by: deliverable.created_by ?? 5, + created_at: toDateOrNull(deliverable.created_at) || new Date(), + updated_by: deliverable.updated_by, + updated_at: toDateOrNull(deliverable.updated_at) || new Date(), + })); + await db.insert(DELIVERABLES).values([ - { - project_id: 1, - deliverable_id: 'ZAZZ-1', - name: 'Deliverables Feature', - description: 'Add deliverable entity, Kanban board, and task graph swim lanes', - type: 'FEATURE', - status: 'IN_PROGRESS', - position: 10, - status_history: [ - { status: 'PLANNING', changedAt: '2026-01-15T10:00:00Z', changedBy: 5 }, - { status: 'IN_PROGRESS', changedAt: '2026-01-20T14:30:00Z', changedBy: 5 } - ], - ded_file_path: '.zazz/deliverables/deliverables-feature-SPEC.md', - plan_file_path: '.zazz/deliverables/deliverables-feature-PLAN.md', - approved_by: 5, - approved_at: new Date('2026-01-20T14:30:00Z'), - git_worktree: 'deliverables-mvp', - git_branch: 'deliverables-mvp', - created_by: 5 - }, - { - project_id: 1, - deliverable_id: 'ZAZZ-2', - name: 'Agent Skill Framework', - description: 'Define and register agent skills for task creation and QA', - type: 'FEATURE', - status: 'PLANNING', - position: 20, - status_history: [{ status: 'PLANNING', changedAt: '2026-02-10T09:00:00Z', changedBy: 5 }], - ded_file_path: 'docs/agent-skills-SPEC.md', - created_by: 5 - }, - { - project_id: 1, - deliverable_id: 'ZAZZ-3', - name: 'Fix Tag Validation Bug', - description: 'Tags with trailing hyphens bypass API validation', - type: 'BUG_FIX', - status: 'IN_REVIEW', - position: 30, - status_history: [ - { status: 'PLANNING', changedAt: '2026-02-01T10:00:00Z', changedBy: 5 }, - { status: 'IN_PROGRESS', changedAt: '2026-02-02T09:00:00Z', changedBy: 5 }, - { status: 'IN_REVIEW', changedAt: '2026-02-05T16:00:00Z', changedBy: null } - ], - ded_file_path: 'docs/fix-tag-validation-SPEC.md', - plan_file_path: 'docs/fix-tag-validation-plan.md', - approved_by: 5, - approved_at: new Date('2026-02-02T09:00:00Z'), - git_worktree: 'fix-tag-validation', - git_branch: 'fix-tag-validation', - pull_request_url: 'https://github.com/zazzcode/zazz-board/pull/12', - created_by: 5 - }, - { - project_id: 2, - deliverable_id: 'MOBDEV-1', - name: 'Auth Screens', - description: 'Login, registration, and password reset screens', - type: 'FEATURE', - status: 'IN_PROGRESS', - position: 10, - status_history: [ - { status: 'PLANNING', changedAt: '2026-01-20T08:00:00Z', changedBy: 2 }, - { status: 'IN_PROGRESS', changedAt: '2026-01-25T10:00:00Z', changedBy: 2 } - ], - ded_file_path: 'docs/mobdev-auth-screens-SPEC.md', - plan_file_path: 'docs/mobdev-auth-screens-plan.md', - approved_by: 2, - approved_at: new Date('2026-01-25T10:00:00Z'), - git_worktree: 'auth-screens', - git_branch: 'auth-screens', - created_by: 2 - }, - { - project_id: 3, - deliverable_id: 'APIMOD-1', - name: 'REST Endpoint Migration', - description: 'Migrate legacy endpoints to modern REST with OpenAPI spec', - type: 'REFACTOR', - status: 'IN_PROGRESS', - position: 10, - status_history: [ - { status: 'PLANNING', changedAt: '2026-01-05T08:00:00Z', changedBy: 3 }, - { status: 'IN_PROGRESS', changedAt: '2026-01-12T11:00:00Z', changedBy: 3 } - ], - ded_file_path: 'docs/apimod-rest-migration-SPEC.md', - plan_file_path: 'docs/apimod-rest-migration-plan.md', - approved_by: 3, - approved_at: new Date('2026-01-12T11:00:00Z'), - git_worktree: 'rest-migration', - git_branch: 'rest-migration', - created_by: 3 - }, - { - project_id: 3, - deliverable_id: 'APIMOD-2', - name: 'Fix Auth Token Expiry', - description: 'Tokens not expiring correctly under high concurrency', - type: 'BUG_FIX', - status: 'PROD', - position: 20, - status_history: [ - { status: 'PLANNING', changedAt: '2026-01-28T10:00:00Z', changedBy: 3 }, - { status: 'IN_PROGRESS', changedAt: '2026-01-29T09:00:00Z', changedBy: 3 }, - { status: 'IN_REVIEW', changedAt: '2026-02-01T16:00:00Z', changedBy: null }, - { status: 'UAT', changedAt: '2026-02-02T11:00:00Z', changedBy: 3 }, - { status: 'STAGED', changedAt: '2026-02-03T10:00:00Z', changedBy: 3 }, - { status: 'PROD', changedAt: '2026-02-05T14:00:00Z', changedBy: 3 } - ], - ded_file_path: 'docs/apimod-auth-token-fix-SPEC.md', - plan_file_path: 'docs/apimod-auth-token-fix-plan.md', - approved_by: 3, - approved_at: new Date('2026-01-29T09:00:00Z'), - git_worktree: 'fix-auth-token-expiry', - git_branch: 'fix-auth-token-expiry', - pull_request_url: 'https://github.com/zazzcode/zazz-board/pull/8', - created_by: 3 - } + ...zazzDeliverables ]); console.log(' ✅ Deliverables seeded successfully'); } catch (error) { diff --git a/api/scripts/seeders/seedProjects.js b/api/scripts/seeders/seedProjects.js index d8c1c14e..fb3d6254 100644 --- a/api/scripts/seeders/seedProjects.js +++ b/api/scripts/seeders/seedProjects.js @@ -1,63 +1,38 @@ import { db } from '../../lib/db/index.js'; import { PROJECTS } from '../../lib/db/schema.js'; +import { loadZazzProjectSnapshot } from './zazzSnapshot.js'; export async function seedProjects() { console.log(' 📝 Seeding projects...'); try { + const snapshot = await loadZazzProjectSnapshot(); + const zazzProject = snapshot.project; + const zedLeaderId = zazzProject.leader_id === 2 ? 3 : 2; + await db.insert(PROJECTS).values([ { - title: 'Zazz Board', - code: 'ZAZZ', - description: 'Zazz-Board application development — primary test project', - leader_id: 5, - next_deliverable_sequence: 4, - status_workflow: ['READY', 'IN_PROGRESS', 'QA', 'COMPLETED'], - deliverable_status_workflow: ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE'], - task_graph_layout_direction: 'LR', - completion_criteria_status: 'COMPLETED', - created_by: 5 + title: zazzProject.title || 'Zazz Board', + code: zazzProject.code || 'ZAZZ', + description: zazzProject.description || 'Zazz-Board application development — primary test project', + leader_id: zazzProject.leader_id || 5, + next_deliverable_sequence: zazzProject.next_deliverable_sequence || 4, + status_workflow: Array.isArray(zazzProject.status_workflow) ? zazzProject.status_workflow : ['READY', 'IN_PROGRESS', 'QA', 'COMPLETED'], + deliverable_status_workflow: Array.isArray(zazzProject.deliverable_status_workflow) ? zazzProject.deliverable_status_workflow : ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE'], + task_graph_layout_direction: zazzProject.task_graph_layout_direction || 'LR', + completion_criteria_status: zazzProject.completion_criteria_status || 'COMPLETED', + created_by: zazzProject.created_by || 5 }, { - title: 'Mobile App Development', - code: 'MOBDEV', - description: 'Native mobile app for iOS and Android platforms', - leader_id: 2, - next_deliverable_sequence: 2, + title: 'Zed Mermaid', + code: 'ZED_MER', + description: 'Product-only project for real deliverable creation', + leader_id: zedLeaderId, + next_deliverable_sequence: 1, status_workflow: ['READY', 'IN_PROGRESS', 'QA', 'COMPLETED'], deliverable_status_workflow: ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE'], - created_by: 2 - }, - { - title: 'API Modernization', - code: 'APIMOD', - description: 'Migrate legacy APIs to modern REST architecture', - leader_id: 3, - next_deliverable_sequence: 3, - status_workflow: ['READY', 'IN_PROGRESS', 'QA', 'COMPLETED'], - deliverable_status_workflow: ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'UAT', 'STAGED', 'PROD'], task_graph_layout_direction: 'LR', completion_criteria_status: 'COMPLETED', - created_by: 3 - }, - { - title: 'Database Migration', - code: 'DATAMIG', - description: 'Migrate all customer data to new clustered database', - leader_id: 5, - next_deliverable_sequence: 1, - status_workflow: ['READY', 'IN_PROGRESS', 'QA', 'COMPLETED'], - deliverable_status_workflow: ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE'], - created_by: 5 - }, - { - title: 'Security Compliance', - code: 'SECURE', - description: 'Annual security audit and compliance updates', - leader_id: 4, - next_deliverable_sequence: 1, - status_workflow: ['READY', 'IN_PROGRESS', 'QA', 'COMPLETED'], - deliverable_status_workflow: ['PLANNING', 'IN_PROGRESS', 'IN_REVIEW', 'STAGED', 'DONE'], - created_by: 4 + created_by: zedLeaderId } ]); console.log(' ✅ Projects seeded successfully'); diff --git a/api/scripts/seeders/seedTaskRelations.js b/api/scripts/seeders/seedTaskRelations.js index 792dee58..6292ab15 100644 --- a/api/scripts/seeders/seedTaskRelations.js +++ b/api/scripts/seeders/seedTaskRelations.js @@ -1,6 +1,11 @@ import { db } from '../../lib/db/index.js'; -import { TASKS, TASK_RELATIONS } from '../../lib/db/schema.js'; -import { eq, sql } from 'drizzle-orm'; +import { TASKS, TASK_RELATIONS, DELIVERABLES } from '../../lib/db/schema.js'; +import { sql } from 'drizzle-orm'; +import { loadZazzProjectSnapshot } from './zazzSnapshot.js'; + +function toDateOrNull(value) { + return value ? new Date(value) : null; +} export async function seedTaskRelations() { console.log(' 📝 Seeding task relations...'); @@ -12,46 +17,78 @@ export async function seedTaskRelations() { return; } - const tasks = await db.select({ id: TASKS.id, title: TASKS.title }).from(TASKS); + const snapshot = await loadZazzProjectSnapshot(); + + const deliverables = await db + .select({ id: DELIVERABLES.id, key: DELIVERABLES.deliverable_code }) + .from(DELIVERABLES); + const deliverableKeyByDbId = new Map(deliverables.map((deliverable) => [deliverable.id, deliverable.key])); + + const tasks = await db + .select({ + id: TASKS.id, + title: TASKS.title, + phaseStep: TASKS.phase_step, + deliverableDbId: TASKS.deliverable_id, + }) + .from(TASKS); if (tasks.length === 0) { console.log(' ⏭️ No tasks found. Skipping task relation seeding.'); return; } - const byTitle = new Map(tasks.map(t => [t.title, t.id])); + const byDeliverableAndPhaseTask = new Map(); + const byDeliverableAndTitle = new Map(); - const depends = (a, b) => { - const taskId = byTitle.get(a); - const relatedId = byTitle.get(b); - if (!taskId || !relatedId) return null; - return { task_id: taskId, related_task_id: relatedId, relation_type: 'DEPENDS_ON' }; - }; + for (const task of tasks) { + const deliverableKey = deliverableKeyByDbId.get(task.deliverableDbId); + if (!deliverableKey) continue; + + if (task.phaseStep) { + byDeliverableAndPhaseTask.set(`${deliverableKey}::${task.phaseStep}`, task.id); + } - const coordinates = (a, b) => { - const taskId = byTitle.get(a); - const relatedId = byTitle.get(b); - if (!taskId || !relatedId) return []; - // Store both directions so consumers that treat the table as directed still see the edge. - return [ - { task_id: taskId, related_task_id: relatedId, relation_type: 'COORDINATES_WITH' }, - { task_id: relatedId, related_task_id: taskId, relation_type: 'COORDINATES_WITH' }, - ]; + byDeliverableAndTitle.set(`${deliverableKey}::${task.title}`, task.id); + } + + const resolveTaskId = (deliverableCode, phaseStep, title) => { + if (phaseStep) { + const byPhaseTask = byDeliverableAndPhaseTask.get(`${deliverableCode}::${phaseStep}`); + if (byPhaseTask) return byPhaseTask; + } + return byDeliverableAndTitle.get(`${deliverableCode}::${title}`) || null; }; - const rels = [ - // ZAZZ-1 simple dependency (1.1 must complete before 1.2) - depends('ZAZZ-1: Remaining work (UI polish + edge cases)', 'ZAZZ-1: Foundation completed (schema + API read paths)'), - - // ZAZZ-3 progression chain (each task depends on the previous phase) - depends('ZAZZ-3: Add regression tests for invalid tag formats', 'ZAZZ-3: Reproduce bug and capture failing cases'), - depends('ZAZZ-3: Confirm API validation contract + error messaging', 'ZAZZ-3: Add regression tests for invalid tag formats'), - depends('ZAZZ-3: Fix validation for trailing hyphen and edge cases', 'ZAZZ-3: Confirm API validation contract + error messaging'), - depends('ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)', 'ZAZZ-3: Fix validation for trailing hyphen and edge cases'), - depends('ZAZZ-3: Ensure tag creation/upsert handles collisions', 'ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)'), - depends('ZAZZ-3: QA run (API + UI) for tag flows', 'ZAZZ-3: Ensure tag creation/upsert handles collisions'), - depends('ZAZZ-3: Address review feedback / small refactor', 'ZAZZ-3: QA run (API + UI) for tag flows'), - depends('ZAZZ-3: Final sign-off checklist', 'ZAZZ-3: Address review feedback / small refactor'), - ].flat().filter(Boolean); + const now = new Date(); + const rels = []; + const seen = new Set(); + + for (const relation of snapshot.task_relations) { + const taskId = resolveTaskId( + relation.from_deliverable_code, + relation.from_phase_step, + relation.from_title + ); + const relatedTaskId = resolveTaskId( + relation.to_deliverable_code, + relation.to_phase_step, + relation.to_title + ); + + if (!taskId || !relatedTaskId) continue; + + const dedupeKey = `${taskId}::${relatedTaskId}::${relation.relation_type}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + rels.push({ + task_id: taskId, + related_task_id: relatedTaskId, + relation_type: relation.relation_type, + updated_by: relation.updated_by, + updated_at: toDateOrNull(relation.updated_at) || now, + }); + } if (rels.length === 0) { console.log(' ⏭️ No relations to insert.'); diff --git a/api/scripts/seeders/seedTaskTags.js b/api/scripts/seeders/seedTaskTags.js index 115694a8..3a91866b 100644 --- a/api/scripts/seeders/seedTaskTags.js +++ b/api/scripts/seeders/seedTaskTags.js @@ -1,6 +1,7 @@ import { db } from '../../lib/db/index.js'; -import { TASKS, TAGS, TASK_TAGS } from '../../lib/db/schema.js'; -import { eq, sql } from 'drizzle-orm'; +import { TASKS, TAGS, TASK_TAGS, DELIVERABLES } from '../../lib/db/schema.js'; +import { sql } from 'drizzle-orm'; +import { loadZazzProjectSnapshot } from './zazzSnapshot.js'; export async function seedTaskTags() { console.log(' 📝 Seeding task-tag relationships...'); @@ -12,32 +13,61 @@ export async function seedTaskTags() { return; } + const snapshot = await loadZazzProjectSnapshot(); const tags = await db.select({ tag: TAGS.tag }).from(TAGS); const tagSet = new Set(tags.map(t => t.tag)); - const tasks = await db.select({ id: TASKS.id, title: TASKS.title }).from(TASKS); + const deliverables = await db + .select({ id: DELIVERABLES.id, key: DELIVERABLES.deliverable_code }) + .from(DELIVERABLES); + const deliverableKeyByDbId = new Map(deliverables.map((deliverable) => [deliverable.id, deliverable.key])); + + const tasks = await db + .select({ + id: TASKS.id, + title: TASKS.title, + phaseStep: TASKS.phase_step, + deliverableDbId: TASKS.deliverable_id, + }) + .from(TASKS); + if (tasks.length === 0) { console.log(' ⏭️ No tasks found. Skipping task-tag seeding.'); return; } - const byTitle = new Map(tasks.map(t => [t.title, t.id])); + const byDeliverableAndPhaseTask = new Map(); + const byDeliverableAndTitle = new Map(); + + for (const task of tasks) { + const deliverableKey = deliverableKeyByDbId.get(task.deliverableDbId); + if (!deliverableKey) continue; + + if (task.phaseStep) { + byDeliverableAndPhaseTask.set(`${deliverableKey}::${task.phaseStep}`, task.id); + } + + byDeliverableAndTitle.set(`${deliverableKey}::${task.title}`, task.id); + } const links = []; - const add = (title, tag) => { - const id = byTitle.get(title); - if (!id) return; - if (!tagSet.has(tag)) return; - links.push({ task_id: id, tag }); - }; - - // Keep tags consistent with TAGS seed data. - add('ZAZZ-1: Foundation completed (schema + API read paths)', 'backend'); - add('ZAZZ-1: Remaining work (UI polish + edge cases)', 'frontend'); - - add('ZAZZ-3: Reproduce bug and capture failing cases', 'bug-fix'); - add('ZAZZ-3: Add regression tests for invalid tag formats', 'testing'); - add('ZAZZ-3: Fix validation for trailing hyphen and edge cases', 'bug-fix'); + const seen = new Set(); + + for (const tagLink of snapshot.task_tags) { + if (!tagSet.has(tagLink.tag)) continue; + + const taskId = tagLink.phase_step + ? byDeliverableAndPhaseTask.get(`${tagLink.deliverable_code}::${tagLink.phase_step}`) + : null; + + const resolvedTaskId = taskId || byDeliverableAndTitle.get(`${tagLink.deliverable_code}::${tagLink.title}`); + if (!resolvedTaskId) continue; + + const dedupeKey = `${resolvedTaskId}::${tagLink.tag}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + links.push({ task_id: resolvedTaskId, tag: tagLink.tag }); + } if (links.length === 0) { console.log(' ⏭️ No task-tag links to insert.'); diff --git a/api/scripts/seeders/seedTasks.js b/api/scripts/seeders/seedTasks.js index 73c05421..a016cced 100644 --- a/api/scripts/seeders/seedTasks.js +++ b/api/scripts/seeders/seedTasks.js @@ -1,6 +1,7 @@ import { db } from '../../lib/db/index.js'; import { PROJECTS, DELIVERABLES, TASKS } from '../../lib/db/schema.js'; -import { and, eq, sql } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; +import { loadZazzProjectSnapshot } from './zazzSnapshot.js'; async function getProjectId(code) { const [project] = await db.select({ id: PROJECTS.id }).from(PROJECTS).where(eq(PROJECTS.code, code)).limit(1); @@ -8,101 +9,74 @@ async function getProjectId(code) { return project.id; } -async function getDeliverableId(projectId, deliverableKey) { - const [deliverable] = await db - .select({ id: DELIVERABLES.id }) +async function getDeliverableIdMap(projectId) { + const deliverables = await db + .select({ id: DELIVERABLES.id, key: DELIVERABLES.deliverable_code }) .from(DELIVERABLES) - .where(and(eq(DELIVERABLES.project_id, projectId), eq(DELIVERABLES.deliverable_id, deliverableKey))) - .limit(1); - if (!deliverable) throw new Error(`Deliverable not found: ${deliverableKey}`); - return deliverable.id; + .where(eq(DELIVERABLES.project_id, projectId)); + + return new Map(deliverables.map((deliverable) => [deliverable.key, deliverable.id])); } -async function tasksExistForDeliverable(deliverableId) { +async function tasksExistForProject(projectId) { const [{ count }] = await db .select({ count: sql`COUNT(*)::int` }) .from(TASKS) - .where(eq(TASKS.deliverable_id, deliverableId)); + .where(eq(TASKS.project_id, projectId)); return (count ?? 0) > 0; } -/** - * Seed interdependent tasks for demo. - * Seed rules requested: - * - ZAZZ-3 (IN_REVIEW): lots of COMPLETED tasks + a small tail of IN_REVIEW/READY. - * - ZAZZ-1 (IN_PROGRESS): one COMPLETED + one READY. - * - Other deliverables: no tasks. - */ +function toDateOrNull(value) { + return value ? new Date(value) : null; +} + export async function seedTasks() { console.log(' 📝 Seeding tasks...'); try { + const snapshot = await loadZazzProjectSnapshot(); const projectId = await getProjectId('ZAZZ'); - - const zazz1 = await getDeliverableId(projectId, 'ZAZZ-1'); - const zazz3 = await getDeliverableId(projectId, 'ZAZZ-3'); - - // Idempotency: if either deliverable already has tasks, don’t insert again. - const zazz1Has = await tasksExistForDeliverable(zazz1); - const zazz3Has = await tasksExistForDeliverable(zazz3); - if (zazz1Has || zazz3Has) { - console.log(' ⏭️ Tasks already exist for seeded deliverables. Skipping task seeding.'); + const hasTasks = await tasksExistForProject(projectId); + if (hasTasks) { + console.log(' ⏭️ Tasks already exist for ZAZZ project. Skipping task seeding.'); return; } - const createdBy = 5; + const deliverableIdMap = await getDeliverableIdMap(projectId); const now = new Date(); - const mk = (overrides) => ({ - project_id: projectId, - created_by: createdBy, - updated_by: createdBy, - created_at: now, - updated_at: now, - priority: 'MEDIUM', - status: 'READY', - position: 10, - ...overrides, - }); - - const tasks = [ - // ---------------- ZAZZ-1 (IN_PROGRESS) ---------------- - mk({ - deliverable_id: zazz1, - phase: 1, - phase_task_id: '1.1', - title: 'ZAZZ-1: Foundation completed (schema + API read paths)', - status: 'COMPLETED', - priority: 'HIGH', - position: 10, - completed_at: now, - }), - mk({ - deliverable_id: zazz1, - phase: 2, - phase_task_id: '2.1', - title: 'ZAZZ-1: Remaining work (UI polish + edge cases)', - status: 'READY', - priority: 'MEDIUM', - position: 20, - }), - - // ---------------- ZAZZ-3 (IN_REVIEW) ---------------- - // Phase 1: reproduce + analysis - mk({ deliverable_id: zazz3, phase: 1, phase_task_id: '1.1', title: 'ZAZZ-3: Reproduce bug and capture failing cases', status: 'COMPLETED', priority: 'HIGH', position: 10, completed_at: now }), - mk({ deliverable_id: zazz3, phase: 1, phase_task_id: '1.2', title: 'ZAZZ-3: Add regression tests for invalid tag formats', status: 'COMPLETED', priority: 'HIGH', position: 20, completed_at: now }), - mk({ deliverable_id: zazz3, phase: 1, phase_task_id: '1.3', title: 'ZAZZ-3: Confirm API validation contract + error messaging', status: 'COMPLETED', priority: 'MEDIUM', position: 30, completed_at: now }), + const tasks = snapshot.tasks.map((task, index) => { + const deliverableDbId = deliverableIdMap.get(task.deliverable_code); + if (!deliverableDbId) { + throw new Error(`Deliverable not found for task seed: ${task.deliverable_code}`); + } - // Phase 2: implement fix - mk({ deliverable_id: zazz3, phase: 2, phase_task_id: '2.1', title: 'ZAZZ-3: Fix validation for trailing hyphen and edge cases', status: 'COMPLETED', priority: 'HIGH', position: 40, completed_at: now }), - mk({ deliverable_id: zazz3, phase: 2, phase_task_id: '2.2', title: 'ZAZZ-3: Add server-side canonicalization (lowercase + hyphens)', status: 'COMPLETED', priority: 'MEDIUM', position: 50, completed_at: now }), - mk({ deliverable_id: zazz3, phase: 2, phase_task_id: '2.3', title: 'ZAZZ-3: Ensure tag creation/upsert handles collisions', status: 'COMPLETED', priority: 'MEDIUM', position: 60, completed_at: now }), - - // Phase 3: QA + wrap-up tail - mk({ deliverable_id: zazz3, phase: 3, phase_task_id: '3.1', title: 'ZAZZ-3: QA run (API + UI) for tag flows', status: 'COMPLETED', priority: 'MEDIUM', position: 70, completed_at: now }), - mk({ deliverable_id: zazz3, phase: 3, phase_task_id: '3.2', title: 'ZAZZ-3: Address review feedback / small refactor', status: 'COMPLETED', priority: 'MEDIUM', position: 80, completed_at: now }), - mk({ deliverable_id: zazz3, phase: 3, phase_task_id: '3.3', title: 'ZAZZ-3: Final sign-off checklist', status: 'COMPLETED', priority: 'LOW', position: 90, completed_at: now }), - ]; + return { + project_id: projectId, + deliverable_id: deliverableDbId, + phase: task.phase, + phase_step: task.phase_step, + title: task.title, + status: task.status || 'READY', + priority: task.priority || 'MEDIUM', + agent_name: task.agent_name, + prompt: task.prompt, + notes: task.notes, + story_points: task.story_points, + position: task.position ?? (index + 1) * 10, + is_blocked: task.is_blocked ?? false, + blocked_reason: task.blocked_reason, + is_cancelled: task.is_cancelled ?? false, + git_worktree: task.git_worktree, + started_at: toDateOrNull(task.started_at), + completed_at: toDateOrNull(task.completed_at), + coordination_code: task.coordination_code, + created_by: task.created_by ?? 5, + created_at: toDateOrNull(task.created_at) || now, + updated_by: task.updated_by ?? task.created_by ?? 5, + updated_at: toDateOrNull(task.updated_at) || now, + }; + }); await db.insert(TASKS).values(tasks); diff --git a/api/scripts/seeders/zazzSnapshot.js b/api/scripts/seeders/zazzSnapshot.js new file mode 100644 index 00000000..1233f7ab --- /dev/null +++ b/api/scripts/seeders/zazzSnapshot.js @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const snapshotPath = resolve(__dirname, 'data/zazz-project-snapshot.json'); + +let cachedSnapshot = null; + +export async function loadZazzProjectSnapshot() { + if (cachedSnapshot) { + return cachedSnapshot; + } + + const raw = await readFile(snapshotPath, 'utf8'); + const parsed = JSON.parse(raw); + + if (!parsed?.project || parsed.project.code !== 'ZAZZ') { + throw new Error('Invalid ZAZZ snapshot data: missing project or unexpected project code'); + } + + parsed.deliverables = Array.isArray(parsed.deliverables) ? parsed.deliverables : []; + parsed.tasks = Array.isArray(parsed.tasks) ? parsed.tasks : []; + parsed.task_tags = Array.isArray(parsed.task_tags) ? parsed.task_tags : []; + parsed.task_relations = Array.isArray(parsed.task_relations) ? parsed.task_relations : []; + + cachedSnapshot = parsed; + return cachedSnapshot; +} diff --git a/api/src/routes/deliverables.js b/api/src/routes/deliverables.js index 660286bb..5f0f2f31 100644 --- a/api/src/routes/deliverables.js +++ b/api/src/routes/deliverables.js @@ -2,7 +2,12 @@ import { authMiddleware } from '../middleware/authMiddleware.js'; import { deliverableSchemas } from '../schemas/validation.js'; export default async function deliverableRoutes(fastify, options) { - const { dbService } = options; + const { dbService, realtimeService } = options; + + const publishEvent = (projectCode, payload) => { + if (!realtimeService) return; + realtimeService.publish(projectCode, payload); + }; fastify.addHook('preHandler', authMiddleware); fastify.get('/projects/:projectCode/deliverables', { schema: deliverableSchemas.getProjectDeliverables }, async (request, reply) => { @@ -40,6 +45,12 @@ export default async function deliverableRoutes(fastify, options) { const project = await dbService.getProjectByCode(projectCode); if (!project) return reply.code(404).send({ error: 'Project not found' }); const created = await dbService.createDeliverable(project.id, request.body, request.user.id); + publishEvent(project.code, { + type: 'deliverable', + eventType: 'deliverable.created', + deliverableId: created.id, + status: created.status, + }); reply.code(201).send(created); } catch (error) { request.log.error(error); @@ -57,6 +68,12 @@ export default async function deliverableRoutes(fastify, options) { const existing = await dbService.getDeliverableById(deliverableId); if (!existing || existing.projectId !== project.id) return reply.code(404).send({ error: 'Deliverable not found' }); const updated = await dbService.updateDeliverable(deliverableId, request.body, request.user.id); + publishEvent(project.code, { + type: 'deliverable', + eventType: 'deliverable.updated', + deliverableId: updated.id, + status: updated.status, + }); reply.send(updated); } catch (error) { request.log.error(error); @@ -72,7 +89,13 @@ export default async function deliverableRoutes(fastify, options) { if (!project) return reply.code(404).send({ error: 'Project not found' }); const existing = await dbService.getDeliverableById(deliverableId); if (!existing || existing.projectId !== project.id) return reply.code(404).send({ error: 'Deliverable not found' }); - const deleted = await dbService.deleteDeliverable(deliverableId); + await dbService.deleteDeliverable(deliverableId); + publishEvent(project.code, { + type: 'deliverable', + eventType: 'deliverable.deleted', + deliverableId: deliverableId, + status: existing.status, + }); reply.send({ message: 'Deliverable deleted successfully' }); } catch (error) { request.log.error(error); @@ -91,6 +114,13 @@ export default async function deliverableRoutes(fastify, options) { const statusDef = await dbService.getStatusDefinitionByCode(request.body.status); if (!statusDef) return reply.code(400).send({ error: `Invalid status: ${request.body.status}` }); const updated = await dbService.updateDeliverableStatus(deliverableId, request.body.status, request.user.id); + publishEvent(project.code, { + type: 'deliverable', + eventType: 'deliverable.status_changed', + deliverableId: updated.id, + status: updated.status, + previousStatus: existing.status, + }); reply.send(updated); } catch (error) { request.log.error(error); @@ -108,6 +138,13 @@ export default async function deliverableRoutes(fastify, options) { const existing = await dbService.getDeliverableById(deliverableId); if (!existing || existing.projectId !== project.id) return reply.code(404).send({ error: 'Deliverable not found' }); const updated = await dbService.approveDeliverablePlan(deliverableId, request.user.id); + publishEvent(project.code, { + type: 'deliverable', + eventType: 'deliverable.updated', + deliverableId: updated.id, + status: updated.status, + approved: true, + }); reply.send(updated); } catch (error) { request.log.error(error); diff --git a/api/src/routes/images.js b/api/src/routes/images.js index baf1101c..84d2b620 100644 --- a/api/src/routes/images.js +++ b/api/src/routes/images.js @@ -1,166 +1,344 @@ import { authMiddleware } from '../middleware/authMiddleware.js'; import { imageSchemas } from '../schemas/validation.js'; +function parseId(value) { + return parseInt(value, 10); +} + export default async function imageRoutes(fastify, options) { const { dbService } = options; // Add authentication middleware to all image routes fastify.addHook('preHandler', authMiddleware); - // GET /tasks/:taskId/images - Get all images for a task - fastify.get('/tasks/:taskId/images', { schema: imageSchemas.getTaskImages }, async (request, reply) => { - try { - const { taskId } = request.params; - request.log.info(`Fetching images for task ${taskId}`); - - const images = await dbService.getTaskImages(parseInt(taskId)); - request.log.info(`Found ${images.length} images for task ${taskId}`); - - reply.send(images); - } catch (error) { - request.log.error(error, 'Failed to fetch task images'); - reply.code(500).send({ error: 'Failed to fetch task images' }); - } - }); - - // POST /tasks/:taskId/images/upload - Upload images to a task - fastify.post('/tasks/:taskId/images/upload', { schema: imageSchemas.uploadTaskImages }, async (request, reply) => { - try { - const { taskId } = request.params; - const { images } = request.body; - - request.log.info(`Uploading ${images?.length || 0} images to task ${taskId}`); - - if (!images || !Array.isArray(images) || images.length === 0) { - return reply.code(400).send({ error: 'No images provided' }); + async function resolveScopedTask(request, reply) { + const { code, delivId, taskId } = request.params; + const deliverableId = parseId(delivId); + const taskIdNum = parseId(taskId); + + const project = await dbService.getProjectByCode(code); + if (!project) { + reply.code(404).send({ error: 'Project not found' }); + return null; + } + + const deliverable = await dbService.getDeliverableById(deliverableId); + if (!deliverable) { + reply.code(404).send({ error: 'Deliverable not found' }); + return null; + } + if (deliverable.projectId !== project.id) { + reply.code(403).send({ error: 'Deliverable does not belong to this project' }); + return null; + } + + const task = await dbService.getTaskById(taskIdNum); + if (!task) { + reply.code(404).send({ error: 'Task not found' }); + return null; + } + if (task.projectId !== project.id || task.deliverableId !== deliverableId) { + reply.code(403).send({ error: 'Task does not belong to this project/deliverable scope' }); + return null; + } + + return { project, deliverable, task, deliverableId, taskIdNum }; + } + + async function resolveScopedDeliverable(request, reply) { + const { code, delivId } = request.params; + const deliverableId = parseId(delivId); + + const project = await dbService.getProjectByCode(code); + if (!project) { + reply.code(404).send({ error: 'Project not found' }); + return null; + } + + const deliverable = await dbService.getDeliverableById(deliverableId); + if (!deliverable) { + reply.code(404).send({ error: 'Deliverable not found' }); + return null; + } + if (deliverable.projectId !== project.id) { + reply.code(403).send({ error: 'Deliverable does not belong to this project' }); + return null; + } + + return { project, deliverable, deliverableId }; + } + + async function resolveImageOwnerProjectId(metadata) { + if (metadata.taskId) { + const task = await dbService.getTaskById(metadata.taskId); + return task?.projectId ?? null; + } + if (metadata.deliverableId) { + const deliverable = await dbService.getDeliverableById(metadata.deliverableId); + return deliverable?.projectId ?? null; + } + return null; + } + + // GET /projects/:code/deliverables/:delivId/tasks/:taskId/images + fastify.get( + '/projects/:code/deliverables/:delivId/tasks/:taskId/images', + { schema: imageSchemas.getScopedTaskImages }, + async (request, reply) => { + try { + const scoped = await resolveScopedTask(request, reply); + if (!scoped) return; + + const images = await dbService.getTaskImages(scoped.taskIdNum); + reply.send(images); + } catch (error) { + request.log.error(error, 'Failed to fetch scoped task images'); + reply.code(500).send({ error: 'Failed to fetch task images' }); } - - const uploadedImages = []; - - for (const imageData of images) { - const { originalName, contentType, fileSize, base64Data } = imageData; - - // Validate required fields - if (!originalName || !contentType || !fileSize || !base64Data) { - request.log.warn('Skipping invalid image data', { originalName, contentType }); - continue; - } - - // Validate content type - if (!contentType.startsWith('image/')) { - request.log.warn('Skipping non-image file', { originalName, contentType }); - continue; - } - - const storedImage = await dbService.storeTaskImage(parseInt(taskId), { - originalName, - contentType, - fileSize, - base64Data + } + ); + + // POST /projects/:code/deliverables/:delivId/tasks/:taskId/images/upload + fastify.post( + '/projects/:code/deliverables/:delivId/tasks/:taskId/images/upload', + { schema: imageSchemas.uploadScopedTaskImages }, + async (request, reply) => { + try { + const scoped = await resolveScopedTask(request, reply); + if (!scoped) return; + + const { images } = request.body; + if (!images || !Array.isArray(images) || images.length === 0) { + return reply.code(400).send({ error: 'No images provided' }); + } + + const uploadedImages = []; + const imageUrlBase = `/projects/${encodeURIComponent(scoped.project.code)}/images`; + + for (const imageData of images) { + const { originalName, contentType, fileSize, base64Data } = imageData; + if (!originalName || !contentType || !fileSize || !base64Data) continue; + if (!contentType.startsWith('image/')) continue; + + const storedImage = await dbService.storeTaskImage( + scoped.taskIdNum, + { originalName, contentType, fileSize, base64Data }, + imageUrlBase + ); + uploadedImages.push(storedImage); + } + + reply.code(201).send({ + success: true, + images: uploadedImages, + count: uploadedImages.length }); - - uploadedImages.push(storedImage); - } - - request.log.info(`Successfully uploaded ${uploadedImages.length} images`); - - reply.code(201).send({ - success: true, - images: uploadedImages, - count: uploadedImages.length - }); - } catch (error) { - request.log.error(error, 'Failed to upload images'); - reply.code(500).send({ error: 'Failed to upload images' }); - } - }); - - // GET /images/:id - Serve individual image - fastify.get('/images/:id', { schema: imageSchemas.getImageById }, async (request, reply) => { - try { - const { id } = request.params; - const imageId = parseInt(id); - - if (isNaN(imageId)) { - return reply.code(400).send({ error: 'Invalid image ID' }); + } catch (error) { + request.log.error(error, 'Failed to upload scoped task images'); + reply.code(500).send({ error: 'Failed to upload task images' }); } - - const imageWithData = await dbService.getImageWithData(imageId); - - if (!imageWithData || !imageWithData.data) { - return reply.code(404).send({ error: 'Image not found' }); + } + ); + + // DELETE /projects/:code/deliverables/:delivId/tasks/:taskId/images/:imageId + fastify.delete( + '/projects/:code/deliverables/:delivId/tasks/:taskId/images/:imageId', + { schema: imageSchemas.deleteScopedTaskImage }, + async (request, reply) => { + try { + const scoped = await resolveScopedTask(request, reply); + if (!scoped) return; + + const imageIdNum = parseId(request.params.imageId); + const imageMetadata = await dbService.getImageMetadata(imageIdNum); + if (!imageMetadata) { + return reply.code(404).send({ error: 'Image not found' }); + } + if (imageMetadata.taskId !== scoped.taskIdNum) { + return reply.code(403).send({ error: 'Image does not belong to the specified task scope' }); + } + + const deletedImage = await dbService.deleteImage(imageIdNum); + if (!deletedImage) { + return reply.code(404).send({ error: 'Image not found' }); + } + + reply.send({ message: 'Image deleted successfully', image: deletedImage }); + } catch (error) { + request.log.error(error, 'Failed to delete scoped task image'); + reply.code(500).send({ error: 'Failed to delete task image' }); } - - // Convert base64 to binary - const binaryData = Buffer.from(imageWithData.data, 'base64'); - - // Set proper headers - reply - .header('Content-Type', imageWithData.contentType) - .header('Content-Length', binaryData.length) - .header('Cache-Control', 'public, max-age=31536000') // Cache for 1 year - .send(binaryData); - - } catch (error) { - request.log.error(error, 'Failed to serve image'); - reply.code(500).send({ error: 'Failed to serve image' }); - } - }); - - // GET /images/:id/metadata - Get image metadata only - fastify.get('/images/:id/metadata', { schema: imageSchemas.getImageMetadata }, async (request, reply) => { - try { - const { id } = request.params; - const imageId = parseInt(id); - - if (isNaN(imageId)) { - return reply.code(400).send({ error: 'Invalid image ID' }); + } + ); + + // GET /projects/:code/deliverables/:delivId/images + fastify.get( + '/projects/:code/deliverables/:delivId/images', + { schema: imageSchemas.getScopedDeliverableImages }, + async (request, reply) => { + try { + const scoped = await resolveScopedDeliverable(request, reply); + if (!scoped) return; + + const images = await dbService.getDeliverableImages(scoped.deliverableId); + reply.send(images); + } catch (error) { + request.log.error(error, 'Failed to fetch scoped deliverable images'); + reply.code(500).send({ error: 'Failed to fetch deliverable images' }); } - - const metadata = await dbService.getImageMetadata(imageId); - - if (!metadata) { - return reply.code(404).send({ error: 'Image not found' }); + } + ); + + // POST /projects/:code/deliverables/:delivId/images/upload + fastify.post( + '/projects/:code/deliverables/:delivId/images/upload', + { schema: imageSchemas.uploadScopedDeliverableImages }, + async (request, reply) => { + try { + const scoped = await resolveScopedDeliverable(request, reply); + if (!scoped) return; + + const { images } = request.body; + if (!images || !Array.isArray(images) || images.length === 0) { + return reply.code(400).send({ error: 'No images provided' }); + } + + const uploadedImages = []; + const imageUrlBase = `/projects/${encodeURIComponent(scoped.project.code)}/images`; + + for (const imageData of images) { + const { originalName, contentType, fileSize, base64Data } = imageData; + if (!originalName || !contentType || !fileSize || !base64Data) continue; + if (!contentType.startsWith('image/')) continue; + + const storedImage = await dbService.storeDeliverableImage( + scoped.deliverableId, + { originalName, contentType, fileSize, base64Data }, + imageUrlBase + ); + uploadedImages.push(storedImage); + } + + reply.code(201).send({ + success: true, + images: uploadedImages, + count: uploadedImages.length + }); + } catch (error) { + request.log.error(error, 'Failed to upload scoped deliverable images'); + reply.code(500).send({ error: 'Failed to upload deliverable images' }); } - - reply.send(metadata); - } catch (error) { - request.log.error(error, 'Failed to fetch image metadata'); - reply.code(500).send({ error: 'Failed to fetch image metadata' }); - } - }); - - // DELETE /tasks/:taskId/images/:imageId - Delete specific image from task (with security validation) - fastify.delete('/tasks/:taskId/images/:imageId', { schema: imageSchemas.deleteTaskImage }, async (request, reply) => { - try { - const { taskId, imageId } = request.params; - const taskIdNum = parseInt(taskId); - const imageIdNum = parseInt(imageId); - - if (isNaN(taskIdNum) || isNaN(imageIdNum)) { - return reply.code(400).send({ error: 'Invalid task ID or image ID' }); + } + ); + + // DELETE /projects/:code/deliverables/:delivId/images/:imageId + fastify.delete( + '/projects/:code/deliverables/:delivId/images/:imageId', + { schema: imageSchemas.deleteScopedDeliverableImage }, + async (request, reply) => { + try { + const scoped = await resolveScopedDeliverable(request, reply); + if (!scoped) return; + + const imageIdNum = parseId(request.params.imageId); + const imageMetadata = await dbService.getImageMetadata(imageIdNum); + if (!imageMetadata) { + return reply.code(404).send({ error: 'Image not found' }); + } + if (imageMetadata.deliverableId !== scoped.deliverableId) { + return reply.code(403).send({ error: 'Image does not belong to the specified deliverable scope' }); + } + + const deletedImage = await dbService.deleteImage(imageIdNum); + if (!deletedImage) { + return reply.code(404).send({ error: 'Image not found' }); + } + + reply.send({ message: 'Image deleted successfully', image: deletedImage }); + } catch (error) { + request.log.error(error, 'Failed to delete scoped deliverable image'); + reply.code(500).send({ error: 'Failed to delete deliverable image' }); } - - request.log.info(`Deleting image ${imageIdNum} from task ${taskIdNum}`); - - // First verify the image belongs to the specified task - const imageMetadata = await dbService.getImageMetadata(imageIdNum); - - if (!imageMetadata) { - return reply.code(404).send({ error: 'Image not found' }); + } + ); + + // GET /projects/:code/images/:id + fastify.get( + '/projects/:code/images/:id', + { schema: imageSchemas.getScopedImageById }, + async (request, reply) => { + try { + const { code, id } = request.params; + const imageId = parseId(id); + + const project = await dbService.getProjectByCode(code); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const metadata = await dbService.getImageMetadata(imageId); + if (!metadata) { + return reply.code(404).send({ error: 'Image not found' }); + } + + const ownerProjectId = await resolveImageOwnerProjectId(metadata); + if (!ownerProjectId) { + return reply.code(404).send({ error: 'Image owner not found' }); + } + if (ownerProjectId !== project.id) { + return reply.code(403).send({ error: 'Image does not belong to this project' }); + } + + const imageWithData = await dbService.getImageWithData(imageId); + if (!imageWithData || !imageWithData.data) { + return reply.code(404).send({ error: 'Image not found' }); + } + + const binaryData = Buffer.from(imageWithData.data, 'base64'); + reply + .header('Content-Type', imageWithData.contentType) + .header('Content-Length', binaryData.length) + .header('Cache-Control', 'public, max-age=31536000') + .send(binaryData); + } catch (error) { + request.log.error(error, 'Failed to serve scoped image'); + reply.code(500).send({ error: 'Failed to serve image' }); } - - if (imageMetadata.taskId !== taskIdNum) { - return reply.code(403).send({ error: 'Image does not belong to the specified task' }); + } + ); + + // GET /projects/:code/images/:id/metadata + fastify.get( + '/projects/:code/images/:id/metadata', + { schema: imageSchemas.getScopedImageMetadata }, + async (request, reply) => { + try { + const { code, id } = request.params; + const imageId = parseId(id); + + const project = await dbService.getProjectByCode(code); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const metadata = await dbService.getImageMetadata(imageId); + if (!metadata) { + return reply.code(404).send({ error: 'Image not found' }); + } + + const ownerProjectId = await resolveImageOwnerProjectId(metadata); + if (!ownerProjectId) { + return reply.code(404).send({ error: 'Image owner not found' }); + } + if (ownerProjectId !== project.id) { + return reply.code(403).send({ error: 'Image does not belong to this project' }); + } + + reply.send(metadata); + } catch (error) { + request.log.error(error, 'Failed to fetch scoped image metadata'); + reply.code(500).send({ error: 'Failed to fetch image metadata' }); } - - // Now delete the image - const deletedImage = await dbService.deleteImage(imageIdNum); - - reply.send({ message: 'Image deleted successfully', image: deletedImage }); - } catch (error) { - request.log.error(error, 'Failed to delete image'); - reply.code(500).send({ error: 'Failed to delete image' }); - } - }); + } + ); } diff --git a/api/src/routes/index.js b/api/src/routes/index.js index f319a4e9..dd75551a 100644 --- a/api/src/routes/index.js +++ b/api/src/routes/index.js @@ -1,4 +1,5 @@ import DatabaseService from '../services/databaseService.js'; +import RealtimeService from '../services/realtimeService.js'; import { tokenService } from '../services/tokenService.js'; import { coreSchemas } from '../schemas/validation.js'; @@ -14,6 +15,7 @@ import taskGraphRoutes from './taskGraph.js'; import deliverableRoutes from './deliverables.js'; const dbService = new DatabaseService(); +const realtimeService = new RealtimeService(); export default async function routes(fastify, options) { // Health check endpoint (public) @@ -34,7 +36,7 @@ export default async function routes(fastify, options) { reply.send({ message: 'Zazz Board API', version: '1.0.0', - endpoints: ['/health', '/users', '/projects', '/deliverables', '/tasks', '/tags', '/images', '/translations', '/status-definitions', '/coordination-types'] + endpoints: ['/health', '/users', '/projects', '/deliverables', '/tasks', '/tags', '/projects/:code/images/:id', '/translations', '/status-definitions', '/coordination-types'] }); }); @@ -61,7 +63,7 @@ export default async function routes(fastify, options) { }); // Register route plugins with shared database service - const pluginOptions = { dbService }; + const pluginOptions = { dbService, realtimeService }; await fastify.register(userRoutes, pluginOptions); await fastify.register(projectRoutes, pluginOptions); diff --git a/api/src/routes/projects.js b/api/src/routes/projects.js index 25aab4d8..7234399c 100644 --- a/api/src/routes/projects.js +++ b/api/src/routes/projects.js @@ -2,11 +2,85 @@ import { projectSchemas } from '../schemas/validation.js'; import { authMiddleware } from '../middleware/authMiddleware.js'; export default async function projectRoutes(fastify, options) { - const { dbService } = options; + const { dbService, realtimeService } = options; + + const publishEvent = (projectCode, payload) => { + if (!realtimeService) return; + realtimeService.publish(projectCode, payload); + }; // Add authentication middleware to all project routes fastify.addHook('preHandler', authMiddleware); + // GET /projects/:code/events - Subscribe to project-scoped realtime events (SSE) + fastify.get('/projects/:code/events', { + schema: { + tags: ['projects'], + summary: 'Subscribe to project realtime events (SSE)', + description: 'Streams task and deliverable updates for a project using Server-Sent Events. Requires TB_TOKEN auth.', + params: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' } + } + }, + response: { + 200: { + type: 'string', + description: 'SSE stream (text/event-stream)' + } + } + } + }, async (request, reply) => { + const { code } = request.params; + const project = await dbService.getProjectByCode(code); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + if (!realtimeService) { + return reply.code(503).send({ error: 'Realtime service unavailable' }); + } + + reply.hijack(); + + const projectCode = String(project.code).toUpperCase(); + const rawReply = reply.raw; + rawReply.setHeader('Content-Type', 'text/event-stream'); + rawReply.setHeader('Cache-Control', 'no-cache, no-transform'); + rawReply.setHeader('Connection', 'keep-alive'); + rawReply.setHeader('X-Accel-Buffering', 'no'); + if (rawReply.flushHeaders) rawReply.flushHeaders(); + + const subscriberId = realtimeService.subscribe(projectCode, { + send: (message) => rawReply.write(message) + }); + + const heartbeat = setInterval(() => { + try { + rawReply.write(': keep-alive\n\n'); + } catch (error) { + clearInterval(heartbeat); + } + }, 20000); + + const cleanup = () => { + clearInterval(heartbeat); + realtimeService.unsubscribe(projectCode, subscriberId); + }; + + request.raw.on('close', cleanup); + request.raw.on('error', cleanup); + + rawReply.write( + `event: connected\ndata: ${JSON.stringify({ + projectCode, + connectedAt: new Date().toISOString(), + })}\n\n` + ); + }); + // GET /projects - List all projects with details fastify.get('/projects', { schema: projectSchemas.getProjects @@ -145,7 +219,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'status'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, status: { type: 'string', pattern: '^[A-Z_]+$', description: 'Column status.' } } }, @@ -180,6 +254,12 @@ export default async function projectRoutes(fastify, options) { } const updatedTasks = await dbService.updateColumnPositions(project.id, status, positionUpdates); + publishEvent(project.code, { + type: 'task', + eventType: 'task.column_reordered', + status, + taskIds: positionUpdates.map((item) => item.taskId), + }); reply.send(updatedTasks); } catch (error) { fastify.log.error(error); @@ -197,7 +277,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } }, @@ -223,6 +303,16 @@ export default async function projectRoutes(fastify, options) { const taskIdNum = parseInt(taskId); const updatedTask = await dbService.updateTaskPosition(taskIdNum, newPosition, status); + const fullTask = updatedTask?.id ? await dbService.getTaskById(updatedTask.id) : null; + + publishEvent(project.code, { + type: 'task', + eventType: 'task.position_updated', + taskId: fullTask?.id || taskIdNum, + deliverableId: fullTask?.deliverableId || null, + status: fullTask?.status || status, + position: fullTask?.position || newPosition, + }); reply.send(updatedTask); } catch (error) { @@ -275,6 +365,14 @@ export default async function projectRoutes(fastify, options) { }); request.log.info(`Task ${taskId} status changed from ${currentTask.status} to ${status}`); + publishEvent(project.code, { + type: 'task', + eventType: 'task.status_changed', + taskId: updatedTask.id, + deliverableId: updatedTask.deliverableId, + status: updatedTask.status, + previousStatus: currentTask.status, + }); reply.send(updatedTask); } catch (error) { request.log.error(error, 'Failed to change task status'); @@ -309,8 +407,19 @@ export default async function projectRoutes(fastify, options) { // Update task — updateTask handles auto-promotion on status change const updatedTask = await dbService.updateTask(currentTask.id, taskData); + const eventType = taskData.status && taskData.status !== currentTask.status + ? 'task.status_changed' + : 'task.updated'; request.log.info(`Task ${taskId} updated`); + publishEvent(project.code, { + type: 'task', + eventType, + taskId: updatedTask.id, + deliverableId: updatedTask.deliverableId, + status: updatedTask.status, + previousStatus: currentTask.status, + }); reply.send(updatedTask); } catch (error) { request.log.error(error, 'Failed to update task'); @@ -343,9 +452,16 @@ export default async function projectRoutes(fastify, options) { } // Delete task - const deletedTask = await dbService.deleteTask(currentTask.id); + await dbService.deleteTask(currentTask.id); request.log.info(`Task ${taskId} deleted`); + publishEvent(project.code, { + type: 'task', + eventType: 'task.deleted', + taskId: currentTask.id, + deliverableId: currentTask.deliverableId, + status: currentTask.status, + }); reply.send({ message: 'Task deleted successfully' }); } catch (error) { request.log.error(error, 'Failed to delete task'); @@ -363,7 +479,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' } + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' } } }, response: { @@ -404,7 +520,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' } + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' } } }, body: { @@ -487,7 +603,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' } + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' } } } } @@ -513,7 +629,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' } + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' } } }, body: { @@ -603,6 +719,13 @@ export default async function projectRoutes(fastify, options) { }; const task = await dbService.createTask(taskData); + publishEvent(project.code, { + type: 'task', + eventType: 'task.created', + taskId: task.id, + deliverableId: task.deliverableId, + status: task.status, + }); reply.code(201).send(task); } catch (error) { request.log.error(error, 'Failed to create task'); @@ -664,8 +787,19 @@ export default async function projectRoutes(fastify, options) { ...currentTask, ...request.body }); + const eventType = request.body.status && request.body.status !== currentTask.status + ? 'task.status_changed' + : 'task.updated'; request.log.info(`Task ${taskId} updated in deliverable ${delivId}`); + publishEvent(project.code, { + type: 'task', + eventType, + taskId: updatedTask.id, + deliverableId: updatedTask.deliverableId, + status: updatedTask.status, + previousStatus: currentTask.status, + }); reply.send(updatedTask); } catch (error) { request.log.error(error, 'Failed to update task'); @@ -683,7 +817,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } @@ -711,6 +845,13 @@ export default async function projectRoutes(fastify, options) { await dbService.deleteTask(taskIdNum); request.log.info(`Task ${taskId} deleted from deliverable ${delivId}`); + publishEvent(project.code, { + type: 'task', + eventType: 'task.deleted', + taskId: currentTask.id, + deliverableId: currentTask.deliverableId, + status: currentTask.status, + }); reply.send({ message: 'Task deleted successfully' }); } catch (error) { request.log.error(error, 'Failed to delete task'); @@ -728,7 +869,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } @@ -774,6 +915,13 @@ export default async function projectRoutes(fastify, options) { }); request.log.info(`Note appended to task ${taskId} in deliverable ${delivId}`); + publishEvent(project.code, { + type: 'task', + eventType: 'task.updated', + taskId: updatedTask.id, + deliverableId: updatedTask.deliverableId, + status: updatedTask.status, + }); reply.send(updatedTask); } catch (error) { request.log.error(error, 'Failed to append note to task'); @@ -791,7 +939,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id from create deliverable.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id from create task.' } } @@ -854,6 +1002,14 @@ export default async function projectRoutes(fastify, options) { }); request.log.info(`Task ${taskId} status changed from ${currentTask.status} to ${status}`); + publishEvent(project.code, { + type: 'task', + eventType: 'task.status_changed', + taskId: updatedTask.id, + deliverableId: updatedTask.deliverableId, + status: updatedTask.status, + previousStatus: currentTask.status, + }); reply.send(updatedTask); } catch (error) { request.log.error(error, 'Failed to change task status'); @@ -871,7 +1027,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } @@ -903,6 +1059,15 @@ export default async function projectRoutes(fastify, options) { }); request.log.info(`Task ${taskId} cancelled in deliverable ${delivId}`); + publishEvent(project.code, { + type: 'task', + eventType: 'task.status_changed', + taskId: updatedTask.id, + deliverableId: updatedTask.deliverableId, + status: updatedTask.status, + previousStatus: currentTask.status, + isCancelled: true, + }); reply.send(updatedTask); } catch (error) { request.log.error(error, 'Failed to cancel task'); @@ -920,7 +1085,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } @@ -956,6 +1121,14 @@ export default async function projectRoutes(fastify, options) { const updatedTask = await dbService.reorderTask(taskIdNum, position); request.log.info(`Task ${taskId} reordered to position ${position}`); + publishEvent(project.code, { + type: 'task', + eventType: 'task.position_updated', + taskId: updatedTask.id, + deliverableId: currentTask.deliverableId, + status: updatedTask.status, + position: updatedTask.position, + }); reply.send(updatedTask); } catch (error) { request.log.error(error, 'Failed to reorder task'); @@ -973,7 +1146,7 @@ export default async function projectRoutes(fastify, options) { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } diff --git a/api/src/routes/statusDefinitions.js b/api/src/routes/statusDefinitions.js index 4f77455a..0c7990b6 100644 --- a/api/src/routes/statusDefinitions.js +++ b/api/src/routes/statusDefinitions.js @@ -19,7 +19,7 @@ export default async function statusDefinitionsRoutes(fastify, options) { type: 'object', properties: { code: { type: 'string' }, - description: { type: ['string', 'null'] }, + description: { type: 'string', nullable: true }, createdAt: { type: 'string' }, updatedAt: { type: 'string' } } diff --git a/api/src/routes/taskGraph.js b/api/src/routes/taskGraph.js index 48acf91c..13820f91 100644 --- a/api/src/routes/taskGraph.js +++ b/api/src/routes/taskGraph.js @@ -2,37 +2,16 @@ import { taskGraphSchemas } from '../schemas/validation.js'; import { authMiddleware } from '../middleware/authMiddleware.js'; export default async function taskGraphRoutes(fastify, options) { - const { dbService } = options; + const { dbService, realtimeService } = options; + + const publishEvent = (projectCode, payload) => { + if (!realtimeService) return; + realtimeService.publish(projectCode, payload); + }; // Add authentication middleware to all task graph routes fastify.addHook('preHandler', authMiddleware); - // GET /projects/:code/graph - Get full task graph for a project - fastify.get('/projects/:code/graph', { - schema: taskGraphSchemas.getProjectGraph - }, async (request, reply) => { - try { - const { code } = request.params; - - const project = await dbService.getProjectByCode(code); - if (!project) { - return reply.code(404).send({ error: 'Project not found' }); - } - - const graph = await dbService.getProjectTaskGraph(project.id); - reply.send({ - projectId: project.id, - projectCode: project.code, - taskGraphLayoutDirection: project.taskGraphLayoutDirection, - completionCriteriaStatus: project.completionCriteriaStatus, - ...graph - }); - } catch (error) { - request.log.error(error, 'Failed to fetch project task graph'); - reply.code(500).send({ error: 'Failed to fetch project task graph' }); - } - }); - // GET /projects/:code/tasks/:taskId/relations - Get all relations for a task fastify.get('/projects/:code/tasks/:taskId/relations', { schema: taskGraphSchemas.getTaskRelations @@ -88,6 +67,14 @@ export default async function taskGraphRoutes(fastify, options) { ); request.log.info(`Created ${relationType} relation: task ${taskIdNum} -> ${relatedTaskId}`); + publishEvent(project.code, { + type: 'relation', + eventType: 'relation.created', + taskId: taskIdNum, + relatedTaskId, + relationType, + deliverableId: task.deliverableId, + }); reply.code(201).send(relations); } catch (error) { // Return 400 for business logic errors (self-ref, cycle, cross-project, not found) @@ -133,6 +120,14 @@ export default async function taskGraphRoutes(fastify, options) { } request.log.info(`Deleted ${relationType} relation: task ${taskIdNum} -> ${relatedId}`); + publishEvent(project.code, { + type: 'relation', + eventType: 'relation.deleted', + taskId: taskIdNum, + relatedTaskId: relatedId, + relationType, + deliverableId: task.deliverableId, + }); reply.send({ message: 'Relation deleted successfully' }); } catch (error) { request.log.error(error, 'Failed to delete task relation'); diff --git a/api/src/schemas/common.js b/api/src/schemas/common.js index ee47b3e6..7af0e237 100644 --- a/api/src/schemas/common.js +++ b/api/src/schemas/common.js @@ -15,7 +15,7 @@ export const codeParam = { type: 'object', required: ['code'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ). Uppercase letters and numbers.' } + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ). Uppercase letters and numbers.' } } }; @@ -31,19 +31,27 @@ export const taskResponseSchema = { type: 'object', properties: { id: { type: 'number', description: 'Numeric task id. Use for API paths.' }, + taskId: { type: 'number', description: 'Alias of id.' }, projectId: { type: 'number', description: 'Project id.' }, deliverableId: { type: 'number', description: 'Deliverable id.' }, + phase: { type: 'number', nullable: true, description: 'Phase number from the execution plan (e.g. 1, 2, 3).' }, + phaseStep: { type: 'string', nullable: true, description: 'Human-readable phase step within the deliverable (e.g. \"1.2\", \"2.1\").' }, title: { type: 'string', description: 'Task title.' }, status: { type: 'string', enum: ['TO_DO', 'READY', 'IN_PROGRESS', 'QA', 'COMPLETED'], description: 'Current workflow status.' }, position: { type: 'number', description: 'Sort order in column.' }, priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }, storyPoints: { type: 'number', nullable: true }, assigneeId: { type: 'number', nullable: true }, + agentName: { type: 'string', nullable: true, description: 'Assigned agent name.' }, + coordinationCode: { type: 'string', nullable: true, description: 'Coordination type for coordinated tasks.' }, prompt: { type: 'string', nullable: true, description: 'Goal, instructions, acceptance criteria for agent.' }, isBlocked: { type: 'boolean', nullable: true }, blockedReason: { type: 'string', nullable: true }, + isCancelled: { type: 'boolean', nullable: true, description: 'Cancellation flag (irreversible once set true).' }, gitWorktree: { type: 'string', nullable: true, description: 'Git worktree for implementation.' }, notes: { type: 'string', nullable: true, description: 'Append-only progress log.' }, + startedAt: { type: 'string', format: 'date-time', nullable: true }, + completedAt: { type: 'string', format: 'date-time', nullable: true }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' } } @@ -54,14 +62,25 @@ export const deliverableResponseSchema = { properties: { id: { type: 'number', description: 'Numeric primary key. Use this for API paths (e.g. create task, update deliverable).' }, projectId: { type: 'number' }, - deliverableId: { type: 'string', description: 'Human-readable ID (e.g. ZAZZ-4). Use for display; use id for API calls.' }, + projectCode: { type: 'string' }, + deliverableCode: { type: 'string', description: 'Human-readable code (e.g. ZAZZ-4). Use for display; use id for API calls.' }, name: { type: 'string' }, description: { type: 'string', nullable: true }, type: { type: 'string', enum: ['FEATURE', 'BUG_FIX', 'REFACTOR', 'ENHANCEMENT', 'CHORE', 'DOCUMENTATION'] }, status: { type: 'string' }, - dedFilePath: { type: 'string', nullable: true, description: 'Relative path or URL to the deliverable specification (SPEC) document.' }, - planFilePath: { type: 'string', nullable: true, description: 'Relative path or URL to the implementation plan (PLAN) document.' }, - prdFilePath: { type: 'string', nullable: true, description: 'Relative path or URL to the PRD document.' }, + statusHistory: { + type: 'array', + items: { + type: 'object', + properties: { + status: { type: 'string' }, + changedAt: { type: 'string', format: 'date-time' }, + changedBy: { type: 'number', nullable: true } + } + } + }, + specFilepath: { type: 'string', nullable: true, description: 'Relative path or URL to the deliverable specification (SPEC) document.' }, + planFilepath: { type: 'string', nullable: true, description: 'Relative path or URL to the implementation plan (PLAN) document.' }, gitWorktree: { type: 'string', nullable: true, description: 'Git worktree name used for implementation (e.g. feature-auth).' }, gitBranch: { type: 'string', nullable: true, description: 'Git branch name for the deliverable work (e.g. feature-auth).' }, pullRequestUrl: { type: 'string', nullable: true }, diff --git a/api/src/schemas/deliverables.js b/api/src/schemas/deliverables.js index ef8a9ac0..f610a9ea 100644 --- a/api/src/schemas/deliverables.js +++ b/api/src/schemas/deliverables.js @@ -12,7 +12,7 @@ export const deliverableSchemas = { params: { type: 'object', required: ['projectCode'], - properties: { projectCode: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' } } + properties: { projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' } } }, querystring: { type: 'object', @@ -33,12 +33,12 @@ export const deliverableSchemas = { getDeliverableById: { tags: ['deliverables'], summary: 'Get deliverable by ID', - description: 'Returns deliverable with dedFilePath (SPEC), planFilePath (PLAN), prdFilePath for document retrieval.', + description: 'Returns deliverable with specFilepath (SPEC) and planFilepath (PLAN) for document retrieval.', params: { type: 'object', required: ['projectCode', 'id'], properties: { - projectCode: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, id: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' } } }, @@ -50,12 +50,12 @@ export const deliverableSchemas = { createDeliverable: { tags: ['deliverables'], summary: 'Create deliverable', - description: 'Creates a new deliverable card in the project. Use this when starting work on a new feature, bug fix, or other work item. The response includes id (numeric—use for create task and other API paths) and deliverableId (string, e.g. ZAZZ-4—use for display). You can include dedFilePath and planFilePath on create if known, or add them later via update deliverable.', + description: 'Creates a new deliverable card in the project. Use this when starting work on a new feature, bug fix, or other work item. The response includes id (numeric—use for create task and other API paths) and deliverableCode (string, e.g. ZAZZ-4—use for display). You can include specFilepath and planFilepath on create if known, or add them later via update deliverable.', params: { type: 'object', required: ['projectCode'], properties: { - projectCode: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ). Uppercase letters and numbers.' } + projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ). Uppercase letters and numbers.' } } }, body: { @@ -65,9 +65,8 @@ export const deliverableSchemas = { name: { type: 'string', minLength: 1, maxLength: 30, description: 'Short name for the deliverable (e.g. "User Auth").' }, description: { type: 'string', description: 'Optional longer description.' }, type: { type: 'string', enum: ['FEATURE', 'BUG_FIX', 'REFACTOR', 'ENHANCEMENT', 'CHORE', 'DOCUMENTATION'], description: 'Type of work.' }, - dedFilePath: { type: 'string', maxLength: 500, description: 'Relative path to the SPEC document (e.g. .zazz/deliverables/user-auth-SPEC.md). Add when SPEC exists.' }, - planFilePath: { type: 'string', maxLength: 500, description: 'Relative path to the PLAN document (e.g. .zazz/deliverables/user-auth-PLAN.md). Add when PLAN exists.' }, - prdFilePath: { type: 'string', maxLength: 500, description: 'Relative path to the PRD document.' }, + specFilepath: { type: 'string', maxLength: 500, description: 'Relative path to the SPEC document (e.g. .zazz/deliverables/user-auth-SPEC.md). Add when SPEC exists.' }, + planFilepath: { type: 'string', maxLength: 500, description: 'Relative path to the PLAN document (e.g. .zazz/deliverables/user-auth-PLAN.md). Add when PLAN exists.' }, gitWorktree: { type: 'string', maxLength: 255, description: 'Git worktree name for implementation (e.g. feature-auth). Add when work begins.' }, gitBranch: { type: 'string', maxLength: 255, description: 'Git branch name (e.g. feature-auth). Add when work begins.' }, pullRequestUrl: { type: 'string', maxLength: 500, description: 'URL to the PR when ready for review.' } @@ -75,19 +74,19 @@ export const deliverableSchemas = { additionalProperties: false }, response: { - 201: { description: 'Deliverable created. Use id for create task and update paths; deliverableId for display.', ...deliverableResponseSchema } + 201: { description: 'Deliverable created. Use id for create task and update paths; deliverableCode for display.', ...deliverableResponseSchema } } }, updateDeliverable: { tags: ['deliverables'], summary: 'Update deliverable', - description: 'Updates deliverable metadata. Use this to add or change: dedFilePath (after SPEC is written), planFilePath (after PLAN is approved), gitWorktree and gitBranch (when work begins), pullRequestUrl (when PR is opened). Send only the fields you are updating. id is the numeric id from create deliverable or list deliverables.', + description: 'Updates deliverable metadata. Use this to add or change: specFilepath (after SPEC is written), planFilepath (after PLAN is approved), gitWorktree and gitBranch (when work begins), pullRequestUrl (when PR is opened). Send only the fields you are updating. id is the numeric id from create deliverable or list deliverables.', params: { type: 'object', required: ['projectCode', 'id'], properties: { - projectCode: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, id: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id from create or list.' } } }, @@ -98,9 +97,8 @@ export const deliverableSchemas = { description: { type: 'string' }, type: { type: 'string', enum: ['FEATURE', 'BUG_FIX', 'REFACTOR', 'ENHANCEMENT', 'CHORE', 'DOCUMENTATION'] }, status: { type: 'string', pattern: '^[A-Z_]+$', description: 'Deliverable status (e.g. PLANNING, IN_PROGRESS, IN_REVIEW, STAGED, DONE). Use update deliverable status for status-only changes.' }, - dedFilePath: { type: 'string', maxLength: 500, description: 'Relative path to SPEC (e.g. .zazz/deliverables/user-auth-SPEC.md). Set when SPEC is created.' }, - planFilePath: { type: 'string', maxLength: 500, description: 'Relative path to PLAN (e.g. .zazz/deliverables/user-auth-PLAN.md). Set when PLAN is approved.' }, - prdFilePath: { type: 'string', maxLength: 500, description: 'Relative path to PRD.' }, + specFilepath: { type: 'string', maxLength: 500, description: 'Relative path to SPEC (e.g. .zazz/deliverables/user-auth-SPEC.md). Set when SPEC is created.' }, + planFilepath: { type: 'string', maxLength: 500, description: 'Relative path to PLAN (e.g. .zazz/deliverables/user-auth-PLAN.md). Set when PLAN is approved.' }, gitWorktree: { type: 'string', maxLength: 255, description: 'Git worktree name (e.g. feature-auth). Set when implementation begins.' }, gitBranch: { type: 'string', maxLength: 255, description: 'Git branch name. Set when implementation begins.' }, pullRequestUrl: { type: 'string', maxLength: 500, description: 'PR URL when ready for review.' }, @@ -121,7 +119,7 @@ export const deliverableSchemas = { type: 'object', required: ['projectCode', 'id'], properties: { - projectCode: { type: 'string', pattern: '^[A-Z0-9]+$' }, + projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$' }, id: { type: 'string', pattern: '^\\d+$' } } }, @@ -138,7 +136,7 @@ export const deliverableSchemas = { type: 'object', required: ['projectCode', 'id'], properties: { - projectCode: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, id: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' } } }, @@ -161,7 +159,7 @@ export const deliverableSchemas = { type: 'object', required: ['projectCode', 'id'], properties: { - projectCode: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, id: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' } } }, @@ -178,7 +176,7 @@ export const deliverableSchemas = { type: 'object', required: ['projectCode', 'id'], properties: { - projectCode: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + projectCode: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, id: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' } } }, diff --git a/api/src/schemas/images.js b/api/src/schemas/images.js index f7afe725..81fbd733 100644 --- a/api/src/schemas/images.js +++ b/api/src/schemas/images.js @@ -1,126 +1,190 @@ /** - * Image route schemas. + * Project-scoped image route schemas. */ +const scopedProjectDeliverableParams = { + type: 'object', + required: ['code', 'delivId'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' } + } +}; + +const scopedProjectDeliverableTaskParams = { + type: 'object', + required: ['code', 'delivId', 'taskId'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, + taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } + } +}; + +const scopedImageIdParams = { + type: 'object', + required: ['code', 'id'], + properties: { + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + id: { type: 'string', pattern: '^\\d+$', description: 'Numeric image id.' } + } +}; + +const imageUploadBody = { + type: 'object', + required: ['images'], + properties: { + images: { + type: 'array', + items: { + type: 'object', + required: ['originalName', 'contentType', 'fileSize', 'base64Data'], + properties: { + originalName: { type: 'string' }, + contentType: { type: 'string', pattern: '^image/' }, + fileSize: { type: 'integer', minimum: 1 }, + base64Data: { type: 'string' } + }, + additionalProperties: false + } + } + }, + additionalProperties: false +}; + +const imageMetadataSchema = { + type: 'object', + properties: { + id: { type: 'number' }, + taskId: { type: 'number', nullable: true }, + deliverableId: { type: 'number', nullable: true }, + originalName: { type: 'string' }, + contentType: { type: 'string' }, + fileSize: { type: 'number' }, + url: { type: 'string' }, + storageType: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' } + } +}; + +const uploadResponseSchema = { + type: 'object', + properties: { + success: { type: 'boolean' }, + images: { type: 'array', items: imageMetadataSchema }, + count: { type: 'number' } + } +}; + export const imageSchemas = { - getTaskImages: { + getScopedTaskImages: { tags: ['images'], - summary: 'List task images', - description: 'Returns metadata (id, originalName, contentType, fileSize) for images attached to a task. Use GET /images/:id for binary.', - params: { - type: 'object', - required: ['taskId'], - properties: { taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } - }, + summary: 'List task images (project scoped)', + description: 'Returns image metadata for a task scoped to project + deliverable + task.', + params: scopedProjectDeliverableTaskParams, response: { 200: { - description: 'List of image metadata', + description: 'Task image metadata list', type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'number' }, - taskId: { type: 'number' }, - originalName: { type: 'string' }, - contentType: { type: 'string' }, - fileSize: { type: 'number' } - } - } - } + items: imageMetadataSchema + }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Task/deliverable scope mismatch' }, + 404: { description: 'Project, deliverable, or task not found' } } }, - uploadTaskImages: { + uploadScopedTaskImages: { tags: ['images'], - summary: 'Upload task images', - description: 'Upload one or more images as base64. Body: { images: [{ originalName, contentType, fileSize, base64Data }] }. contentType must be image/*.', + summary: 'Upload task images (project scoped)', + description: 'Uploads one or more task-owned images scoped by project + deliverable + task.', + params: scopedProjectDeliverableTaskParams, + body: imageUploadBody, + response: { + 201: { + description: 'Task images uploaded', + ...uploadResponseSchema + }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Task/deliverable scope mismatch' }, + 404: { description: 'Project, deliverable, or task not found' } + } + }, + + deleteScopedTaskImage: { + tags: ['images'], + summary: 'Delete task image (project scoped)', + description: 'Deletes a task-owned image scoped by project + deliverable + task.', params: { type: 'object', - required: ['taskId'], - properties: { taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } - }, - body: { - type: 'object', - required: ['images'], + required: ['code', 'delivId', 'taskId', 'imageId'], properties: { - images: { - type: 'array', - items: { - type: 'object', - required: ['originalName', 'contentType', 'fileSize', 'base64Data'], - properties: { - originalName: { type: 'string' }, - contentType: { type: 'string', pattern: '^image/' }, - fileSize: { type: 'integer', minimum: 1 }, - base64Data: { type: 'string' } - } - } - } + code: { type: 'string', pattern: '^[A-Z0-9_]+$' }, + delivId: { type: 'string', pattern: '^\\d+$' }, + taskId: { type: 'string', pattern: '^\\d+$' }, + imageId: { type: 'string', pattern: '^\\d+$' } } }, response: { - 201: { - description: 'Images uploaded', + 200: { + description: 'Image deleted', type: 'object', properties: { - success: { type: 'boolean' }, - images: { type: 'array', items: { type: 'object' } }, - count: { type: 'number' } + message: { type: 'string' }, + image: { type: 'object' } } - } + }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Image/task scope mismatch' }, + 404: { description: 'Project, deliverable, task, or image not found' } } }, - getImageById: { + getScopedDeliverableImages: { tags: ['images'], - summary: 'Get image binary', - description: 'Returns image binary. Use id from GET /tasks/:taskId/images. Content-Type indicates format.', - params: { - type: 'object', - required: ['id'], - properties: { id: { type: 'string', pattern: '^\\d+$', description: 'Numeric image id.' } } - }, + summary: 'List deliverable images (project scoped)', + description: 'Returns image metadata for images attached directly to a deliverable.', + params: scopedProjectDeliverableParams, response: { - 200: { description: 'Image binary' }, - 404: { description: 'Image not found' } + 200: { + description: 'Deliverable image metadata list', + type: 'array', + items: imageMetadataSchema + }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Deliverable does not belong to project' }, + 404: { description: 'Project or deliverable not found' } } }, - getImageMetadata: { + uploadScopedDeliverableImages: { tags: ['images'], - summary: 'Get image metadata', - description: 'Returns image metadata (id, taskId, originalName, contentType, fileSize) without binary. Use when you need metadata only.', - params: { - type: 'object', - required: ['id'], - properties: { id: { type: 'string', pattern: '^\\d+$', description: 'Numeric image id.' } } - }, + summary: 'Upload deliverable images (project scoped)', + description: 'Uploads one or more deliverable-owned images scoped by project + deliverable.', + params: scopedProjectDeliverableParams, + body: imageUploadBody, response: { - 200: { - description: 'Image metadata', - type: 'object', - properties: { - id: { type: 'number' }, - taskId: { type: 'number' }, - originalName: { type: 'string' }, - contentType: { type: 'string' }, - fileSize: { type: 'number' } - } + 201: { + description: 'Deliverable images uploaded', + ...uploadResponseSchema }, - 404: { description: 'Image not found' } + 401: { description: 'Unauthorized' }, + 403: { description: 'Deliverable does not belong to project' }, + 404: { description: 'Project or deliverable not found' } } }, - deleteTaskImage: { + deleteScopedDeliverableImage: { tags: ['images'], - summary: 'Delete task image', - description: 'Deletes an image. Verifies image belongs to the specified task. Returns 403 if image belongs to different task.', + summary: 'Delete deliverable image (project scoped)', + description: 'Deletes a deliverable-owned image scoped by project + deliverable.', params: { type: 'object', - required: ['taskId', 'imageId'], + required: ['code', 'delivId', 'imageId'], properties: { - taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' }, - imageId: { type: 'string', pattern: '^\\d+$', description: 'Numeric image id.' } + code: { type: 'string', pattern: '^[A-Z0-9_]+$' }, + delivId: { type: 'string', pattern: '^\\d+$' }, + imageId: { type: 'string', pattern: '^\\d+$' } } }, response: { @@ -132,8 +196,38 @@ export const imageSchemas = { image: { type: 'object' } } }, - 403: { description: 'Image does not belong to the specified task' }, - 404: { description: 'Image not found' } + 401: { description: 'Unauthorized' }, + 403: { description: 'Image/deliverable scope mismatch' }, + 404: { description: 'Project, deliverable, or image not found' } + } + }, + + getScopedImageById: { + tags: ['images'], + summary: 'Get image binary (project scoped)', + description: 'Returns image binary for an image that belongs to the specified project.', + params: scopedImageIdParams, + response: { + 200: { description: 'Image binary' }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Image belongs to a different project' }, + 404: { description: 'Project or image not found' } + } + }, + + getScopedImageMetadata: { + tags: ['images'], + summary: 'Get image metadata (project scoped)', + description: 'Returns image metadata for an image that belongs to the specified project.', + params: scopedImageIdParams, + response: { + 200: { + description: 'Image metadata', + ...imageMetadataSchema + }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Image belongs to a different project' }, + 404: { description: 'Project or image not found' } } } }; diff --git a/api/src/schemas/projects.js b/api/src/schemas/projects.js index b87c06ae..4f666276 100644 --- a/api/src/schemas/projects.js +++ b/api/src/schemas/projects.js @@ -95,7 +95,7 @@ export const projectSchemas = { type: 'object', required: ['code', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } }, @@ -119,7 +119,7 @@ export const projectSchemas = { type: 'object', required: ['code', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } }, @@ -152,7 +152,7 @@ export const projectSchemas = { type: 'object', required: ['code', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$' }, taskId: { type: 'string', pattern: '^\\d+$' } } } @@ -161,13 +161,13 @@ export const projectSchemas = { createDeliverableTask: { tags: ['projects'], summary: 'Create task in deliverable', - description: 'Creates a task within a deliverable. delivId is the numeric id from the create deliverable response (not deliverableId). The deliverable must be approved before creating tasks. Include prompt with goal, instructions, and acceptance criteria. Use phase and phaseTaskId to align with PLAN structure (e.g. phase 1, phaseTaskId "1.2").', + description: 'Creates a task within a deliverable. delivId is the numeric id from the create deliverable response (not deliverableCode). The deliverable must be approved before creating tasks. Include prompt with goal, instructions, and acceptance criteria. Use phase and phaseStep to align with PLAN structure (e.g. phase 1, phaseStep \"1.2\").', params: { type: 'object', required: ['code', 'delivId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, - delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id from create deliverable response. Use the id field, not deliverableId.' } + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, + delivId: { type: 'string', pattern: '^[0-9]+$', description: 'Numeric deliverable id from create deliverable response. Use the id field, not deliverableCode.' } } }, body: { @@ -181,10 +181,10 @@ export const projectSchemas = { agentName: { type: 'string', maxLength: 50, description: 'Agent name if pre-assigning.' }, storyPoints: { type: 'integer', minimum: 1, maximum: 21 }, position: { type: 'integer', minimum: 0 }, - phaseTaskId: { + phaseStep: { type: 'string', maxLength: 20, - description: 'Phase task ID from PLAN (e.g. "1.2"). Auto-generated from phase if omitted. Use "1.2.1" for rework tasks.' + description: 'Phase step from PLAN (e.g. "1.2"). Auto-generated from phase if omitted. Use "1.2.1" for rework tasks.' }, prompt: { type: 'string', maxLength: 10000, description: 'Goal, instructions, and acceptance criteria for the agent.' }, gitWorktree: { type: 'string', maxLength: 255 }, @@ -211,7 +211,7 @@ export const projectSchemas = { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } @@ -229,7 +229,7 @@ export const projectSchemas = { type: 'object', required: ['code', 'delivId', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } diff --git a/api/src/schemas/taskGraph.js b/api/src/schemas/taskGraph.js index 422eff2b..98d1e0a6 100644 --- a/api/src/schemas/taskGraph.js +++ b/api/src/schemas/taskGraph.js @@ -2,15 +2,13 @@ * Task graph and relation route schemas. */ -import { codeParam } from './common.js'; - const graphTaskItem = { type: 'object', properties: { id: { type: 'integer', description: 'Integer primary key' }, taskId: { type: 'integer', description: 'Same as id' }, phase: { type: 'integer', description: 'Phase number (e.g. 1, 2, 3)' }, - phaseTaskId: { type: 'string', description: 'Human-readable ID within a deliverable, e.g. "1.2". Rework tasks use "1.2.1" format.' }, + phaseStep: { type: 'string', description: 'Human-readable ID within a deliverable, e.g. "1.2". Rework tasks use "1.2.1" format.' }, title: { type: 'string' }, status: { type: 'string' }, priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }, @@ -34,26 +32,6 @@ const graphRelationItem = { }; export const taskGraphSchemas = { - getProjectGraph: { - tags: ['task-graph'], - summary: 'Get full task graph for a project', - description: 'Returns all tasks and intra-project relations. Polled every 3 s by the UI for live updates.', - params: codeParam, - response: { - 200: { - type: 'object', - properties: { - projectId: { type: 'integer' }, - projectCode: { type: 'string' }, - taskGraphLayoutDirection: { type: 'string', enum: ['LR', 'TB'] }, - completionCriteriaStatus: { type: 'string' }, - tasks: { type: 'array', items: graphTaskItem }, - relations: { type: 'array', items: graphRelationItem } - } - } - } - }, - getTaskRelations: { tags: ['task-graph'], summary: 'Get task relations', @@ -62,7 +40,7 @@ export const taskGraphSchemas = { type: 'object', required: ['code', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } }, @@ -79,7 +57,7 @@ export const taskGraphSchemas = { type: 'object', required: ['code', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id (source task).' } } }, @@ -102,7 +80,7 @@ export const taskGraphSchemas = { type: 'object', required: ['code', 'taskId', 'relatedTaskId', 'relationType'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric source task id.' }, relatedTaskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric related task id.' }, relationType: { type: 'string', enum: ['DEPENDS_ON', 'COORDINATES_WITH'], description: 'Relation type to remove.' } @@ -118,7 +96,7 @@ export const taskGraphSchemas = { type: 'object', required: ['code', 'taskId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, taskId: { type: 'string', pattern: '^\\d+$', description: 'Numeric task id.' } } }, @@ -151,7 +129,7 @@ export const taskGraphSchemas = { type: 'object', required: ['code', 'delivId'], properties: { - code: { type: 'string', pattern: '^[A-Z0-9]+$', description: 'Project code (e.g. ZAZZ).' }, + code: { type: 'string', pattern: '^[A-Z0-9_]+$', description: 'Project code (e.g. ZAZZ).' }, delivId: { type: 'string', pattern: '^\\d+$', description: 'Numeric deliverable id.' } } }, diff --git a/api/src/server.js b/api/src/server.js index 0b239154..0a66e2c3 100644 --- a/api/src/server.js +++ b/api/src/server.js @@ -47,9 +47,9 @@ const start = async () => { description: `Kanban-style orchestration API for coordinating AI agents and humans. Auth: TB_TOKEN header or Authorization: Bearer (except /health, /, /db-test, /token-info, /openapi.json). **Common operations (agent quick reference)**: -- Create deliverable: POST /projects/{projectCode}/deliverables — body: name, type; optional: dedFilePath, planFilePath. Response id = use for create task. -- Create task: POST /projects/{code}/deliverables/{delivId}/tasks — delivId = numeric id from create deliverable. Body: title, prompt, phase, phaseTaskId. -- Update deliverable: PUT /projects/{projectCode}/deliverables/{id} — add dedFilePath (spec path), planFilePath (plan path), gitWorktree, gitBranch when known. +- Create deliverable: POST /projects/{projectCode}/deliverables — body: name, type; optional: specFilepath, planFilepath. Response id = use for create task. +- Create task: POST /projects/{code}/deliverables/{delivId}/tasks — delivId = numeric id from create deliverable. Body: title, prompt, phase, phaseStep. +- Update deliverable: PUT /projects/{projectCode}/deliverables/{id} — add specFilepath (spec path), planFilepath (plan path), gitWorktree, gitBranch when known. - Change deliverable status: PATCH /projects/{projectCode}/deliverables/{id}/status — body: { status }. - Change task status: PATCH /projects/{code}/deliverables/{delivId}/tasks/{taskId}/status — body: { status }; optional agentName to claim.`, version: '1.0.0' @@ -64,7 +64,7 @@ const start = async () => { { name: 'tags', description: 'Tags' }, { name: 'translations', description: 'i18n' }, { name: 'status-definitions', description: 'Status lookup' }, - { name: 'images', description: 'Task images' } + { name: 'images', description: 'Project-scoped task and deliverable images' } ], components: { securitySchemes: { @@ -117,4 +117,3 @@ const start = async () => { }; start(); - diff --git a/api/src/services/databaseService.js b/api/src/services/databaseService.js index 494be7d3..66c0458b 100644 --- a/api/src/services/databaseService.js +++ b/api/src/services/databaseService.js @@ -367,15 +367,15 @@ class DatabaseService { const rows = await db.select({ id: DELIVERABLES.id, projectId: DELIVERABLES.project_id, - deliverableId: DELIVERABLES.deliverable_id, + projectCode: DELIVERABLES.project_code, + deliverableCode: DELIVERABLES.deliverable_code, name: DELIVERABLES.name, description: DELIVERABLES.description, type: DELIVERABLES.type, status: DELIVERABLES.status, statusHistory: DELIVERABLES.status_history, - dedFilePath: DELIVERABLES.ded_file_path, - planFilePath: DELIVERABLES.plan_file_path, - prdFilePath: DELIVERABLES.prd_file_path, + specFilepath: DELIVERABLES.spec_filepath, + planFilepath: DELIVERABLES.plan_filepath, approvedBy: DELIVERABLES.approved_by, approvedByName: USERS.full_name, approvedAt: DELIVERABLES.approved_at, @@ -397,15 +397,15 @@ class DatabaseService { .groupBy( DELIVERABLES.id, DELIVERABLES.project_id, - DELIVERABLES.deliverable_id, + DELIVERABLES.project_code, + DELIVERABLES.deliverable_code, DELIVERABLES.name, DELIVERABLES.description, DELIVERABLES.type, DELIVERABLES.status, DELIVERABLES.status_history, - DELIVERABLES.ded_file_path, - DELIVERABLES.plan_file_path, - DELIVERABLES.prd_file_path, + DELIVERABLES.spec_filepath, + DELIVERABLES.plan_filepath, DELIVERABLES.approved_by, USERS.full_name, DELIVERABLES.approved_at, @@ -427,16 +427,15 @@ class DatabaseService { const [deliverable] = await db.select({ id: DELIVERABLES.id, projectId: DELIVERABLES.project_id, - projectCode: PROJECTS.code, - deliverableId: DELIVERABLES.deliverable_id, + projectCode: DELIVERABLES.project_code, + deliverableCode: DELIVERABLES.deliverable_code, name: DELIVERABLES.name, description: DELIVERABLES.description, type: DELIVERABLES.type, status: DELIVERABLES.status, statusHistory: DELIVERABLES.status_history, - dedFilePath: DELIVERABLES.ded_file_path, - planFilePath: DELIVERABLES.plan_file_path, - prdFilePath: DELIVERABLES.prd_file_path, + specFilepath: DELIVERABLES.spec_filepath, + planFilepath: DELIVERABLES.plan_filepath, approvedBy: DELIVERABLES.approved_by, approvedByName: USERS.full_name, approvedAt: DELIVERABLES.approved_at, @@ -452,12 +451,12 @@ class DatabaseService { completedTaskCount: sql`COUNT(CASE WHEN ${TASKS.status} = 'COMPLETED' THEN 1 END)`.as('completedTaskCount') }) .from(DELIVERABLES) - .leftJoin(PROJECTS, eq(DELIVERABLES.project_id, PROJECTS.id)) .leftJoin(USERS, eq(DELIVERABLES.approved_by, USERS.id)) .leftJoin(TASKS, eq(DELIVERABLES.id, TASKS.deliverable_id)) .where(eq(DELIVERABLES.id, id)) .groupBy( - DELIVERABLES.id, PROJECTS.code, USERS.full_name + DELIVERABLES.id, + USERS.full_name ) .limit(1); return deliverable || null; @@ -468,7 +467,7 @@ class DatabaseService { const [project] = await tx.select().from(PROJECTS).where(eq(PROJECTS.id, projectId)).limit(1); if (!project) throw new Error('Project not found'); - const deliverableId = `${project.code}-${project.next_deliverable_sequence}`; + const deliverableCode = `${project.code}-${project.next_deliverable_sequence}`; await tx.update(PROJECTS) .set({ next_deliverable_sequence: project.next_deliverable_sequence + 1, updated_by: userId, updated_at: new Date() }) .where(eq(PROJECTS.id, projectId)); @@ -479,15 +478,15 @@ class DatabaseService { const [row] = await tx.insert(DELIVERABLES).values({ project_id: projectId, - deliverable_id: deliverableId, + project_code: project.code, + deliverable_code: deliverableCode, name: data.name, description: data.description, type: data.type, status: 'PLANNING', status_history: [{ status: 'PLANNING', changedAt: new Date().toISOString(), changedBy: userId }], - ded_file_path: data.dedFilePath, - plan_file_path: data.planFilePath, - prd_file_path: data.prdFilePath, + spec_filepath: data.specFilepath, + plan_filepath: data.planFilepath, git_worktree: data.gitWorktree, git_branch: data.gitBranch, pull_request_url: data.pullRequestUrl, @@ -508,9 +507,8 @@ class DatabaseService { if (data.description !== undefined) updateData.description = data.description; if (data.type !== undefined) updateData.type = data.type; if (data.status !== undefined) updateData.status = data.status; - if (data.dedFilePath !== undefined) updateData.ded_file_path = data.dedFilePath; - if (data.planFilePath !== undefined) updateData.plan_file_path = data.planFilePath; - if (data.prdFilePath !== undefined) updateData.prd_file_path = data.prdFilePath; + if (data.specFilepath !== undefined) updateData.spec_filepath = data.specFilepath; + if (data.planFilepath !== undefined) updateData.plan_filepath = data.planFilepath; if (data.gitWorktree !== undefined) updateData.git_worktree = data.gitWorktree; if (data.gitBranch !== undefined) updateData.git_branch = data.gitBranch; if (data.pullRequestUrl !== undefined) updateData.pull_request_url = data.pullRequestUrl; @@ -531,7 +529,7 @@ class DatabaseService { async approveDeliverablePlan(id, userId) { const deliverable = await this.getDeliverableById(id); if (!deliverable) throw new Error('Deliverable not found'); - if (!deliverable.planFilePath) throw new Error('plan_file_path must be set before approval'); + if (!deliverable.planFilepath) throw new Error('plan_filepath must be set before approval'); if (deliverable.approvedAt) throw new Error('Deliverable already approved'); if (deliverable.status !== 'PLANNING') throw new Error('Only PLANNING deliverables can be approved'); @@ -552,7 +550,7 @@ class DatabaseService { if (!project?.workflow?.includes(status)) throw new Error(`Status ${status} not allowed for this project`); if (status === 'IN_PROGRESS') { - if (!deliverable.planFilePath) throw new Error('plan_file_path must be set before moving to IN_PROGRESS'); + if (!deliverable.planFilepath) throw new Error('plan_filepath must be set before moving to IN_PROGRESS'); if (!deliverable.approvedAt) throw new Error('Deliverable must be approved before moving to IN_PROGRESS'); } @@ -623,7 +621,7 @@ class DatabaseService { id: TASKS.id, taskId: TASKS.id, phase: TASKS.phase, - phaseTaskId: TASKS.phase_task_id, + phaseStep: TASKS.phase_step, title: TASKS.title, status: TASKS.status, priority: TASKS.priority, @@ -698,7 +696,7 @@ class DatabaseService { id: TASKS.id, taskId: TASKS.id, phase: TASKS.phase, - phaseTaskId: TASKS.phase_task_id, + phaseStep: TASKS.phase_step, title: TASKS.title, status: TASKS.status, priority: TASKS.priority, @@ -741,7 +739,7 @@ class DatabaseService { id: TASKS.id, taskId: TASKS.id, phase: TASKS.phase, - phaseTaskId: TASKS.phase_task_id, + phaseStep: TASKS.phase_step, title: TASKS.title, status: TASKS.status, priority: TASKS.priority, @@ -777,7 +775,7 @@ class DatabaseService { } /** - * Create new task with phase_task_id generation, dependency wiring, and auto-promotion. + * Create new task with phase_step generation, dependency wiring, and auto-promotion. * Leader provides phase + optional dependencies array; system handles the rest. */ async createTask(taskData) { @@ -803,16 +801,16 @@ class DatabaseService { .where(and(eq(TASKS.project_id, projectId), eq(TASKS.status, status))); const nextPosition = Math.floor(maxPos.max / 10) * 10 + 10; - // --- phase_task_id generation --- + // --- phase_step generation --- // Format: "{phase}.{seq}" e.g. "1.1", "1.2" - // Rework tasks can be created with explicit phaseTaskId like "1.2.1" - let phaseTaskId = taskData.phaseTaskId || null; + // Rework tasks can be created with explicit phaseStep like "1.2.1" + let phaseStep = taskData.phaseStep || null; const phase = taskData.phase ?? null; - if (phase !== null && !phaseTaskId) { - // Find all existing phase_task_ids for this deliverable+phase to determine next seq + if (phase !== null && !phaseStep) { + // Find all existing phase_steps for this deliverable+phase to determine next seq // Match format "{phase}.{digits}" (direct children only, not rework like "1.2.1") - const existing = await tx.select({ phaseTaskId: TASKS.phase_task_id }) + const existing = await tx.select({ phaseStep: TASKS.phase_step }) .from(TASKS) .where( and( @@ -825,12 +823,12 @@ class DatabaseService { let maxSeq = 0; const directPattern = new RegExp(`^${phase}\\.(\\d+)$`); for (const row of existing) { - if (row.phaseTaskId) { - const m = row.phaseTaskId.match(directPattern); + if (row.phaseStep) { + const m = row.phaseStep.match(directPattern); if (m) maxSeq = Math.max(maxSeq, parseInt(m[1], 10)); } } - phaseTaskId = `${phase}.${maxSeq + 1}`; + phaseStep = `${phase}.${maxSeq + 1}`; } // --- Insert task --- @@ -846,7 +844,7 @@ class DatabaseService { prompt: taskData.prompt, notes: taskData.notes || null, phase, - phase_task_id: phaseTaskId, + phase_step: phaseStep, is_blocked: taskData.isBlocked || false, blocked_reason: taskData.blockedReason, is_cancelled: taskData.isCancelled || false, @@ -1248,6 +1246,8 @@ class DatabaseService { async getTaskImages(taskId) { const images = await db.select({ id: IMAGE_METADATA.id, + taskId: IMAGE_METADATA.task_id, + deliverableId: IMAGE_METADATA.deliverable_id, originalName: IMAGE_METADATA.original_name, contentType: IMAGE_METADATA.content_type, fileSize: IMAGE_METADATA.file_size, @@ -1263,24 +1263,48 @@ class DatabaseService { } /** - * Store image with metadata and binary data + * Get all images attached directly to a deliverable */ - async storeTaskImage(taskId, imageData) { + async getDeliverableImages(deliverableId) { + const images = await db.select({ + id: IMAGE_METADATA.id, + taskId: IMAGE_METADATA.task_id, + deliverableId: IMAGE_METADATA.deliverable_id, + originalName: IMAGE_METADATA.original_name, + contentType: IMAGE_METADATA.content_type, + fileSize: IMAGE_METADATA.file_size, + url: IMAGE_METADATA.url, + storageType: IMAGE_METADATA.storage_type, + createdAt: IMAGE_METADATA.created_at + }) + .from(IMAGE_METADATA) + .where(eq(IMAGE_METADATA.deliverable_id, deliverableId)) + .orderBy(asc(IMAGE_METADATA.created_at)); + + return images; + } + + /** + * Store task-owned image with metadata and binary data + */ + async storeTaskImage(taskId, imageData, imageUrlBase = '/images') { // Insert image metadata const [metadata] = await db.insert(IMAGE_METADATA) .values({ task_id: taskId, + deliverable_id: null, original_name: imageData.originalName, content_type: imageData.contentType, file_size: imageData.fileSize, - url: `/images/0`, // Temporary, will update with actual ID + url: `${imageUrlBase}/0`, // Temporary, will update with actual ID storage_type: 'local' }) .returning(); - + // Update URL with actual image ID + const finalUrl = `${imageUrlBase}/${metadata.id}`; await db.update(IMAGE_METADATA) - .set({ url: `/images/${metadata.id}` }) + .set({ url: finalUrl }) .where(eq(IMAGE_METADATA.id, metadata.id)); // Insert binary data @@ -1293,10 +1317,53 @@ class DatabaseService { return { id: metadata.id, + taskId: metadata.task_id, + deliverableId: metadata.deliverable_id, + originalName: metadata.original_name, + contentType: metadata.content_type, + fileSize: metadata.file_size, + url: finalUrl, + storageType: metadata.storage_type, + createdAt: metadata.created_at + }; + } + + /** + * Store deliverable-owned image with metadata and binary data + */ + async storeDeliverableImage(deliverableId, imageData, imageUrlBase = '/images') { + const [metadata] = await db.insert(IMAGE_METADATA) + .values({ + task_id: null, + deliverable_id: deliverableId, + original_name: imageData.originalName, + content_type: imageData.contentType, + file_size: imageData.fileSize, + url: `${imageUrlBase}/0`, + storage_type: 'local' + }) + .returning(); + + const finalUrl = `${imageUrlBase}/${metadata.id}`; + await db.update(IMAGE_METADATA) + .set({ url: finalUrl }) + .where(eq(IMAGE_METADATA.id, metadata.id)); + + await db.insert(IMAGE_DATA) + .values({ + id: metadata.id, + data: imageData.base64Data, + thumbnail_data: null + }); + + return { + id: metadata.id, + taskId: metadata.task_id, + deliverableId: metadata.deliverable_id, originalName: metadata.original_name, contentType: metadata.content_type, fileSize: metadata.file_size, - url: `/images/${metadata.id}`, + url: finalUrl, storageType: metadata.storage_type, createdAt: metadata.created_at }; @@ -1309,9 +1376,13 @@ class DatabaseService { const [result] = await db .select({ id: IMAGE_METADATA.id, + taskId: IMAGE_METADATA.task_id, + deliverableId: IMAGE_METADATA.deliverable_id, originalName: IMAGE_METADATA.original_name, contentType: IMAGE_METADATA.content_type, fileSize: IMAGE_METADATA.file_size, + url: IMAGE_METADATA.url, + storageType: IMAGE_METADATA.storage_type, data: IMAGE_DATA.data, thumbnailData: IMAGE_DATA.thumbnail_data }) @@ -1330,6 +1401,7 @@ class DatabaseService { const [image] = await db.select({ id: IMAGE_METADATA.id, taskId: IMAGE_METADATA.task_id, + deliverableId: IMAGE_METADATA.deliverable_id, originalName: IMAGE_METADATA.original_name, contentType: IMAGE_METADATA.content_type, fileSize: IMAGE_METADATA.file_size, @@ -1359,6 +1431,8 @@ class DatabaseService { return deletedImage ? { id: deletedImage.id, + taskId: deletedImage.task_id, + deliverableId: deletedImage.deliverable_id, originalName: deletedImage.original_name, contentType: deletedImage.content_type } : null; @@ -1591,7 +1665,7 @@ class DatabaseService { id: TASKS.id, taskId: TASKS.id, phase: TASKS.phase, - phaseTaskId: TASKS.phase_task_id, + phaseStep: TASKS.phase_step, title: TASKS.title, status: TASKS.status, priority: TASKS.priority, @@ -1632,7 +1706,7 @@ class DatabaseService { id: TASKS.id, taskId: TASKS.id, phase: TASKS.phase, - phaseTaskId: TASKS.phase_task_id, + phaseStep: TASKS.phase_step, title: TASKS.title, status: TASKS.status, priority: TASKS.priority, diff --git a/api/src/services/realtimeService.js b/api/src/services/realtimeService.js new file mode 100644 index 00000000..84df0afb --- /dev/null +++ b/api/src/services/realtimeService.js @@ -0,0 +1,71 @@ +/** + * In-memory Server-Sent Events broker scoped by project code. + * This keeps active subscribers per project and broadcasts lightweight events + * when task/graph-related API mutations occur. + */ +export default class RealtimeService { + constructor() { + this.subscribersByProject = new Map(); + this.nextSubscriberId = 1; + this.nextEventId = 1; + } + + normalizeProjectCode(projectCode) { + return String(projectCode || '').toUpperCase(); + } + + subscribe(projectCode, subscriber) { + const normalizedCode = this.normalizeProjectCode(projectCode); + if (!this.subscribersByProject.has(normalizedCode)) { + this.subscribersByProject.set(normalizedCode, new Map()); + } + + const subscriberId = this.nextSubscriberId++; + this.subscribersByProject.get(normalizedCode).set(subscriberId, subscriber); + return subscriberId; + } + + unsubscribe(projectCode, subscriberId) { + const normalizedCode = this.normalizeProjectCode(projectCode); + const projectSubscribers = this.subscribersByProject.get(normalizedCode); + if (!projectSubscribers) return; + + projectSubscribers.delete(subscriberId); + if (projectSubscribers.size === 0) { + this.subscribersByProject.delete(normalizedCode); + } + } + + getSubscriberCount(projectCode) { + const normalizedCode = this.normalizeProjectCode(projectCode); + return this.subscribersByProject.get(normalizedCode)?.size || 0; + } + + publish(projectCode, payload = {}) { + const normalizedCode = this.normalizeProjectCode(projectCode); + const projectSubscribers = this.subscribersByProject.get(normalizedCode); + if (!projectSubscribers || projectSubscribers.size === 0) return; + + const event = { + id: this.nextEventId++, + timestamp: new Date().toISOString(), + projectCode: normalizedCode, + ...payload, + }; + + const sseMessage = this.toSseMessage(event); + for (const [subscriberId, subscriber] of projectSubscribers.entries()) { + try { + subscriber.send(sseMessage); + } catch (error) { + // Drop dead subscribers so one broken connection does not poison broadcasts. + this.unsubscribe(normalizedCode, subscriberId); + } + } + } + + toSseMessage(event) { + const eventName = event.eventType || 'message'; + return `id: ${event.id}\nevent: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`; + } +} diff --git a/client/src/App.jsx b/client/src/App.jsx index d12e7816..d7f9cc98 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -222,6 +222,56 @@ function AppContent() { console.log('=================='); }, []); + // Hydrate last selected graph deliverable when switching projects. + useEffect(() => { + if (!selectedProject?.code) { + setSelectedDeliverableId(null); + return; + } + const savedDeliverableId = localStorage.getItem( + `taskGraph:lastDeliverable:${selectedProject.code}` + ); + setSelectedDeliverableId(savedDeliverableId || null); + }, [selectedProject?.code]); + + // Persist last selected graph deliverable per project. + useEffect(() => { + if (!selectedProject?.code || !selectedDeliverableId) return; + localStorage.setItem( + `taskGraph:lastDeliverable:${selectedProject.code}`, + String(selectedDeliverableId) + ); + }, [selectedProject?.code, selectedDeliverableId]); + + // Keep persisted deliverable selection valid as deliverables list changes. + useEffect(() => { + if (!selectedProject?.code) return; + if (!Array.isArray(deliverables) || deliverables.length === 0) return; + if (selectedDeliverableId) return; + + const savedDeliverableId = localStorage.getItem( + `taskGraph:lastDeliverable:${selectedProject.code}` + ); + if (!savedDeliverableId) return; + + const exists = deliverables.some((deliverable) => String(deliverable.id) === savedDeliverableId); + if (!exists) { + localStorage.removeItem(`taskGraph:lastDeliverable:${selectedProject.code}`); + return; + } + + setSelectedDeliverableId(savedDeliverableId); + }, [selectedProject?.code, deliverables, selectedDeliverableId]); + + useEffect(() => { + if (!selectedProject?.code || !selectedDeliverableId) return; + if (!Array.isArray(deliverables) || deliverables.length === 0) return; + const exists = deliverables.some((deliverable) => String(deliverable.id) === String(selectedDeliverableId)); + if (exists) return; + localStorage.removeItem(`taskGraph:lastDeliverable:${selectedProject.code}`); + setSelectedDeliverableId(null); + }, [selectedProject?.code, deliverables, selectedDeliverableId]); + const toggleTheme = () => { const newTheme = colorScheme === 'dark' ? 'light' : 'dark'; setColorScheme(newTheme); @@ -576,11 +626,8 @@ function AppContent() { {isProjectPage && selectedProject && ( isTaskGraphPage ? (