Skip to content

Project link — piece 3: CRM opportunity → project

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A CRM opportunity can say which project it is for, shows it, and “Create quote” from the opportunity opens a new quotation with that project already chosen.

Architecture: One nullable column crm_opportunity.project_account_id (composite FK to analytic_account), validated in Go as an active account on the project axis; read back with code and name via the opportunity select. No stored opportunity↔quote link (PL5): the quote prefill is a URL search param on the existing trade create route, applied with piece 2’s withProject. CRM never imports receivables or trade (TestCRMDoesNotImportReceivables); the only cross-module hop is a web route.

Tech Stack: Go (pgx, goose, gin), OpenAPI → oapi-codegen, React/TypeScript, vitest.

Spec: docs/superpowers/specs/2026-10-10-project-link-design.md (PL4, PL5). Parent: 2026-10-06-project-usage-design.md (PU7).

  • Migration db/migrations/01192_crm_opportunity_project.sql, inside the declared export range 1190–1199 (extend nothing; if internal/kernel/db/migrationranges_test.go needs an entry for 01192’s owner, follow the existing pattern). Column nullable, no backfill, goose Down drops it. No new table, so no RLS/retention change; the existing org_isolation policy covers the column.
  • FK is composite (org_id, project_account_id) REFERENCES analytic_account (org_id, id).
  • The account must be on the axis with key = 'project' and is_active when SET or CHANGED; an already-set retired project stays readable and unchanged opportunities can still be saved (only a different inactive account is refused).
  • Refusal codes follow the CRM FieldError{Rule: ...} convention; add both new rules (project_invalid, project_inactive) to the CRM error mapping, api/openapi.yaml x-codes, and en/ms/zh refusal sentences (the refusal-code translation drift test must pass).
  • api/openapi.yaml first; regenerate Go and TS. Additive only. Opportunity responses gain project: {id, code, name} | null (reuse the existing DocumentProject schema) and requests gain project_id (uuid, nullable).
  • CRM must not import receivables, trade, or another module (modules never import modules); importing platform/accounting/analytic is allowed but not needed — raw SQL against analytic_account/analytic_axis is the established CRM style.
  • Anonymisation (01172, anonymise.go) must be unaffected: a project is not personal data. Verify, don’t assume.
  • Web: the project control shows only with accounting.ledger.read (same gate as pieces 1–2); “Create quote” needs sales.quotation.create.
  • ms/zh strings need native review: add rows to docs/review/{ms,zh}-review.csv.
  • Every absence assertion in a test is preceded by a presence assertion.
  • Commit trailer Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>; no push/merge; no git stash.
  • Create db/migrations/01192_crm_opportunity_project.sql.
  • Modify internal/platform/crm/{opportunity.go,errors.go} (+ tests opportunity_test.go), internal/modules/crm/{dto.go,opportunity_handlers.go} (+ handlers_test.go), api/openapi.yaml, generated Go/TS.
  • Modify web: web/src/features/crm/{api.ts,OpportunityDrawer.tsx,PipelinePage.tsx} (+ tests), web/src/features/trade/{routes.tsx,DocumentDialog.tsx} (+ tests), locales crm.json, common.json refusals, review CSVs, docs/ROADMAP.md.

Task 1: Backend — column, validation, API

Section titled “Task 1: Backend — column, validation, API”

Files: as above (backend subset).

Interfaces:

  • Produces (Go): Opportunity.ProjectAccountID *uuid.UUID, Opportunity.Project *ProjectRef (ProjectRef{ID uuid.UUID; Code, Name string}), OpportunityInput.ProjectAccountID *uuid.UUID; FieldError rules project_invalid (not an account of this org’s project axis) and project_inactive (a different, retired project).

  • Produces (API): opportunity create/update body project_id; opportunity DTO project.

  • Step 1: Failing tests (platform, mirroring opportunity_test.go helpers): create with a project → Project.Code returned; create with a cost-centre account (other axis) → project_invalid; with another org’s account id → project_invalid; with an inactive project → project_inactive; update to nil clears it (presence of the set project asserted first); update leaving a retired project unchanged succeeds while changing to a different retired one is refused (project_inactive); GetOpportunity/ListOpportunities return the project. Handler test: POST/PATCH round trip with project_id, response project object, bad uuid → 422. Migration test following migration01172_test.go’s style: column exists, nullable, FK rejects a foreign-org account (insert through the admin pool).

  • Step 2: go test ./internal/platform/crm/ ./internal/modules/crm/ -count=1 → FAIL.

  • Step 3: Implement. Migration (Up adds the column + FK, Down drops them). oppSelect LEFT JOINs analytic_account p ON p.org_id = o.org_id AND p.id = o.project_account_id and selects p.id, p.code, p.name; scan into nullable fields. CreateOpportunity/UpdateOpportunity call a small unexported checkProject(ctx, tx, org, id *uuid.UUID, cur *uuid.UUID) that runs one query joining analytic_axis x ON x.key='project' and returns the rules above (skip when id is nil or equals the current value). Handler body gains project_id *uuid.UUID; DTO gains project. OpenAPI first, then regenerate (make generate; the repo may regenerate internal/gen/sqlc/models.go too — commit it if so).

  • Step 4: run the two CRM packages, go test ./internal/app/ ./internal/kernel/db/ ./internal/platform/crm/... -count=1 (route/openapi/migration-range/refusal-code drift tests), and TestCRMDoesNotImportReceivables. Watch the project-axis test fail once by removing the axis condition.

  • Step 5: commit — feat(crm): an opportunity can name its project.

