From 4ca2635dd4dd0d75263cf40e94f667a657bb83d1 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 3 May 2026 14:44:14 +0200 Subject: [PATCH 001/218] =?UTF-8?q?docs(adr):=20ADR-0010=20=E2=80=94=20?= =?UTF-8?q?=C3=A9=C3=A9n=20product=20=3D=20=C3=A9=C3=A9n=20repo,=20cross-p?= =?UTF-8?q?roduct=20planning=20later=20via=20Initiative-laag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bevestigt het huidige datamodel (Product.repo_url is single) en kiest "duplicate-PBI per product" voor cross-repo werk; markeert een Initiative-laag (boven PBI) als toekomstige uitbreiding zodra de duplicatie-pijn te groot wordt. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 1 + ...product-per-repo-cross-product-planning.md | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 docs/adr/0010-product-per-repo-cross-product-planning.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 9e21dc9..8441cfd 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -17,6 +17,7 @@ Auto-generated on 2026-05-03 from front-matter and headings. | 0006 | [ADR-0006: Demo-user write protection enforced in three layers](./adr/0006-demo-user-three-layer-policy.md) | accepted | | 0007 | [ADR-0007: Agent ↔ user question channel via persistent table + LISTEN/NOTIFY](./adr/0007-claude-question-channel-design.md) | accepted | | 0008 | [ADR-0008: Agent instructions in CLAUDE.md + topical runbooks](./adr/0008-agent-instructions-in-claude-md.md) | accepted | +| 0010 | [ADR-0010: Eén product = één repo; cross-product planning vereist (later) een Initiative-laag](./adr/0010-product-per-repo-cross-product-planning.md) | accepted | ## Specifications diff --git a/docs/adr/0010-product-per-repo-cross-product-planning.md b/docs/adr/0010-product-per-repo-cross-product-planning.md new file mode 100644 index 0000000..533ab18 --- /dev/null +++ b/docs/adr/0010-product-per-repo-cross-product-planning.md @@ -0,0 +1,120 @@ +--- +status: accepted +date: 2026-05-03 +decision-makers: Janpeter Visser +consulted: Claude (planning agent) +informed: alle Scrum4Me-gebruikers die met meerdere repos werken +--- + +# ADR-0010: Eén product = één repo; cross-product planning vereist (later) een Initiative-laag + +## Context and Problem Statement + +Een feature kan meerdere repositories raken — bv. de "agent merge-policy"-feature +heeft tegelijk werk nodig in `Scrum4Me` (schema, server actions, UI) én in +`scrum4me-mcp` (twee nieuwe MCP-tools). Het Scrum4Me-datamodel kent op dit +moment één `repo_url` per `Product`, en stories hangen aan één PBI dat aan één +product hangt. Hoe modelleren we werk dat structureel meerdere repos raakt? + +## Decision Drivers + +- Per-repo PR-volgorde en deploy-pipeline blijft simpel (1 PR = 1 repo). +- Auditbaarheid: de link tussen "wat is gemerged" en "in welke repo" moet + triviaal te leggen zijn. +- Het datamodel moet niet de complexiteit van een feature dragen die in 80% van + de gevallen toch single-repo is. +- Plannen wel kunnen overspannen: een idee kan zonder fricted tegelijk taken + in twee repo's beschrijven. + +## Considered Options + +- **A. Eén product = één repo, plan-overspanning via duplicate-PBI per product.** + Een feature die twee repos raakt wordt als twee PBIs vastgelegd (één in elk + product), met handmatige verwijzing tussen de twee in de description. +- **B. Multi-repo per product (`Product.repo_urls: String[]`).** + Eén product representeert een logisch "systeem" en heeft N repo's; PBIs en + stories blijven onder dat product. +- **C. Initiative-laag boven PBI**, product-onafhankelijk; een Initiative bundelt + PBIs uit verschillende producten onder één plan. + +## Decision Outcome + +**Gekozen: optie A — voor nu**, met optie C als geïdentificeerde toekomstige +uitbreiding zodra cross-repo werk regelmatig genoeg voorkomt. + +Rationale: optie B vermengt het datamodel rond een conceptueel troebele eenheid +("product = systeem" vs. "product = repo") en breekt de 1:1-aanname in +batch-enqueue, PR-gating en CI-flow. Optie C is structureel het juiste antwoord, +maar verdient een eigen feature-traject (datamodel, UI, gating-semantiek per +initiative) en wordt nu niet bevroren in een ad-hoc implementatie. + +### Consequences + +- Goed, omdat: per-repo flow simpel blijft (1 product = 1 repo = 1 PR-stack); + bestaande gating-logica (`pr_url`/`pr_merged_at` op PBI, sequentiële PBI's) + werkt zonder aanpassing. +- Goed, omdat: cross-repo werk wordt expliciet zichtbaar als gespiegelde PBIs + in beide producten — geen verborgen koppelingen. +- Slecht, omdat: voor cross-repo features moet een mens nu zelf twee PBIs + aanmaken en de afhankelijkheidsvolgorde tussen ze (bv. "MCP eerst, Scrum4Me + daarna") in de descriptions documenteren. Plan-drift tussen de twee PBIs is + een reëel risico. +- Slecht, omdat: er komt geen "één klik = één feature klaar" flow voor + cross-repo werk; de PB-owner moet over twee producten heen schedulen en + mergen. + +### Confirmation + +Bevestiging dat deze ADR effectief is: + +1. Producten in Scrum4Me hebben elk precies één `repo_url`. Geen + schema-wijziging die `repo_urls` introduceert. +2. PBIs voor cross-repo werk verwijzen in hun description expliciet naar de + spiegel-PBI in het andere product (id-link). +3. Er staat een open backlog-item `Cross-product planning via Initiative-laag` + in product Scrum4Me als trigger om optie C te realiseren wanneer de pijn + van duplicatie te groot wordt. + +## Pros and Cons of the Options + +### A. Eén product = één repo, duplicate-PBI + +- Goed: minimaal datamodel-veranderingen. +- Goed: PR-flow, batch-gating, deploy-pijplijn blijven 1:1 met repo. +- Goed: per-product permissies en token-scoping blijven simpel. +- Neutraal: cross-product werk vereist twee PBIs (zichtbaar werk, niet verborgen). +- Slecht: dubbel onderhoud aan plan-tekst; risico op divergentie. + +### B. Multi-repo per product + +- Goed: één plek voor alles wat bij een "systeem" hoort. +- Slecht: PR-gating per PBI moet plotseling N repos tegelijk modelleren. +- Slecht: deploy-volgorde (eerst MCP, dan Scrum4Me) zit niet in het datamodel + → ad-hoc encoded in description of CI. +- Slecht: vervaagt wat een "product" is — repo-technisch of business-technisch. + +### C. Initiative-laag boven PBI + +- Goed: structureel correct — werk dat de scope van één product overstijgt + krijgt zijn eigen niveau. +- Goed: per-product gating en flow blijven simpel; alleen de Initiative + orkestreert. +- Slecht: feature op zich (UI, datamodel, gating-semantiek per initiative, + velocity-rapportage) — significante investering vóór de eerste praktische + payoff. +- Slecht: introduceert een vierde hiërarchielaag (Initiative → PBI → Story → + Task), met UI-druk om die zichtbaar te maken. + +## More Information + +- Verwante PBIs: + - Scrum4Me product, PBI `Agent merge-policy: geen auto-merge, sequentieel per + PBI` (id `cmoppwpwu000evt17ev2c4oo4`) — gebruikt deze ADR als grondvest voor + de gating per single-repo PR. + - scrum4me-mcp product, PBI `MCP: set_pbi_pr & mark_pbi_pr_merged voor + sequentiële PBI-gating` (id `cmoprewcf000qvt17pf42t0ig`) — concrete + spiegel-PBI van bovenstaande, in het MCP-repo. +- Wanneer optie C overwegen: zodra er tegelijk drie of meer "spiegel-PBIs" open + staan op verschillende producten, of wanneer de afhankelijkheidsvolgorde + tussen ze leidt tot meer dan één gemiste merge per maand. +- Re-visit: in de retro na de eerste vier cross-repo features. From 8d6bdef57ea4e15d907bdbe50b2b7ba4c3e5cb5d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 3 May 2026 17:09:39 +0200 Subject: [PATCH 002/218] docs(runbook): agent-flow open issues & decision log Bundelt vier valkuilen in de huidige agent-flow: PBI-ordering, schema-conflicten bij parallelle migraties, branch-naam-collisies via 8-char suffix, cross-product orchestratie. Eerste is al gedekt door de merge-policy PBI; de andere drie zijn entries onder anchor-PBI "Agent-flow: openstaande beslissingen" (prio 4). Lokaal commit; PR pas wanneer er meer aanverwante docs-changes zijn. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 1 + docs/runbooks/agent-flow-pitfalls.md | 83 ++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 docs/runbooks/agent-flow-pitfalls.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 8441cfd..3f6bd26 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -97,6 +97,7 @@ Auto-generated on 2026-05-03 from front-matter and headings. | [DevPlanner — Product Backlog](./product-backlog.md) | `product-backlog.md` | active | 2026-05-03 | | [Scrum4Me — API Test Plan](./qa/api-test-plan.md) | `qa/api-test-plan.md` | active | 2026-05-03 | | [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | active | 2026-05-03 | +| [Agent-flow: open issues & decision log](./runbooks/agent-flow-pitfalls.md) | `runbooks/agent-flow-pitfalls.md` | active | 2026-05-03 | | [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 | | [Vercel Deployment](./runbooks/deploy-vercel.md) | `runbooks/deploy-vercel.md` | active | 2026-05-03 | | [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-03 | diff --git a/docs/runbooks/agent-flow-pitfalls.md b/docs/runbooks/agent-flow-pitfalls.md new file mode 100644 index 0000000..fb54597 --- /dev/null +++ b/docs/runbooks/agent-flow-pitfalls.md @@ -0,0 +1,83 @@ +--- +title: "Agent-flow: open issues & decision log" +status: active +audience: [ai-agent, contributor, pb-owner] +language: nl +last_updated: 2026-05-03 +when_to_read: "When designing or auditing how the agent claims jobs and produces PRs across multiple stories, PBIs or products." +--- + +# Agent-flow: open issues & decision log + +Deze runbook bundelt vier valkuilen in de huidige agent-flow waarover later +een bewuste beslissing moet vallen. Elk issue is óf gedekt door een +bestaande PBI óf gekoppeld aan een story onder de anchor-PBI +[`Agent-flow: openstaande beslissingen`][anchor-pbi]. + +Status per issue is een van: `open` (nog geen besluit), `decided` (besluit +genomen, mitigatie volgt), `mitigated` (geïmplementeerd; story gesloten). +Promote een story naar een eigen prio-2 PBI zodra het issue acuut wordt. + +## 1. PBI-ordering binnen één batch — `decided` + +**Probleem**: in één batch kunnen jobs uit verschillende PBIs door elkaar +lopen, omdat `wait_for_job` FIFO claimt zonder PBI-grouping. + +**Status**: gedekt door PBI [`Agent merge-policy: geen auto-merge, +sequentieel per PBI`][merge-policy-pbi]. Daar wordt een gate ingebouwd +die voorkomt dat PBI B start zolang PBI A's PR nog open is. + +**Niet hier dupliceren**. + +## 2. Schema-conflict bij parallelle Prisma-migraties — `open` + +**Probleem**: twee stories die elk een Prisma-migratie toevoegen krijgen +elk een eigen migration-file met eigen timestamp. Mergen in willekeurige +volgorde kan de schema-state inconsistent maken; oudere timestamps die +ná nieuwere mergen geven Prisma-onverklaarbaar gedrag. + +**Mitigatie-opties**: sequential-gating (#1 lost dit grotendeels op), +migration-rename CI-hook, geen agent-migrations toelaten, of +`prisma migrate diff` als CI-gate. + +→ [Story `Schema-conflict tussen parallelle stories`][story-schema] + +## 3. Branch naam-collisie via 8-char-suffix — `open` + +**Probleem**: `feat/story-` is geen garantie tegen +botsingen. Met genoeg stories wordt botskans niet-triviaal en de +worktree-create faalt of vermengt commits. + +**Mitigatie-opties**: volledige cuid in branchnaam, `Story.code` als +branchnaam (bv. `feat/ST-1115`), suffix-lengte verhogen, of niets doen +en monitoren. + +→ [Story `Branch naam-collisie via 8-char-suffix van story-id`][story-branch] + +## 4. Cross-product orchestratie — `open` + +**Probleem**: een feature in twee producten (bv. tool in scrum4me-mcp + +gebruik in Scrum4Me) heeft een dependency-volgorde die niet door het +systeem wordt afgedwongen. Mergen in verkeerde volgorde breekt main. + +**Mitigatie-opties**: mens-discipline + description-flag, +Initiative-laag boven PBI (ADR-0010 optie C), gating per repo-pair, of +"blocked-by"-tekstuele link. + +→ [Story `Cross-product orchestratie: dependency-volgorde en +mens-tussenstappen`][story-cross-product] + +## Re-visit cadans + +Bekijk dit document maandelijks of na elke significant agent-incident. +Promote een story naar prio 2 zodra het issue concreet pijn doet. + +## Gerelateerde ADRs + +- [ADR-0010: Eén product = één repo; cross-product planning](../adr/0010-product-per-repo-cross-product-planning.md) + +[anchor-pbi]: # "PBI cmopwlxgu0016vt170ratrrur op product Scrum4Me" +[merge-policy-pbi]: # "PBI cmoppwpwu000evt17ev2c4oo4 op product Scrum4Me" +[story-schema]: # "Story cmopwmc0f0017vt17azn2aynr" +[story-branch]: # "Story cmopwmqcf0018vt17583a25q2" +[story-cross-product]: # "Story cmopwn5mc0019vt17wwrq0ib7" From 0ee03c6b7273b413c267d57118a6300d0f249fe8 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sun, 3 May 2026 17:56:33 +0200 Subject: [PATCH 003/218] ProductDialog: create + edit met alle velden (#68) * feat(ST-?): createProductAction + updateProductAction (data-object API) Voegt data-object-gebaseerde createProductAction(data) en updateProductAction(id, data) toe aan actions/products.ts voor gebruik door ProductDialog. Bevat Zod-validatie (incl. github-regex op repo_url), productAccessFilter voor update, pg_notify bij update, en productMember- aanleg bij create. FormData-varianten hernoemd naar ...FormAction; callers bijgewerkt. 9 nieuwe tests groen. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-?): ProductDialog component (create + edit modes) Voegt components/dialogs/product-dialog.tsx toe op basis van het entity-dialog-patroon. Gebruikt react-hook-form + zodResolver voor client-side validatie. Roept createProductAction/updateProductAction aan en werkt stores/products-store.ts optimistisch bij. Demo-modus disabled alle velden + submit-knop via DemoTooltip. 7 tests groen. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-?): UI triggers voor ProductDialog op dashboard en product-detail Voegt NewProductButton toe op het dashboard (vervangt de /products/new link) en EditProductButton op de product-detail pagina. Bewerken-knop is alleen zichtbaar voor de product-eigenaar en verborgen in demo-modus. Co-Authored-By: Claude Sonnet 4.6 * fix(test): cast toast via unknown to satisfy strict TS `toast as { success, error }` direct-cast faalt omdat sonner's toast een callable + properties is. TS2352. Cast via unknown lost het op zonder gedrag te wijzigen. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- __tests__/actions/products.test.ts | 160 +++++++++++ .../dialogs/product-dialog.test.tsx | 134 +++++++++ actions/products.ts | 116 +++++++- app/(app)/dashboard/page.tsx | 6 +- app/(app)/products/[id]/page.tsx | 14 + app/(app)/products/[id]/settings/page.tsx | 4 +- app/(app)/products/new/page.tsx | 4 +- components/dashboard/new-product-button.tsx | 23 ++ components/dialogs/product-dialog.tsx | 270 ++++++++++++++++++ components/products/edit-product-button.tsx | 27 ++ stores/products-store.ts | 29 ++ 11 files changed, 777 insertions(+), 10 deletions(-) create mode 100644 __tests__/actions/products.test.ts create mode 100644 __tests__/components/dialogs/product-dialog.test.tsx create mode 100644 components/dashboard/new-product-button.tsx create mode 100644 components/dialogs/product-dialog.tsx create mode 100644 components/products/edit-product-button.tsx create mode 100644 stores/products-store.ts diff --git a/__tests__/actions/products.test.ts b/__tests__/actions/products.test.ts new file mode 100644 index 0000000..b2cc766 --- /dev/null +++ b/__tests__/actions/products.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { + mockGetSession, + mockFindFirstProduct, + mockCreateProduct, + mockUpdateProduct, + mockCreateMember, + mockExecuteRaw, + mockTransaction, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstProduct: vi.fn(), + mockCreateProduct: vi.fn(), + mockUpdateProduct: vi.fn(), + mockCreateMember: vi.fn(), + mockExecuteRaw: vi.fn().mockResolvedValue(undefined), + mockTransaction: vi.fn(), +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/navigation', () => ({ redirect: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({ OR: [{ user_id: 'user-1' }] }), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: mockFindFirstProduct, create: mockCreateProduct, update: mockUpdateProduct }, + productMember: { create: mockCreateMember }, + $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, + }, +})) + +import { createProductAction, updateProductAction } from '@/actions/products' +import { getIronSession } from 'iron-session' + +const mockSession = getIronSession as ReturnType + +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } +const PRODUCT_ID = 'product-1' + +const VALID_DATA = { + name: 'Test Product', + code: 'TP', + description: 'Een product', + repo_url: 'https://github.com/org/repo', + definition_of_done: 'Alles groen', + auto_pr: false, +} + +beforeEach(() => { + vi.clearAllMocks() + mockExecuteRaw.mockResolvedValue(undefined) + mockSession.mockResolvedValue(SESSION_USER) +}) + +// ============================================================= +// createProductAction +// ============================================================= +describe('createProductAction', () => { + it('happy path: maakt product + member aan en retourneert productId', async () => { + mockFindFirstProduct.mockResolvedValue(null) // geen dubbele code + mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise) => { + return fn({ + product: { + create: vi.fn().mockResolvedValue({ id: PRODUCT_ID }), + }, + productMember: { + create: vi.fn().mockResolvedValue({}), + }, + }) + }) + + const result = await createProductAction(VALID_DATA) + + expect(result).toEqual({ success: true, productId: PRODUCT_ID }) + }) + + it('demo-user → error', async () => { + mockSession.mockResolvedValue(SESSION_DEMO) + + const result = await createProductAction(VALID_DATA) + + expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('ongeldige repo_url (niet github) → validatiefout', async () => { + const result = await createProductAction({ ...VALID_DATA, repo_url: 'https://gitlab.com/org/repo' }) + + expect(result).toMatchObject({ error: expect.any(String) }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('dubbele code → error', async () => { + mockFindFirstProduct.mockResolvedValue({ id: 'other-product' }) + + const result = await createProductAction(VALID_DATA) + + expect(result).toMatchObject({ error: expect.stringContaining('gebruik') }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('naam ontbreekt → validatiefout', async () => { + const result = await createProductAction({ ...VALID_DATA, name: '' }) + + expect(result).toMatchObject({ error: expect.any(String) }) + }) +}) + +// ============================================================= +// updateProductAction +// ============================================================= +describe('updateProductAction', () => { + it('happy path: werkt product bij en stuurt pg_notify', async () => { + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockUpdateProduct.mockResolvedValue({ id: PRODUCT_ID }) + + const result = await updateProductAction(PRODUCT_ID, VALID_DATA) + + expect(result).toEqual({ success: true }) + expect(mockUpdateProduct).toHaveBeenCalled() + expect(mockExecuteRaw).toHaveBeenCalledTimes(1) + }) + + it('demo-user → error', async () => { + mockSession.mockResolvedValue(SESSION_DEMO) + + const result = await updateProductAction(PRODUCT_ID, VALID_DATA) + + expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + expect(mockUpdateProduct).not.toHaveBeenCalled() + }) + + it('geen toegang tot product → error', async () => { + mockFindFirstProduct.mockResolvedValue(null) + + const result = await updateProductAction(PRODUCT_ID, VALID_DATA) + + expect(result).toMatchObject({ error: expect.stringContaining('toegang') }) + expect(mockUpdateProduct).not.toHaveBeenCalled() + }) + + it('ongeldige repo_url → validatiefout', async () => { + const result = await updateProductAction(PRODUCT_ID, { ...VALID_DATA, repo_url: 'https://bitbucket.org/x' }) + + expect(result).toMatchObject({ error: expect.any(String) }) + expect(mockUpdateProduct).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/components/dialogs/product-dialog.test.tsx b/__tests__/components/dialogs/product-dialog.test.tsx new file mode 100644 index 0000000..bbbb51c --- /dev/null +++ b/__tests__/components/dialogs/product-dialog.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +vi.mock('@/actions/products', () => ({ + createProductAction: vi.fn(), + updateProductAction: vi.fn(), +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/stores/products-store', () => ({ + useProductsStore: vi.fn((selector: (s: { addProduct: () => void; updateProduct: () => void }) => unknown) => + selector({ addProduct: vi.fn(), updateProduct: vi.fn() }) + ), +})) + +import { ProductDialog } from '@/components/dialogs/product-dialog' +import { createProductAction, updateProductAction } from '@/actions/products' +import { toast } from 'sonner' + +const mockCreate = createProductAction as ReturnType +const mockUpdate = updateProductAction as ReturnType +const mockToast = toast as unknown as { + success: ReturnType + error: ReturnType +} + +const PRODUCT = { + id: 'prod-1', + name: 'Mijn Product', + code: 'MP', + description: 'Een product', + repo_url: 'https://github.com/org/repo', + definition_of_done: 'Alles groen', + auto_pr: false, +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('ProductDialog — create mode', () => { + it('rendert met lege velden en "Nieuw product" titel', () => { + render( + + ) + expect(screen.getByText('Nieuw product')).toBeTruthy() + expect(screen.getByLabelText(/Naam/)).toBeTruthy() + expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('') + }) + + it('toont validatiefout als naam leeg is bij submit', async () => { + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Aanmaken' })) + + await waitFor(() => { + expect(screen.getByText('Naam is verplicht')).toBeTruthy() + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('roept createProductAction aan bij geldig formulier', async () => { + mockCreate.mockResolvedValue({ success: true, productId: 'new-prod' }) + + render( + + ) + + fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Nieuw Product' } }) + fireEvent.submit(document.getElementById('product-form')!) + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Nieuw Product' }) + ) + }) + expect(mockToast.success).toHaveBeenCalledWith('Product aangemaakt') + }) + + it('toont error-toast als createProductAction een error retourneert', async () => { + mockCreate.mockResolvedValue({ error: 'Code is al in gebruik' }) + + render( + + ) + + fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Test' } }) + fireEvent.submit(document.getElementById('product-form')!) + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith('Code is al in gebruik') + }) + }) +}) + +describe('ProductDialog — edit mode', () => { + it('rendert met bestaande waarden vooringevuld', () => { + render( + + ) + expect(screen.getByText('Product bewerken')).toBeTruthy() + expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('Mijn Product') + }) + + it('roept updateProductAction aan bij opslaan', async () => { + mockUpdate.mockResolvedValue({ success: true }) + + render( + + ) + + fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Gewijzigd Product' } }) + fireEvent.submit(document.getElementById('product-form')!) + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith( + PRODUCT.id, + expect.objectContaining({ name: 'Gewijzigd Product' }) + ) + }) + expect(mockToast.success).toHaveBeenCalledWith('Product opgeslagen') + }) +}) + +describe('ProductDialog — demo mode', () => { + it('submit-knop is disabled in demo-modus', () => { + render( + + ) + const submitBtn = screen.getByRole('button', { name: 'Aanmaken' }) + expect(submitBtn).toHaveProperty('disabled', true) + }) +}) diff --git a/actions/products.ts b/actions/products.ts index 892569f..a43f492 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -9,6 +9,7 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { Role } from '@prisma/client' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' +import { productAccessFilter } from '@/lib/product-access' const productSchema = z.object({ name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), @@ -28,11 +29,122 @@ const productSchema = z.object({ .max(500, 'Definition of Done mag maximaal 500 tekens bevatten'), }) +// Dialog-based schema (data-object API) +const productInput = z.object({ + name: z.string().min(1).max(200), + code: z.string().max(20).optional(), + description: z.string().max(4000).optional(), + repo_url: z + .string() + .url() + .regex(/^https:\/\/github\.com\//) + .optional() + .nullable(), + definition_of_done: z.string().max(4000).optional(), + auto_pr: z.boolean().default(false), +}) + +export type ProductInput = z.infer + +type ProductActionResult = { success: true; productId: string } | { error: string } +type UpdateProductResult = { success: true } | { error: string } + async function getSession() { return getIronSession(await cookies(), sessionOptions) } -export async function createProductAction(_prevState: unknown, formData: FormData) { +// Data-object API used by ProductDialog +export async function createProductAction(data: ProductInput): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = productInput.safeParse(data) + if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + + const code = normalizeCode(parsed.data.code) + if (code !== null && !isValidCode(code)) { + return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } + } + + if (code) { + const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } }) + if (dup) return { error: 'Code is al in gebruik' } + } + + const userId = session.userId + const product = await prisma.$transaction(async (tx) => { + const p = await tx.product.create({ + data: { + user_id: userId, + name: parsed.data.name, + code: code ?? null, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url ?? null, + definition_of_done: parsed.data.definition_of_done ?? '', + auto_pr: parsed.data.auto_pr, + }, + }) + await tx.productMember.create({ data: { product_id: p.id, user_id: userId } }) + return p + }) + + revalidatePath('/products') + revalidatePath('/dashboard') + return { success: true, productId: product.id } +} + +// Data-object API used by ProductDialog +export async function updateProductAction(id: string, data: ProductInput): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = productInput.safeParse(data) + if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } + + const product = await prisma.product.findFirst({ + where: { id, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Product niet gevonden of geen toegang' } + + const code = normalizeCode(parsed.data.code) + if (code !== null && !isValidCode(code)) { + return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } + } + + const userId = session.userId + + await prisma.product.update({ + where: { id }, + data: { + name: parsed.data.name, + code: code ?? null, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url ?? null, + ...(parsed.data.definition_of_done !== undefined && { + definition_of_done: parsed.data.definition_of_done, + }), + auto_pr: parsed.data.auto_pr, + }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'product_updated', + product_id: id, + user_id: userId, + })}::text) + ` + + revalidatePath(`/products/${id}`) + revalidatePath('/dashboard') + return { success: true } +} + +// FormData-based actions for existing ProductForm components +export async function createProductFormAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } @@ -78,7 +190,7 @@ export async function createProductAction(_prevState: unknown, formData: FormDat redirect(`/products/${product.id}`) } -export async function updateProductAction(_prevState: unknown, formData: FormData) { +export async function updateProductFormAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 9497e54..95a84d4 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -4,8 +4,8 @@ import { SessionData, sessionOptions } from '@/lib/session' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import Link from 'next/link' -import { Button } from '@/components/ui/button' import { ProductList } from '@/components/dashboard/product-list' +import { NewProductButton } from '@/components/dashboard/new-product-button' interface Props { searchParams: Promise<{ archived?: string }> @@ -43,9 +43,7 @@ export default async function DashboardPage({ searchParams }: Props) { )} - {!session.isDemo && !showArchived && ( - - )} + {!session.isDemo && !showArchived && } )} + {!isDemo && product.user_id === session.userId && ( + + )} (await cookies(), sessionOptions) @@ -12,7 +12,7 @@ export default async function NewProductPage() { return (

