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).
Global Constraints
Section titled “Global Constraints”- Migration
db/migrations/01192_crm_opportunity_project.sql, inside the declared export range 1190–1199 (extend nothing; ifinternal/kernel/db/migrationranges_test.goneeds 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 existingorg_isolationpolicy 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'andis_activewhen 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.yamlx-codes, and en/ms/zh refusal sentences (the refusal-code translation drift test must pass). api/openapi.yamlfirst; regenerate Go and TS. Additive only. Opportunity responses gainproject: {id, code, name} | null(reuse the existingDocumentProjectschema) and requests gainproject_id(uuid, nullable).- CRM must not import receivables, trade, or another module (modules never import modules); importing
platform/accounting/analyticis allowed but not needed — raw SQL againstanalytic_account/analytic_axisis 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” needssales.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; nogit stash.
File Structure
Section titled “File Structure”- Create
db/migrations/01192_crm_opportunity_project.sql. - Modify
internal/platform/crm/{opportunity.go,errors.go}(+ testsopportunity_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), localescrm.json,common.jsonrefusals, 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;FieldErrorrulesproject_invalid(not an account of this org’sprojectaxis) andproject_inactive(a different, retired project). -
Produces (API): opportunity create/update body
project_id; opportunity DTOproject. -
Step 1: Failing tests (platform, mirroring
opportunity_test.gohelpers): create with a project →Project.Codereturned; 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 tonilclears 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/ListOpportunitiesreturn the project. Handler test: POST/PATCH round trip withproject_id, responseprojectobject, bad uuid → 422. Migration test followingmigration01172_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).
oppSelectLEFT JOINsanalytic_account p ON p.org_id = o.org_id AND p.id = o.project_account_idand selectsp.id, p.code, p.name; scan into nullable fields.CreateOpportunity/UpdateOpportunitycall a small unexportedcheckProject(ctx, tx, org, id *uuid.UUID, cur *uuid.UUID)that runs one query joininganalytic_axis x ON x.key='project'and returns the rules above (skip when id is nil or equals the current value). Handler body gainsproject_id *uuid.UUID; DTO gainsproject. OpenAPI first, then regenerate (make generate; the repo may regenerateinternal/gen/sqlc/models.gotoo — 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), andTestCRMDoesNotImportReceivables. 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 sendsproject_id, “No project” sendsnull; the drawer showsCODE · Namewhen 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 originalproject_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_inactiverefusals 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.createand 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.tsxaccepts and forwardsprojectinvalidateSearch. (c)DocumentDialogopened withprojectId=Afor a new document submits its lines with the A share (via piece 2’swithProject/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 withfrom=(start from source) ignoresprojectId. - Step 2: run → FAIL.
- Step 3: Implement. Apply the prefill exactly once (an effect guarded by a ref) after
useProjectIdsanduseProjectOptionshave 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 1once; known flake sources: Docker contention (rerun failing packages alone) and the pre-existingTestEveryTenantQueryCarriesAnExplicitOrgPredicatefailure frommain(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.
Self-review notes
Section titled “Self-review notes”- 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).