Task 2: Web — set and show the project on an opportunity

Section titled “Task 2: Web — set and show the project on an opportunity”

Files: web/src/features/crm/{api.ts,OpportunityDrawer.tsx,PipelinePage.tsx} and their tests, crm.json (en/ms/zh), common.json refusals, review CSVs.

Interfaces: Consumes the project / project_id API fields; reuses piece 2’s ProjectPicker-style Field+Combobox (or ProjectPicker itself if it can be used with a single value — check web/src/features/projects/ProjectPicker.tsx; if it is lines-based, build the small single-value control from useProjectOptions instead of changing the picker).

  • Step 1: Failing tests: the opportunity form (create and edit) shows a Project field with accounting.ledger.read (presence), choosing sends project_id, “No project” sends null; the drawer shows CODE · Name when set and nothing when null; the pipeline card/list shows the project code; the field is absent without the permission and an unchanged edit does not alter the project (shares nothing — body carries the original project_id).
  • Step 2: pnpm --dir web vitest run src/features/crm → FAIL.
  • Step 3: Implement the field in the opportunity form, display in the drawer and (one line) on the pipeline card; map project_invalid / project_inactive refusals to the translated sentences via the existing refusal-code mechanism.
  • Step 4: crm vitest folder, i18n drift test, typecheck, lint.
  • Step 5: commit — feat(web): set and show an opportunity's project.

Task 3: “Create quote” prefills the project

Section titled “Task 3: “Create quote” prefills the project”

Files: web/src/features/crm/OpportunityDrawer.tsx, web/src/features/trade/{routes.tsx,DocumentDialog.tsx} + tests, crm.json, review CSVs, docs/ROADMAP.md.

Interfaces: Produces the URL contract /sales/quotations/new?project=<analytic_account_id>; DocumentDialog gains an optional prop projectId?: string.

  • Step 1: Failing tests: (a) the drawer shows a “Create quote” button only with sales.quotation.create and an opportunity that has a project (and, for presence, with a permission and a project the button exists first); clicking navigates to /sales/quotations/new?project=<id>. (b) routes.tsx accepts and forwards project in validateSearch. (c) DocumentDialog opened with projectId=A for a new document submits its lines with the A share (via piece 2’s withProject/useCanCarry, applied once the project axis has loaded and only if A is active — a retired or unknown id is silently dropped; assert A is submitted first, then that a retired id is not). Opening with from= (start from source) ignores projectId.
  • Step 2: run → FAIL.
  • Step 3: Implement. Apply the prefill exactly once (an effect guarded by a ref) after useProjectIds and useProjectOptions have loaded; do not overwrite shares the user already chose. Wording of the button: crm:opportunity.createQuote.
  • Step 4: run the crm + trade vitest folders, then the full web suite once, typecheck, lint. Full go test ./... -p 1 once; known flake sources: Docker contention (rerun failing packages alone) and the pre-existing TestEveryTenantQueryCarriesAnExplicitOrgPredicate failure from main (groupstd.go, counterparty.go) which is not yours — report, do not fix.
  • Step 5: Browser check only if the API stack and a login are feasible; otherwise say so and do not claim a visual check.
  • Step 6: tick piece 3 in docs/ROADMAP.md (“Project link on documents”) honestly; commit — feat: Create quote from an opportunity prefills its project; roadmap.
  • Spec PL4 → Task 1–2 (column, FK, axis check, display, clear); PL5 → Task 3 (prefill only, retired project dropped, no stored link).
  • Members-only projects: the API does not enforce project membership for opportunities (PU: membership is claim-line only); picker options come from the ledger-readable project list. State this as a limitation in the ROADMAP line.
  • Names consistent: project_account_id (DB/Go), project_id (request), project (response), projectId (React prop), ?project= (URL).