diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index fd7cea6e9..a7433f314 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -11,16 +11,16 @@ MSSQL_PID=Developer MSSQL_SA_PASSWORD=YourStrong!Passw0rd SQLSERVER_HOST_PORT=1433 +DATABASE_READONLY_PASSWORD_ENV=DB_READONLY_PASSWORD +DB_ENCRYPT=true DB_HOST=db -DB_PORT=1433 DB_NAME=kravhantering -DB_READONLY_USER=readonly +DB_PORT=1433 DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7 -DATABASE_READONLY_PASSWORD_ENV=DB_READONLY_PASSWORD -DB_ENCRYPT=true +DB_READONLY_USER=readonly DB_TRUST_SERVER_CERTIFICATE=true -NODE_ENV=development NEXT_TELEMETRY_DISABLED=1 +NODE_ENV=development # Optional explicit overrides for the derived DB_* connection settings above: # DATABASE_URL=mssql://sa:YourStrong!Passw0rd@db:1433/kravhantering?encrypt=true&trustServerCertificate=true diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3620f912a..3fbeea2b1 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -21,6 +21,10 @@ RUN apt-get update && \ mkcert && \ rm -rf /var/lib/apt/lists/* +# Install dotenv-linter for env file checks. +RUN curl -sSfL https://raw.githubusercontent.com/dotenv-linter/dotenv-linter/master/install.sh | \ + sh -s -- -b /usr/local/bin + # Set the working directory WORKDIR /workspace diff --git a/.env.development b/.env.development index 0fd222fb0..e3ae7c3ea 100644 --- a/.env.development +++ b/.env.development @@ -9,31 +9,31 @@ # Development-only HMAC secret (intentionally public, non-production value) ANALYTICS_HASH_SECRET=dev-local-secret-not-for-production +DB_ENCRYPT=true # Local SQL Server defaults for host-based development. # The devcontainer injects its own DB_* values from `.devcontainer/.env`, # and those existing shell variables take precedence over these committed # defaults when the app runs inside the container. DB_HOST=127.0.0.1 -DB_PORT=1433 DB_NAME=kravhantering -MSSQL_SA_PASSWORD=YourStrong!Passw0rd -DB_READONLY_USER=readonly +DB_PORT=1433 DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7 -DB_ENCRYPT=true +DB_READONLY_USER=readonly DB_TRUST_SERVER_CERTIFICATE=true +MSSQL_SA_PASSWORD=YourStrong!Passw0rd NEXTJS_ENV=development NEXT_PUBLIC_SITE_URL=http://localhost:3000 +AUTH_OIDC_CLIENT_ID=kravhantering-app +AUTH_OIDC_CLIENT_SECRET=dev-only-app-secret # -------------------------------------- # Authentication (PhenixID OIDC) — local dev defaults targeting the local # Keycloak realm. See docs/auth-developer-workflow.md. # -------------------------------------- AUTH_OIDC_ISSUER_URL=http://localhost:8080/realms/kravhantering-dev -AUTH_OIDC_CLIENT_ID=kravhantering-app -AUTH_OIDC_CLIENT_SECRET=dev-only-app-secret -AUTH_OIDC_REDIRECT_URI=http://localhost:3000/api/auth/callback AUTH_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:3000/ +AUTH_OIDC_REDIRECT_URI=http://localhost:3000/api/auth/callback # Iron-session cookie key (≥32 chars). Dev-only fixed value so every # contributor gets a working setup without manual generation. Real envs # generate this with `openssl rand -base64 48` and store it in a Secret. diff --git a/.env.example b/.env.example index b19aa62c1..d4fe6d797 100644 --- a/.env.example +++ b/.env.example @@ -87,26 +87,26 @@ AUTH_ENABLED=true # without a server round-trip. NEXT_PUBLIC_AUTH_ENABLED=true +AUTH_OIDC_CLIENT_ID=kravhantering-app +AUTH_OIDC_CLIENT_SECRET=dev-only-app-secret # OIDC issuer base URL (used for discovery at /.well-known/openid-configuration). # Local Keycloak default: http://localhost:8080/realms/kravhantering-dev AUTH_OIDC_ISSUER_URL=http://localhost:8080/realms/kravhantering-dev -AUTH_OIDC_CLIENT_ID=kravhantering-app -AUTH_OIDC_CLIENT_SECRET=dev-only-app-secret +AUTH_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:3000/ # Full callback URL, must be pre-registered with the IdP. AUTH_OIDC_REDIRECT_URI=http://localhost:3000/api/auth/callback -AUTH_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:3000/ -# Optional. Defaults shown. -AUTH_OIDC_SCOPES=openid profile email -AUTH_OIDC_ROLES_CLAIM=roles # Audience expected on access tokens (MCP). Defaults to AUTH_OIDC_CLIENT_ID. AUTH_OIDC_API_AUDIENCE= +AUTH_OIDC_ROLES_CLAIM=roles +# Optional. Defaults shown. +AUTH_OIDC_SCOPES="openid profile email" +AUTH_SESSION_COOKIE_NAME=kravhantering_session # Iron-session encryption password. MUST be at least 32 characters. # Generate with: openssl rand -base64 48 AUTH_SESSION_COOKIE_PASSWORD=replace-with-32-bytes-of-randomness-XXXXXXXX -AUTH_SESSION_COOKIE_NAME=kravhantering_session AUTH_SESSION_TTL_SECONDS=28800 # When the app runs behind a reverse proxy (OpenShift Route, ingress), diff --git a/.env.sqlserver.ci b/.env.sqlserver.ci index 3089ad16a..80e3ed567 100644 --- a/.env.sqlserver.ci +++ b/.env.sqlserver.ci @@ -1,15 +1,25 @@ -# CI defaults for integration tests (no secrets — safe to commit). -# MSSQL_SA_PASSWORD must be supplied by the CI runner (GitHub Actions -# variable / secret) and appended to .env.sqlserver before `npm run db:up`. -# Read-only browse credentials are intentionally omitted; CI only needs -# the SA login to provision the database and run integration tests. +# CI defaults for integration tests and browse workflows (safe to commit). +# MSSQL_SA_PASSWORD is a well-known SQL Server example password intentionally +# committed because forked PRs cannot access GitHub Actions secrets or vars. +# The read-only credentials block is also committed for integration tests and +# read-only developer browse use. ACCEPT_EULA=Y MSSQL_PID=Developer SQLSERVER_HOST_PORT=1433 +# This password is a well-known password (used in examples) to build the test +# database and run integration tests in CI. It is intentionally set here and not +# a secret or variable in GitHub Actions because the integration tests need to +# run on pull requests from forks, where secrets and variables are not available. +MSSQL_SA_PASSWORD=YourStrong!Passw0rd + +DB_ENCRYPT=true DB_HOST=127.0.0.1 -DB_PORT=1433 DB_NAME=kravhantering -DB_ENCRYPT=true +DB_PORT=1433 DB_TRUST_SERVER_CERTIFICATE=true + +DATABASE_READONLY_PASSWORD_ENV=DB_READONLY_PASSWORD +DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7 +DB_READONLY_USER=readonly diff --git a/.env.sqlserver.example b/.env.sqlserver.example index acd4de28e..d387d752f 100644 --- a/.env.sqlserver.example +++ b/.env.sqlserver.example @@ -13,13 +13,13 @@ MSSQL_PID=Developer MSSQL_SA_PASSWORD=YourStrong!Passw0rd SQLSERVER_HOST_PORT=1433 +DATABASE_READONLY_PASSWORD_ENV=DB_READONLY_PASSWORD +DB_ENCRYPT=true DB_HOST=127.0.0.1 -DB_PORT=1433 DB_NAME=kravhantering -DB_READONLY_USER=readonly +DB_PORT=1433 DB_READONLY_PASSWORD=BrowseOnly!Passw0rd7 -DATABASE_READONLY_PASSWORD_ENV=DB_READONLY_PASSWORD -DB_ENCRYPT=true +DB_READONLY_USER=readonly DB_TRUST_SERVER_CERTIFICATE=true # Optional explicit overrides for the derived DB_* connection settings above: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4f14c06bd..d62bbea54 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,7 +15,8 @@ ## Commands - `npm run check` - all checks (`type-check`, `lint:py`, - `format:check`, `spell:check`, `lint`, `lint:md`, `test`) + `dotenv:check`, `format:check`, `spell:check`, `lint`, `lint:md`, + `test`) - `npm run test` - run tests - `npm run dev` - start dev server diff --git a/.github/instructions/mcp-tool-contract.instructions.md b/.github/instructions/mcp-tool-contract.instructions.md new file mode 100644 index 000000000..3200a35a1 --- /dev/null +++ b/.github/instructions/mcp-tool-contract.instructions.md @@ -0,0 +1,20 @@ +--- +applyTo: "{lib/mcp/**/*.ts,lib/requirements/**/*.ts,lib/dal/**/*.ts,app/api/**/*.ts,docs/mcp-server-*.md,tests/unit/mcp-http.test.ts}" +--- + +# MCP Tool Contract Upkeep + +## When Changing Callable Behavior + +- If logic changes what an MCP client must send, can receive, or should do + next, update `lib/mcp/server.ts` in the same change. +- Keep tool `description`, `inputSchema`, `outputSchema`, and field + `.describe(...)` text aligned with the handler/service behavior. +- State prerequisite tool calls and exact source/destination fields for values + clients must echo, such as `requirements_get_requirement` + `requirement.versions[0].id` to `requirement.baseVersionId`. +- Do not rely on user docs alone to teach MCP clients how to call a tool. +- Update `docs/mcp-server-user-guide.md`, + `docs/mcp-server-contributor-guide.md`, and `tests/unit/mcp-http.test.ts`. +- For outward-facing MCP invariants, update the quality siblings described in + `.github/instructions/quality-spec.instructions.md`. diff --git a/.github/skills/run-spec-audit/references/scrutiny-areas.md b/.github/skills/run-spec-audit/references/scrutiny-areas.md index 0b3b5d34b..37fee7949 100644 --- a/.github/skills/run-spec-audit/references/scrutiny-areas.md +++ b/.github/skills/run-spec-audit/references/scrutiny-areas.md @@ -57,7 +57,19 @@ maintenance rule. - **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 4"` -## 5. Package-Local Requirements and Deviations — Scenario 6, 7 +## 5. Draft Edit Concurrency — Scenario 11 + +- **Code:** `lib/dal/requirements.ts` and + `lib/requirements/service.ts` — `baseVersionId`/`baseRevisionToken` + optimistic edit preconditions. +- **Spec:** `docs/lifecycle-workflow.md`. +- **Req tag:** `[Req: formal — docs/lifecycle-workflow.md "Draft"]` +- **Question:** Are stale draft edits rejected before content or joins are + rewritten? +- **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts + -t "Scenario 11"` + +## 6. Package-Local Requirements and Deviations — Scenario 6, 7 - **Code:** `lib/dal/requirement-packages.ts` — package-local requirement, needs-reference, and deviation-gated status functions. @@ -73,7 +85,7 @@ maintenance rule. - **Verify (S7):** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 7"` -## 6. Deviation Lifecycle Guards and Decision Immutability — Scenario 9 +## 7. Deviation Lifecycle Guards and Decision Immutability — Scenario 9 - **Code:** `lib/dal/deviations.ts` — approval/rejection logic, review-requested guards, edit/delete guards. @@ -87,7 +99,7 @@ maintenance rule. - **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 9"` -## 7. Suggestion Terminal State — Scenario 8 +## 8. Suggestion Terminal State — Scenario 8 - **Code:** `lib/dal/improvement-suggestions.ts` — resolution logic. - **Spec:** `docs/lifecycle-workflow.md`. @@ -98,7 +110,7 @@ maintenance rule. - **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 8"` -## 8. List View Defensive Parsing +## 9. List View Defensive Parsing - **Code:** `lib/requirements/list-view.ts`. - **Spec:** `docs/requirements-ui-behaviour.md` and @@ -106,7 +118,7 @@ maintenance rule. - **Question:** Do malformed admin defaults, invalid visible-column JSON, hidden filters, or bad widths fail safely? -## 9. REST and MCP Output Consistency +## 10. REST and MCP Output Consistency - **Code:** `lib/mcp/http.ts`, `lib/mcp/server.ts`, `app/api/requirements/[id]/route.ts`, @@ -118,7 +130,7 @@ maintenance rule. - **Question:** Do REST and MCP outputs, transport rules, and field names match the documentation and field contracts? -## 10. CSV Export +## 11. CSV Export - **Code:** `lib/export-csv.ts`. - **Spec:** `docs/reports.md`. @@ -127,7 +139,7 @@ maintenance rule. - **Question:** Does export behavior match the documented CSV expectations for separators and escaping? -## 11. Coverage Target Alignment +## 12. Coverage Target Alignment - **Source:** `tests/quality/QUALITY.md` — Coverage Targets table. - **Question:** Do the subsystems and file paths listed in the @@ -135,7 +147,7 @@ maintenance rule. Flag new DAL files, renamed modules, or removed subsystems that make the targets stale. -## 12. MCP Tool Inventory Parity — Scenario 10 +## 13. MCP Tool Inventory Parity — Scenario 10 - **Code:** `lib/mcp/server.ts`. - **Spec:** `docs/mcp-server-contributor-guide.md` ("Server Contract", @@ -161,7 +173,7 @@ This file must stay in sync with `tests/quality/QUALITY.md`: references here. - See `tests/quality/AGENTS.md` for the authoritative sync rule. -## 13. Reference Data Behavioral Contracts +## 14. Reference Data Behavioral Contracts - **Code:** `lib/dal/norm-references.ts`, `lib/dal/owners.ts`, `lib/dal/package-implementation-types.ts`, @@ -176,7 +188,7 @@ This file must stay in sync with `tests/quality/QUALITY.md`: (`nameSv` for taxonomy, `normReferenceId` for norm references, `lastName`/`firstName` for owners)? -## 14. AI Generation Contracts +## 15. AI Generation Contracts - **Code:** `lib/ai/openrouter-client.ts`, `lib/ai/requirement-prompt.ts`, `lib/ai/taxonomy.ts`. diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c549f526e..f4abb972f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -52,25 +52,12 @@ jobs: - name: Prepare SQL Server env file run: | cp .env.sqlserver.ci .env.sqlserver - # This uses a well-known password from examples, but it's stored as a - # GitHub Actions variable to avoid hitting any security scanners that - # look for hardcoded secrets. - echo "MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD}" >> .env.sqlserver - env: - MSSQL_SA_PASSWORD: ${{ vars.MSSQL_SA_PASSWORD }} - name: Start local database service run: npm run db:up - name: Setup local SQL Server database run: npm run db:setup - env: - MSSQL_SA_PASSWORD: ${{ vars.MSSQL_SA_PASSWORD }} - DB_HOST: 127.0.0.1 - DB_PORT: 1433 - DB_NAME: kravhantering - DB_ENCRYPT: 'true' - DB_TRUST_SERVER_CERTIFICATE: 'true' - name: Install Playwright Browsers run: npx playwright install --with-deps chromium @@ -79,12 +66,6 @@ jobs: run: ${{ matrix.testCommand }} env: CI: true - MSSQL_SA_PASSWORD: ${{ vars.MSSQL_SA_PASSWORD }} - DB_HOST: 127.0.0.1 - DB_PORT: 1433 - DB_NAME: kravhantering - DB_ENCRYPT: 'true' - DB_TRUST_SERVER_CERTIFICATE: 'true' - name: Upload test results (${{ matrix.server }}) if: always() diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 9e1d910d2..aea0fb7ac 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + checks: write pull-requests: write jobs: @@ -45,6 +46,20 @@ jobs: - name: Run Python type check run: npm run lint:py + - name: Run dotenv-linter root check + uses: dotenv-linter/action-dotenv-linter@v3 + with: + dotenv_linter_flags: . + filter_mode: nofilter + fail_level: any + + - name: Run dotenv-linter devcontainer check + uses: dotenv-linter/action-dotenv-linter@v3 + with: + dotenv_linter_flags: .devcontainer --recursive + filter_mode: nofilter + fail_level: any + - name: Run tests and coverage run: npm run test:coverage @@ -84,7 +99,8 @@ jobs: { name: 'Lint', command: 'npm run lint' }, { name: 'Markdown Lint', command: 'npm run lint:md' }, { name: 'Type Check', command: 'npm run type-check' }, - { name: 'Python Type Check', command: 'npm run lint:py' } + { name: 'Python Type Check', command: 'npm run lint:py' }, + { name: 'dotenv-linter', command: 'npm run dotenv:check' } ]; let body = '❌ **Quality checks failed!** Please fix the following issues:\n\n'; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7b575665..5ae23e489 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ for setup, migrations, and the read-only browse workflow. | `npm run start:prodlike` | Rebuild and start the prod-like app on port 3001 (`NODE_ENV=production`) | | `npm run build` | Production build | | `npm run start` | Start the production server | -| `npm run check` | Run all checks (TS, Python, format, lint, tests) | +| `npm run check` | Run all checks (TS, Python, dotenv, format, lint, tests) | | `npm run test` | Run unit tests with Vitest | | `npm run test:watch` | Run unit tests in watch mode | | `npm run test:coverage` | Run unit tests with coverage | @@ -39,10 +39,12 @@ for setup, migrations, and the read-only browse workflow. | `npm run lint` | Lint with Biome | | `npm run lint:fix` | Lint and auto-fix with Biome | | `npm run lint:py` | Type-check Python scripts with Pyright | +| `npm run dotenv:check` | Check dotenv files with dotenv-linter | +| `npm run dotenv:fix` | Auto-fix dotenv files with dotenv-linter | | `npm run format` | Format code with Biome | | `npm run spell` | Spell check with cspell | | `npm run lint:md` | Lint Markdown files | -| `npm run fix` | Auto-fix formatting, linting & Markdown | +| `npm run fix` | Auto-fix formatting, linting, Markdown, and dotenv files | | `npm run type-check` | TypeScript type checking | diff --git a/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx b/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx index 5dc90040c..4b9047c14 100644 --- a/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx +++ b/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx @@ -46,6 +46,10 @@ export default function EditRequirementClient({ number[] >([]) const [initialScenarioIds, setInitialScenarioIds] = useState([]) + const [baseRevisionToken, setBaseRevisionToken] = useState( + null, + ) + const [baseVersionId, setBaseVersionId] = useState(null) const [uniqueId, setUniqueId] = useState('') const [isPublished, setIsPublished] = useState(false) const [loading, setLoading] = useState(true) @@ -53,6 +57,8 @@ export default function EditRequirementClient({ const fetchData = useCallback(async () => { setFetchError(null) + setBaseRevisionToken(null) + setBaseVersionId(null) setIsPublished(false) setLoading(true) @@ -84,6 +90,8 @@ export default function EditRequirementClient({ return } setIsPublished(latest.status === STATUS_PUBLISHED) + setBaseRevisionToken(latest.revisionToken) + setBaseVersionId(latest.id) setInitialData({ areaId: data.area?.id != null ? String(data.area.id) : '', categoryId: @@ -169,10 +177,13 @@ export default function EditRequirementClient({ )}
diff --git a/app/[locale]/requirements/requirements-client.tsx b/app/[locale]/requirements/requirements-client.tsx index 075d00794..0db12ac3c 100644 --- a/app/[locale]/requirements/requirements-client.tsx +++ b/app/[locale]/requirements/requirements-client.tsx @@ -188,6 +188,7 @@ function mapRequirementDetailToRow( categoryNameSv: version.category?.nameSv ?? null, description: version.description, requiresTesting: version.requiresTesting, + revisionToken: version.revisionToken, status: version.status, statusColor: version.statusColor, statusNameEn: version.statusNameEn, diff --git a/app/api/requirements/[id]/route.ts b/app/api/requirements/[id]/route.ts index 84c8296a7..95423f9a1 100644 --- a/app/api/requirements/[id]/route.ts +++ b/app/api/requirements/[id]/route.ts @@ -64,6 +64,12 @@ export async function PUT( ? String(body.acceptanceCriteria) : undefined, areaId: body.areaId ? Number(body.areaId) : undefined, + baseRevisionToken: + body.baseRevisionToken != null + ? String(body.baseRevisionToken) + : undefined, + baseVersionId: + body.baseVersionId != null ? Number(body.baseVersionId) : undefined, categoryId: body.categoryId ? Number(body.categoryId) : undefined, createdBy: body.ownerId ? String(body.ownerId) : undefined, description: String(body.description ?? ''), diff --git a/components/RequirementForm.tsx b/components/RequirementForm.tsx index 9db01ad28..7fb6edd6f 100644 --- a/components/RequirementForm.tsx +++ b/components/RequirementForm.tsx @@ -1,7 +1,7 @@ 'use client' import { motion } from 'framer-motion' -import { Plus, X } from 'lucide-react' +import { AlertTriangle, ExternalLink, Plus, RotateCcw, X } from 'lucide-react' import { useTranslations } from 'next-intl' import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' @@ -11,17 +11,30 @@ import RequirementFormFields, { } from '@/components/RequirementFormFields' import { useRouter } from '@/i18n/routing' import { apiFetch } from '@/lib/http/api-fetch' +import type { RequirementDetailResponse } from '@/lib/requirements/types' interface RequirementFormProps { + baseRevisionToken?: string | null + baseVersionId?: number | null initialData?: Partial< Omit > initialNormReferenceIds?: number[] initialScenarioIds?: number[] mode: 'create' | 'edit' + onRefreshLatest?: () => Promise | void requirementId?: number | string } +interface RequirementEditErrorPayload { + code?: string + details?: { + latest?: RequirementDetailResponse | null + reason?: string + } + error?: string +} + interface NormReferenceOption { id: number issuer: string @@ -46,9 +59,12 @@ const EMPTY_FORM: RequirementFormFieldValues = { } export default function RequirementForm({ + baseRevisionToken, + baseVersionId, initialData, initialNormReferenceIds, initialScenarioIds, + onRefreshLatest, requirementId, mode, }: RequirementFormProps) { @@ -73,7 +89,11 @@ export default function RequirementForm({ >([]) const [submitting, setSubmitting] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) const [error, setError] = useState(null) + const [staleConflict, setStaleConflict] = useState<{ + latest: RequirementDetailResponse | null + } | null>(null) const [saveDestination, setSaveDestination] = useState<'inline' | 'page'>( () => { try { @@ -138,6 +158,7 @@ export default function RequirementForm({ e.preventDefault() setSubmitting(true) setError(null) + setStaleConflict(null) try { const url = @@ -156,6 +177,8 @@ export default function RequirementForm({ : undefined, riskLevelId: form.riskLevelId ? Number(form.riskLevelId) : undefined, description: form.description || undefined, + baseRevisionToken: mode === 'edit' ? baseRevisionToken : undefined, + baseVersionId: mode === 'edit' ? baseVersionId : undefined, acceptanceCriteria: form.acceptanceCriteria || undefined, requiresTesting: form.requiresTesting, verificationMethod: form.requiresTesting @@ -198,9 +221,18 @@ export default function RequirementForm({ router.push(`/requirements?selected=${targetUniqueId}`) } } else { - const err = (await res.json().catch(() => null)) as { - error?: string - } | null + const err = (await res + .json() + .catch(() => null)) as RequirementEditErrorPayload | null + if ( + res.status === 409 && + err?.code === 'conflict' && + err.details?.reason === 'stale_requirement_edit' + ) { + setStaleConflict({ latest: err.details.latest ?? null }) + setError(null) + return + } setError(err?.error ?? res.statusText) } } catch (e) { @@ -210,9 +242,32 @@ export default function RequirementForm({ } } + const latestConflictVersion = staleConflict?.latest?.versions[0] + const latestConflictTarget = staleConflict?.latest?.uniqueId + const latestConflictHref = staleConflict?.latest + ? latestConflictVersion?.versionNumber + ? `/requirements/${latestConflictTarget}/${latestConflictVersion.versionNumber}` + : `/requirements/${latestConflictTarget}` + : null + + const handleRefreshLatest = async () => { + if (!onRefreshLatest || isRefreshing) return + setIsRefreshing(true) + setError(null) + try { + await onRefreshLatest() + setStaleConflict(null) + } catch (e) { + setError(e instanceof Error ? e.message : tc('error')) + } finally { + setIsRefreshing(false) + } + } + const normReferenceCreateButton = ( + )} + {onRefreshLatest && ( + + )} + + + + + )} + {error && (

{error} @@ -314,12 +411,16 @@ export default function RequirementForm({

-