Nieuw product

- +
) } diff --git a/components/dashboard/new-product-button.tsx b/components/dashboard/new-product-button.tsx new file mode 100644 index 0000000..cf3e36a --- /dev/null +++ b/components/dashboard/new-product-button.tsx @@ -0,0 +1,23 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { ProductDialog } from '@/components/dialogs/product-dialog' + +export function NewProductButton() { + const [open, setOpen] = useState(false) + const router = useRouter() + + return ( + <> + + router.push(`/products/${id}`)} + /> + + ) +} diff --git a/components/dialogs/product-dialog.tsx b/components/dialogs/product-dialog.tsx new file mode 100644 index 0000000..920b5b7 --- /dev/null +++ b/components/dialogs/product-dialog.tsx @@ -0,0 +1,270 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { createProductAction, updateProductAction } from '@/actions/products' +import { useProductsStore } from '@/stores/products-store' + +const formSchema = z.object({ + name: z.string().min(1, 'Naam is verplicht').max(200), + code: z.string().max(20).optional(), + description: z.string().max(4000).optional(), + repo_url: z.string().max(200).optional(), + definition_of_done: z.string().max(4000).optional(), + auto_pr: z.boolean(), +}) + +type FormValues = z.infer + +export interface ProductDialogProduct { + id: string + name: string + code?: string | null + description?: string | null + repo_url?: string | null + definition_of_done?: string | null + auto_pr?: boolean +} + +type Props = + | { mode: 'create'; open: boolean; onOpenChange: (v: boolean) => void; onSaved?: (id: string) => void; isDemo?: boolean } + | { mode: 'edit'; open: boolean; onOpenChange: (v: boolean) => void; product: ProductDialogProduct; onSaved?: (id: string) => void; isDemo?: boolean } + +export function ProductDialog(props: Props) { + const { mode, open, onOpenChange, isDemo = false } = props + const product = mode === 'edit' ? props.product : null + const addProduct = useProductsStore((s) => s.addProduct) + const updateProduct = useProductsStore((s) => s.updateProduct) + + const [isPending, setIsPending] = useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: product?.name ?? '', + code: product?.code ?? '', + description: product?.description ?? '', + repo_url: product?.repo_url ?? '', + definition_of_done: product?.definition_of_done ?? '', + auto_pr: product?.auto_pr ?? false, + }, + }) + + // Reset form when dialog opens or switches product + useEffect(() => { + if (open) { + form.reset({ + name: product?.name ?? '', + code: product?.code ?? '', + description: product?.description ?? '', + repo_url: product?.repo_url ?? '', + definition_of_done: product?.definition_of_done ?? '', + auto_pr: product?.auto_pr ?? false, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, product?.id]) + + async function onSubmit(values: FormValues) { + if (isDemo) { + toast.error('Niet beschikbaar in demo-modus') + return + } + setIsPending(true) + try { + const payload = { + name: values.name, + code: values.code || undefined, + description: values.description || undefined, + repo_url: values.repo_url || null, + definition_of_done: values.definition_of_done || undefined, + auto_pr: values.auto_pr, + } + + if (mode === 'create') { + const result = await createProductAction(payload) + if ('error' in result) { + toast.error(result.error) + return + } + addProduct({ + id: result.productId, + name: values.name, + code: values.code ?? null, + description: values.description ?? null, + repo_url: values.repo_url ?? null, + definition_of_done: values.definition_of_done ?? '', + auto_pr: values.auto_pr, + }) + toast.success('Product aangemaakt') + onOpenChange(false) + props.onSaved?.(result.productId) + } else { + const result = await updateProductAction(product!.id, payload) + if ('error' in result) { + toast.error(result.error) + return + } + updateProduct(product!.id, { + name: values.name, + code: values.code ?? null, + description: values.description ?? null, + repo_url: values.repo_url ?? null, + definition_of_done: values.definition_of_done ?? '', + auto_pr: values.auto_pr, + }) + toast.success('Product opgeslagen') + onOpenChange(false) + props.onSaved?.(product!.id) + } + } finally { + setIsPending(false) + } + } + + const autoPr = form.watch('auto_pr') + + return ( + + + + {mode === 'edit' ? 'Product bewerken' : 'Nieuw product'} + + +
+
+ + + {form.formState.errors.name && ( +

{form.formState.errors.name.message}

+ )} +
+ +
+ + +
+ +
+ +