docs(04): create phase plan — marketplace & portal navigation (4 plans, 3 waves)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:17:02 +02:00
parent 670fe088a4
commit 0470d500b2
5 changed files with 770 additions and 2 deletions
@@ -0,0 +1,180 @@
---
phase: 04-marketplace-portal-navigation
plan: 03
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar-search.tsx
- apps/web/src/components/layout/sidebar.test.tsx
- apps/web/src/components/layout/sidebar-search.test.tsx
autonomous: true
requirements: [MRKT-03, PRTAL-02, PRTAL-03, PRTAL-05]
must_haves:
truths:
- "Sidebar shows only the tenant's activated modules, grouped by category, with an individual clickable link per module (PRTAL-02, MRKT-03)"
- "Clicking a module link in the sidebar navigates client-side to /modules/{category}/{slug} and the module opens in the main content area (PRTAL-03)"
- "The currently open page/module is highlighted active in the sidebar via usePathname (PRTAL-03)"
- "A sidebar search field filters visible modules and categories by name in real time; non-matching categories hide (PRTAL-05)"
- "After a module is activated/deactivated in the marketplace, the sidebar refreshes its active-module list without a full page reload"
artifacts:
- path: "apps/web/src/components/layout/sidebar-search.tsx"
provides: "Sidebar search input filtering modules + categories"
min_lines: 20
- path: "apps/web/src/components/layout/sidebar.tsx"
provides: "Sidebar using Next.js Link + usePathname active state + per-module links + search + marketplace-store refresh subscription"
contains: "usePathname"
key_links:
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "/modules/active"
via: "fetch on mount and on marketplace-store sidebarRefreshKey change"
pattern: "modules/active"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "marketplace-store sidebarRefreshKey"
via: "useEffect dependency triggers re-fetch"
pattern: "sidebarRefreshKey"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "next/navigation usePathname"
via: "active-state comparison against link href"
pattern: "usePathname"
---
<objective>
Enhance the existing sidebar so it becomes the live navigation surface for activated modules: migrate all links from raw `<a>` to Next.js `<Link>`, add `usePathname`-based active highlighting, render an individual link per activated module under each category, add a sidebar search field, and subscribe to the marketplace-store refresh signal so newly activated/deactivated modules appear without a page reload.
Purpose: Delivers PRTAL-02 (sidebar shows categories + activated modules), PRTAL-03 (selected module opens in main area + active highlight), PRTAL-05 (sidebar search/filter), and MRKT-03 (only activated modules appear).
Output: Modified sidebar.tsx, new sidebar-search.tsx, and unit tests.
</objective>
## Phase Goal
**As a** Tessera user, **I want to** see my tenant's activated modules in the sidebar, search them, and click one to open it, **so that** I can navigate directly to the tools I use without leaving the portal.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md
@.planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md
@.planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md
@.planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md
@.planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Build SidebarSearch and migrate sidebar to Next.js Link + usePathname active state</name>
<read_first>
- apps/web/src/components/layout/sidebar.tsx (the file being modified — current raw `<a>` links, hardcoded Dashboard active state, categories accordion with per-module `<a>` links at lines 144-169, admin section)
- apps/web/src/app/(portal)/admin/tenants/page.tsx (input styling for the search field)
- .planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md (sidebar.tsx MODIFY section: Link/usePathname insertion points, active CSS classes; sidebar-search no-analog guidance)
- .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Sidebar Enhancement: SidebarSearch 36px height, placement between main nav and categories, collapsed-mode rule, active classes)
- .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Pitfall 1 raw `<a>` reloads, Pitfall 3 active state, Pitfall 5 accordion state reset)
- apps/web/src/messages/de.json (sidebar.search + sidebar.noResults added in Plan 01)
</read_first>
<behavior>
- Test 1: Sidebar renders the Dashboard and Marketplace links as Next.js Link elements (anchor with client-side href, no full reload semantics) — assert hrefs "/" and "/marketplace"
- Test 2: Given pathname "/marketplace", the Marketplace link has the active class (`bg-sidebar-accent`) and Dashboard does not
- Test 3: SidebarSearch renders an input with the `sidebar.search` placeholder and calls onChange with typed value
- Test 4: With a search term that matches no module, the sidebar renders the `sidebar.noResults` text
</behavior>
<action>
Create `apps/web/src/components/layout/sidebar-search.tsx`: `'use client'` controlled `<input>`, props `{ value: string; onChange: (v: string) => void }`, placeholder from `useTranslations('sidebar')('search')`, height 36px, `border border-border rounded-md`, focus `focus:ring-2 focus:ring-ring focus:border-transparent`, `px-3` within the sidebar nav padding. Include an accessible label (`aria-label` = placeholder). No debounce needed (client-side, tiny dataset) — filter immediately.
Modify `apps/web/src/components/layout/sidebar.tsx`:
1. Add imports `import Link from 'next/link'` and `import { usePathname } from 'next/navigation'`. Add `const pathname = usePathname()` and an `isActive(href)` helper: for "/" return `pathname === '/'`, otherwise `pathname.startsWith(href)`.
2. Replace EVERY `<a href=...>` (Dashboard, Marketplace, category links, per-module links, all admin links) with `<Link href=...>`. Remove the hardcoded Dashboard active class; instead apply active classes conditionally via `isActive(href)`: active = `bg-sidebar-accent text-sidebar-accent-foreground font-medium`, inactive = `text-sidebar-foreground hover:bg-muted` (for top-level) / muted variants for nested module links. Per-module links use `/modules/{category}/{slug}` and get active highlight when `isActive` matches that path.
3. Insert `<SidebarSearch>` between the main-nav `<ul>` (Dashboard/Marketplace) and the categories accordion, only when `!isCollapsed` (collapsed mode hides search per UI-SPEC). Add `searchQuery` state.
4. Filter the activated-module list and categories by `searchQuery` (case-insensitive match on module name and category name). Hide categories whose modules all filter out. When the search yields zero modules, render `t('noResults')` instead of the accordion list. Keep accordion expand/collapse state stable across search changes (track expanded categories in a Set keyed by category name so clearing search does not collapse them — Pitfall 5).
Create `apps/web/src/components/layout/sidebar-search.test.tsx` and `apps/web/src/components/layout/sidebar.test.tsx` implementing the four <behavior> tests. Mock `next/navigation` `usePathname`, `next-intl`, the auth-store, and `fetch` for `/modules/active`.
Do NOT leave any raw `<a href>` in the sidebar (causes full reloads — Pitfall 1). Do NOT show the search field or module list in collapsed (64px) mode.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/components/layout/sidebar" 2>&1 | grep -qiE "passed" && grep -vE '^\s*//|^\s*\*' src/components/layout/sidebar.tsx | grep -c '<a href' | grep -qx 0 && echo SIDEBAR_OK</automated>
</verify>
<acceptance_criteria>
- apps/web/src/components/layout/sidebar.tsx contains `usePathname` and `import Link from 'next/link'`
- sidebar.tsx contains zero `<a href` occurrences (excluding comments) — all migrated to `<Link>`
- sidebar.tsx applies `bg-sidebar-accent` via `isActive(...)` (not hardcoded on Dashboard)
- sidebar-search.tsx renders an input with the `sidebar.search` placeholder and an `aria-label`
- SidebarSearch is rendered only when `!isCollapsed`
- sidebar.test.tsx + sidebar-search.test.tsx all pass; `pnpm type-check` exits 0
</acceptance_criteria>
<done>Sidebar uses Next.js Link with dynamic active highlighting, shows per-module links under categories, and a search field filters modules/categories (hidden when collapsed); all tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Subscribe sidebar to marketplace-store refresh signal so activations appear live</name>
<read_first>
- apps/web/src/components/layout/sidebar.tsx (modified in Task 1 — has fetchActiveModules useCallback + useEffect)
- apps/web/src/lib/stores/marketplace-store.ts (created Plan 01 — sidebarRefreshKey, bumpSidebarRefresh)
- .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Pitfall 2 race condition + Open Question 1: shared Zustand store as refresh mechanism; anti-pattern: re-fetch on every navigation)
</read_first>
<behavior>
- Test 1: When `bumpSidebarRefresh()` is called on the marketplace-store, the sidebar re-invokes its fetch of /modules/active (assert fetch call count increases)
- Test 2: The sidebar does NOT re-fetch /modules/active purely because pathname changed (navigation alone must not trigger a re-fetch)
</behavior>
<action>
Modify `apps/web/src/components/layout/sidebar.tsx` to read `sidebarRefreshKey` from `useMarketplaceStore` and add it to the dependency array of the existing `useEffect` that calls `fetchActiveModules`. Keep the mount fetch. Do NOT add `pathname` to that effect's dependency array (re-fetching on every navigation is the anti-pattern in 04-RESEARCH). This makes the sidebar re-fetch active modules exactly when the marketplace bumps the signal after a successful activate/deactivate, closing the Pitfall 2 race (marketplace awaits the POST response before bumping).
Update `apps/web/src/components/layout/sidebar.test.tsx` (or add a focused test) implementing the two <behavior> tests: render the sidebar, capture fetch call count, call `useMarketplaceStore.getState().bumpSidebarRefresh()` (or trigger via the store) and assert the fetch count increased; separately change the mocked pathname and assert the fetch count did not change.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/components/layout/sidebar" 2>&1 | grep -qiE "passed" && cd /home/vicolab/projects/tessera-ctl/apps/web && pnpm vitest run 2>&1 | grep -qiE "passed" && echo REFRESH_OK</automated>
</verify>
<acceptance_criteria>
- sidebar.tsx imports `useMarketplaceStore` and reads `sidebarRefreshKey`
- `sidebarRefreshKey` is in the fetchActiveModules useEffect dependency array
- `pathname` is NOT in that effect's dependency array
- Test proves bumpSidebarRefresh triggers a re-fetch and navigation alone does not
- Full `pnpm vitest run` in apps/web exits 0; `pnpm type-check` exits 0
</acceptance_criteria>
<done>Activating/deactivating a module in the marketplace causes the sidebar to refresh its module list without a full page reload; navigation alone does not trigger refetch; tests pass.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
Symbols created/modified by this plan (excluded from drift verification by downstream review):
- React component `SidebarSearch` — apps/web/src/components/layout/sidebar-search.tsx
- Modified `Sidebar` component: now imports `usePathname`, `Link`, `useMarketplaceStore`; adds `isActive` helper, `searchQuery` state, expanded-categories Set
- New test files: sidebar.test.tsx, sidebar-search.test.tsx
- Consumes (from Plan 01): `useMarketplaceStore.sidebarRefreshKey`, `sidebar.search` / `sidebar.noResults` i18n keys
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → NestJS API | Sidebar fetches the tenant's active modules (GET /modules/active) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-08 | Information Disclosure | Sidebar /modules/active list | mitigate | GET /modules/active is tenant-scoped server-side (TenantMiddleware + RLS, Phase 2/3). Sidebar renders whatever the backend returns for the authenticated tenant — no client-side tenant selection here. |
| T-04-09 | Tampering | Module name rendered in sidebar link | mitigate | Rendered as React text content; auto-escaped. Module slug used in href is a registry-controlled slug, not free user input. |
| T-04-10 | Denial of Service | Sidebar re-fetch loop | mitigate | Re-fetch keyed only on mount + explicit sidebarRefreshKey bump; pathname deliberately excluded from deps to avoid per-navigation fetch storms (anti-pattern guarded). |
</threat_model>
<verification>
- `cd apps/web && pnpm vitest run` exits 0 (all sidebar tests green)
- `cd apps/web && pnpm type-check` exits 0
- Manual: activate a module in marketplace → it appears in sidebar without page reload; click it → opens in main area and sidebar highlights it; clear-then-type in sidebar search keeps expanded categories
</verification>
<success_criteria>
- Sidebar shows only activated modules grouped by category with per-module links (PRTAL-02, MRKT-03)
- Clicking a module opens it in the main area client-side with active highlight (PRTAL-03)
- Sidebar search filters modules/categories in real time (PRTAL-05)
- Sidebar refreshes on marketplace activation without full reload; no per-navigation re-fetch
- All raw `<a>` links migrated to Next.js `<Link>`
</success_criteria>
<output>
Create `.planning/phases/04-marketplace-portal-navigation/04-03-SUMMARY.md` when done
</output>