Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
# UX research: Admin, roles/plans, support desk
**Product:** Descrybe — product feed / AI catalog SaaS (Go API + SvelteKit admin).
**Scope:** Clean ops panels, plan/role permission matrices, support queue with assignment + CSAT.
**Date:** 2026-08-05
**Method:** Web research (admin tables, RBAC/entitlements, support SLA/CSAT) + codehelper skim of existing admin UI.
---
## Existing Descrybe surface (baseline)
| Area | What exists today | Paths / symbols |
| --- | --- | --- |
| Admin chrome | Fixed left nav, dark shell (`.dark`), brand “Descrybe” + Admin pill | `AdminNav.svelte` |
| Page frame | `PageShell` → title + description + actions; `max-w-7xl` | `PageShell.svelte` |
| Tables | `TableShell` = bordered `bg-card` + scroll; row hover `muted/40` | `TableShell.svelte` |
| Support queue | Status chip filters, search, Badge status map, click-through rows | `/admin/support`, `listAdminSupportTickets` |
| Ticket model | status, priority, category, `assignee_admin_user_id`, message thread | `support/admin-api.ts` |
| Plan features | Package select + section filter + search; checkbox matrix; package Badges | `PlanPermissionsPanel.svelte` |
| Tokens | Majorelle primary, russian-violet ink, slate-friendly cards; semantic Badge variants | `layout.css` (`--primary` 247°, success/warning Badges) |
**Implication:** Do not invent a second admin visual system. Extend `PageShell` + `Card` + `TableShell` + `Badge` + filter chips already used on Support. Prefer semantic tokens (`border-border`, `bg-primary/15`, `text-muted-foreground`) over one-off `slate-*` where the rest of admin already uses tokens (Support does; PlanPermissions still hardcodes some slate).
---
## A) Clean admin / ops panels
### Patterns to apply
1. **Job-first hierarchy (3 levels)**
- L1: entity/queue purpose + critical alerts + primary actions (Refresh, Assign, Resolve).
- L2: filters + operational table.
- L3: audit / history / advanced settings on demand (drawer or sub-route).
Source pattern: SaaS admin hierarchy guides (Taqwah, PyColors).
2. **Density as a tool, not a vibe**
- Ops tables: comfortable default (~4048px rows); optional compact for power users later.
- Cap overview KPI cards at **35** (admin overview already leans summary + tool grid — keep that restraint).
- Sticky table headers; freeze identity column (subject / company name) when horizontal scroll appears.
3. **Queue-shaped tables**
Useful traits: strong status, workflow filters, clear row entry, stable column order.
Weak traits: ambiguous state, decorative charts instead of work, actions buried in kebab menus for primary work.
4. **Filter UX**
- Removable filter chips + result count (“12 of 240 · status open · priority high”).
- Global search + a few workflow filters (status / assignee / plan tier / SLA risk) — not a filter drawer per column on day one.
- Persist key filters in URL (`?status=open`) — Support already does this; extend to assignee/SLA.
5. **Layout zones for ticket / user detail**
Entity header (state + plan + SLA) → primary thread/table → side sheet for assignment / meta → audit strip.
Prefer split pane or side sheet over full-page form for assignment so the queue context stays visible.
### DO / DON'T — avoiding the “ugly admin” look (Descrybe-native)
| DO | DON'T |
| --- | --- |
| Reuse `PageShell`, `Card`, `TableShell`, Lucide icons at `h-4`/`h-5` | New dashboard kits, glassmorphism, neon charts, multi-shadow cards |
| Use majorelle **sparingly**: active nav, selected filter, primary CTA | Purple-to-indigo hero gradients, glow rings, “AI sparkle” chrome on every control |
| Semantic status via `Badge` (`warning`/`success`/`secondary`) | Rainbow row backgrounds or emoji status columns |
| One composition per page: title → filters → table | KPI strip + chart wall + three card grids above every queue |
| Tabular nums + truncate with tooltip for IDs/emails | Wrapping every cell; monospace walls of UUID |
| Destructive actions visually separate + confirm | Red “Delete” next to “Reply” without consequence copy |
| Match admin dark shell tokens from `layout.css` | Flat gray Bootstrap admin or cream/serif “editorial” admin |
Brand note: Descrybes primary *is* majorelle blue. “Not purple AI slop” means **dont decorate** — use brand as selection/affordance, not as atmosphere.
---
## B) Plan / role permission matrices
### Model (separate two axes)
Research consensus (Stripe-style entitlements + B2B RBAC UX):
| Axis | Question | Descrybe mapping |
| --- | --- | --- |
| **Role** | What can this *person* do? | Platform admin vs company admin vs member; future scoped ops roles |
| **Plan / entitlement** | What has this *account* bought? | Plans + `PlanPermissionsPanel` feature catalog + feature gates |
| **Scope** | Where does it apply? | Company / catalog / feed — keep visible if multi-tenant ops act across companies |
| **Lifecycle** | Trial, past_due, suspended, custom deal | Already surfaced in billing helpers (`billing-display`, package Badges) |
**Never blur** “member can export” (role) with “Advanced export is on Enterprise” (plan). UI should explain *which* layer blocked an action.
### Matrix UX patterns
1. **Read matrix for audit; edit via focused builder**
Full checkbox grids are good for platform ops (current Plan features panel). For end-customer role assignment, prefer named roles + plain-language summary, not a wall of toggles.
2. **Group by task domain**
Sections already in `PLAN_FEATURE_SECTIONS` — keep section headers, global gate Badge (“Globally disabled”), and search. Add “affected companies / seats” count before bulk enable/disable.
3. **Honest gating states**
Hide vs disable vs upgrade CTA vs “ask admin” — pick per feature. Support/billing should show *why* (plan vs role vs lifecycle).
4. **Role chips**
Compact chips: plan name, `is_custom`, trial, platform-admin. Use `Badge variant="outline"` for ladder defaults and `secondary` for custom deals (already started in PlanPermissionsPanel). Avoid inventing 8 near-identical admin role names.
5. **Exceptions expire**
Support overrides and custom deals need owner, reason, expiry, audit — otherwise Support becomes the policy engine.
### DO / DON'T — permissions UI
| DO | DON'T |
| --- | --- |
| Label packages in customer language (Free / Pro / Enterprise / Custom deal) | Internal flags as the only label (`is_custom` alone) |
| Show count enabled / total; section + search filters | Unfiltered 80-row checkbox dump with no scan path |
| Confirm bulk “Disable all” with blast radius | Silent global section flips |
| Log who changed plan features | Toggles with no success/error feedback (panel already alerts — keep it) |
| Separate “AI roles” (provider keys) from user RBAC | Mixing `admin-ai-roles` (LLM slots) into the human permission matrix naming |
---
## C) Support desk: assignment, queues, SLA, CSAT
### Current hooks
- Queue: status filters + search; columns subject/status/priority/company/requester/updated.
- API update already allows `assignee_admin_user_id` / `clear_assignee`.
- Priorities: `low | normal | high`. Categories: `billing | bug | account | other`.
- Gap vs research: no SLA timers, no assignee column/filter, no CSAT fields, no “my queue” / unassigned views.
### Ticket queue patterns
1. **Ownership model**
Default for B2B SaaS: route by **product area / category** + **customer plan tier**, then assign.
Assignment modes to support:
- Unassigned pool → claim
- Round-robin / load-balance for general
- Skill / category for billing vs bug
- VIP / Enterprise bypass for high impact
2. **Queue views (filters, not separate apps)**
- All open · Unassigned · Mine · SLA at risk · Waiting on customer (`pending`)
Keep chip style from Support page (`border-primary bg-primary/15` when active).
3. **Row information hierarchy**
Primary: subject + company. Secondary: status Badge, priority, assignee avatar/initials, last message age. Tertiary: category under subject (already). Add SLA countdown only when defined.
4. **SLA as timers + events**
- Tier by priority (and optionally plan): first response + resolution targets.
- States: on track → **at risk** (e.g. 80% elapsed) → breached.
- Pause when waiting on customer.
- UI: subtle text for on-track; `Badge variant="warning"` at risk; `destructive` only for breach — not red rows.
5. **Assignment UX**
- Inline assignee control on detail header; optional bulk assign from queue.
- Show workload (open count) in assignee picker.
- Reassignment reason for misroutes (feeds quality metrics).
### CSAT patterns
1. **One rating question + optional comment** (15 or Good/Neutral/Bad). Extra questions kill response rate.
2. **Ask in-thread or embed in resolution email** — avoid “click this survey link” as primary path.
3. **Timing:** immediate on resolve for highest response; optional short delay (14h) if you need confirmation the fix stuck. Cap frequency (~1 survey / customer / 1430 days).
4. **Closed loop:** rating ≤2 → auto follow-up task for lead/manager within 24h; do not only score the agent.
5. **Ops dashboard:** weekly CSAT trend, by category and by assignee — not a vanity gauge on every ticket list row (show star only when rated).
### DO / DON'T — support UX
| DO | DON'T |
| --- | --- |
| Explicit assignee + unassigned queue | Tickets that only change status with no owner |
| Concrete priority definitions (impact × urgency) | “High” used for every billing question |
| Pair speed (FRT/SLA) with CSAT / reopen rate | Optimize SLA alone until quality collapses |
| Internal notes distinct from customer replies | Agents accidentally sending notes to customers |
| Macros that sound human; show company plan on ticket | Generic AI purple assist panel auto-replying without gate (`support` AI role stays off until product gate) |
---
## Concrete patterns checklist (build against this)
| Pattern | Apply on |
| --- | --- |
| Density | Support + Users + Billing tables via shared table cell padding |
| Hierarchy | PageShell title → chip filters → TableShell → detail |
| Tables | Sticky header; identity first; Badge for state; truncate + title |
| Filters | Chips + URL params + count; search left-icon Input |
| Role / plan chips | Badge outline vs secondary; plan name humanized |
| Ticket queues | Unassigned / Mine / At risk views; assignee column |
| SLA | Timer + at-risk Badge; pause on `pending` |
| Ratings | Post-resolve 15; optional comment; closed-loop on lows |
---
## Top 8 recommendations
1. **Standardize ops pages on one skeleton**`PageShell` + filter chip bar + `TableShell` + empty/forbidden/unavailable states (copy Support; retire ad-hoc slate-only cards where tokens suffice).
2. **Ship assignee-centric queues next** — column + filter + “Unassigned / Mine”; wire existing `assignee_admin_user_id` before adding SLA or CSAT chrome.
3. **Keep plan entitlements and human roles as two panels** — extend `PlanPermissionsPanel` for packages; separate company/platform RBAC; never merge with `admin-ai-roles` LLM slots in the same matrix.
4. **Permission matrix = grouped sections + search + blast-radius confirms** — preserve section gates and package Badges; add “who is affected” on enable/disable all.
5. **Add SLA as a thin operational layer** — first-response + resolution by priority (optionally × plan tier); show at-risk/breach Badges; pause on `pending`.
6. **CSAT: one click in the resolution path** — 15 (or 3-point) in ticket UI / resolution email; optional comment on low scores only; frequency cap; manager follow-up automation for ≤2.
7. **Visual discipline = brand as signal, not decoration** — majorelle for selection/CTA only; semantic Badges for state; no gradient dashboards, glow, or decorative KPI walls.
8. **Measure ops health with a short metric set** — FRT, SLA at-risk/breach, misroute/reassign rate, reopen rate, CSAT weekly by category — surface on Support overview, not as chart spam on the queue.
---
## Sources (web)
- [SaaS Admin Panel Design — hierarchy & density](https://taqwah.agency/blog/saas-admin-panel-design-guide)
- [Admin tables & queues](https://pycolors.io/guides/saas-admin-panels)
- [Data table density, chips, sticky headers](https://www.setproduct.com/blog/data-table-ui-design)
- [Permission matrix as product surface (roles vs entitlements)](https://0r8it.com/blog/the-permission-matrix-is-a-product-surface)
- [Multi-role B2B SaaS — role / plan / scope](https://dardesign.io/blog/multi-role-b2b-saas-ux-roles-permissions-flows)
- [Support triage, routing, SLA events](https://thinkbot.agency/blog/support-ticket-automation-playbook-triage-routing-slas-knowledge-qa)
- [Queue strategy & SLA milestones](https://www.supportbench.com/support-queue-strategy-triage-routing-ownership/)
- [CSAT timing, one-question surveys, closed loop](https://supp.support/blog/how-to-set-up-csat-surveys)
---
## Open follow-ups (out of scope for this note)
- Exact SLA targets (minutes/hours) per priority × plan — needs ops decision.
- Whether CSAT is in-app only, email-embedded, or both.
- Company-side roles matrix vs platform-admin-only for v1.
@@ -0,0 +1,251 @@
# 02 — Current inventory: admin, roles, plans (A1/legacy), support
**Agent:** 2/20 · **Mode:** read-only inventory (docs only)
**Tools:** codehelper `project_context` (short) → `kickoff``investigate`/`query`/`context`; Read/Grep/Glob fallback where workspace file tools were unavailable.
**Desired target (from parent brief / sibling agents 36):**
| Persona / package | Desired access |
|-------------------|----------------|
| **Legacy plan (A1 + legacy-marked)** | Limited product nav only (Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Billing, Settings). **No** Background Tasks / `processing.monitor`, stores, marketing extras |
| **Platform admin + developer** | Full `/admin` + full ops capabilities |
| **support_staff** | Support ticket queue / assign / reply only — **not** billing/plan mutation |
---
## 1. Executive summary
Today the platform has a **binary** staff gate (`users.is_platform_admin`) and **company membership** roles (`admin` | `member`, plus API-key role `api`). There is **no** `staff_role` / `support_staff` / `developer` enum in code or schema (`is_legacy`, `staff_role`, `support_staff`, `developer`**zero** matches).
**A1** is modeled as a **client deal / custom package** (non-public plan name → `IsCustomPackage` → default **all features ON**), not as a limited “legacy nav” profile. Demo tenant **Local Demo Co** (exA1 Slovenija) is on public **Enterprise** (`is_custom=true`), which also resolves to all-ON features.
Support desk exists (tenant + admin APIs, assignee field, agent replies) but **admin support routes require full platform admin**. No CSAT/rating fields found under `apps/api/internal/support`.
---
## 2. Role model (what exists)
### 2.1 Platform staff — single boolean
| Symbol / field | Path | Notes |
|----------------|------|-------|
| `User.IsPlatformAdmin` | `apps/api/internal/auth/service.go` (`User`) | JSON `is_platform_admin` |
| `Service.IsPlatformAdmin` | `apps/api/internal/auth/invites.go:209` | DB: `SELECT is_platform_admin FROM users WHERE id = $1 AND is_active` |
| `RequirePlatformAdmin` | `apps/api/internal/httpapi/middleware.go:245` | Session + DB check; **not** a client claim |
| `checkPlatformAdmin` | `apps/api/internal/httpapi/middleware.go:262` | Test hook `testPlatformAdmin` or `Auth.IsPlatformAdmin` |
| `requirePlatformAdmin` | `apps/web/src/lib/admin-gate.ts:8` | Client gate via `GET /api/auth/me` |
| `authSession.isPlatformAdmin` | `apps/web/src/lib/auth-session.svelte.ts` | UI session mirror |
| `applyPlatformAdmins` | `apps/api/cmd/migrator/admins.go:13` | Legacy MySQL `admin_users``users.is_platform_admin` |
| `seed-demo` | `apps/api/cmd/seed-demo/main.go` | Forces demo user `is_platform_admin = true` |
**Missing vs desired:** `admin` / `developer` / `support_staff` staff roles; APIs to list/assign staff roles; middleware that allows support-only routes without full platform admin.
### 2.2 Company membership roles
| Concept | Where | Values |
|---------|-------|--------|
| Membership `role` | `memberships.role` (auth/company handlers) | `admin`, `member` (invite normalize rejects `owner`) |
| `CompanyAdminAllowed` | `middleware.go:43` | `admin` \|\| `api` |
| `requireCompanyAdmin` | `middleware.go:48` | 403 `"admin required"` |
| `allowCompanyAdminOrPlatform` | `middleware.go:58` | Company admin **or** platform admin (cutover: all-member tenants) |
| Team promote/demote | `company_handlers.go`, `company_member_role_test.go` | Last-admin guards |
Orthogonal to plan features (also noted in `docs/plan-permissions/02-plans-permissions-current.md` § membership).
### 2.3 Support message “agent” role (not staff RBAC)
Ticket messages use `author_role``user` | `agent` | `system` (`apps/web/src/lib/support/admin-api.ts`). `ReplyAsAgent` (`support/tickets.go:325`) writes `author_role='agent'`. That is **message authorship**, not a user staff role.
---
## 3. Admin panel — web routes
All under `apps/web/src/routes/admin/`. Pages call `requirePlatformAdmin()` (except layout which only shows chrome when `me.user.is_platform_admin`).
| Route | File | In `AdminNav`? | Purpose |
|-------|------|----------------|---------|
| `/admin` | `+page.svelte` | Overview | Analytics summary + tool cards |
| `/admin/users` | `users/+page.svelte` | Yes | User list, set-password emails, **dev** set-password / impersonate |
| `/admin/analytics` | `analytics/+page.svelte` | Yes | Tokens / credits / jobs |
| `/admin/billing` | `billing/+page.svelte` | Yes | Plans, assign, credits, cycles + **PlanPermissionsPanel** |
| `/admin/support` | `support/+page.svelte` | Yes | Ticket queue |
| `/admin/support/[id]` | `support/[id]/+page.svelte` | (via Support) | Ticket detail / reply |
| `/admin/stuck-products` | `stuck-products/+page.svelte` | Yes | Stuck jobs |
| `/admin/settings` | `settings/+page.svelte` | Yes | Platform settings |
| `/admin/tasks-cleanup` | `tasks-cleanup/+page.svelte` | No (overview card only) | Same stuck cleanup |
| `/admin/logs` | `logs/+page.svelte` | No (deep link; nav comment: no logs API) | Placeholder |
| `/admin/bootstrap` | `bootstrap/+page.svelte` | No | Check admin flag; cannot create admins via API |
| `/admin/migrate-organizations` | `migrate-organizations/+page.svelte` | No | Migration helper UI |
**Nav chrome:** `AdminNav``apps/web/src/lib/components/AdminNav.svelte` (`menuItems`).
**Layout wiring:** `apps/web/src/routes/+layout.svelte``showAdminNav = Boolean(me?.user?.is_platform_admin)`; main app `Nav` gets `showAdmin` for “Platform admin” link (`adminOnly: true`).
---
## 4. Admin panel — API routes
Mounted in `Server.Router``apps/api/internal/httpapi/server.go:294325`:
```
/api/admin/* → RequireSession + RequirePlatformAdmin
```
| Method | Path | Handler (approx) |
|--------|------|------------------|
| GET | `/users` | `handleAdminListUsers` |
| POST | `/users/{id}/dev-password` | `handleAdminDevSetPassword` (non-prod) |
| POST | `/users/{id}/impersonate` | `handleAdminDevImpersonate` (non-prod) |
| GET | `/companies` | `handleAdminListCompanies` |
| GET | `/readiness` | `handleAdminReadiness` |
| GET | `/analytics` | `handleAdminAnalytics` |
| GET | `/jobs` | `handleAdminListJobs` |
| POST | `/jobs/stuck-cleanup` | `handleAdminStuckCleanup` |
| GET/PUT | `/settings` | platform settings |
| POST | `/settings/mail/test` | mail probe |
| GET/POST | `/plans` | list / upsert |
| GET/PUT | `/plans/{planID}/features` | plan feature overrides |
| POST | `/plans/{planID}/features/enable-all` | convenience |
| POST | `/plans/{planID}/features/disable-all` | convenience |
| GET/PUT | `/feature-gates` | global gates |
| PUT | `/feature-gates/sections/{section}` | section toggle |
| POST | `/plans/assign` | assign plan to company |
| POST | `/credits` | add credits |
| POST | `/billing/run-cycles` | renewals |
| POST | `/emails/set-password` | cutover emails |
| GET | `/support/tickets` | admin queue |
| GET | `/support/tickets/{id}` | admin get |
| POST | `/support/tickets/{id}/messages` | `ReplyAsAgent` |
| PATCH | `/support/tickets/{id}` | `UpdateAdmin` (status/priority/assignee) |
**Dev-only staff tooling** (not a “developer” role): `admin_dev_handlers.go` — blocked when `Config.IsProduction()`.
---
## 5. PlanPermissionsPanel & plan features
| Piece | Path / symbol |
|-------|----------------|
| UI panel | `apps/web/src/lib/components/admin/PlanPermissionsPanel.svelte` |
| Host page | `/admin/billing` Permissions tab — `billing/+page.svelte` |
| Client API | `apps/web/src/lib/admin-plan-permissions.ts` (`listAdminPlansWithFeatures`, `loadFeatureGates`, enable/disable-all, …) |
| Catalog | `apps/web/src/lib/plan-feature-catalog.ts` (`isDefaultPublicPlanName`, sections) |
| Prior design doc | `docs/plan-permissions/08-admin-ui.md` |
**Package kind badges** (panel `$derived packageKind`):
- `default` — public ladder name + `!is_custom`
- `ladder_custom` — public ladder + `is_custom` (Enterprise)
- `custom``is_custom`
- `deal` — non-ladder name without treating as custom badge path
Stub mode when feature APIs return 404/501 (`isPlanPermissionsApiUnavailable`).
Backend resolve path: `DefaultPlanFeatures``PlanAllowsFeature``ResolveEffectiveFeatures` (`apps/api/internal/billing/plan_features.go`). Custom packages default **all catalog keys ON**.
---
## 6. Plans: `is_custom`, public ladder, A1 / “legacy”
### 6.1 Public vs client deals
| Symbol | Path | Behavior |
|--------|------|----------|
| `IsPublicProductPlan` | `billing/service.go:485` | `free\|starter\|growth\|business\|enterprise` only |
| `ListPublicPlans` | `billing/service.go:600` | Marketing / self-serve; **excludes** client deals |
| Admin `ListPlans` | via `/api/admin/plans` | **All** plan rows (incl. A1, Merkur, …) |
| `EnsureDefaultPlans` | `service.go:496` | Syncs public ladder only; named deals untouched |
Comments explicitly name **A1**, Merkur trial, legacy Basic/Professional as non-public client deals.
### 6.2 `IsCustomPackage` / `is_custom`
| Symbol | Path | Rule |
|--------|------|------|
| `IsCustomPackage(name, isCustom)` | `billing/custom_package_features.go:19` | `isCustom \|\| !IsPublicProductPlan(name)` |
| `prepareCustomPackageCreateFeatures` | same file `:49` | Non-public names force `IsCustom=true`; empty features → all-ON map |
| Enterprise seed | `defaultPublicPlans()` | Public name **with** `IsCustom: true` |
**Tests:** `custom_package_features_test.go``"A1", false, true` (name alone ⇒ custom treatment).
### 6.3 Company A1 / Local Demo Co
| Fact | Source |
|------|--------|
| Legacy MySQL company | `A1 Slovenija` / `97e1a309-3d23-4aa2-b518-8e8d7afdfec7` |
| Postgres id | `ee246275-dec0-4446-9e83-58d0c16c258a` |
| Display name after seed | **Local Demo Co** (`docs/demo-user.md`, `docs/migrate-from-descrybe-new.md`) |
| Demo plan | **Enterprise** (`is_custom=true`, 1M credits) — **not** a plan named `A1` |
| Migrated teammate emails | `…@legacy.local` (dev password / impersonate) |
**Implication:** Even if a DB row named `A1` exists as a client plan, feature defaults are **enable-all**, opposite of desired **legacy limited nav**. There is **no** `is_legacy` column or legacy feature profile in billing today.
### 6.4 Product nav gating (tenant UI)
`Nav.svelte` filters items via `planCapabilities.can(featureKey)`.
`processing.monitor` (Background Tasks / Processing) is a normal feature key — currently ON for custom/Enterprise. Desired legacy matrix would turn it **OFF** (and hide stores/marketing extras).
---
## 7. Support — tenant + admin
### 7.1 Tenant (company-scoped)
| Surface | Path |
|---------|------|
| UI | `/support`, `/support/new`, `/support/[ticketId]` under `apps/web/src/routes/support/` |
| API | `server.go:472+``GET/POST /api/support/tickets`, messages, notifications |
| Handlers | `handleListSupportTickets`, `Create`, `Get`, `Reply` — session + **RequireCompany** |
| Service | `ListForUser` / `Create` / `GetForUser` / `ReplyAsUser` |
Nav item: `/support` gated by feature `support.center`.
### 7.2 Admin / staff queue
| Surface | Path |
|---------|------|
| UI | `/admin/support`, `/admin/support/[id]` |
| Client | `apps/web/src/lib/support/admin-api.ts` |
| API | `/api/admin/support/tickets*` (platform admin only) |
| Service | `ListAdmin`, `GetAdmin`, `ReplyAsAgent`, `UpdateAdmin` |
| Schema | `apps/api/sql/schema/025_support_center.sql` (indexed; includes `assignee_admin_user_id`) |
**Assignee:** `UpdateAdmin` / PATCH body `assignee_admin_user_id` / `clear_assignee`. No separate “support staff inbox” or claim-only policy — any platform admin sees the full queue.
**Ratings / CSAT:** not present in support package types/handlers (gap for agent 11+).
---
## 8. Gaps vs desired state
| Desired | Current | Gap |
|---------|---------|-----|
| Legacy limited nav (A1 / legacy plans) | Custom/non-public → **all features ON**; demo A1 tenant on **Enterprise** all-ON | Need legacy profile / `is_legacy` / seed matrix; map A1 (+ similar) to sparse features; turn off `processing.monitor`, stores, marketing extras |
| Admin + developer full admin | Only `is_platform_admin` boolean | Need staff role enum; map admin+developer → full `/api/admin`; optionally keep prod-safe “dev tools” separate from role |
| support_staff ticket access only | Support admin APIs behind **same** `RequirePlatformAdmin` as billing | Need least-privilege middleware + AdminNav subset (Support only); block plans/settings/credits |
| Role chips / assign staff from orgs UI | Users list shows `is_platform_admin` badge only | No assign APIs; bootstrap page cannot create admins |
| Support assign + staff inbox + CSAT | Assignee field exists; no staff role, no CSAT | Extend UpdateAdmin + UI; add rating schema |
| Differentiate legacy vs `is_custom` | `is_custom` / deal name ⇒ enable-all | Contract must separate **custom deal packaging** from **legacy feature profile** |
---
## 9. Extension points (summary)
Prefer extending existing gates rather than parallel systems:
1. **Staff RBAC** — extend `checkPlatformAdmin` / `RequirePlatformAdmin` (or sibling `RequireStaffRole`) reading a new `users.staff_role` (or replace boolean carefully).
2. **Legacy features** — extend `DefaultPlanFeatures` / `IsCustomPackage` / seed (`default_plan_features_seed.go`) with a `legacy` profile; detection: plan name patterns (`A1`, …) and/or `is_legacy` flag.
3. **Admin nav**`AdminNav.menuItems` filtered by staff capability; support_staff → Support (+ maybe Overview read-only if needed).
4. **Support** — keep `/api/admin/support/*` but authorize `admin|developer|support_staff`; optionally filter `ListAdmin` by assignee for least privilege.
5. **PlanPermissionsPanel** — already the UI to inspect/set matrices; add Legacy badge + apply-legacy-profile action once backend exists.
Machine-readable companion: [`02-extension-points.json`](./02-extension-points.json).
---
## 10. Related docs
- `docs/plan-permissions/02-plans-permissions-current.md` — plans / features / roles orthogonality
- `docs/plan-permissions/08-admin-ui.md` — PlanPermissionsPanel contract
- `docs/demo-user.md` — Local Demo Co / A1 migration
- `docs/migrate-from-descrybe-new.md` — A1-only migrator scope
- Sibling agents will add `03-roles-matrix`, `04-contract`, `05-legacy-seed`, `06-staff-roles`, …
@@ -0,0 +1,340 @@
{
"doc": "02-extension-points",
"agent": "2/20",
"generated_for": "admin-roles-support",
"desired": {
"legacy_limited_nav": [
"dashboard.overview",
"catalog.products",
"feeds.list",
"feeds.export_feeds",
"catalog.categories",
"catalog.attributes",
"catalog.standard_fields",
"billing.overview",
"settings.profile"
],
"legacy_explicitly_off_examples": [
"processing.monitor",
"stores.hub",
"marketing.campaigns",
"marketing.brand_kit",
"marketing.seo",
"marketing.content_calendar",
"marketing.reviews"
],
"staff_roles": ["admin", "developer", "support_staff"],
"staff_access": {
"admin": "full /admin and /api/admin",
"developer": "full /admin and /api/admin (align with admin; keep prod-blocked dev-password/impersonate as env gate)",
"support_staff": "support ticket queue/reply/assign only; no billing/plan/settings mutation"
}
},
"current_role_model": {
"platform": {
"mechanism": "boolean users.is_platform_admin",
"symbols": [
{
"name": "IsPlatformAdmin",
"kind": "method",
"path": "apps/api/internal/auth/invites.go",
"line": 209,
"sym": "sym:descrybe-v2:apps/api/internal/auth/invites.go:209:IsPlatformAdmin"
},
{
"name": "RequirePlatformAdmin",
"kind": "method",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 245,
"sym": "sym:descrybe-v2:apps/api/internal/httpapi/middleware.go:245:RequirePlatformAdmin"
},
{
"name": "checkPlatformAdmin",
"kind": "method",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 262,
"sym": "sym:descrybe-v2:apps/api/internal/httpapi/middleware.go:262:checkPlatformAdmin"
},
{
"name": "requirePlatformAdmin",
"kind": "function",
"path": "apps/web/src/lib/admin-gate.ts",
"line": 8,
"sym": "sym:descrybe-v2:apps/web/src/lib/admin-gate.ts:8:requirePlatformAdmin"
},
{
"name": "applyPlatformAdmins",
"kind": "function",
"path": "apps/api/cmd/migrator/admins.go",
"line": 13,
"sym": "sym:descrybe-v2:apps/api/cmd/migrator/admins.go:13:applyPlatformAdmins"
}
],
"missing": ["staff_role enum", "support_staff", "developer role", "assign-staff APIs"]
},
"company_membership": {
"roles": ["admin", "member"],
"api_key_role": "api",
"symbols": [
{
"name": "CompanyAdminAllowed",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 43
},
{
"name": "allowCompanyAdminOrPlatform",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 58
}
]
},
"support_message_author_roles": ["user", "agent", "system"],
"note": "author_role=agent is message authorship, not users.staff_role"
},
"admin_web_routes": {
"nav_component": {
"name": "AdminNav",
"path": "apps/web/src/lib/components/AdminNav.svelte",
"line": 30,
"menu_items": [
"/admin",
"/admin/users",
"/admin/analytics",
"/admin/billing",
"/admin/support",
"/admin/stuck-products",
"/admin/settings"
]
},
"layout_gate": {
"path": "apps/web/src/routes/+layout.svelte",
"showAdminNav": "me.user.is_platform_admin"
},
"pages": [
{ "href": "/admin", "file": "apps/web/src/routes/admin/+page.svelte", "in_nav": true },
{ "href": "/admin/users", "file": "apps/web/src/routes/admin/users/+page.svelte", "in_nav": true },
{ "href": "/admin/analytics", "file": "apps/web/src/routes/admin/analytics/+page.svelte", "in_nav": true },
{ "href": "/admin/billing", "file": "apps/web/src/routes/admin/billing/+page.svelte", "in_nav": true, "hosts": "PlanPermissionsPanel" },
{ "href": "/admin/support", "file": "apps/web/src/routes/admin/support/+page.svelte", "in_nav": true },
{ "href": "/admin/support/[id]", "file": "apps/web/src/routes/admin/support/[id]/+page.svelte", "in_nav": false },
{ "href": "/admin/stuck-products", "file": "apps/web/src/routes/admin/stuck-products/+page.svelte", "in_nav": true },
{ "href": "/admin/settings", "file": "apps/web/src/routes/admin/settings/+page.svelte", "in_nav": true },
{ "href": "/admin/tasks-cleanup", "file": "apps/web/src/routes/admin/tasks-cleanup/+page.svelte", "in_nav": false },
{ "href": "/admin/logs", "file": "apps/web/src/routes/admin/logs/+page.svelte", "in_nav": false },
{ "href": "/admin/bootstrap", "file": "apps/web/src/routes/admin/bootstrap/+page.svelte", "in_nav": false },
{ "href": "/admin/migrate-organizations", "file": "apps/web/src/routes/admin/migrate-organizations/+page.svelte", "in_nav": false }
]
},
"admin_api": {
"mount": {
"path": "apps/api/internal/httpapi/server.go",
"line": 294,
"middleware": ["RequireSession", "RequirePlatformAdmin"],
"prefix": "/api/admin"
},
"routes": [
"GET /users",
"POST /users/{id}/dev-password",
"POST /users/{id}/impersonate",
"GET /companies",
"GET /readiness",
"GET /analytics",
"GET /jobs",
"POST /jobs/stuck-cleanup",
"GET|PUT /settings",
"POST /settings/mail/test",
"GET|POST /plans",
"GET|PUT /plans/{planID}/features",
"POST /plans/{planID}/features/enable-all",
"POST /plans/{planID}/features/disable-all",
"GET|PUT /feature-gates",
"PUT /feature-gates/sections/{section}",
"POST /plans/assign",
"POST /credits",
"POST /billing/run-cycles",
"POST /emails/set-password",
"GET /support/tickets",
"GET /support/tickets/{id}",
"POST /support/tickets/{id}/messages",
"PATCH /support/tickets/{id}"
],
"dev_handlers": {
"path": "apps/api/internal/httpapi/admin_dev_handlers.go",
"production": "404 / blocked"
}
},
"plan_permissions_panel": {
"component": {
"name": "PlanPermissionsPanel",
"path": "apps/web/src/lib/components/admin/PlanPermissionsPanel.svelte",
"line": 84
},
"client": {
"path": "apps/web/src/lib/admin-plan-permissions.ts",
"symbols": [
"isPlanPermissionsApiUnavailable",
"listAdminPlansWithFeatures",
"loadFeatureGates",
"saveFeatureGates",
"enableAllPlanFeatures",
"disableAllPlanFeatures"
]
},
"catalog": "apps/web/src/lib/plan-feature-catalog.ts",
"host": "apps/web/src/routes/admin/billing/+page.svelte",
"prior_doc": "docs/plan-permissions/08-admin-ui.md"
},
"plans_is_custom_legacy_a1": {
"symbols": [
{
"name": "IsPublicProductPlan",
"path": "apps/api/internal/billing/service.go",
"line": 485,
"public_names": ["Free", "Starter", "Growth", "Business", "Enterprise"]
},
{
"name": "IsCustomPackage",
"path": "apps/api/internal/billing/custom_package_features.go",
"line": 19,
"rule": "isCustom || !IsPublicProductPlan(name)",
"sym": "sym:descrybe-v2:apps/api/internal/billing/custom_package_features.go:19:IsCustomPackage"
},
{
"name": "DefaultPlanFeatures",
"path": "apps/api/internal/billing/plan_features.go",
"line": 71,
"custom_default": "all FeatureCatalogKeys true"
},
{
"name": "prepareCustomPackageCreateFeatures",
"path": "apps/api/internal/billing/custom_package_features.go",
"line": 49
}
],
"a1_as_test_fixture": {
"tests": "apps/api/internal/billing/custom_package_features_test.go",
"behavior": "plan name A1 ⇒ IsCustomPackage true ⇒ all features ON"
},
"company_a1": {
"legacy_mysql_name": "A1 Slovenija",
"legacy_company_id": "97e1a309-3d23-4aa2-b518-8e8d7afdfec7",
"postgres_company_id": "ee246275-dec0-4446-9e83-58d0c16c258a",
"display_name_after_seed": "Local Demo Co",
"seed_plan": "Enterprise",
"seed_is_custom": true,
"docs": ["docs/demo-user.md", "docs/migrate-from-descrybe-new.md"]
},
"is_legacy_flag": false,
"legacy_feature_profile": false
},
"support": {
"tenant": {
"ui": [
"apps/web/src/routes/support/+page.svelte",
"apps/web/src/routes/support/new/+page.svelte",
"apps/web/src/routes/support/[ticketId]/+page.svelte"
],
"api_prefix": "/api/support",
"handlers_file": "apps/api/internal/httpapi/support_handlers.go",
"auth": "RequireSession + RequireCompany"
},
"admin": {
"ui": [
"apps/web/src/routes/admin/support/+page.svelte",
"apps/web/src/routes/admin/support/[id]/+page.svelte"
],
"client": "apps/web/src/lib/support/admin-api.ts",
"api_prefix": "/api/admin/support",
"auth": "RequirePlatformAdmin only",
"service_symbols": [
{
"name": "ListAdmin",
"path": "apps/api/internal/support/tickets.go",
"line": 80
},
{
"name": "GetAdmin",
"path": "apps/api/internal/support/tickets.go",
"line": 158
},
{
"name": "ReplyAsAgent",
"path": "apps/api/internal/support/tickets.go",
"line": 325
},
{
"name": "UpdateAdmin",
"path": "apps/api/internal/support/tickets.go",
"line": 428,
"supports": ["status", "priority", "assignee_admin_user_id", "clear_assignee"]
}
],
"schema": "apps/api/sql/schema/025_support_center.sql"
},
"csat_rating": false,
"staff_inbox_filter": false
},
"tenant_nav_gating": {
"component": "apps/web/src/lib/components/Nav.svelte",
"mechanism": "planCapabilities.can(featureKey); adminOnly uses showAdmin",
"processing_item": {
"href": "/processing",
"feature": "processing.monitor",
"desired_for_legacy": "off"
}
},
"extension_points": [
{
"id": "staff-rbac",
"prefer": "extend RequirePlatformAdmin / checkPlatformAdmin",
"add": "users.staff_role (admin|developer|support_staff) additive migration",
"apis": "list/assign staff roles (admin-only)",
"web": "filter AdminNav + requirePlatformAdmin → requireStaffCapability"
},
{
"id": "legacy-feature-profile",
"prefer": "extend DefaultPlanFeatures + seed/ApplyDefaultMatrix",
"detect": ["plan name patterns (A1, …)", "optional plans.is_legacy", "company flag if needed"],
"do_not_conflate": "is_custom / IsCustomPackage enable-all packaging",
"ui": "PlanPermissionsPanel legacy badge + apply profile"
},
{
"id": "support-staff-least-privilege",
"prefer": "same /api/admin/support/* handlers with role-aware middleware",
"optional": "ListAdmin filter by assignee; claim/unassign UX",
"block_for_support_staff": ["/api/admin/plans*", "/api/admin/credits", "/api/admin/settings", "/api/admin/billing/*"]
},
{
"id": "support-csat",
"status": "absent",
"hook": "after ticket resolved/closed — new columns + tenant UI"
}
],
"gaps": [
{
"desired": "legacy limited nav",
"current": "A1/custom/Enterprise resolve to all features ON",
"severity": "high"
},
{
"desired": "admin/developer full admin",
"current": "single is_platform_admin boolean; no developer role",
"severity": "high"
},
{
"desired": "support_staff ticket access only",
"current": "support admin requires full platform admin (same as billing)",
"severity": "high"
},
{
"desired": "staff assign from orgs UI",
"current": "users list badge only; bootstrap cannot create admins",
"severity": "medium"
},
{
"desired": "CSAT / ratings",
"current": "not in support package",
"severity": "medium"
}
]
}
@@ -0,0 +1,771 @@
{
"agent": "03/20",
"source": [
"docs/plan-permissions/01-feature-keys.json",
"docs/plan-permissions/06-defaults-matrix.json",
"docs/plan-permissions/03-permission-contract.md",
"apps/web/src/lib/components/Nav.svelte",
"apps/web/src/lib/components/AdminNav.svelte",
"docs/migrate-from-descrybe-new.md",
"docs/demo-user.md"
],
"assumptions": [
"legacy_user / standard_user are product-cohort roles derived from plan name patterns (and/or plans.cohort), not company membership admin|member",
"A1 company/plan example maps to legacy profile (including Local Demo Co when it is the renamed A1 tenant)",
"effective(feature) = role_allows AND plan_allows AND global gates",
"standard_user matrix is a ceiling (all registry keys true); Free/Starter denials still apply via 06-defaults-matrix",
"support_staff has no dedicated DB flag yet — design-only until schema lands",
"Platform /admin/* is orthogonal to dashboard feature_keys (is_platform_admin / staff)"
],
"legacy_nav_allowlist": [
{ "label": "Dashboard", "route": "/dashboard", "feature": "dashboard.overview" },
{ "label": "Products", "route": "/products", "feature": "catalog.products" },
{ "label": "Feeds", "route": "/feeds", "feature": "feeds.list" },
{ "label": "Export Feeds", "route": "/export-feeds", "feature": "feeds.export_feeds" },
{ "label": "Categories", "route": "/categories", "feature": "catalog.categories" },
{ "label": "Attributes", "route": "/attributes", "feature": "catalog.attributes" },
{ "label": "Standard Fields", "route": "/standard-fields", "feature": "catalog.standard_fields" },
{ "label": "Usage & Billing", "route": "/billing", "feature": "billing.overview" },
{ "label": "Settings", "route": "/settings", "feature": "settings.profile" }
],
"legacy_nav_excluded": [
"processing.monitor",
"stores.hub",
"stores.woocommerce",
"stores.shopify",
"marketing.campaigns",
"marketing.content_calendar",
"marketing.brand_kit",
"marketing.seo",
"marketing.reviews",
"integrations.ai",
"integrations.email",
"support.center",
"catalog.structured_descriptions",
"catalog.vector_categories"
],
"plan_name_patterns": [
{
"profile": "legacy",
"match": "exact",
"patterns": ["legacy"]
},
{
"profile": "legacy",
"match": "regex",
"patterns": ["(?i)^a1(\\b|[\\s_-])", "(?i)a1\\s*slovenija"],
"examples": ["A1", "A1 Slovenija", "A1-Legacy"]
},
{
"profile": "legacy",
"match": "company_example",
"patterns": ["A1 Slovenija"],
"legacy_company_id": "97e1a309-3d23-4aa2-b518-8e8d7afdfec7",
"note": "Demo may rename display to Local Demo Co; cohort remains legacy when flagged or plan name matches"
},
{
"profile": "standard",
"match": "exact",
"patterns": ["free", "starter", "growth", "business", "enterprise"],
"ladder_ref": "docs/plan-permissions/06-defaults-matrix.json"
},
{
"profile": "custom",
"match": "is_custom_true_non_legacy",
"patterns": [],
"note": "is_custom=true and name does not match legacy patterns → all-on custom defaults"
}
],
"roles": {
"legacy_user": {
"description": "End-user on legacy-pattern company/plan; nav = legacy allow-list only",
"plan_profile": "legacy",
"platform_admin": false,
"company_membership": ["admin", "member"],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": false,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": false,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": false,
"catalog.vector_categories": false,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": false,
"stores.woocommerce": false,
"stores.woocommerce.connection": false,
"stores.woocommerce.categories": false,
"stores.woocommerce.attributes": false,
"stores.woocommerce.orders": false,
"stores.woocommerce.reviews": false,
"stores.woocommerce.settings": false,
"stores.shopify": false,
"stores.shopify.connection": false,
"stores.shopify.orders": false,
"stores.shopify.settings": false,
"processing.monitor": false,
"marketing.campaigns": false,
"marketing.campaigns.create": false,
"marketing.campaigns.generate_ai": false,
"marketing.campaigns.send": false,
"marketing.content_calendar": false,
"marketing.brand_kit": false,
"marketing.brand_ai_apply": false,
"marketing.seo": false,
"marketing.seo.template_fill": false,
"marketing.seo.ai_rewrite": false,
"marketing.reviews": false,
"integrations.ai": false,
"integrations.ai.byok": false,
"integrations.email": false,
"integrations.email.test": false,
"integrations.email.blast": false,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": false,
"support.ticket_create": false,
"support.ticket_thread": false,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": false,
"capability.email_live_send": false,
"capability.brand_ai_apply": false,
"capability.seo_ai_rewrite": false,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": false
}
},
"standard_user": {
"description": "End-user on public ladder; features further restricted by Free→Enterprise plan matrix",
"plan_profile": "standard",
"platform_admin": false,
"company_membership": ["admin", "member"],
"plan_matrix_ref": "docs/plan-permissions/06-defaults-matrix.json",
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": true,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": true,
"catalog.vector_categories": true,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": true,
"stores.woocommerce": true,
"stores.woocommerce.connection": true,
"stores.woocommerce.categories": true,
"stores.woocommerce.attributes": true,
"stores.woocommerce.orders": true,
"stores.woocommerce.reviews": true,
"stores.woocommerce.settings": true,
"stores.shopify": true,
"stores.shopify.connection": true,
"stores.shopify.orders": true,
"stores.shopify.settings": true,
"processing.monitor": true,
"marketing.campaigns": true,
"marketing.campaigns.create": true,
"marketing.campaigns.generate_ai": true,
"marketing.campaigns.send": true,
"marketing.content_calendar": true,
"marketing.brand_kit": true,
"marketing.brand_ai_apply": true,
"marketing.seo": true,
"marketing.seo.template_fill": true,
"marketing.seo.ai_rewrite": true,
"marketing.reviews": true,
"integrations.ai": true,
"integrations.ai.byok": true,
"integrations.email": true,
"integrations.email.test": true,
"integrations.email.blast": true,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": true,
"capability.email_live_send": true,
"capability.brand_ai_apply": true,
"capability.seo_ai_rewrite": true,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": true
}
},
"admin": {
"description": "Platform admin (users.is_platform_admin); full dashboard + AdminNav",
"plan_profile": null,
"platform_admin": true,
"admin_nav": [
"/admin",
"/admin/users",
"/admin/analytics",
"/admin/billing",
"/admin/support",
"/admin/stuck-products",
"/admin/settings"
],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": true,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": true,
"catalog.vector_categories": true,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": true,
"stores.woocommerce": true,
"stores.woocommerce.connection": true,
"stores.woocommerce.categories": true,
"stores.woocommerce.attributes": true,
"stores.woocommerce.orders": true,
"stores.woocommerce.reviews": true,
"stores.woocommerce.settings": true,
"stores.shopify": true,
"stores.shopify.connection": true,
"stores.shopify.orders": true,
"stores.shopify.settings": true,
"processing.monitor": true,
"marketing.campaigns": true,
"marketing.campaigns.create": true,
"marketing.campaigns.generate_ai": true,
"marketing.campaigns.send": true,
"marketing.content_calendar": true,
"marketing.brand_kit": true,
"marketing.brand_ai_apply": true,
"marketing.seo": true,
"marketing.seo.template_fill": true,
"marketing.seo.ai_rewrite": true,
"marketing.reviews": true,
"integrations.ai": true,
"integrations.ai.byok": true,
"integrations.email": true,
"integrations.email.test": true,
"integrations.email.blast": true,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": true,
"capability.email_live_send": true,
"capability.brand_ai_apply": true,
"capability.seo_ai_rewrite": true,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": true
}
},
"developer": {
"description": "Engineering/ops; full feature ceiling including debug catalog extras",
"plan_profile": null,
"platform_admin": true,
"admin_nav": [
"/admin",
"/admin/users",
"/admin/analytics",
"/admin/billing",
"/admin/support",
"/admin/stuck-products",
"/admin/settings"
],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": true,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": true,
"catalog.vector_categories": true,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": true,
"stores.woocommerce": true,
"stores.woocommerce.connection": true,
"stores.woocommerce.categories": true,
"stores.woocommerce.attributes": true,
"stores.woocommerce.orders": true,
"stores.woocommerce.reviews": true,
"stores.woocommerce.settings": true,
"stores.shopify": true,
"stores.shopify.connection": true,
"stores.shopify.orders": true,
"stores.shopify.settings": true,
"processing.monitor": true,
"marketing.campaigns": true,
"marketing.campaigns.create": true,
"marketing.campaigns.generate_ai": true,
"marketing.campaigns.send": true,
"marketing.content_calendar": true,
"marketing.brand_kit": true,
"marketing.brand_ai_apply": true,
"marketing.seo": true,
"marketing.seo.template_fill": true,
"marketing.seo.ai_rewrite": true,
"marketing.reviews": true,
"integrations.ai": true,
"integrations.ai.byok": true,
"integrations.email": true,
"integrations.email.test": true,
"integrations.email.blast": true,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": true,
"capability.email_live_send": true,
"capability.brand_ai_apply": true,
"capability.seo_ai_rewrite": true,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": true
}
},
"support_staff": {
"description": "Support agents; support queue + assisted tenant context; no platform settings/billing writes",
"plan_profile": null,
"platform_admin": false,
"staff_flag": "is_support_staff [UNCERTAIN — not in schema yet]",
"admin_nav": [
"/admin/support",
"/admin/users",
"/admin/stuck-products"
],
"admin_nav_denied": [
"/admin/settings",
"/admin/billing"
],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": false,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": false,
"catalog.vector_categories": false,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": false,
"stores.woocommerce": false,
"stores.woocommerce.connection": false,
"stores.woocommerce.categories": false,
"stores.woocommerce.attributes": false,
"stores.woocommerce.orders": false,
"stores.woocommerce.reviews": false,
"stores.woocommerce.settings": false,
"stores.shopify": false,
"stores.shopify.connection": false,
"stores.shopify.orders": false,
"stores.shopify.settings": false,
"processing.monitor": true,
"marketing.campaigns": false,
"marketing.campaigns.create": false,
"marketing.campaigns.generate_ai": false,
"marketing.campaigns.send": false,
"marketing.content_calendar": false,
"marketing.brand_kit": false,
"marketing.brand_ai_apply": false,
"marketing.seo": false,
"marketing.seo.template_fill": false,
"marketing.seo.ai_rewrite": false,
"marketing.reviews": false,
"integrations.ai": false,
"integrations.ai.byok": false,
"integrations.email": false,
"integrations.email.test": false,
"integrations.email.blast": false,
"billing.overview": true,
"billing.customer_portal": false,
"billing.quick_upgrade": false,
"billing.plans_compare": true,
"billing.checkout": false,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": false,
"settings.team": true,
"settings.team_invite": false,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": false,
"capability.email_live_send": false,
"capability.brand_ai_apply": false,
"capability.seo_ai_rewrite": false,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": false,
"capability.byok": false
}
}
},
"plan_profiles": {
"legacy": {
"description": "A1 / Legacy package — same feature map as legacy_user",
"features_ref_role": "legacy_user",
"on_keys": [
"shell.navigation",
"shell.command_palette",
"shell.company_switcher",
"shell.tutorial",
"shell.account_menu",
"shell.billing_recovery_banner",
"dashboard.overview",
"dashboard.stats",
"dashboard.quick_links",
"dashboard.recent_jobs",
"dashboard.news_feed",
"dashboard.activation_checklist",
"dashboard.migrated_checklist",
"dashboard.etl_gaps",
"dashboard.upgrade_banners",
"catalog.products",
"catalog.products.tab_processed",
"catalog.products.tab_needs_review",
"catalog.products.tab_error",
"catalog.products.tab_processing",
"catalog.products.tab_unprocessed",
"catalog.products.process_categories",
"catalog.products.process_attributes",
"catalog.products.process_ai_titles",
"catalog.products.process_ai_descriptions",
"catalog.products.enrichment_review",
"catalog.products.export_selection",
"catalog.products.upgrade_prompt",
"catalog.categories",
"catalog.categories.title_formula",
"catalog.categories.description_formula",
"catalog.attributes",
"catalog.attributes.bulk_import",
"catalog.standard_fields",
"catalog.standard_fields.groups",
"feeds.list",
"feeds.add_url",
"feeds.add_csv",
"feeds.sync",
"feeds.mapping",
"feeds.mapping.select_item",
"feeds.mapping.map_fields",
"feeds.export_feeds",
"feeds.export_feeds.create",
"feeds.export_feeds.generate",
"feeds.uploads",
"billing.overview",
"billing.customer_portal",
"billing.quick_upgrade",
"billing.plans_compare",
"billing.checkout",
"settings.profile",
"settings.company",
"settings.alerts",
"settings.api_keys",
"settings.team",
"settings.team_invite",
"capability.sku_cap",
"capability.ai_credits",
"capability.ai_processing",
"capability.eprel",
"capability.normalize_specs_fill",
"capability.feed_source_limit",
"capability.export_feed_limit",
"capability.storage_limit",
"capability.api_access"
],
"off_sections": [
"stores",
"processing",
"marketing",
"integrations",
"support"
]
},
"standard": {
"description": "Public ladder Free→Enterprise; use 06-defaults-matrix per plan name",
"matrix_ref": "docs/plan-permissions/06-defaults-matrix.json"
},
"custom": {
"description": "is_custom=true non-legacy deals; all registry keys ON by default",
"all_features_on": true
}
}
}
+294
View File
@@ -0,0 +1,294 @@
# 03 — Roles & legacy plan matrix (agent 3/20)
**Status:** Design contract for admin roles + legacy cohort packaging.
**Machine-readable twin:** [`03-roles-matrix.json`](./03-roles-matrix.json).
**Feature keys:** align with [`docs/plan-permissions/01-feature-keys.json`](../plan-permissions/01-feature-keys.json).
**Plan defaults (public ladder):** [`docs/plan-permissions/06-defaults-matrix.json`](../plan-permissions/06-defaults-matrix.json).
---
## PROBLEM
Migrated / legacy tenants (example: **A1** → Local Demo Co catalog) must see a **narrow product surface** matching the legacy nav only — not the full v2 Operate/Setup marketing suite. Separately, platform roles need a clear permission ceiling that composes with plan feature keys.
---
## Effective resolution
```
effective(feature) =
role_allows(role, feature)
AND plan_allows(company.plan, feature)
AND global_section_enabled(section(feature))
AND global_feature_enabled(feature)
```
- **Company end-users** (`legacy_user`, `standard_user`): plan matrix is the primary gate; role is the ceiling.
- **Staff roles** (`admin`, `developer`, `support_staff`): role ceiling is wide; company plan still applies when acting *as* a tenant unless impersonation / platform override is explicit.
- Platform admin routes (`/admin/*`) are **orthogonal** to dashboard `feature_key`s — gated by `users.is_platform_admin` (and future staff flags), not by plan JSON.
**ASSUMPTION:** `legacy_user` / `standard_user` are product-cohort roles derived from plan name patterns (and/or an explicit `plans.cohort = legacy|standard` when implemented), not company membership `admin|member`.
**ASSUMPTION:** A1 company / plan example maps to the **legacy** plan profile (see plan name patterns below).
---
## Legacy nav allow-list (ONLY)
These are the **only** user-facing nav destinations for the legacy cohort. Everything else is OFF.
| Nav label | Route | Parent feature key(s) |
|-----------|-------|------------------------|
| Dashboard | `/dashboard` | `dashboard.overview` |
| Products | `/products` | `catalog.products` |
| Feeds | `/feeds` | `feeds.list` (+ mapping) |
| Export Feeds | `/export-feeds` | `feeds.export_feeds` |
| Categories | `/categories` | `catalog.categories` |
| Attributes | `/attributes` | `catalog.attributes` |
| Standard Fields | `/standard-fields` | `catalog.standard_fields` |
| Usage & Billing | `/billing` | `billing.overview` |
| Settings | `/settings` | `settings.profile` (+ settings tabs) |
### Explicitly excluded from legacy
| Surface | Reason |
|---------|--------|
| Background Tasks / Processing (`processing.monitor`) | Out of legacy nav |
| Stores / Woo / Shopify | Out of scope |
| Marketing campaigns, content calendar | Out of scope |
| Brand kit / brand AI | Out of scope |
| SEO | Out of scope |
| Reviews | Out of scope |
| AI integrations / Email sending | Integration extras beyond feeds |
| Support center (tickets) | Support-heavy; not essential for legacy nav |
| Structured descriptions / vector categories | Extra catalog tools, no legacy nav |
| Platform admin chrome | Staff-only (`admin` / `developer`) |
---
## Legacy feature allow-list (ON keys)
Canonical ON set for plan profile **`legacy`** (and role **`legacy_user`**). All other keys in `01-feature-keys.json` are **OFF**.
### Shell
- `shell.navigation`
- `shell.command_palette`
- `shell.company_switcher`
- `shell.tutorial`
- `shell.account_menu`
- `shell.billing_recovery_banner`
OFF: `shell.support_notifications`
### Dashboard
- `dashboard.overview`
- `dashboard.stats`
- `dashboard.quick_links`
- `dashboard.recent_jobs` *(informational only; no Processing nav)*
- `dashboard.news_feed`
- `dashboard.activation_checklist`
- `dashboard.migrated_checklist`
- `dashboard.etl_gaps`
- `dashboard.upgrade_banners`
OFF: `dashboard.store_reconnect`
### Catalog
- `catalog.products` (+ all product tabs)
- `catalog.products.tab_processed`
- `catalog.products.tab_needs_review`
- `catalog.products.tab_error`
- `catalog.products.tab_processing`
- `catalog.products.tab_unprocessed`
- `catalog.products.process_categories`
- `catalog.products.process_attributes`
- `catalog.products.process_ai_titles`
- `catalog.products.process_ai_descriptions`
- `catalog.products.enrichment_review`
- `catalog.products.export_selection`
- `catalog.products.upgrade_prompt`
- `catalog.categories`
- `catalog.categories.title_formula`
- `catalog.categories.description_formula`
- `catalog.attributes`
- `catalog.attributes.bulk_import`
- `catalog.standard_fields`
- `catalog.standard_fields.groups`
OFF: `catalog.structured_descriptions`, `catalog.vector_categories`
### Feeds (import + export + CSV uploads needed for feeds)
- `feeds.list`
- `feeds.add_url`
- `feeds.add_csv`
- `feeds.sync`
- `feeds.mapping`
- `feeds.mapping.select_item`
- `feeds.mapping.map_fields`
- `feeds.export_feeds`
- `feeds.export_feeds.create`
- `feeds.export_feeds.generate`
- `feeds.uploads`
### Billing (Usage & Billing)
- `billing.overview`
- `billing.customer_portal`
- `billing.quick_upgrade`
- `billing.plans_compare`
- `billing.checkout`
### Settings
- `settings.profile`
- `settings.company`
- `settings.alerts`
- `settings.api_keys`
- `settings.team`
- `settings.team_invite`
### Capabilities (core catalog/feeds only)
- `capability.sku_cap`
- `capability.ai_credits`
- `capability.ai_processing`
- `capability.eprel`
- `capability.normalize_specs_fill`
- `capability.feed_source_limit`
- `capability.export_feed_limit`
- `capability.storage_limit`
- `capability.api_access`
OFF: `capability.campaign_ai`, `capability.email_live_send`, `capability.brand_ai_apply`, `capability.seo_ai_rewrite`, `capability.byok`
### Entire sections OFF for legacy
- **stores.*** — all keys
- **processing.*** — `processing.monitor`
- **marketing.*** — all keys
- **integrations.*** — all keys
- **support.*** — all keys
---
## Plan name patterns → profile
| Pattern (case-insensitive) | Profile | Notes |
|----------------------------|---------|-------|
| exact `legacy` | `legacy` | Explicit legacy package |
| `^a1(\b\|[\s_-])` / contains `a1 slovenija` | `legacy` | A1 company / plan example |
| company display historically **A1 Slovenija** (MySQL `97e1a309-…`) | `legacy` | Even if renamed to Local Demo Co for demo |
| exact `free` \| `starter` \| `growth` \| `business` \| `enterprise` | `standard` (+ ladder tier) | Public ladder |
| `is_custom=true` and name does **not** match legacy patterns | `custom` | All-on defaults per plan-permissions contract |
| unknown / empty | `standard` / Free | Fail closed to Free matrix |
**A1 example = legacy.** Treat A1s assigned plan (or a dedicated `Legacy` plan row seeded for that tenant) as the legacy profile.
---
## Roles
| Role key | Who | Plan / feature source | Ceiling |
|----------|-----|----------------------|---------|
| `legacy_user` | End-user on a legacy-pattern company/plan | **legacy** matrix | Legacy allow-list only |
| `standard_user` | End-user on public ladder | Plan matrix from `06-defaults-matrix` (Free→Enterprise) | Full catalog; plan denies Free/Starter AI/BYOK/etc. |
| `admin` | Platform admin (`users.is_platform_admin`) | N/A for `/admin/*`; full dashboard keys ON | All feature keys + AdminNav |
| `developer` | Engineering / ops with platform access | Same as admin + debug catalog extras always ON | All keys; intended for non-prod diagnostics |
| `support_staff` | Support agents | Support + read-heavy tenant assist | Support keys ON; marketing/integrations mostly OFF; no destructive platform settings |
### Role notes
#### `legacy_user`
- Sees only the legacy nav allow-list.
- AI titles/descriptions + credits remain ON (legacy customers processed catalog data).
- No stores, processing monitor, marketing, brand, SEO, integrations extras, or support ticket UI.
#### `standard_user`
- Full v2 dashboard surface as allowed by their **public plan** (see `06-defaults-matrix.json`).
- Role matrix in JSON is the **ceiling** (all registry keys `true`); Free/Starter denials still apply via plan.
#### `admin` (platform)
- `is_platform_admin = true`.
- All dashboard feature keys ON.
- Platform surfaces: Overview, Users, Analytics, Platform billing, Support, Stuck Products, Platform settings (`AdminNav.svelte`).
- May impersonate users (non-prod / gated admin APIs).
#### `developer`
- Same feature ceiling as `admin`.
- Explicitly keeps debug/extra catalog keys ON (`catalog.vector_categories`, `catalog.structured_descriptions`).
- Expected to use platform admin + API tooling; not a customer-facing role.
#### `support_staff`
- Dashboard: shell + dashboard overview/stats + support.* ON.
- Catalog/feeds/billing: **read-assist** — parent keys ON so staff can open customer context when impersonating; write-heavy marketing/integrations OFF.
- Platform: Support queue (`/admin/support`), Users (limited), Stuck Products; **not** Platform settings or Platform billing write.
- [UNCERTAIN] No dedicated `is_support_staff` column today — design assumes a future staff flag or group; until then map to a subset of platform-admin users.
---
## Role × section summary
| Section | legacy_user | standard_user | admin | developer | support_staff |
|---------|:-----------:|:-------------:|:-----:|:---------:|:-------------:|
| shell (core) | ON | ON | ON | ON | ON |
| shell.support_notifications | OFF | ON | ON | ON | ON |
| dashboard (core) | ON | ON | ON | ON | ON |
| dashboard.store_reconnect | OFF | ON | ON | ON | OFF |
| catalog (products/categories/attributes/standard fields) | ON | ON | ON | ON | ON† |
| catalog extras (structured/vector) | OFF | ON | ON | ON | OFF |
| feeds (+ export + uploads) | ON | ON | ON | ON | ON† |
| stores | OFF | ON | ON | ON | OFF |
| processing.monitor | OFF | ON | ON | ON | ON† |
| marketing | OFF | ON | ON | ON | OFF |
| integrations | OFF | ON | ON | ON | OFF |
| billing | ON | ON | ON | ON | ON† |
| settings | ON | ON | ON | ON | ON† |
| support | OFF | ON | ON | ON | ON |
| capabilities (core AI/SKU/feeds) | ON | plan | ON | ON | ON† |
| capabilities (campaign/email/brand/seo/byok) | OFF | plan | ON | ON | OFF |
| `/admin/*` | OFF | OFF | ON | ON | partial |
† support_staff: intended for assisted sessions / impersonation; not for self-serve marketing ops.
---
## COMPOSITION with company membership
Existing company membership roles (`admin` \| `member`) stay orthogonal:
| Concern | Gate |
|---------|------|
| Invite / team / API keys / Stripe portal | company `admin` (or platform admin) |
| Which nav/features appear | plan profile + product role (`legacy_user` / `standard_user`) |
| Platform console | `is_platform_admin` / staff role |
Do **not** overload company membership `admin` with platform `admin`.
---
## IMPLEMENTATION HOOKS (non-binding)
| Need | Likely home |
|------|-------------|
| Resolve legacy profile from plan name | `DefaultPlanFeatures` / plan name normalizer beside public ladder |
| Seed Legacy plan row | `EnsureDefaultPlans` + assign to A1/demo migrated tenant |
| Staff role flag | users column or staff group — [UNCERTAIN] until agent/schema decides |
| Nav filter | existing `feature` on `Nav.svelte` items + ResolveFeatures |
---
## VERIFICATION checklist
- [ ] Legacy matrix ON keys ⊆ `01-feature-keys.json`
- [ ] No `processing.monitor`, `stores.*`, `marketing.*`, `integrations.*`, `support.*` ON for legacy
- [ ] A1 / `legacy` name patterns resolve to legacy profile
- [ ] `standard_user` ceiling does not bypass Free/Starter denials in `06-defaults-matrix`
- [ ] Platform `admin` retains `/admin/*` independent of plan JSON
+276
View File
@@ -0,0 +1,276 @@
{
"agent": "04/20",
"title": "Unified contract: plan features + staff roles + legacy",
"status": "design_only",
"version": "1.0.0",
"coordinates_with": [
"docs/admin-roles-support/01-ux-research.md",
"docs/admin-roles-support/02-current-inventory.md",
"docs/admin-roles-support/03-roles-matrix.md",
"docs/admin-roles-support/03-roles-matrix.json",
"docs/plan-permissions/03-permission-contract.md",
"docs/plan-permissions/03-permission-contract.json",
"docs/plan-permissions/01-feature-keys.json",
"docs/plan-permissions/06-defaults-matrix.json"
],
"assumptions": [
"Product cohort (legacy/standard/custom) is a plan feature_profile, not a stored end-user RBAC role; legacy_user/standard_user are derived labels",
"support_staff is least privilege: ticket queue assign/reply only — narrower than optional read-assist in 03",
"packages ≡ plans rows; no parallel permission service outside billing + auth staff flags",
"is_platform_admin retained; staff_role additive with null+admin-flag ⇒ staff_role admin back-compat",
"A1/Local Demo Co Enterprise → Legacy assign is explicit ops step, not silent migration"
],
"axes": {
"plan_features": {
"subject": "company.active_plan",
"storage": ["plans.features", "plans.feature_profile", "platform_feature_gates"],
"resolver": "ResolveEffectiveFeatures / CapabilitiesForCompany",
"failure": { "http": 402, "code": "plan_gate" }
},
"company_membership": {
"subject": "user in company",
"storage": "memberships.role",
"values": ["admin", "member"],
"notes": "Orthogonal; do not overload with platform admin"
},
"platform_staff": {
"subject": "user",
"storage": ["users.is_platform_admin", "users.staff_role"],
"resolver": "resolve_staff_role + staff_allows(capability)",
"failure": { "http": 403, "error": "staff capability required" }
}
},
"runtime_formulas": {
"effective_feature": "plan_allows(key) AND global_section_enabled(section(key)) AND global_feature_enabled(key)",
"plan_allows": [
"if key in plans.features -> plans.features[key]",
"else if resolve_plan_profile == legacy -> LegacyMatrix[key] (missing -> false)",
"else if resolve_plan_profile == custom -> true",
"else -> DefaultPlanFeatures(name, false)[key]"
],
"resolve_plan_profile_priority": [
"plans.feature_profile if set (legacy|ladder|custom)",
"legacy name patterns from 03-roles-matrix",
"IsCustomPackage -> custom",
"else ladder"
],
"staff_allows": "user.is_active AND resolve_staff_role(user) in allowed_roles(capability)",
"resolve_staff_role": [
"inactive -> none",
"staff_role if not null",
"else if is_platform_admin -> admin",
"else none"
]
},
"legacy": {
"profile": "legacy",
"shipping_modes": [
{
"id": "named_profile",
"preferred": true,
"mechanism": "plans.feature_profile='legacy' or name-pattern derive; DefaultPlanFeatures/LegacyMatrix"
},
{
"id": "sparse_or_dense_features",
"preferred": false,
"mechanism": "plans.features explicit map; requires profile precedence so missing keys are not custom-all-on"
}
],
"must_not": "Fall through IsCustomPackage all-ON for A1/legacy names",
"allowlist_authority": "docs/admin-roles-support/03-roles-matrix.md",
"excluded_sections": [
"processing.monitor",
"stores.*",
"marketing.*",
"integrations.*",
"support.*"
],
"name_patterns_ref": "docs/admin-roles-support/03-roles-matrix.json#plan_name_patterns",
"a1_example": {
"legacy_company_id": "97e1a309-3d23-4aa2-b518-8e8d7afdfec7",
"demo_note": "Local Demo Co may still be on Enterprise until explicit Legacy assign"
}
},
"staff_roles": {
"values": ["admin", "developer", "support_staff"],
"storage": {
"keep": "users.is_platform_admin",
"add": {
"column": "users.staff_role",
"type": "TEXT NULL CHECK IN (admin, developer, support_staff)",
"invariant": "staff_role set implies is_platform_admin=true"
}
},
"backfill": "UPDATE users SET staff_role='admin' WHERE is_platform_admin AND staff_role IS NULL",
"capabilities": {
"staff.admin_shell": ["admin", "developer", "support_staff"],
"staff.support.queue": ["admin", "developer", "support_staff"],
"staff.support.reply": ["admin", "developer", "support_staff"],
"staff.support.assign": ["admin", "developer", "support_staff"],
"staff.users.read": ["admin", "developer"],
"staff.users.write": ["admin", "developer"],
"staff.analytics": ["admin", "developer"],
"staff.billing": ["admin", "developer"],
"staff.plans_features": ["admin", "developer"],
"staff.feature_gates": ["admin", "developer"],
"staff.settings": ["admin", "developer"],
"staff.jobs_stuck": ["admin", "developer"],
"staff.impersonate": ["admin", "developer"],
"staff.dev_password": ["admin", "developer"]
},
"support_staff_least_privilege": {
"allowed_web": ["/admin/support", "/admin/support/[id]"],
"allowed_api": [
"GET /api/admin/support/tickets",
"GET /api/admin/support/tickets/{id}",
"POST /api/admin/support/tickets/{id}/messages",
"PATCH /api/admin/support/tickets/{id}"
],
"denied": [
"billing",
"plans",
"feature_gates",
"credits",
"settings",
"users_write",
"impersonate",
"stuck_cleanup",
"self_escalate_staff_role"
],
"deferred": "ticket-side read-only company context without tenant write APIs"
},
"middleware": {
"prefer": ["RequireStaff", "RequireStaffCapability"],
"extend": [
"apps/api/internal/httpapi/middleware.go#RequirePlatformAdmin",
"apps/api/internal/httpapi/middleware.go#checkPlatformAdmin",
"apps/api/internal/auth/invites.go#IsPlatformAdmin"
],
"router_today": "apps/api/internal/httpapi/server.go /api/admin RequireSession+RequirePlatformAdmin",
"ui": [
"apps/web/src/lib/admin-gate.ts",
"apps/web/src/lib/components/AdminNav.svelte"
]
}
},
"security": {
"session": "scs cookie + RequireSession; staff flags from DB not client claims",
"csrf": "double-submit X-CSRF-Token on dashboard /api including /api/admin; v1/public/webhooks exempt",
"company_isolation": [
"tenant APIs RequireCompany scoped",
"admin support cross-company ticket view does not open tenant write APIs",
"impersonate admin|developer non-prod only; never support_staff",
"allowCompanyAdminOrPlatform: only admin|developer count as platform bypass — not support_staff"
],
"no_privilege_escalation": [
"capability middleware on admin routes",
"no self-service staff_role",
"plan features never grant /admin",
"staff never granted by plan JSON",
"fail closed on missing role/capability"
]
},
"performance": {
"existing": {
"symbol": "featureETag",
"path": "apps/api/internal/billing/plan_features.go",
"semantics": "sha256 of sorted enabled feature keys"
},
"rules": [
"Do not fold staff_role into feature_etag",
"Keep company capabilities cache keyed by company_id + feature_etag",
"Invalidate on plan assign, plans.features write, global gates write",
"etag remains pure function of effective enabled-key set (profile already reflected in map)",
"Resolve staff role once per request in middleware"
],
"me_payload_additive": {
"user.staff_role": "string|null",
"staff_capabilities": "string[]",
"credits.feature_profile": "legacy|ladder|custom|null",
"credits.feature_etag": "unchanged semantics"
}
},
"migration": {
"file_suggestion": "apps/api/sql/schema/027_staff_roles_legacy_profile.sql",
"idempotent": true,
"destructive": false,
"steps": [
"ALTER users ADD staff_role NULL CHECK",
"ALTER plans ADD feature_profile NULL CHECK",
"Backfill staff_role=admin for existing platform admins",
"Ensure Legacy plan row with feature_profile=legacy",
"Optional sparse plans.features from LegacyMatrix",
"Explicit A1/demo assign — no silent Enterprise downgrade",
"EnsureDefaultPlans must not clobber features or feature_profile"
],
"optional_flag": "STAFF_RBAC=1 to enforce capability middleware",
"breaking_changes": [],
"preserve": [
"is_platform_admin",
"public ladder names",
"Stripe public-ladder checkout",
"402 plan_gate shapes",
"support assignee_admin_user_id schema",
"CSRF session behavior"
]
},
"reuse_symbols": {
"billing": [
"DefaultPlanFeatures",
"SparseDefaultOverrides",
"IsCustomPackage",
"PlanAllowsFeature",
"ResolveEffectiveFeatures",
"CapabilitiesForCompany",
"featureETag",
"EntitlementsForCompany"
],
"auth_http": [
"IsPlatformAdmin",
"RequirePlatformAdmin",
"checkPlatformAdmin",
"CSRF",
"RequireSession",
"allowCompanyAdminOrPlatform"
],
"support": [
"ListAdmin",
"GetAdmin",
"ReplyAsAgent",
"UpdateAdmin",
"handleAdminListSupportTickets"
],
"web": [
"requirePlatformAdmin",
"AdminNav",
"PlanPermissionsPanel",
"planCapabilities",
"Nav.svelte"
]
},
"do_not_add": [
"parallel permissions package outside billing/auth",
"packages table",
"tenant feature_keys for /admin/*",
"mixing LLM admin-ai-roles into staff RBAC",
"staff fields inside feature_etag"
],
"verification": [
"legacy excludes processing/stores/marketing/integrations/support",
"A1/legacy profile not custom-all-on",
"custom non-legacy still all-on",
"ladder Free/Starter denials unchanged",
"null staff_role + is_platform_admin => admin",
"support_staff 403 on billing/plans/settings",
"support_staff AdminNav Support-only",
"CSRF still on admin mutations",
"feature_etag stable across staff_role-only changes",
"tenant APIs remain company-scoped for support_staff"
],
"open_questions_defaults": {
"a1_demo_cutover": "explicit_ops_assign",
"support_staff_sets_is_platform_admin": true,
"company_admin_platform_bypass": "admin_and_developer_only",
"legacy_seed_density": "profile_first_sparse_optional"
}
}
+405
View File
@@ -0,0 +1,405 @@
# 04 — Unified contract: plan features + staff roles + legacy
**Status:** Design only — no app code in this doc. Implementers own schema/API/UI.
**Agent:** 4/20
**Machine-readable twin:** [`04-contract.json`](./04-contract.json)
**Coordinates with:** [`01-ux-research.md`](./01-ux-research.md), [`02-current-inventory.md`](./02-current-inventory.md), [`03-roles-matrix.md`](./03-roles-matrix.md) / [`.json`](./03-roles-matrix.json), [`docs/plan-permissions/03-permission-contract.md`](../plan-permissions/03-permission-contract.md)
---
## PROBLEM
Three concerns are blurred in production today:
1. **Legacy / A1 cohort** should see a **limited** dashboard (nav allow-list in `03-roles-matrix`) but currently resolves like **custom all-ON** via `IsCustomPackage`.
2. **Platform staff** is a single boolean (`users.is_platform_admin`) — no `admin` / `developer` / `support_staff` split; support queue sits behind full admin.
3. Implementers need one **additive** contract that extends Entitlements / plan features / platform admin flags — **not** a parallel permission service.
---
## CONTEXT (tools / sources)
| Source | Finding |
|--------|---------|
| Agent 1 UX | Keep plan entitlements and human/staff roles as **two axes**; never merge with LLM `admin-ai-roles` |
| Agent 2 inventory | Binary `is_platform_admin`; membership `admin\|member`; support admin under `RequirePlatformAdmin`; no legacy profile |
| Agent 3 matrix | Legacy ON keys + plan name patterns; staff role ceilings; company membership stays orthogonal |
| Plan-permissions 03 | `plans.features` sparse + globals + `ResolveEffectiveFeatures` / `featureETag` / `CapabilitiesForCompany` |
| Code | `DefaultPlanFeatures`, `IsCustomPackage`, `RequirePlatformAdmin`, `CSRF` + session on dashboard `/api/*` |
**ASSUMPTION:** Product cohort (`legacy` vs public ladder vs custom deal) is expressed as a **plan feature profile**, not a second end-user RBAC table. Agent 3s `legacy_user` / `standard_user` are **derived labels** for docs/UI, not stored membership roles.
**ASSUMPTION:** `support_staff` is **least privilege**: ticket queue (+ assign/reply) only — narrower than agent 3s optional read-assist/impersonation notes.
**ASSUMPTION:** Packages remain `plans` rows (no packages table) — same as plan-permissions contract.
---
## 1. Three orthogonal axes (normative)
| Axis | Question | Storage / resolver | Gates |
|------|----------|--------------------|-------|
| **A. Plan features** | What did this *company* buy / inherit? | `plans.features` + profile + globals | Tenant nav, `AssertFeature`, meters compose |
| **B. Company membership** | What can this *person* do *in that company*? | `memberships.role` (`admin` \| `member`) | Invites, team, API keys, Stripe portal |
| **C. Platform staff** | What can this *person* do on the *platform console*? | `users.is_platform_admin` + `users.staff_role` | `/admin/*`, `/api/admin/*` |
**Never:**
- Put `/admin/*` keys into the tenant `feature_key` registry.
- Use company `memberships.role = admin` as platform admin.
- Treat `is_custom` / client-deal packaging as the same thing as **legacy limited nav**.
- Mix LLM provider “AI roles” into this matrix.
```
# Tenant dashboard / tenant APIs (company-scoped)
effective_feature(key) =
plan_allows(company.active_plan, key)
AND global_section_enabled(section(key))
AND global_feature_enabled(key)
# Platform console (user-scoped, company plan irrelevant)
staff_allows(user, capability) =
user.is_active
AND resolve_staff_role(user) IN allowed_roles(capability)
```
Company membership checks stay as today (`requireCompanyAdmin`, `allowCompanyAdminOrPlatform`) and **compose** with `effective_feature` for tenant mutations (e.g. invite requires company admin **and** `settings.team_invite` when enforced).
---
## 2. Plan feature profiles (legacy + ladder + custom)
### 2.1 Profile enum
| Profile | Meaning | Default matrix source |
|---------|---------|------------------------|
| `legacy` | Migrated/limited cohort (A1 + explicit Legacy) | Allow-list in `03-roles-matrix` — all other registry keys **OFF** |
| `ladder` | Public Free→Enterprise by plan name | `06-defaults-matrix` / `DefaultPlanFeatures` |
| `custom` | Client deal / enable-all package | All registry keys ON unless override `false` |
### 2.2 How to select profile (priority)
```
resolve_plan_profile(plan) =
1. if plans.feature_profile IN ('legacy','ladder','custom') -> that value # preferred additive column
2. else if normalize(name) matches legacy patterns (03) -> 'legacy'
3. else if IsCustomPackage(name, is_custom) -> 'custom'
4. else -> 'ladder'
```
Legacy patterns (from agent 3): exact `legacy`; `(?i)^a1(\b|[\s_-])`; `(?i)a1\s*slovenija`; known A1 company id `97e1a309-…` when ops assign a Legacy plan to that tenant.
**Critical fix vs today:** Profile `legacy` **must not** fall through `IsCustomPackage` → all-ON. Name `A1` alone currently yields custom treatment (`custom_package_features_test`); the contract requires legacy detection **before** custom all-ON.
### 2.3 `plan_allows` (extends plan-permissions 03)
```
plan_allows(key) =
if key present in plans.features -> plans.features[key] # sparse or dense override wins
else if resolve_plan_profile(plan) == 'legacy' -> LegacyMatrix[key] # missing key -> false
else if resolve_plan_profile(plan) == 'custom' -> true
else -> DefaultPlanFeatures(name, false)[key] # ladder; unknown -> false
```
Globals unchanged:
```
effective_feature(key) = plan_allows(key)
AND global_section_enabled(section(key))
AND global_feature_enabled(key)
```
### 2.4 Two equivalent ways to ship legacy limited matrix
Both are valid; prefer (a) for clarity, (b) works without waiting on every resolver caller:
| Mode | Mechanism | When |
|------|-----------|------|
| **(a) Named profile `legacy`** | `plans.feature_profile = 'legacy'` (or name-pattern derive) + `DefaultPlanFeatures` / `LegacyMatrix` | Steady state; PlanPermissionsPanel badge “Legacy” |
| **(b) Sparse / dense `plans.features`** | Store explicit map: ON keys `true`, and either all OFF keys `false` **or** rely on profile so missing ≠ custom-all-on | Migration seed / one-off admin fix |
**Seed recommendation:** create/ensure a public-or-deal plan row named `Legacy` with `feature_profile='legacy'` and optionally materialize sparse overrides (false keys only) for audit in admin UI. Assign A1 / Local Demo Co **away from Enterprise all-ON** when the product intent is legacy nav (ops decision — see migration).
### 2.5 Symbols to extend (no parallel system)
| Concern | Extend |
|---------|--------|
| Defaults | `DefaultPlanFeatures`, `SparseDefaultOverrides`, `IsCustomPackage` (legacy short-circuit) |
| Resolve | `PlanAllowsFeature`, `ResolveEffectiveFeatures`, `CapabilitiesForCompany` |
| Admin | `PlanPermissionsPanel` + plan feature APIs — Legacy badge / “apply legacy profile” |
| Nav | Existing `planCapabilities.can` / `Nav.svelte` feature keys |
---
## 3. Platform staff roles
### 3.1 Role keys
| `staff_role` | Who | Ceiling |
|--------------|-----|---------|
| `admin` | Full platform operators | All staff capabilities |
| `developer` | Engineering / ops | Same as `admin` for console; **plus** non-prod debug tools (existing `admin_dev_handlers` stay env-gated) |
| `support_staff` | Support agents | **Ticket queue only** (list/get/reply/assign/status) |
### 3.2 Additive storage
Prefer extending users — **do not** replace `is_platform_admin` in v1:
```text
users.is_platform_admin -- keep: “has any platform console access”
users.staff_role TEXT NULL
CHECK (staff_role IS NULL OR staff_role IN ('admin','developer','support_staff'))
```
**Resolution:**
```
resolve_staff_role(user) =
if not user.is_active -> none
if user.staff_role IS NOT NULL -> user.staff_role
else if user.is_platform_admin -> 'admin' # back-compat for migrated admin_users
else -> none
```
**Invariant:** `staff_role` set ⇒ `is_platform_admin = true` (writers enforce). Clearing staff access sets both off / null.
**Migrator:** existing `applyPlatformAdmins` continues to set `is_platform_admin`; backfill `staff_role='admin'` where admin and role null.
### 3.3 Staff capability catalog (platform — not feature_keys)
| Capability | admin | developer | support_staff |
|------------|:-----:|:---------:|:-------------:|
| `staff.admin_shell` | ✓ | ✓ | ✓ (Support-only nav) |
| `staff.support.queue` | ✓ | ✓ | ✓ |
| `staff.support.reply` | ✓ | ✓ | ✓ |
| `staff.support.assign` | ✓ | ✓ | ✓ |
| `staff.users.read` | ✓ | ✓ | ✗ |
| `staff.users.write` | ✓ | ✓ | ✗ |
| `staff.analytics` | ✓ | ✓ | ✗ |
| `staff.billing` | ✓ | ✓ | ✗ |
| `staff.plans_features` | ✓ | ✓ | ✗ |
| `staff.feature_gates` | ✓ | ✓ | ✗ |
| `staff.settings` | ✓ | ✓ | ✗ |
| `staff.jobs_stuck` | ✓ | ✓ | ✗ |
| `staff.impersonate` | ✓* | ✓* | ✗ |
| `staff.dev_password` | ✓* | ✓* | ✗ |
\* Existing non-prod / env gates remain (`Config.IsProduction()`).
### 3.4 HTTP / UI mapping
| Surface | Gate |
|---------|------|
| `GET/PATCH /api/admin/support/tickets*`, `POST …/messages` | `staff.support.*` (`admin` \| `developer` \| `support_staff`) |
| All other `/api/admin/*` | `admin` \| `developer` only |
| Web `/admin/support/**` | support_staff allowed |
| Web `/admin/**` (billing, settings, users, …) | admin \| developer; support_staff → 403 / redirect |
| `AdminNav` | Filter `menuItems` by staff capability (Support only for `support_staff`) |
**Middleware shape (preferred):** keep `RequireSession` on `/api/admin`; replace blanket-only `RequirePlatformAdmin` with:
1. `RequireStaff``resolve_staff_role ≠ none` (replaces binary admin check for “any staff”).
2. `RequireStaffCapability(cap)` — per-route or route-group allow-list.
Until split lands, **do not** grant support_staff by setting `is_platform_admin` alone without capability middleware — that would privilege-escalate to billing.
### 3.5 Difference from plan features (explicit)
| | Plan features | Staff roles |
|-|---------------|-------------|
| Subject | Company (active plan) | User |
| Payload | `features` / `feature_etag` on credits & capabilities | `staff_role` / `staff_capabilities` on `/api/auth/me` |
| Failure | 402 `plan_gate` / `feature_disabled` | 403 `platform admin required` / `staff capability required` |
| Cache | Company-scoped capabilities cache | User session / me payload — **not** folded into `feature_etag` |
---
## 4. Support staff — least privilege
Normative for `support_staff`:
**Allowed**
- Open `/admin/support` and ticket detail.
- List / filter / search admin ticket queue.
- Reply as agent; update status / priority / `assignee_admin_user_id`.
**Denied**
- Platform billing, plan upsert, feature matrices, global feature gates, credits, cycle runs.
- Platform settings (mail, AI configs, etc.).
- User list mutations, set-password blast, impersonation, stuck-job cleanup.
- Tenant session switching without a future audited impersonation flow (out of scope for support_staff).
- Expanding own `staff_role` or `is_platform_admin` via any API.
**Optional later (not v1 contract):** read-only company context cards on the ticket — still **no** tenant write APIs. Agent 3s broader “read-assist” ceiling is **deferred**; least privilege wins.
Tenant `/support` (company-scoped tickets) remains gated by plan feature `support.center` and `RequireCompany` — unrelated to staff queue.
---
## 5. Security
### 5.1 Session
- Staff and tenant routes use the same cookie session (`scs`) + `RequireSession`.
- Staff flags loaded from **DB** (`IsPlatformAdmin` / future `staff_role`) — never trust client claims alone (`admin-gate.ts` is UX only).
- Inactive users (`is_active=false`) fail all staff checks.
### 5.2 CSRF
- Dashboard `/api/*` (including `/api/admin/*`) stays behind existing double-submit `CSRF` middleware (`X-CSRF-Token`).
- Public `/api/v1`, `/api/public/`, `/api/webhooks/` remain CSRF-exempt (API key / signature).
- No new CSRF bypass for staff tools.
### 5.3 Company isolation
- Tenant handlers keep `RequireCompany` + company-scoped queries.
- Admin support `ListAdmin` / `GetAdmin` may return cross-company ticket metadata; that does **not** open tenant data APIs.
- Impersonation (when enabled) is `admin`/`developer` + non-prod only; must audit; support_staff **cannot** impersonate.
- `allowCompanyAdminOrPlatform` must not treat `support_staff` as company admin for invites/billing unless an explicit product decision says otherwise — **default: platform staff ≠ company admin**. Prefer checking `resolve_staff_role ∈ {admin, developer}` if platform bypass remains for team cutover.
### 5.4 No privilege escalation
| Risk | Control |
|------|---------|
| support_staff hits `/api/admin/plans` | Capability middleware 403 |
| User sets `is_platform_admin` via profile API | No self-service; only admin/developer staff APIs (future) or DB/migrator |
| Plan feature ON does not grant `/admin` | Staff axis independent |
| Custom/legacy plan does not grant staff | Staff axis independent |
| Client sends `staff_role` on `/me` POST | Ignore; server is SOT |
| Feature override JSON includes admin keys | Reject unknown / non-registry keys on upsert (existing) |
Fail closed on missing staff role / capability.
---
## 6. Performance — capabilities + etag
Existing:
- `CapabilitiesForCompany` resolves plan ∩ globals.
- `featureETag(features)` = `sha256:` of sorted **enabled** feature keys (`plan_features.go`).
**Contract for extension:**
1. **Do not** put `staff_role` into `feature_etag` — staff is per-user; features are per-company. Mixing forces every staff login to invalidate tenant feature caches.
2. Optional additive fields on `/api/auth/me`:
```json
{
"user": {
"is_platform_admin": true,
"staff_role": "support_staff"
},
"credits": {
"features": { "...": true },
"feature_etag": "sha256:…",
"feature_profile": "legacy"
},
"staff_capabilities": ["staff.support.queue", "staff.support.reply", "staff.support.assign"]
}
```
3. If caching capabilities by `(company_id, feature_etag)`, keep that key. Invalidate on plan assign, `plans.features` write, global gate write — **unchanged**.
4. If `feature_profile` is exposed, include it in etag **input** only when it changes effective features (hash profile name + enabled keys, or keep hashing enabled keys alone since profile is already reflected in the map). Prefer **etag remains a pure function of the effective enabled-key set** so clients need not special-case profile.
5. Avoid N+1: resolve staff role once per request in middleware; do not re-query per admin handler beyond existing patterns.
---
## 7. Migration strategy (additive)
Suggested goose file: `027_staff_roles_legacy_profile.sql` (number may shift — after `026_plan_features`).
### 7.1 Schema (idempotent)
```sql
ALTER TABLE users
ADD COLUMN IF NOT EXISTS staff_role TEXT NULL
CHECK (staff_role IS NULL OR staff_role IN ('admin', 'developer', 'support_staff'));
ALTER TABLE plans
ADD COLUMN IF NOT EXISTS feature_profile TEXT NULL
CHECK (feature_profile IS NULL OR feature_profile IN ('legacy', 'ladder', 'custom'));
-- Optional helpful indexes
CREATE INDEX IF NOT EXISTS users_staff_role_idx ON users (staff_role)
WHERE staff_role IS NOT NULL;
```
No drops; no rewriting meters; ETL ignores unknown columns safely.
### 7.2 Data backfill (safe order)
1. `UPDATE users SET staff_role = 'admin' WHERE is_platform_admin = true AND staff_role IS NULL;`
2. Ensure `Legacy` plan row: `feature_profile='legacy'`, meters as ops decide; **do not** set `is_custom` in a way that bypasses legacy (resolver must honor profile first).
3. Optionally seed `plans.features` sparse false-map from LegacyMatrix for admin visibility.
4. **A1 / Local Demo Co:** do **not** auto-downgrade Enterprise without ops confirmation — document a one-shot assign script (`05-legacy-seed` agent). Default contract: *tools exist*; cutover is explicit.
5. `EnsureDefaultPlans` / feature seeders: never clobber non-empty `features` or explicit `feature_profile`.
### 7.3 Rollout flags (optional)
| Flag | Purpose |
|------|---------|
| (none required) | Additive columns default null → todays behavior via back-compat resolution |
| `STAFF_RBAC=1` | Enforce capability middleware (vs temporary “any platform admin”) |
| existing `FEATURES_ENFORCE` | Unrelated; keep for tenant AssertFeature |
### 7.4 BREAKING
**None preferred.** Additive columns and fields only. Preserve:
- `is_platform_admin` semantics for existing admins.
- Public ladder names, Stripe checkout rules, 402 `plan_gate` shapes.
- Support ticket schema (`assignee_admin_user_id`, etc.).
---
## 8. API / me contract (additive fields)
| Endpoint | Add |
|----------|-----|
| `GET /api/auth/me` | `user.staff_role`; optional `staff_capabilities[]`; `credits.feature_profile` |
| `GET /api/billing/capabilities` | `feature_profile` alongside existing `features` / `feature_etag` |
| `GET/POST /api/admin/plans` | `feature_profile` read/write for admin\|developer |
| Future | `PATCH /api/admin/users/{id}/staff-role` — admin\|developer only; validate escalation rules |
Errors:
```json
{ "error": "staff capability required", "capability": "staff.billing" }
```
HTTP 403 (not 402 — not a plan gate).
---
## 9. Verification checklist
- [ ] Legacy profile: `processing.monitor`, `stores.*`, `marketing.*`, `integrations.*`, `support.*` effective false
- [ ] Name `A1` / `feature_profile=legacy` does **not** all-ON via `IsCustomPackage`
- [ ] Custom non-legacy deal still all-ON
- [ ] Free/Starter ladder denials unchanged (`06-defaults-matrix`)
- [ ] `is_platform_admin` + null `staff_role` ⇒ behaves as `admin`
- [ ] `support_staff` can hit support admin APIs; 403 on plans/settings/credits
- [ ] support_staff AdminNav shows Support only
- [ ] CSRF still required on admin POST/PATCH
- [ ] `feature_etag` unchanged when only `staff_role` changes
- [ ] Company tenant APIs still company-scoped for support_staff sessions
- [ ] Migrator admin backfill sets `staff_role='admin'`
---
## 10. Open questions (defaults chosen)
1. **A1 demo plan cutover** — Local Demo Co is on Enterprise today; legacy assign is explicit ops step (default: do not silent-migrate).
2. **support_staff + `is_platform_admin`** — both true when role set (default).
3. **Company-admin bypass for platform staff** — only `admin`/`developer`, not `support_staff` (default).
4. **Dense vs sparse legacy seed** — profile + LegacyMatrix sufficient; sparse false keys optional for UI (default: profile-first).
---
## Related implementer docs (downstream agents)
| Doc | Expected owner |
|-----|----------------|
| `05-legacy-seed` | Seed Legacy plan + A1 assign runbook |
| `06-staff-roles` | Schema + middleware + AdminNav filter |
| Plan-permissions enforcement | Keep AssertFeature on tenant mutations |
@@ -0,0 +1,65 @@
# 05 — Legacy plan feature seed (agent 5/20)
**Status:** Implemented in `apps/api/internal/billing`.
**Coordinates with:** [`03-roles-matrix.md`](./03-roles-matrix.md), [`04-contract.md`](./04-contract.md), [`docs/plan-permissions/06-defaults-matrix.md`](../plan-permissions/06-defaults-matrix.md).
---
## PROBLEM
A1 / migrated packages previously resolved as **custom all-ON**, which exposed Background Tasks (`processing.monitor`), stores, marketing, etc. Legacy tenants must get the **image-nav** matrix only.
---
## Behavior
| Concern | Implementation |
|---------|----------------|
| Profile | `PlanProfileLegacy` / `ResolvePlanProfile` |
| Allow-list | `LegacyFeatureAllowed` + `legacyFeatureAllowlist` in `legacy_plan.go` |
| Defaults | `DefaultPlanFeatures` / `DefaultPlanFeaturesEx` check legacy **before** custom all-ON |
| Sparse store | `SparseLegacyOverrides` / `SparseDefaultOverridesEx` |
| Detect | `IsLegacyPlanName` (exact `legacy`, `A1` / `A1 …`), `IsLegacyPlan(name, is_legacy)`, `IsLegacyCompanyID` |
| Column | `plans.is_legacy` via `028_plan_is_legacy.sql` (additive; code falls back if missing) |
| Seed | `EnsureDefaultFeatureSeeds``EnsureLegacyPlanFeatureSeeds``EnsureLegacyDefaults` |
| Reset | `ApplyDefaultMatrix` writes sparse legacy for legacy plans |
| Create | `prepareCustomPackageCreateFeatures` seeds sparse legacy for A1-named plans |
### Seed / backfill rules (`EnsureLegacyDefaults`)
1. Upsert plan row named **Legacy** (`is_legacy=true`).
2. For each plan matching name or `is_legacy`:
- **empty** `features` → write `SparseLegacyOverrides`
- **full enable-all** map (prior custom create) → repair to sparse legacy
- **`is_legacy=true`** → re-apply sparse legacy
- **non-empty customized** (not enable-all, not flagged) → **leave alone**
3. Assign Legacy plan to A1 cohort companies (`legacy_company_id` / A1 name / Local Demo Co stand-in).
### Explicitly OFF (examples)
- `processing.monitor` (Background Tasks)
- all `stores.*`, `marketing.*`, `integrations.*`, `support.*`
- `catalog.structured_descriptions`, `catalog.vector_categories`
- `shell.support_notifications`, `dashboard.store_reconnect`
- `capability.byok`, campaign/email/brand/seo capabilities
### Explicitly ON (image-nav)
Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Usage & Billing, Settings — plus required shell/capabilities for catalog AI.
---
## Verification
```bash
cd apps/api
go test ./internal/billing/ -count=1 -run "Legacy|DefaultPlanFeatures|SparseDefault|IsCustomPackage|Prepare"
```
---
## ASSUMPTIONS
1. Name patterns from agent 3 win over `is_custom` for matrix resolution.
2. Non-legacy client deals (e.g. Merkur) remain enable-all custom.
3. Local Demo Co may be treated as A1 stand-in during demo seed when remapped.
+110
View File
@@ -0,0 +1,110 @@
# 06 — Platform staff roles
**Agent:** 6/20
**Contract:** [`04-contract.md`](./04-contract.md) §3
**Status:** Implemented (additive schema + middleware + admin APIs + tests)
---
## PROBLEM
Platform access was a single boolean (`users.is_platform_admin`). Support desk and billing/plan mutation shared the same gate. Contract 04 requires three staff roles with least privilege for `support_staff`.
---
## Model
| Column | Meaning |
|--------|---------|
| `users.is_platform_admin` | Retained. Any platform console access (including support_staff). |
| `users.staff_role` | `admin` \| `developer` \| `support_staff` \| NULL |
**Resolution** (`ResolveStaffRole` / `ResolveStaffAccess` in `apps/api/internal/auth/staff.go`):
1. Inactive → none
2. `staff_role` if set
3. Else `is_platform_admin` → treat as `admin` (legacy back-compat)
4. Else none
**Capabilities:**
| Role | Full `/api/admin/*` | Support desk | Notes |
|------|:-------------------:|:------------:|-------|
| `admin` | yes | yes | Full console |
| `developer` | yes | yes | Same as admin; env-gated dev tools unchanged |
| `support_staff` | **no** | yes | Tickets only — no plans/billing/settings/credits |
| legacy `is_platform_admin` + NULL role | yes | yes | Migrated admins |
**Invariant:** assigning a non-empty `staff_role` sets `is_platform_admin=true`. Clearing role clears both.
---
## Schema
Migration: `apps/api/sql/schema/029_staff_roles.sql`
- Additive `staff_role` CHECK + partial index
- Idempotent backfill: `staff_role='admin'` where `is_platform_admin` and role NULL
- Migrator `applyPlatformAdmins` also `COALESCE(staff_role, 'admin')`
---
## Middleware (extends existing — no parallel auth)
| Middleware | Allows |
|------------|--------|
| `RequirePlatformAdmin` | `FullAdmin` (admin/developer/legacy) — **excludes** support_staff |
| `RequireSupportDesk` | `SupportDesk` (admin/developer/support_staff/legacy) |
| `IsPlatformAdmin` / `checkPlatformAdmin` | Now resolves via `GetStaffAccess().FullAdmin` |
Router (`server.go`):
- `/api/admin/support/tickets*``RequireSupportDesk`
- All other `/api/admin/*``RequirePlatformAdmin`
- Staff assign APIs sit under full admin group
`StaffRoleAllowsAdminRoute`: support_staff → `/admin/support*` only (contract 04).
---
## APIs (admin \| developer only)
| Method | Path | Body | Behavior |
|--------|------|------|----------|
| `GET` | `/api/admin/staff` | — | List users with staff access |
| `PATCH` | `/api/admin/users/{id}/staff-role` | `{"staff_role":"admin"\|"developer"\|"support_staff"\|null}` | Assign/clear; **cannot change own role** |
| `GET` | `/api/admin/users` | — | Includes `staff_role` |
| `GET` | `/api/auth/me` | — | Additive `staff_access`, `staff_capabilities` when staff |
Errors: 400 invalid role, 403 self-change / capability, 404 unknown user. CSRF still required on mutating admin routes.
Support convenience (also full-admin): `PUT /api/admin/support/agents/{id}` grants/revokes `support_staff` only (does not demote admin/developer).
---
## Tests
- `internal/auth`: `TestResolveStaffAccess`, `TestNormalizeStaffRole`, `TestStaffCapabilities`, `TestStaffRoleAllowsAdminRouteContract`
- `internal/httpapi`: `TestRequirePlatformAdmin*` (incl. support_staff forbidden), `TestRequireSupportDesk`, `TestHandleAdminSetStaffRoleRejectsSelf`
```text
go test ./internal/auth/ ./internal/httpapi/ -count=1
```
---
## Files touched
- `apps/api/sql/schema/029_staff_roles.sql`
- `apps/api/internal/auth/staff.go`, `staff_test.go`, `staff_role_defaults.go`, `service.go`, `invites.go`
- `apps/api/internal/httpapi/middleware.go` (existing Require*), `server.go`, `admin_staff_handlers.go`, `admin_handlers.go`, `auth_handlers.go`, `admin_authz_test.go`
- `apps/api/cmd/migrator/admins.go`
- `apps/api/internal/support/agents.go` (platform_admin invariant on grant/revoke)
---
## Out of scope / follow-ups
- AdminNav UI filter (agent 7/10/14) — consume `staff_access` / `staff_capabilities` from `/me`
- Ticket queue assignee filtering (agent 12/14)
- Do not conflate with company `memberships.role` or LLM AI roles
@@ -0,0 +1,61 @@
# 07 — Admin shell (layout / nav / chrome)
Platform admin UI lives under `/admin/*` with a **dedicated shell** (not the tenant dashboard layout). This doc covers the shared chrome only — page bodies own their own data/actions.
Stack: **Go chi API + SvelteKit 5 / Svelte / Tailwind** (not Laravel).
## Goals
- Brand sidebar via semantic `--sidebar-*` tokens (russian-violet light / deepened dark).
- **Both themes:** `html.dark` + shared `$lib/theme.svelte`; content uses `bg-background` / `text-foreground` / `bg-card` — not a light-only paint.
- Chart tokens in `layout.css` stay preserved for analytics.
- No sticky `AdminHeader`. Theme via `ThemeToggle` in sidebar footer + mobile chrome.
- No Migration readiness / cutover banner in the shell.
- `/admin` is the **command center** (signals + curated shortcuts) — not a flat tool-card grid.
- Responsive: persistent sidebar ≥ `lg`; off-canvas drawer below; mobile menu hook only in layout.
- Accessible: skip link, `aria-current`, focus-visible rings, mobile focus trap + Escape.
## Files
| Piece | Path |
| --- | --- |
| Admin branch | `apps/web/src/routes/+layout.svelte` (`#admin-shell`) |
| Nav IA catalog | `apps/web/src/lib/admin-nav.ts` |
| Sidebar | `apps/web/src/lib/components/AdminNav.svelte` |
| Drawer state | `apps/web/src/lib/admin-nav-ui.svelte.ts` |
| Theme | `apps/web/src/lib/theme.svelte.ts` + `ThemeToggle.svelte` |
| Command center | `apps/web/src/routes/admin/+page.svelte` |
| Tokens | `apps/web/src/routes/layout.css` |
## Nav IA
Source: `ADMIN_NAV_ROUTES` / `ADMIN_NAV_SECTIONS` in `$lib/admin-nav.ts`.
| Section | Links | Gate |
| --- | --- | --- |
| **Overview** | Command center (`/admin`), Analytics | Analytics: full admin |
| **Directory** | Users & orgs | full admin |
| **Support** | Tickets, Knowledge | Knowledge: full admin |
| **Operations** | Diagnostics, Stuck products | full admin |
| **Commerce** | Billing | full admin |
| **System** | Settings | full admin |
Support-only: Tickets (+ Command center in nav; home redirects to `/admin/support`).
Footer: staff chip · ThemeToggle · Back to app · Sign out.
## Structure
```
[SkipLink]
[#admin-shell] /* inherits html.dark tokens */
[AdminNav #admin-sidebar] w-[15.5rem], bg-sidebar, sidebar-* tokens
[content lg:ml-[15.5rem]]
SystemModeBanner / BillingRecoveryBanner
[mobile] menu + adminPageTitle + ThemeToggle
<main data-admin-shell="main">
```
## Page chrome contract
Use `PageShell`. Slots: `data-admin-shell="main"`, `data-admin-slot="diagnostics-panels"`, `data-admin-chrome="staff"`.
@@ -0,0 +1,96 @@
# 08 — Admin Permissions UI (agent 8/20)
**Owns:** Admin → Billing → **Permissions** tab (`PlanPermissionsPanel` + `admin-plan-permissions` client).
**Entry:** `/admin/billing` → Permissions.
**Docs home:** [`README.md`](./README.md) (when present) · prior matrix: [`../plan-permissions/`](../plan-permissions/).
---
## PROBLEM
Make plan feature editing clean and usable: named profiles (Legacy / Free / Starter…), bulk section toggles, search/filters, clear default vs custom, and one-click Legacy for A1-like plans — wired to real admin APIs (no stub mode).
---
## Features delivered
1. **Plan profiles** — Legacy, Free, Starter, Growth, Business, Enterprise/all-on. Apply writes a full override matrix via `PUT /api/admin/plans/{id}/features`.
2. **Legacy (A1)** — Allowlist nav (Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Billing, Settings). `processing.monitor` and stores/marketing/integrations/support extras stay **OFF**. Prominent **Apply Legacy (A1)** when the selected plan is A1-like (`A1*`, name contains `legacy`, or `is_legacy`).
3. **Bulk section toggles** — Per-plan Enable/Disable section (all keys in that section). Global section masters stay in the right rail (optional “+ features” updates global feature-gate rows).
4. **Search / filters** — Text (key or label), section filter, state filter (all / enabled / disabled / differs from plan-name default).
5. **Default vs custom** — Badges: Legacy / Default ladder / Custom deal; **Defaults** vs **Custom overrides**; profile chip shows ✓ when the resolved matrix matches a named profile; **Clear overrides** sends `{}`.
6. **Real APIs** — No 404 stub / in-session-only path. Mutations use feature endpoints; errors surface in alerts.
---
## Source files
| File | Intent |
|------|--------|
| `apps/web/src/lib/components/admin/PlanPermissionsPanel.svelte` | Permissions checklist UI |
| `apps/web/src/lib/admin-plan-permissions.ts` | Profiles, Legacy detect, API client |
| `apps/web/src/lib/plan-feature-catalog.ts` | Catalog / sections (shared) |
| `apps/web/src/routes/admin/billing/+page.svelte` | Hosts Permissions tab |
---
## API contract used
| Method | Path | Use |
|--------|------|-----|
| `GET` | `/api/admin/plans` | List packages |
| `GET\|PUT` | `/api/admin/plans/{id}/features` | Load / replace overrides |
| `POST` | `/api/admin/plans/{id}/features/enable-all` | Full ON |
| `POST` | `/api/admin/plans/{id}/features/disable-all` | Full OFF |
| `GET\|PUT` | `/api/admin/feature-gates` | Global masters |
| `PUT` | `/api/admin/feature-gates/sections/{section}` | Global section switch |
Auth: session + platform admin + CSRF.
---
## Legacy allowlist (summary)
Canonical ON set matches [`03-roles-matrix.md`](./03-roles-matrix.md) / `legacy_user` — including AI titles/descriptions, API keys, and `capability.ai_processing` / `api_access`. Shell skips `shell.support_notifications`; dashboard skips `dashboard.store_reconnect`.
OFF (among others): `processing.monitor`, all `stores.*`, all `marketing.*`, integrations, support tickets, campaign/email/brand/SEO/BYOK capabilities.
Machine source: `LEGACY_FEATURE_ALLOWLIST` in `admin-plan-permissions.ts`. Backend seed (agent 5) should align; UI Apply Legacy persists even if seed lags.
---
## Visual language
Uses admin shell tokens (`border-border`, `bg-card`, `text-muted-foreground`, `bg-primary/15` active chips) — same vocabulary as `AdminNav` / agent 7 shell — not hard-coded slate cards.
---
## Manual verify
1. Platform admin → `/admin/billing`**Permissions**.
2. Select **Free** → profile chip should show Free ✓ (or Apply Free). Toggle one key → **Custom overrides** + Differs badge.
3. Select **A1** (or create an A1-named deal) → **Apply Legacy (A1)**`processing.monitor` off; Products/Feeds on. Reload → persisted.
4. Section **Enable section** / **Disable section** on Catalog for the selected plan only.
5. Flip a **Global section** switch; confirm tenant capabilities respect it (agent 9/16).
6. **Clear overrides** → Defaults badge; resolved view matches plan-name defaults (Legacy-like names still resolve Legacy on the client when empty).
---
## Coordination
| Agent | Touchpoint |
|-------|------------|
| 3 / 4 | Roles matrix + contract — profiles follow Legacy allow-nav |
| 5 | Backend Legacy seed / `is_legacy` — UI already detects A1 + `is_legacy` |
| 7 | Admin shell tokens — Permissions uses the same |
| 9 | Plans table → Edit features deep-link (unchanged prop `selectedPlanId`) |
| 16 | User nav gating consumes capabilities after Legacy apply |
| 19 | Defaults alignment — keep `LEGACY_FEATURE_ALLOWLIST` in sync with seeds |
---
## ASSUMPTIONS
1. **ASSUMPTION:** Empty `plans.features` means “use defaults”; Apply Profile always writes an explicit full map.
2. **ASSUMPTION:** Until agent 5 seeds Legacy on the API, A1 empty overrides may resolve all-ON server-side; **Apply Legacy** is the reliable admin path.
3. Global “Off + features” updates **global feature masters**, not every plans overrides.
@@ -0,0 +1,70 @@
# 09 — Admin billing plans UI (agent 9/20)
**Owns:** Admin → Billing → **Plans** tab (table, filters, badges), create/edit plan dialogs, assign-plan UX, companies-tab assign shortcuts.
**Does not own:** Permissions tab / `PlanPermissionsPanel` (agent 8).
**Entry:** `/admin/billing` (gate: `requirePlatformAdmin`).
---
## Delivered
1. **Plans table** — search + visibility filters (All / Public / Legacy / Custom) with counts.
2. **Badges** — Public (ladder FreeEnterprise), Legacy (A1, Basic, Professional, Mini, Merkur…), Custom (other client deals). Public + `is_custom` also shows “Custom package flag” (Enterprise).
3. **Create / edit** — polished dialog: name, description, monthly/yearly credits, max products, term, custom flag; preview of visibility kind. Wired to `POST /api/admin/plans` (upsert with `id` for edit).
4. **Assign plan** — dialog with company + plan selects, optional trial + trial credits; row actions from Plans and Companies. `POST /api/admin/plans/assign`.
5. **Link to permissions** — Plans row → Permissions tab with that plan selected (`PlanPermissionsPanel`).
6. **Companies polish** — search, has/no active plan filter, Assign plan + Add credits actions; summary card for companies without a plan.
---
## Source files
| File | Intent |
|------|--------|
| `apps/web/src/routes/admin/billing/+page.svelte` | Host page: stats, tabs, dialogs, companies table |
| `apps/web/src/lib/components/admin/AdminPlansPanel.svelte` | Plans table + filters + row actions |
| `apps/web/src/lib/admin-billing-plans.ts` | Visibility classify/filter, upsert/assign API helpers |
---
## API contract (existing)
| Method | Path | Use |
|--------|------|-----|
| `GET` | `/api/admin/plans` | List all plans (ladder + deals) |
| `POST` | `/api/admin/plans` | Upsert plan (`id` optional); body: name, description, monthly_credits, yearly_credits, max_products, is_custom, term |
| `POST` | `/api/admin/plans/assign` | `{ company_id, plan_id, is_trial?, trial_credits? }` |
| `GET` | `/api/admin/companies` | Companies + `has_active_plan` |
| `POST` | `/api/admin/credits` | Adjust balance |
| `POST` | `/api/admin/billing/run-cycles` | Due renewals |
---
## Visibility rules (UI)
| Kind | Rule |
|------|------|
| **Public** | Name ∈ Free, Starter, Growth, Business, Enterprise (`isDefaultPublicPlanName`) |
| **Legacy** | Known migrated names: A1 (+ `a1 …` prefix), Basic, Professional, Mini, Merkur*, Meur, or name contains `legacy` |
| **Custom** | Everything else (client deals / `is_custom` non-ladder) |
ASSUMPTION: Backend may later expose an explicit `is_legacy` / profile field; until then UI classifies by name (aligned with `IsPublicProductPlan` + migrated deal set).
---
## Manual verify
1. Platform admin → `/admin/billing` → Plans.
2. Filter Public / Legacy / Custom; search by name.
3. Create a custom plan; edit credits/description; confirm row badges.
4. Assign plan to a company (from Plans row or Companies → Assign plan); optional trial.
5. Click Permissions on a plan → Permissions tab opens with that plan selected.
6. Companies filter “No active plan”; Add credits still works.
---
## Coordination
- Agent 8 owns Permissions panel styling/profiles — do not rewrite `PlanPermissionsPanel`.
- Agent 5/19 may add explicit legacy profile on plans; when present, prefer API flag over name heuristics (additive).
- Agent 10 may deepen org/company management; this page only adds assign/credits filters needed for billing ops.
@@ -0,0 +1,77 @@
# 10 — Admin users & organizations UI (agent 10/20)
**Owns:** `/admin/users` — users directory, company list with plan view/assign, platform staff role assignment.
**Does not own:** Billing plans table (agent 9), Permissions panel (agent 8), staff middleware/model (agent 6), admin shell chrome (agent 7 — nav label only).
**Gate:** `requirePlatformAdmin` (full admin / developer). `support_staff` is excluded.
---
## PROBLEM
`/admin/users` was a thin client-filtered user list (no companies, no plan view, no staff role edits, no server pagination). Operators needed one place to manage orgs and staff after cutover.
---
## Delivered
1. **Users tab** — server-paginated (`limit`/`offset`, page size 25), search (`q`), staff-only filter, set-password invite + local dev tools preserved.
2. **Staff roles** — dialog → `PATCH /api/admin/users/{id}/staff-role` with `admin` | `developer` | `support_staff` | clear. Uses agent 6 API; self-change blocked server-side.
3. **Companies tab** — paginated list with active plan name, legacy/public/custom badge, credits remaining/total, “without plan” filter.
4. **Assign plan** — dialog lists all admin plans (ladder + legacy + custom) → `POST /api/admin/plans/assign`.
5. **API enrichments**`GET /api/admin/users` returns `total`, `staff_role`, `resolved_role`, search/filters; `GET /api/admin/companies` returns `total`, `plan_id` / `plan_name` / `plan_is_*`, search.
---
## Source files
| File | Intent |
|------|--------|
| `apps/web/src/routes/admin/users/+page.svelte` | Orgs UI (users + companies tabs) |
| `apps/web/src/lib/admin-orgs.ts` | Client helpers + types |
| `apps/api/internal/httpapi/admin_orgs_handlers.go` | Paginated list handlers |
| `apps/api/internal/httpapi/admin_staff_handlers.go` | Staff role PATCH (agent 6) |
| `apps/web/src/lib/components/AdminNav.svelte` | Nav label “Users & orgs” |
---
## API contract
| Method | Path | Use |
|--------|------|-----|
| `GET` | `/api/admin/users?limit&offset&q&staff_only&active_only&inactive_only` | User directory |
| `PATCH` | `/api/admin/users/{id}/staff-role` | Body `{ "staff_role": "admin"\|"developer"\|"support_staff"\|null }` |
| `GET` | `/api/admin/companies?limit&offset&q&without_active_plan` | Companies + plan summary |
| `GET` | `/api/admin/plans` | Plan picker |
| `POST` | `/api/admin/plans/assign` | `{ company_id, plan_id }` |
| `POST` | `/api/admin/emails/set-password` | Existing invite tooling |
All under `RequireSession` + `RequirePlatformAdmin` (full admin only).
---
## Security / performance
- AuthZ enforced server-side; UI gate is UX only.
- Cannot change own staff role (agent 6 handler).
- Lists use SQL `LIMIT`/`OFFSET` + `COUNT(*)` — no full-table client filter.
- Company plan join is a single query (no N+1).
**ASSUMPTION:** `users.staff_role` migration (`029_staff_roles.sql`) applied. UI shows an info banner if PATCH returns 404/501.
---
## Manual verify
1. Platform admin → `/admin/users`.
2. Search users; toggle Staff only; page Next/Previous.
3. Assign staff role (admin / developer / support_staff); confirm badge.
4. Companies tab → see plan badge; filter Without plan; Assign plan (legacy + public).
5. As `support_staff`, confirm `/admin/users` is forbidden (full-admin-only nav).
---
## Coordination
- Agent 6: staff model + `PATCH …/staff-role` + `RequirePlatformAdmin` excluding support_staff.
- Agent 9: billing assign UX remains; orgs page is the directory-focused assign path.
- Agent 14: support_staff queue access after role grant from this UI.
@@ -0,0 +1,521 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Descrybe v2 Support Desk API Contract",
"version": "11.0.0-design",
"status": "design-only",
"related": "docs/admin-roles-support/11-support-design.md",
"conventions": {
"pagination": {
"query": ["limit", "offset"],
"defaults": { "limit": 50, "max_limit": 200 },
"response": { "total": "number", "limit": "number", "offset": "number" }
},
"timestamps": "RFC3339 UTC",
"ids": "UUID string",
"errors": {
"shape": { "error": "string", "code": "string?" },
"auth": {
"401": "unauthorized",
"403": "forbidden (capability or scope)",
"404": "not found (also used for agent cross-assignee hide)"
}
}
},
"enums": {
"ticket_status": ["open", "pending", "resolved", "closed"],
"ticket_category": ["billing", "bug", "account", "other"],
"ticket_priority": ["low", "normal", "high"],
"author_role": ["user", "agent", "system"],
"notification_kind": [
"ticket_created",
"agent_reply",
"status_changed",
"user_reply",
"ticket_claimed",
"csat_requested"
],
"staff_list_scope": ["inbox", "mine", "unassigned", "all"],
"email_stub_event": [
"ticket_created",
"agent_reply",
"status_resolved",
"ticket_claimed",
"user_reply"
]
},
"types": {
"SupportMessage": {
"type": "object",
"required": ["id", "ticket_id", "author_role", "body", "is_internal_note", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"ticket_id": { "type": "string", "format": "uuid" },
"author_user_id": { "type": ["string", "null"], "format": "uuid" },
"author_role": { "$ref": "#/enums/author_role" },
"body": { "type": "string", "maxLength": 10000 },
"is_internal_note": { "type": "boolean" },
"created_at": { "type": "string", "format": "date-time" }
}
},
"SupportCsat": {
"type": "object",
"required": ["score", "created_at"],
"properties": {
"score": { "type": "integer", "minimum": 1, "maximum": 5 },
"comment": { "type": "string", "maxLength": 2000 },
"created_at": { "type": "string", "format": "date-time" }
}
},
"SupportTicket": {
"type": "object",
"required": [
"id",
"company_id",
"created_by_user_id",
"subject",
"category",
"status",
"priority",
"created_at",
"updated_at"
],
"properties": {
"id": { "type": "string", "format": "uuid" },
"company_id": { "type": "string", "format": "uuid" },
"created_by_user_id": { "type": "string", "format": "uuid" },
"subject": { "type": "string", "maxLength": 200 },
"category": { "$ref": "#/enums/ticket_category" },
"status": { "$ref": "#/enums/ticket_status" },
"priority": { "$ref": "#/enums/ticket_priority" },
"assignee_admin_user_id": { "type": ["string", "null"], "format": "uuid" },
"resolved_by_user_id": { "type": ["string", "null"], "format": "uuid" },
"last_message_at": { "type": ["string", "null"], "format": "date-time" },
"last_customer_message_at": { "type": ["string", "null"], "format": "date-time" },
"last_agent_message_at": { "type": ["string", "null"], "format": "date-time" },
"resolved_at": { "type": ["string", "null"], "format": "date-time" },
"closed_at": { "type": ["string", "null"], "format": "date-time" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" },
"messages": {
"type": "array",
"items": { "$ref": "#/types/SupportMessage" },
"description": "Present on GET detail only. Customer GETs omit is_internal_note=true."
},
"company_name": { "type": "string", "description": "Staff detail/list only" },
"created_by_email": { "type": "string", "description": "Staff detail/list only" },
"csat": {
"oneOf": [
{ "$ref": "#/types/SupportCsat" },
{ "type": "null" }
],
"description": "Present when rated; null/omitted when eligible but unrated"
},
"csat_eligible": {
"type": "boolean",
"description": "Customer detail: true when status resolved|closed and no rating yet"
}
}
},
"SupportNotification": {
"type": "object",
"required": ["id", "user_id", "ticket_id", "kind", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"user_id": { "type": "string", "format": "uuid" },
"ticket_id": { "type": "string", "format": "uuid" },
"message_id": { "type": ["string", "null"], "format": "uuid" },
"kind": { "$ref": "#/enums/notification_kind" },
"read_at": { "type": ["string", "null"], "format": "date-time" },
"created_at": { "type": "string", "format": "date-time" },
"subject": { "type": "string" }
}
},
"SupportAgent": {
"type": "object",
"required": ["id", "email", "is_support_agent", "is_platform_admin"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string" },
"name": { "type": "string" },
"is_support_agent": { "type": "boolean" },
"is_platform_admin": { "type": "boolean" },
"is_active": { "type": "boolean" }
}
}
},
"auth": {
"customer": ["RequireSession", "RequireCompany"],
"support_desk": ["RequireSession", "RequireSupportDesk"],
"platform_admin": ["RequireSession", "RequirePlatformAdmin"],
"public_csat": ["none", "rate_limited"],
"RequireSupportDesk": "DB check: users.is_platform_admin OR users.is_support_agent",
"visibility_policy": "queue_plus_claim",
"visibility_rules": {
"customer": "created_by_user_id = me AND company_id = selected company; never internal notes",
"support_agent_list": "scope inbox|mine|unassigned only; never other assignees",
"support_agent_get": "assignee=me OR (unassigned AND status in open|pending); else 404",
"platform_admin": "unrestricted desk access; scope=all default"
}
},
"endpoints": [
{
"id": "customer.list_tickets",
"method": "GET",
"path": "/api/support/tickets",
"auth": "customer",
"query": {
"status": { "enum_ref": "ticket_status", "optional": true },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"tickets": { "type": "array", "items": "SupportTicket" },
"total": "number",
"limit": "number",
"offset": "number"
}
},
"notes": "Existing. No messages embedded."
},
{
"id": "customer.create_ticket",
"method": "POST",
"path": "/api/support/tickets",
"auth": "customer",
"body": {
"subject": { "type": "string", "required": true, "maxLength": 200 },
"category": { "enum_ref": "ticket_category", "default": "other" },
"priority": { "enum_ref": "ticket_priority", "default": "normal" },
"body": { "type": "string", "required": true, "maxLength": 10000 }
},
"response": { "201": "SupportTicket with first message" },
"side_effects": ["status=open", "email_stub:ticket_created optional"]
},
{
"id": "customer.get_ticket",
"method": "GET",
"path": "/api/support/tickets/{id}",
"auth": "customer",
"response": {
"200": "SupportTicket + public messages + csat/csat_eligible",
"404": "not found"
}
},
{
"id": "customer.reply",
"method": "POST",
"path": "/api/support/tickets/{id}/messages",
"auth": "customer",
"body": {
"body": { "type": "string", "required": true, "maxLength": 10000 }
},
"response": {
"200": "SupportTicket",
"400": "closed or validation",
"404": "not found"
},
"side_effects": [
"reopens to open; clears resolved_at",
"notifies assignee if set",
"email_stub:user_reply optional"
]
},
{
"id": "customer.submit_csat",
"method": "POST",
"path": "/api/support/tickets/{id}/csat",
"auth": "customer",
"status": "new",
"body": {
"score": { "type": "integer", "required": true, "minimum": 1, "maximum": 5 },
"comment": { "type": "string", "optional": true, "maxLength": 2000 }
},
"response": {
"201": "SupportCsat",
"400": "not eligible status",
"404": "not found",
"409": "already rated"
},
"rules": [
"only ticket owner",
"status must be resolved or closed",
"one rating per ticket"
]
},
{
"id": "customer.list_notifications",
"method": "GET",
"path": "/api/support/notifications",
"auth": "customer",
"query": {
"unread": { "type": "boolean", "optional": true },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"notifications": { "type": "array", "items": "SupportNotification" },
"total": "number",
"unread": "number",
"limit": "number",
"offset": "number"
}
}
},
{
"id": "customer.mark_notification_read",
"method": "POST",
"path": "/api/support/notifications/{id}/read",
"auth": "customer",
"response": { "200": { "status": "ok" }, "404": "not found" }
},
{
"id": "customer.mark_all_notifications_read",
"method": "POST",
"path": "/api/support/notifications/read-all",
"auth": "customer",
"response": { "200": { "status": "ok", "updated": "number" } }
},
{
"id": "staff.list_tickets",
"method": "GET",
"path": "/api/admin/support/tickets",
"auth": "support_desk",
"query": {
"scope": {
"enum_ref": "staff_list_scope",
"default_agent": "inbox",
"default_platform_admin": "all"
},
"status": { "enum_ref": "ticket_status", "optional": true },
"company_id": { "type": "uuid", "optional": true },
"assignee_id": {
"type": "uuid",
"optional": true,
"platform_admin_only": true
},
"q": { "type": "string", "optional": true, "minLength_recommended": 2 },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"tickets": { "type": "array", "items": "SupportTicket" },
"total": "number",
"limit": "number",
"offset": "number"
},
"403": "agent requested scope=all or admin-only filter"
},
"indexes": [
"support_tickets_assignee_queue_idx",
"support_tickets_unassigned_queue_idx",
"support_tickets_admin_queue_idx"
]
},
{
"id": "staff.get_ticket",
"method": "GET",
"path": "/api/admin/support/tickets/{id}",
"auth": "support_desk",
"response": {
"200": "SupportTicket including internal notes + csat",
"404": "missing or not visible to agent"
}
},
{
"id": "staff.reply",
"method": "POST",
"path": "/api/admin/support/tickets/{id}/messages",
"auth": "support_desk",
"body": {
"body": { "type": "string", "required": true, "maxLength": 10000 },
"is_internal_note": { "type": "boolean", "default": false },
"status": { "enum_ref": "ticket_status", "optional": true }
},
"response": {
"200": "SupportTicket",
"404": "not visible",
"409": "assigned to another agent"
},
"side_effects": [
"public reply on unassigned auto-claims actor if still NULL",
"default status open|resolved -> pending on public reply",
"customer notification + email_stub:agent_reply when public",
"sets resolved_by_user_id when transitioning to resolved"
]
},
{
"id": "staff.update_ticket",
"method": "PATCH",
"path": "/api/admin/support/tickets/{id}",
"auth": "support_desk",
"body": {
"status": { "enum_ref": "ticket_status", "optional": true },
"priority": { "enum_ref": "ticket_priority", "optional": true },
"assignee_admin_user_id": {
"type": "uuid",
"optional": true,
"platform_admin_only": true
},
"clear_assignee": {
"type": "boolean",
"optional": true,
"platform_admin_only": true
}
},
"response": {
"200": "SupportTicket",
"403": "agent attempted assign fields or unowned ticket",
"404": "not visible"
},
"side_effects": [
"resolved -> csat invite notification + email_stub:status_resolved",
"status_changed notification to customer"
]
},
{
"id": "staff.claim",
"method": "POST",
"path": "/api/admin/support/tickets/{id}/claim",
"auth": "support_desk",
"status": "new",
"body": {},
"response": {
"200": "SupportTicket",
"404": "not found / not claimable visibility",
"409": { "code": "already_claimed|not_claimable" }
},
"sql": "UPDATE … SET assignee=$actor WHERE id=$id AND assignee IS NULL AND status IN ('open','pending') RETURNING id",
"side_effects": ["email_stub:ticket_claimed optional", "notification ticket_claimed"]
},
{
"id": "staff.release",
"method": "POST",
"path": "/api/admin/support/tickets/{id}/release",
"auth": "support_desk",
"status": "new",
"body": {},
"response": {
"200": "SupportTicket",
"403": "not assignee (unless platform admin)",
"404": "not found"
},
"rules": ["agent may release only when assignee=me", "platform admin may force-release via PATCH clear_assignee"]
},
{
"id": "admin.list_agents",
"method": "GET",
"path": "/api/admin/support/agents",
"auth": "platform_admin",
"status": "new",
"query": {
"include_platform_admins": { "type": "boolean", "default": true },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"agents": { "type": "array", "items": "SupportAgent" },
"total": "number",
"limit": "number",
"offset": "number"
}
}
},
{
"id": "admin.set_agent",
"method": "PUT",
"path": "/api/admin/support/agents/{userId}",
"auth": "platform_admin",
"status": "new",
"body": {
"is_support_agent": { "type": "boolean", "required": true }
},
"response": {
"200": "SupportAgent",
"404": "user not found"
},
"notes": "Does not grant is_platform_admin. Revoking does not clear open ticket assignees (admin should reassign)."
},
{
"id": "public.submit_csat_token",
"method": "POST",
"path": "/api/public/support/csat",
"auth": "public_csat",
"status": "new_optional",
"body": {
"token": { "type": "string", "required": true },
"score": { "type": "integer", "required": true, "minimum": 1, "maximum": 5 },
"comment": { "type": "string", "optional": true, "maxLength": 2000 }
},
"response": {
"201": "SupportCsat",
"400": "invalid token or score",
"409": "already rated",
"429": "rate limited"
},
"security": [
"compare SHA-256(token) to support_tickets.csat_token_hash",
"clear hash after success",
"no ticket id in response beyond generic ok/csat"
]
}
],
"session_me_extension": {
"path": "/api/auth/me",
"additive_fields": {
"is_support_agent": {
"type": "boolean",
"description": "Expose so web nav can show /admin/support for agents without platform admin"
}
}
},
"email_stubs": {
"transport": "apps/api/internal/mail.Mailer",
"gate": "SUPPORT_EMAIL_ENABLED=true AND Mailer.Enabled()",
"pii": "never log To or Body",
"events": {
"ticket_created": { "to": "configured support inbox or all is_support_agent emails" },
"agent_reply": { "to": "ticket owner" },
"status_resolved": { "to": "ticket owner", "includes": "CSAT deep link" },
"ticket_claimed": { "to": "claiming agent" },
"user_reply": { "to": "assignee if set" }
},
"failure_mode": "log and continue; never fail the HTTP mutating request"
},
"indexes_required": [
{
"name": "support_tickets_assignee_queue_idx",
"sql": "CREATE INDEX support_tickets_assignee_queue_idx ON support_tickets (assignee_admin_user_id, status, last_message_at DESC NULLS LAST)"
},
{
"name": "support_tickets_unassigned_queue_idx",
"sql": "CREATE INDEX support_tickets_unassigned_queue_idx ON support_tickets (status, last_message_at DESC NULLS LAST) WHERE assignee_admin_user_id IS NULL"
},
{
"name": "support_csat_ratings_created_idx",
"sql": "CREATE INDEX support_csat_ratings_created_idx ON support_csat_ratings (created_at DESC)"
}
],
"breaking_changes": [
{
"audience": "support_agent_future",
"change": "Staff without platform admin cannot use scope=all or read other agents' tickets",
"migration": "N/A today — all desk users are platform admins; document when agents ship"
}
],
"test_matrix": [
{ "actor": "customer", "action": "get other user ticket", "expect": 404 },
{ "actor": "customer", "action": "see internal note", "expect": "filtered out" },
{ "actor": "customer", "action": "csat twice", "expect": 409 },
{ "actor": "agent_a", "action": "get agent_b assigned ticket", "expect": 404 },
{ "actor": "agent_a", "action": "claim unassigned", "expect": 200 },
{ "actor": "agent_b", "action": "claim same after a", "expect": 409 },
{ "actor": "agent_a", "action": "scope=all", "expect": 403 },
{ "actor": "platform_admin", "action": "scope=all + assign", "expect": 200 },
{ "actor": "platform_admin", "action": "PUT agents grant", "expect": 200 },
{ "actor": "support_agent", "action": "PUT agents", "expect": 403 }
]
}
@@ -0,0 +1,464 @@
# 11 — Support desk design (complete)
**Status:** Design only — no implementation in this document.
**Agent:** 11/20 (`admin-roles-support`)
**Date:** 2026-08-05
**Repo:** `F:/laragon/www/_MY/descrybe-v2`
**API contract:** [11-support-api-contract.json](./11-support-api-contract.json)
---
## 0. North star
Extend the **existing** Support Center (`025_support_center.sql` + `internal/support`) into a least-privilege staff desk:
- Customers open/reply on **their** tickets.
- Platform admins **grant** support-agent capability and may **assign** tickets.
- Support agents work a **shared unassigned queue** + **claim** model; they only see assigned or claimable tickets.
- After **resolve**, customers may leave a **CSAT** rating (once).
- Outbound email is **optional stubs** on top of `internal/mail` (noop when SMTP disabled).
```
Customer (/support)
│ create / reply / rate (CSAT)
support_tickets ──◄── support_messages (public + internal notes)
├── unassigned queue ── claim ──► assignee inbox
└── platform admin: assign / reassign / revoke staff
```
**Principle:** Prefer extending `ListAdmin` / `GetAdmin` / `UpdateAdmin` / `ReplyAsAgent` with **actor-scoped filters** over a parallel ticket system.
---
## 1. Inventory — what exists today
### 1.1 Schema (`apps/api/sql/schema/025_support_center.sql`)
| Table | Purpose |
|-------|---------|
| `support_tickets` | Subject, category, status, priority, `assignee_admin_user_id`, message timestamps, resolved/closed |
| `support_messages` | Thread; `author_role``user\|agent\|system`; `is_internal_note` |
| `support_notifications` | In-app bell; kinds: `ticket_created`, `agent_reply`, `status_changed`, `user_reply` |
**Statuses:** `open``pending``resolved``closed` (CHECK).
**Categories:** `billing`, `bug`, `account`, `other`.
**Priorities:** `low`, `normal`, `high`.
**Indexes today:**
- `support_tickets_admin_queue_idx (status, last_message_at DESC)`
- `support_tickets_company_user_idx (company_id, created_by_user_id, updated_at DESC)`
- `support_tickets_company_status_idx (company_id, status)`
- `support_messages_ticket_idx (ticket_id, created_at)`
- notification user unread / created indexes
**Missing for this design:** assignee queue indexes, CSAT table, support-agent capability, claim audit, email event stubs.
### 1.2 API (Go)
| Surface | Mount | Auth today |
|---------|-------|------------|
| User tickets | `/api/support/tickets` (+ messages) | `RequireSession` + `RequireCompany` |
| Notifications | `/api/support/notifications*` | same |
| Admin desk | `/api/admin/support/tickets*` | `RequireSession` + **`RequirePlatformAdmin`** |
Handlers: `apps/api/internal/httpapi/support_handlers.go`.
Domain: `apps/api/internal/support/{tickets,types,validate,notifications,ai_auto_reply}.go`.
Notable behavior already shipped:
- `GetForUser` / `ListForUser` — owner-only; **strips internal notes**.
- `GetAdmin` / `ListAdmin`**all companies**, includes internal notes; no assignee filter.
- `ReplyAsAgent` — auto-sets assignee via `COALESCE(assignee, actor)` on public reply; defaults status `open|resolved``pending`.
- `UpdateAdmin` — patch status / priority / assignee / `clear_assignee`.
- `TryAutoReplyLLM`**safe stub** (`ErrAIAutoReplyDisabled`); keep unused for human desk.
Pagination: `ParseLimitOffset` — default **50**, max **200**.
### 1.3 Web UI
| Route | Client |
|-------|--------|
| `/support`, `/support/new`, `/support/[ticketId]` | `$lib/support/api.ts``/api/support/tickets` |
| `/admin/support`, `/admin/support/[id]` | `$lib/support/admin-api.ts``/api/admin/support/tickets` |
Plan gates (catalog): `support.center`, `support.ticket_create`, `support.ticket_thread` — fail-open for Free (`docs/plan-permissions`).
### 1.4 Gaps vs goal
| Goal | Today |
|------|-------|
| Support staff ≠ full platform admin | Only `users.is_platform_admin`; admin support routes are platform-admin-only |
| Staff inbox (assigned + queue) | Flat global list for every platform admin |
| Claim / least privilege | Assignee column exists; no claim race, no visibility rules |
| CSAT after resolve | None |
| Email on events | In-app notifications only; platform SMTP (`internal/mail`) unused by support |
| Admin assigns staff | Can set `assignee_admin_user_id` on a ticket, but cannot grant a support-only role |
---
## 2. Roles & capability model
### 2.1 Capabilities (additive flags)
Keep `users.is_platform_admin`. Add:
```sql
ALTER TABLE users
ADD COLUMN is_support_agent BOOLEAN NOT NULL DEFAULT false;
```
| Actor | Capability |
|-------|------------|
| **Customer** (company member) | Own tickets only (`created_by_user_id` + `company_id`) |
| **Support agent** (`is_support_agent`) | Queue + claim + own inbox; internal notes; reply/status on allowed tickets |
| **Platform admin** (`is_platform_admin`) | Everything agents can do **plus** global list, force-assign/reassign, grant/revoke `is_support_agent`, CSAT aggregates |
**ASSUMPTION:** `is_platform_admin` implies support access (no need to also set `is_support_agent`). Middleware: `is_platform_admin OR is_support_agent`.
**ASSUMPTION:** Support agents are Descrybe platform users (not tenant company roles). They may still belong to a company for login, but support desk is platform-scoped.
### 2.2 Auth middleware
| Middleware | Gate |
|------------|------|
| `RequirePlatformAdmin` | unchanged — billing, plans, impersonation, staff grants |
| **`RequireSupportDesk`** (new) | session + (`is_platform_admin` OR `is_support_agent`) from DB |
Mount **desk** routes under `/api/admin/support/*` with `RequireSupportDesk` (not full admin).
Mount **staff management** under `/api/admin/support/agents*` with `RequirePlatformAdmin` only.
---
## 3. Visibility policy — **queue + claim** (chosen)
Least-privilege default: agents never list or open tickets assigned to someone else.
### 3.1 List scopes
Query param `scope` on staff list:
| `scope` | Who | Filter |
|---------|-----|--------|
| `inbox` (default for agents) | agent | `assignee_admin_user_id = me OR assignee IS NULL` and `status IN ('open','pending')` |
| `mine` | agent | `assignee = me` |
| `unassigned` | agent | `assignee IS NULL` and status open/pending |
| `all` | **platform admin only** | optional filters: status, company_id, assignee_id, q |
Agents requesting `scope=all`**403**.
### 3.2 Get / reply / update
| Action | Agent allowed when |
|--------|--------------------|
| GET ticket | assignee = me **OR** (unassigned AND status ∈ open/pending) |
| Public reply / internal note | assignee = me **OR** claim-first on unassigned |
| PATCH status/priority | assignee = me |
| Force assign / clear / reassign | **platform admin only** |
| Claim | unassigned + open/pending; atomic |
| Release | assignee = me → set NULL (optional system note) |
**Get on another agent's ticket → 404** (not 403) to avoid ticket-id probing. Platform admin gets 200 always.
### 3.3 Claim (atomic)
```sql
UPDATE support_tickets
SET assignee_admin_user_id = $actor,
updated_at = now()
WHERE id = $id
AND assignee_admin_user_id IS NULL
AND status IN ('open', 'pending')
RETURNING id;
```
- 0 rows → `409 conflict` (`already_claimed` or `not_claimable`).
- On success: optional internal system message + notification to claimant; email stub `ticket_claimed`.
**First public agent reply** may continue to auto-claim via existing `COALESCE(assignee, actor)` **only if** still unassigned; if assigned to another agent → **403/404**.
### 3.4 Alternatives considered (rejected for MVP)
| Model | Why not |
|-------|---------|
| Shared team inbox (all agents see all) | Violates least privilege; leaks tenant PII across staff |
| Assigned-only (no queue) | Admin must hand-assign every ticket; high latency |
| Round-robin auto-assign | Needs fair staffing metrics; defer |
---
## 4. Status workflow
Keep existing enum; tighten **transitions** in service layer (today: mostly free-form normalize).
```
┌──────────────┐
│ open │◄──── customer reply (from pending/resolved)
└──────┬───────┘
│ agent public reply (default)
┌──────────────┐
│ pending │ waiting on customer
└──────┬───────┘
agent resolve │
┌──────────────┐
│ resolved │ CSAT window opens
└──────┬───────┘
close / idle │
┌──────────────┐
│ closed │ no customer replies
└──────────────┘
```
| From → To | Who |
|-----------|-----|
| * → `pending` | agent (public reply default) |
| * → `resolved` | agent assignee or platform admin |
| `resolved``closed` | agent assignee, platform admin, or optional job after N days |
| `resolved\|pending``open` | customer reply (existing) |
| `closed` → * | platform admin only (reopen); customer blocked (`ErrTicketClosed`) |
Set `resolved_at` / `closed_at` as today. On resolve: enqueue CSAT invite (in-app + email stub).
Internal notes **must not** change status unless `status` is explicitly sent (existing `ReplyAsAgent` behavior).
---
## 5. CSAT / rating
### 5.1 Schema
```sql
CREATE TABLE support_csat_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL UNIQUE REFERENCES support_tickets(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5),
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX support_csat_ratings_created_idx
ON support_csat_ratings (created_at DESC);
```
Optional opaque token for email deep-link (hash-at-rest):
```sql
-- on support_tickets
csat_token_hash BYTEA, -- NULL until resolved
csat_invite_sent_at TIMESTAMPTZ
```
### 5.2 Rules
- Only `created_by_user_id` may rate.
- Ticket status must be `resolved` or `closed`.
- One rating per ticket (`UNIQUE ticket_id`); duplicate → `409`.
- Comment max 2000 runes; strip NUL; optional.
- Rating is **customer-visible** on own ticket; staff see score on GET (admin/agent).
- No edits after submit (MVP).
### 5.3 UX
- Banner on `/support/[ticketId]` when resolved and unrated.
- Admin analytics later: avg score by agent (join assignee at resolve time — optional `resolved_by_user_id` column if needed for fairness after reassignment).
**ASSUMPTION:** Store `resolved_by_user_id` (nullable) set when transitioning to `resolved` so CSAT attributes to the resolver, not a later reassignment.
---
## 6. Internal notes
Already modeled: `support_messages.is_internal_note` + `author_role='agent'`.
Preserve:
- Never return internal notes from `GetForUser` / user list serializers.
- Staff GET includes notes.
- Notes do not trigger `agent_reply` customer notifications (existing).
- Add notification kind `internal_note` for **other staff on same ticket**? **Defer** — with claim model only assignee (+ admins) see the ticket.
---
## 7. Email — optional stubs
Reuse platform `internal/mail.Mailer` (SMTP or noop). Do **not** use tenant marketing email (`internal/email`).
| Event | Recipient | Stub behavior |
|-------|-----------|---------------|
| `ticket_created` | support agents (or shared inbox address) | noop logs subject if SMTP off |
| `agent_reply` | ticket owner | link to `/support/{id}` |
| `status_resolved` | ticket owner | CSAT CTA + tokenized link |
| `ticket_claimed` | claiming agent | optional |
| `user_reply` | assignee | if assigned |
Implementation sketch:
```go
// internal/support/notify_email.go
func (s *Service) enqueueSupportMail(ctx, kind, ticketID) {
if s.Mail == nil || !s.Mail.Enabled() { return }
// build Message; never log To/Body PII
}
```
Feature flag: `SUPPORT_EMAIL_ENABLED` (default false) **or** rely solely on `Mailer.Enabled()`. Prefer explicit flag so ops can enable SMTP for invites without support mail spam.
River/async: optional later; MVP sync-best-effort after commit (errors logged, not failed request).
---
## 8. Schema deltas (implementation later)
```sql
-- +goose Up (sketch — new migration, e.g. 0xx_support_desk.sql)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_support_agent BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE support_tickets
ADD COLUMN IF NOT EXISTS resolved_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS csat_token_hash BYTEA,
ADD COLUMN IF NOT EXISTS csat_invite_sent_at TIMESTAMPTZ;
CREATE INDEX support_tickets_assignee_queue_idx
ON support_tickets (assignee_admin_user_id, status, last_message_at DESC NULLS LAST);
CREATE INDEX support_tickets_unassigned_queue_idx
ON support_tickets (status, last_message_at DESC NULLS LAST)
WHERE assignee_admin_user_id IS NULL;
-- support_csat_ratings as in §5.1
ALTER TABLE support_notifications
DROP CONSTRAINT IF EXISTS support_notifications_kind_check;
-- recreate CHECK to include: ticket_claimed, csat_requested (if used)
```
---
## 9. API surface (summary)
Full OpenAPI-ish shapes: [11-support-api-contract.json](./11-support-api-contract.json).
### 9.1 Customer (existing + CSAT)
| Method | Path | Change |
|--------|------|--------|
| GET/POST | `/api/support/tickets` | unchanged contract |
| GET | `/api/support/tickets/{id}` | add optional `csat` object |
| POST | `/api/support/tickets/{id}/messages` | unchanged |
| **POST** | **`/api/support/tickets/{id}/csat`** | **new**`{ score, comment? }` |
| GET/POST | `/api/support/notifications*` | unchanged |
### 9.2 Staff desk (`RequireSupportDesk`)
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/admin/support/tickets` | add `scope`, `assignee_id`; enforce §3 |
| GET | `/api/admin/support/tickets/{id}` | visibility §3.2 |
| POST | `/api/admin/support/tickets/{id}/messages` | visibility + auto-claim rules |
| PATCH | `/api/admin/support/tickets/{id}` | agent: status/priority only if assigned; assign fields admin-only |
| **POST** | **`/api/admin/support/tickets/{id}/claim`** | atomic claim |
| **POST** | **`/api/admin/support/tickets/{id}/release`** | assignee self-clear |
### 9.3 Staff directory (`RequirePlatformAdmin`)
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/admin/support/agents` | list users with `is_support_agent` (+ admins optional) |
| PUT | `/api/admin/support/agents/{userId}` | `{ is_support_agent: bool }` |
### 9.4 Public CSAT token (optional)
| Method | Path | Notes |
|--------|------|-------|
| POST | `/api/public/support/csat` | `{ token, score, comment? }` — rate-limited; no session |
---
## 10. Web UI (later)
| Route | Audience |
|-------|----------|
| `/support*` | customer — add CSAT panel |
| `/admin/support` | rename mentally to “desk”; agents see Inbox tabs: Unassigned / Mine |
| `/admin/support/[id]` | hide assign controls for agents; show Claim if unassigned |
| `/admin/support/agents` | platform admin — grant/revoke agents |
Nav: show Support desk link if `is_platform_admin || is_support_agent` (session `/api/auth/me` must expose `is_support_agent`).
---
## 11. Security notes
1. **AuthZ server-side** — never trust client `scope` / assignee; re-check on every GET/PATCH/reply.
2. **404 vs 403** on cross-assignee reads for agents (anti-enumeration).
3. **Internal notes** — filter at SQL (`is_internal_note = false`) for customer paths; double-filter in TS serializers.
4. **CSAT token** — store only SHA-256 hash; single-use invalidate on submit; rate-limit public endpoint.
5. **Staff grant** — platform admin only; audit log recommended (who granted whom).
6. **PII** — list search already ILIKE email/company; keep limit/offset caps; no unbounded export in MVP.
7. **Claim races** — single `UPDATE … WHERE assignee IS NULL` in a transaction; no read-modify-write.
8. **Closed tickets** — customers cannot reply; agents notes-only or admin reopen.
9. **AI** — keep `TryAutoReplyLLM` disabled; human desk only.
10. **Email** — no logging of To/Body; header-break validation via existing mailer.
---
## 12. Performance notes
| Path | Guidance |
|------|----------|
| Customer list | existing `(company_id, created_by_user_id, updated_at)` |
| Agent inbox | **partial** `WHERE assignee IS NULL` + `(assignee, status, last_message_at)` |
| Admin global | existing status queue idx; avoid `ILIKE %q%` without length floor (min 23 chars) |
| Messages | always by `ticket_id` + order; do not load all messages for list endpoints |
| Notifications | existing unread partial-friendly indexes |
| CSAT aggregates | index `created_at`; optional later `(resolved_by_user_id)` |
| Pagination | keep default 50 / max 200; return `total` via count query (already) |
| N+1 | list endpoints return ticket rows only — no nested messages (already) |
Claim and resolve paths use `FOR UPDATE` on the ticket row (already in Reply/Update) — keep that for status transitions.
---
## 13. Implementation phasing (for later agents)
1. Migration: `is_support_agent`, assignee indexes, CSAT table, `resolved_by_user_id`.
2. Middleware `RequireSupportDesk`; expose flag on `/me`.
3. Service visibility + claim/release; tighten admin list filters.
4. CSAT POST + ticket payload field.
5. Staff agents admin API + UI.
6. Email stubs behind flag.
7. Tests: authz matrix (customer / agent A / agent B / platform admin), claim conflict, CSAT once, internal notes leak check.
**Out of scope for this design doc:** SLA timers, macros, attachments, full-text search, multi-assignee, customer-visible agent names beyond email, AI auto-reply enablement.
---
## 14. Contracts preserved
- Customer ticket JSON fields remain stable; additive `csat` only.
- Existing admin paths stay; new claim/release/agents/csat endpoints are additive.
- Status/category/priority enums unchanged.
- `TryAutoReplyLLM` stub remains refuse-by-default.
**BREAKING (intentional, staff-only):** agents lose unrestricted `ListAdmin`/`GetAdmin` visibility — platform admins unaffected. Document in release notes when implementing.
---
## 15. Coordination with sibling agents
| Doc | Alignment |
|-----|-----------|
| [02-current-inventory.md](./02-current-inventory.md) | Confirms gaps this design fills (CSAT, staff-only gate, claim inbox). |
| [03-roles-matrix.md](./03-roles-matrix.md) | Staff role name **`support_staff`**. This designs `users.is_support_agent` is the **MVP column** that implements that role until a unified `staff_role` enum lands. Treat names as aliases. |
| [18-performance.md](./18-performance.md) | Prefers assignee/activity indexes in `027_capabilities_support_perf.sql`. **Implementers:** reuse those index names if 027 ships first; do not duplicate. Partial unassigned index from §8 remains additive if not covered. |
**ASSUMPTION:** If agent 03 later ships `staff_role TEXT` (`admin|developer|support_staff`), migrate `is_support_agent``staff_role = 'support_staff'` and keep `RequireSupportDesk` as `is_platform_admin OR staff_role IN ('support_staff','admin','developer')` per final matrix — do not invent a second parallel flag.
@@ -0,0 +1,88 @@
# 12 — Support tickets backend
**Status:** Implemented (extends Support Center)
**Agent:** 12/20
**Date:** 2026-08-05
**Design:** [11-support-design.md](./11-support-design.md) · [11-support-api-contract.json](./11-support-api-contract.json)
## Summary
Additive support-desk backend on top of `025_support_center` + `029_staff_roles`:
- Customers: create / list / reply with **company + owner isolation** (unchanged contract).
- Staff desk (`RequireSupportDesk`): queue + claim visibility for `staff_role=support_staff`; full list for platform admins.
- Claim / release endpoints; assign force-fields remain admin-oriented.
- Agents directory via `staff_role` (maps API `is_support_agent` without a second boolean column).
- Migration `030_support_desk.sql`: `resolved_by_user_id`, CSAT table (ratings owned by agent 13), unassigned index, notification kinds.
## Schema
| Artifact | Purpose |
|----------|---------|
| `029_staff_roles.sql` | `users.staff_role``admin\|developer\|support_staff` |
| `027_capabilities_support_perf.sql` | assignee / status activity indexes |
| `030_support_desk.sql` | `resolved_by_user_id`, CSAT table + token cols, unassigned partial index, `ticket_claimed` / `csat_requested` notification kinds |
**ASSUMPTION:** Designs `is_support_agent` boolean is implemented as `staff_role='support_staff'` (roles agents landed first). API still exposes `is_support_agent` on agent DTOs / `/me` staff block.
## Auth
| Middleware | Who |
|------------|-----|
| `RequireSession` + `RequireCompany` | Customer ticket + notification routes |
| `RequireSupportDesk` | `/api/admin/support/tickets*` claim/release |
| `RequirePlatformAdmin` | `/api/admin/support/agents*` (+ other admin) |
`auth.ResolveStaffAccess`: `support_staff` → desk only; `admin`/`developer`/legacy `is_platform_admin` → full admin + desk.
## Visibility (queue + claim)
| Scope | Filter |
|-------|--------|
| `inbox` (agent default) | assignee IS NULL OR me; status ∈ open\|pending |
| `mine` | assignee = me |
| `unassigned` | assignee IS NULL; open\|pending |
| `all` (admin default) | optional status / company_id / assignee_id / q |
Agent `scope=all`**403**. Cross-assignee GET → **404**. Reply on another agents ticket → **409 already_claimed**.
Claim SQL (atomic):
```sql
UPDATE support_tickets
SET assignee_admin_user_id = $actor, updated_at = now()
WHERE id = $id AND assignee_admin_user_id IS NULL AND status IN ('open','pending');
```
## Key packages
| Path | Role |
|------|------|
| `internal/support/tickets.go` | CRUD, reply, update (+ `resolved_by_user_id` on resolve) |
| `internal/support/desk.go` | scopes, claim, release, `GetAdminForActor` |
| `internal/support/agents.go` | list/set support agents via `staff_role` |
| `internal/httpapi/support_handlers.go` | HTTP + staff visibility helpers |
| `internal/httpapi/admin_staff_handlers.go` | set agent |
| `internal/auth/staff.go` | StaffAccess resolution |
## Tests
```bash
cd apps/api
go test ./internal/support/ -count=1
# with DATABASE_URL:
go test ./internal/support/ -run 'TicketCRUDAuthOwnership|StaffQueueClaimRelease' -count=1
go test ./internal/httpapi/ -run Support -count=1
```
## Out of scope here
- CSAT submit/aggregate HTTP (agent 13 — schema prepared in `029`).
- Staff/customer UI (later agents).
- Support email stubs (optional; mailer unused by support MVP).
## Verification notes
- Customer list/get still require `company_id` + `created_by_user_id`.
- Internal notes never returned on customer GET.
- Pagination clamped 50 default / 200 max (`clampListBounds`).
@@ -0,0 +1,90 @@
# 13 — Support ticket ratings / CSAT
**Owner:** agent 13
**Depends on:** agent 12 schema (`030_support_desk.sql``support_csat_ratings`)
**Status:** implemented (API + tests)
## Goal
Customers rate **resolved** or **closed** tickets once (score 15 + optional comment). Platform admins see aggregate CSAT (no PII).
## Schema (agent 12)
Table `support_csat_ratings`:
| Column | Notes |
|--------|--------|
| `ticket_id` | `UNIQUE` — one rating per ticket |
| `company_id`, `user_id` | owner + tenant |
| `score` | `SMALLINT` 15 |
| `comment` | optional, max 2000 runes (truncated) |
| `created_at` | UTC |
Ticket columns `csat_token_hash` / `csat_invite_sent_at` exist for optional public token flow (not wired in this agent).
## Rules
1. Only `created_by_user_id` may rate (others → `404`).
2. Status must be `resolved` or `closed` else `400` (`ticket not eligible for rating`).
3. Duplicate insert → `409` (`already rated`).
4. Logs: `ticket_id` + `score` only — **never** comment, email, or names.
## APIs
| Method | Path | Auth | Response |
|--------|------|------|----------|
| `POST` | `/api/support/tickets/{id}/csat` | session + company | `201` `SupportCsat` |
| `GET` | `/api/support/tickets/{id}` | customer | additive `csat` / `csat_eligible` |
| `GET` | `/api/admin/support/tickets/{id}` | support desk | includes `csat` when present |
| `GET` | `/api/admin/support/csat?from=&to=` | platform admin | aggregate |
### Customer submit body
```json
{ "score": 4, "comment": "optional" }
```
### Admin aggregate
```json
{
"total": 12,
"average": 4.25,
"distribution": { "1": 0, "2": 1, "3": 2, "4": 4, "5": 5 },
"from": null,
"to": null
}
```
`from` / `to` are optional RFC3339 bounds on `created_at` (`to` exclusive).
## Code map
| Piece | Path |
|-------|------|
| Service | `apps/api/internal/support/ratings.go` |
| Types | `CSATInput`, `CSATRating`, `CSATAggregate` in `types.go` |
| Handlers | `apps/api/internal/httpapi/support_csat_handlers.go` |
| Routes | customer POST csat; admin GET `/api/admin/support/csat` |
| Unit tests | `ratings_test.go`, `support_csat_auth_test.go` |
| Integration | `ratings_integration_test.go` (needs `DATABASE_URL` + goose) |
## Verification
```bash
cd apps/api
go test ./internal/support/ -count=1 -run 'CSAT|NormalizeCSAT|ClientErrorCSAT'
go test ./internal/httpapi/ -count=1 -run 'SupportCSAT|SupportTicketCRUDAuth'
```
With DB migrated:
```bash
go test ./internal/support/ -count=1 -run TestSubmitCSATOwnershipAndOnce
```
## Out of scope (this agent)
- Public token CSAT (`POST /api/public/support/csat`)
- User/staff UI panels (agents 1415)
- Email CSAT invite stubs
@@ -0,0 +1,91 @@
# 14 — Support staff UI
**Agent:** 14/20
**Status:** Implemented
**Coordinates with:** `06-staff-roles` (middleware + `staff_role`), `11-support-design` / `12-support-backend` (claim/scope/agents), `07-admin-shell`, `10-admin-orgs-ui` (grant role)
---
## PROBLEM
Platform support work lived behind full `is_platform_admin`. Staff need an inbox with claim/unassign, assignment, and filters — without billing/settings access. Platform admins must be able to grant `support_staff` from the orgs/users UI.
---
## WHAT SHIPPED
### Access model
| Actor | Gate | Surfaces |
|-------|------|----------|
| Full admin (`admin` / `developer` / legacy platform admin) | `RequirePlatformAdmin` / `staff.full_admin` | Full `/admin/*` |
| `support_staff` | `RequireSupportDesk` / `staff.support_desk` | `/admin/support/**` (+ Overview in nav) |
| Customers | company session | `/support/**` (unchanged) |
- API: `/api/admin/support/*` mounted under `RequireSupportDesk` (not full admin).
- Web: `requireSupportDesk()` on support pages; `requirePlatformAdmin()` elsewhere.
- `AdminNav` hides billing/settings/users/analytics for support-only staff.
- Layout shows admin shell when `staff_access.support_desk` or `full_admin`.
### Queue + ticket UI (`/admin/support`)
- **Scopes:** Inbox · Mine · Unassigned · All (All = full admin only). Persisted in `?scope=`.
- **Status chips** + search (subject / email / company).
- **Claim** on unassigned open/pending rows and on ticket detail.
- **Unassign / release** when assignee is self (or force for full admin).
- **Assign to staff** (full admin): agent picker from `GET /api/admin/support/agents`.
- Assignee column shows email / “You” / Unassigned.
- Visual language matches reworked admin shell (`PageShell`, `Card`, `TableShell`, filter chips).
### Role grant coordination (orgs UI)
Platform admins grant roles via:
- `PATCH /api/admin/users/{id}/staff-role` body `{ "staff_role": "support_staff" | "admin" | "developer" | null }`
- Admin Users UI (`apps/web/src/routes/admin/users/+page.svelte` + `$lib/admin-orgs.ts`) — role dialog owned with agent 10.
- Optional alias: `PUT /api/admin/support/agents/{id}` `{ "is_support_agent": true }` → sets `staff_role=support_staff`.
`/api/auth/me` returns `staff_access` + `staff_capabilities` for client gating.
---
## FILES (primary)
| Area | Path |
|------|------|
| Client API | `apps/web/src/lib/support/admin-api.ts` |
| Gates | `apps/web/src/lib/admin-gate.ts` (`requireSupportDesk`) |
| Types / session | `apps/web/src/lib/types.ts`, `auth-session.svelte.ts` |
| Nav / layout | `AdminNav.svelte`, `routes/+layout.svelte` |
| Queue / detail | `routes/admin/support/+page.svelte`, `[id]/+page.svelte` |
| Handlers | `apps/api/internal/httpapi/support_handlers.go` (scope, claim, release, agents) |
| Desk domain | `apps/api/internal/support/desk.go`, `agents.go` |
---
## VERIFICATION
```text
cd apps/api && go test ./internal/support/ ./internal/auth/ ./internal/httpapi/ -count=1
```
Manual:
1. As platform admin: Users → set a user `staff_role=support_staff`.
2. Sign in as that user → admin shell shows Support (not billing).
3. Open `/admin/support?scope=unassigned` → Claim → appears under Mine.
4. Unassign → returns to Unassigned.
5. As full admin: open ticket → Assign to staff → Apply.
6. Confirm `/admin/billing` returns 403 for support_staff (API + UI).
---
## ASSUMPTIONS
- ASSUMPTION: Agent 6 `ResolveStaffAccess``support_staff` never gets `full_admin` even if `is_platform_admin` column is true.
- ASSUMPTION: Least-privilege visibility for agents (inbox / claim) is enforced server-side in list/get/update (agent 12).
- CSAT ratings UI is owned by sibling agents; this doc does not cover rating widgets.
## ROLLBACK
Revert support route UI/gate/nav changes and remount support APIs under `RequirePlatformAdmin` only if needed. Staff role column (`029_staff_roles`) is additive — leave in place.
@@ -0,0 +1,96 @@
# 15 — User-facing Support Center UI
**Agent:** 15/20
**Date:** 2026-08-05
**Scope:** Customer `/support` surfaces — list, create, thread, CSAT rating.
**Contract:** [11-support-design.md](./11-support-design.md) §5 / §9.1 (`POST /api/support/tickets/{id}/csat`).
---
## Outcome
Polished, accessible user Support Center that:
1. Lists the callers tickets with status chips + URL `?status=` persistence.
2. Creates tickets (subject, category, priority, body) when `support.ticket_create` allows.
3. Shows a threaded conversation with quiet polling (visibility-aware).
4. Prompts CSAT (15 + optional comment) when status is `resolved` or `closed` and no rating yet.
5. Respects plan feature keys via existing `PlanRouteGuard` + `FeatureGate` / `planCapabilities.can`.
---
## Surfaces
| Route | Feature key | Notes |
|-------|-------------|-------|
| `/support` | `support.center` | Status filter chips, clickable rows, count |
| `/support/new` | `support.ticket_create` | Priority + char counters; upgrade panel if denied |
| `/support/[ticketId]` | `support.ticket_thread` | Thread + CSAT banner/form |
Legacy plans: if the matrix turns `support.*` off (see `03-roles-matrix`), `PlanRouteGuard` shows the upgrade panel. When keys are absent (pre-cutover), `CORE_ALWAYS_ON` keeps basic support available.
CSAT is **not** a separate feature key — it rides on `support.ticket_thread` once the ticket is resolved/closed (design §5).
---
## Client API
| Helper | Path |
|--------|------|
| `listSupportTickets` / `getSupportTicket` / `createSupportTicket` / `replySupportTicket` | `apps/web/src/lib/support/api.ts` |
| `submitSupportCsat``POST …/csat` `{ score, comment? }` | same |
| `canRateTicket` / `canReplyToTicket` | same |
| Types `SupportCsat`, `SubmitSupportCsatInput` | `apps/web/src/lib/support/types.ts` |
| UI | `SupportTicketRating.svelte` |
**Graceful backend lag:** if agent 13s CSAT route is not mounted yet, `isSupportUnavailable` / 404503 surfaces a clear message without breaking the thread. Duplicate ratings (`409`) show “already rated”.
**ASSUMPTION:** Response may be a full ticket (with `csat`) or a CSAT object; UI merges `csat` onto the in-memory ticket either way.
---
## UX / a11y / perf
- Status filters: `aria-pressed` chip group (matches admin queue pattern).
- Rows: keyboard activatable (`Enter` / `Space`), `role="link"`.
- CSAT: `role="radiogroup"` star buttons with explicit `aria-label`s; comment encouraged for scores ≤2.
- Thread: `role="log"` + `aria-live="polite"`; capped scroll region; auto-scroll on new messages.
- Polling: 20s interval skipped when `document.visibilityState === "hidden"`; refresh on focus.
- No extra cards in hero chrome — reuse `PageShell` + existing tokens.
---
## Files touched
- `apps/web/src/lib/support/types.ts`
- `apps/web/src/lib/support/api.ts`
- `apps/web/src/lib/components/SupportTicketRating.svelte` (new)
- `apps/web/src/routes/support/+page.svelte`
- `apps/web/src/routes/support/new/+page.svelte`
- `apps/web/src/routes/support/[ticketId]/+page.svelte`
- `apps/web/src/lib/plan-capabilities.ts` (upgrade titles for create/thread)
---
## Verification
```bash
cd apps/web && npm run check
```
Manual:
1. `/support` — filter chips update URL; empty + list states.
2. `/support/new` — create with priority; gated plan shows upgrade.
3. Resolved ticket — CSAT form appears; after submit, “Your rating” card; reply still reopens when allowed.
4. Legacy matrix with `support.center=false` — route guard upgrade panel.
**Depends on:** agent 13 mounting `POST /api/support/tickets/{id}/csat` and optional `csat` on GET. UI is ready ahead of that mount.
---
## Out of scope (other agents)
- Schema / CSAT backend (1213)
- Staff assign / claim UI (14)
- Admin CSAT aggregates
+104
View File
@@ -0,0 +1,104 @@
# 16 — Legacy (A1) user dashboard nav (agent 16/20)
**Status:** Frontend nav + route guards aligned to the legacy image allow-list.
**Coordinates with:** [`03-roles-matrix.md`](./03-roles-matrix.md), [`05-legacy-seed.md`](./05-legacy-seed.md) (agent 5), [`../plan-permissions/09-dashboard-gating.md`](../plan-permissions/09-dashboard-gating.md).
---
## PROBLEM
Legacy tenants (A1 / Local Demo Co cohort) must see a flat primary nav matching the product screenshot — **without** Background Tasks / Processing, stores, or marketing suite chrome.
---
## Target nav (image order)
| # | Label | Route | Feature key |
|---|-------|-------|-------------|
| 1 | Dashboard | `/dashboard` | `dashboard.overview` |
| 2 | Products | `/products` | `catalog.products` |
| 3 | Feeds | `/feeds` | `feeds.list` |
| 4 | Export Feeds | `/export-feeds` | `feeds.export_feeds` |
| 5 | Categories | `/categories` | `catalog.categories` |
| 6 | Attributes | `/attributes` | `catalog.attributes` |
| 7 | Standard Fields | `/standard-fields` | `catalog.standard_fields` |
| 8 | Usage & Billing | `/billing` | `billing.overview` |
| 9 | Settings | `/settings` | `settings.profile` |
**Must be OFF (no nav link):** `processing.monitor` (Background Tasks), `stores.*`, `marketing.*`, `integrations.*`, `support.center`, catalog extras.
When only the allow-list is ON, the **More** panel is omitted entirely (no empty More control).
---
## Implementation (this agent)
| Piece | Path | Change |
|-------|------|--------|
| Primary nav order + labels | `apps/web/src/lib/components/Nav.svelte` | Flat primary = image list; Stores/Processing/marketing under More; hide More when empty |
| Constants + upgrade titles | `apps/web/src/lib/plan-capabilities.ts` | `LEGACY_PRIMARY_NAV`, `LEGACY_NAV_DENIED_FEATURES`; upgrade copy for processing/stores/support/… |
| Command palette | `apps/web/src/lib/components/CommandPalette.svelte` | Same labels; destinations filtered by `planCapabilities.can` |
| Route guard (existing) | `PlanRouteGuard.svelte` | Deep-link to denied route → `PlanUpgradePanel` |
| Dashboard chrome | `DashboardStats.svelte`, `dashboard/+page.svelte` | Hide processing quick link / stats / “View all” when `processing.monitor` OFF; gate store reconnect |
**ASSUMPTION:** Nav visibility is driven solely by `GET /api/billing/capabilities` (and `/me` credits.features) — no client-side plan-name hardcoding. Backend legacy seed (agent 5) must return the deny map.
---
## API verification (after legacy seed)
### Expected for an A1 / Legacy company session
```http
GET /api/billing/capabilities
```
| Key | Expected |
|-----|----------|
| `dashboard.overview``settings.profile` (nav parents) | `true` |
| `processing.monitor` | `false` |
| `stores.hub` | `false` |
| `marketing.campaigns` / `brand_kit` / `seo` / … | `false` |
| `integrations.ai` / `email` | `false` |
| `support.center` | `false` |
### Live check (2026-08-05, local)
| Check | Result |
|-------|--------|
| Demo login → Local Demo Co | Active plan resolved as **Enterprise** (`is_custom=true`) via capabilities |
| Enterprise features | All nav parents **ON** including `processing.monitor` / `stores.hub` / marketing |
| Admin `GET /api/admin/plans/5/features` (plan **A1**) | Name matches legacy pattern, but stored `features` JSON is a **partial** sparse map (shell/dashboard extras OFF only). Resolved still had `processing.monitor=true` on the **running** API process at verify time |
| Company assignment | Local Demo Co was **not** on plan A1 / Legacy — still Enterprise |
**Blocker for end-to-end image match on demo:** agent 5 seed must (1) apply full `SparseLegacyOverrides` to plan **A1** (or mark `is_legacy` and re-seed), (2) assign Local Demo Co / A1 tenant to that plan, (3) restart API so `DefaultPlanFeaturesEx` / `EnsureLegacyPlanFeatureSeeds` are live. Frontend is ready once capabilities return the legacy map.
### Manual UI checklist (once capabilities correct)
- [ ] Sidebar shows exactly the 9 labels above (order + casing)
- [ ] No “Processing” / Background Tasks, Stores, Campaigns, Brand, SEO, Support
- [ ] No **More** panel (unless platform admin — then only Platform admin)
- [ ] Direct `/processing`, `/stores`, `/campaigns` → upgrade panel, not empty shell
- [ ] Command palette omits Processing / Support when denied
---
## Verification commands
```bash
cd apps/web && npm run check
```
Go (seed / matrix; agent 5):
```bash
cd apps/api && go test ./internal/billing/ -run Legacy -count=1
```
---
## CONTRACT
- Public feature keys unchanged.
- `PlanRouteGuard` behavior unchanged (in-place upgrade panel).
- Platform admin (`showAdmin`) may still see Platform admin under More — orthogonal to plan matrix.
+95
View File
@@ -0,0 +1,95 @@
# 17 — Security pass: staff roles, plan features, support tickets
**Agent:** 17/20 · **Scope:** API handlers / middleware security fixes (no git).
## Goal
Close IDOR, missing authz, mass-assignment, and CSRF gaps around platform staff, plan-feature admin APIs, and support tickets. Enforce **least privilege** for `support_staff`. Keep secrets out of logs. Add forbidden-access tests.
## Threat model (in scope)
| Actor | May access | Must not |
|-------|------------|----------|
| Customer (company member) | Own tickets in active company | Other users' tickets; admin APIs; internal notes |
| `support_staff` | Support desk queue (unassigned + own) | Plan features, feature gates, billing, settings, users list, credits |
| `admin` / `developer` / legacy `is_platform_admin` | Full `/api/admin/*` | N/A (full staff) |
| Unauthenticated | Public / CSRF cookie seed on GET | Mutating `/api/*` without CSRF |
## Controls implemented
### 1. Staff roles (`users.staff_role`)
- Migration: `apps/api/sql/schema/029_staff_roles.sql`
- Values: `admin` \| `developer` \| `support_staff` (NULL allowed)
- Resolver: `auth.ResolveStaffAccess` / `auth.GetStaffAccess`
- `support_staff`**support desk only** (even if `is_platform_admin=true`)
- `admin` / `developer` → full admin + support desk
- NULL + `is_platform_admin`**legacy full admin** (backward compatible)
- Missing column (pre-migration) → boolean-only fallback
### 2. Middleware least privilege
| Middleware | Allows |
|------------|--------|
| `RequirePlatformAdmin` | Full admin only (`FullAdmin`) |
| `RequireSupportDesk` | Full admin **or** `support_staff` |
Route split in `Server.Router` (`server.go`):
- `/api/admin/support/*``RequireSession` + `RequireSupportDesk`
- All other `/api/admin/*` (plans, features, gates, billing, settings, users, …) → `RequireSession` + `RequirePlatformAdmin`
CSRF remains on the outer session group (double-submit cookie + `X-CSRF-Token`). Admin mutations are not CSRF-exempt.
### 3. Support ticket IDOR / visibility
- Customer paths always bind `company_id` + `user_id` from **session context** (not JSON body). Handlers fail closed if context missing.
- `GetForUser` / `ReplyAsUser` already filter by company + creator; internal notes excluded at SQL.
- Customer reply uses `UserReplyInput` (`body` only) — **mass assignment** of `is_internal_note` / `status` rejected via `DisallowUnknownFields`.
- `support_staff` list forced to `UnassignedOrSelf` (client `assignee_id` ignored).
- `support_staff` get/reply/update of another agent's ticket → **404** (anti-enumeration).
- Assignee updates: zero UUID rejected; assignee must be support-capable; `support_staff` may only assign self (or clear).
### 4. Plan feature admin APIs
- Remain behind `RequirePlatformAdmin` (support_staff → 403).
- Feature keys / sections already allowlisted in billing (`validateFeatureOverrides` / `validateGatesUpdate`).
- Unknown JSON fields rejected by `DecodeJSON`.
### 5. Secrets in logs
- `LogAndError` redacts password/secret/api_key/token/`sk_live`/`whsec_` patterns via `redactForLog`.
- Request logger continues to log method/path/status only (no bodies).
## Tests
| Test | Asserts |
|------|---------|
| `auth.TestResolveStaffAccess` | Capability matrix |
| `TestRequireSupportDeskForbiddenAndAllow` | 401 / member 403 / support_staff 204 |
| `TestRequirePlatformAdminExcludesSupportStaff` | support_staff blocked from full admin |
| `TestSupportStaffForbiddenOnPlanFeatures` | plan/gate routes 403 for support_staff |
| `TestMemberForbiddenOnAdminSupportAndPlanRoutes` | plain users 403 |
| `TestUserReplyMassAssignmentRejected` | unknown fields on customer reply |
| `TestStaffMayAccessTicket` | visibility rules |
| `TestRedactForLog` | secrets scrubbed |
| Existing `TestTicketCRUDAuthOwnership` | customer IDOR ownership |
| Existing CSRF tests | mutating requests need token |
Run:
```bash
cd apps/api
go test ./internal/auth ./internal/httpapi -count=1 -run "Staff|SupportDesk|PlatformAdmin|Redact|UserReply|MemberForbidden"
```
## Residual / handoff
- Staff **assignment APIs** (grant/revoke `staff_role`) owned by agent 6 — must call `NormalizeStaffRole` and remain `RequirePlatformAdmin`.
- Claim race / CSAT / email stubs owned by support backend agents; keep visibility checks on every new staff handler.
- Web UI gates should mirror `SupportDesk` vs `FullAdmin` (do not trust client-only hides).
- Apply migration `029_staff_roles` before relying on `staff_role` in production.
## Rollback
Revert middleware/route split + `029_staff_roles` down migration; restore prior `RequirePlatformAdmin` on all `/api/admin/*`.
@@ -0,0 +1,78 @@
# 18 — Performance (capabilities, permission matrices, support queues)
Date: 2026-08-05
Owner: agent 18/20
## Scope
Concrete query/index/cache fixes for:
1. **Capabilities resolution** (`GET /api/billing/capabilities`)
2. **Admin permission matrices** (`GET /api/admin/plans`, plan features, feature gates)
3. **Support queues** (user list + admin/staff inbox)
No million-SKU product-list work (already covered in `docs/perf-notes.md`).
## Findings (before)
| Path | Issue | Severity |
|------|--------|----------|
| `CapabilitiesForCompany` | Active plan lookup used only `company_plans(company_id)` | Medium |
| `handleGetCapabilities` | Body had `feature_etag` but no HTTP `ETag` / 304 (OpenAPI already patterned) | Medium |
| Admin `ListPlans` | Already one query + in-memory `resolved_features` (no N+1) | OK |
| Admin matrices | No `Cache-Control` — risk of intermediary caching | Low |
| `ListAdmin` count | Always joined `users` + `companies` even without search | Medium |
| Support indexes | Missing assignee + COALESCE(activity) indexes for queue sorts | Medium |
| Pagination | Handlers already use `ParseLimitOffset` (default 50, max 200) | OK |
## Changes
### 1. Indexes — goose `027_capabilities_support_perf.sql`
- `company_plans_company_active_created_idx` — partial `(company_id, created_at DESC) WHERE is_active`
- `support_tickets_status_activity_idx``(status, COALESCE(last_message_at, updated_at) DESC)`
- `support_tickets_assignee_activity_idx` — staff inbox partial on assignee
- `support_tickets_user_activity_idx` — user ticket list activity sort
Apply via existing migrate script after deploy.
### 2. Capabilities HTTP cache headers
`handleGetCapabilities` now sets:
- `Cache-Control: private, max-age=30, must-revalidate`
- `ETag: "sha256:…"` via `billing.CapabilitiesResponseETag` (features **plus** plan id / name / remaining credits)
- `304 Not Modified` when `If-None-Match` matches
Body `feature_etag` is unchanged (feature-map only) for the web client.
### 3. Admin permission matrices
- `ListPlans` already selects `features` once and resolves matrices in-process (no per-plan round-trip).
- Added `Cache-Control: private, no-store` on:
- `GET /api/admin/plans`
- `GET /api/admin/plans/{id}/features`
- `GET /api/admin/feature-gates`
### 4. Support queues
- Count query skips user/company joins unless `search`/`q` is set.
- Service-layer `clampListBounds` (default 50 / max 200) as defense in depth.
- Admin list accepts `assignee_id` filter (uses new assignee index).
- Handlers continue to use `ParseLimitOffset`.
## Tests
- `apps/api/internal/billing/capabilities_etag_test.go`
- `apps/api/internal/support/list_bounds_test.go`
```powershell
cd apps/api
go test ./internal/billing/ ./internal/support/ ./internal/httpapi/ -count=1
```
## Out of scope / deferred
- In-process TTL cache for `GetFeatureGates` (tiny table; premature)
- Keyset pagination for support (volume still small)
- Changing product-list OFFSET pagination (see `docs/perf-notes.md` P0)
@@ -0,0 +1,88 @@
# 19 — Plan / role defaults alignment (agent 19/20)
**Status:** Implemented end-to-end defaults for public ladder, custom enable-all, Legacy (A1), and staff role ceilings.
**Sources:** `03-roles-matrix.md` / `.json`, `docs/plan-permissions/06-defaults-matrix.md`.
---
## PROBLEM
Public ladder defaults, custom enable-all, and Legacy (A1) limited nav must agree across API resolve, seeds, admin UI profiles, and demo assignment — without treating A1 as custom all-ON.
---
## Effective matrices
| Profile | Source of truth | Default behavior |
|---------|-----------------|------------------|
| Free | `freePlanFeatureOff` | AI / API keys / BYOK / campaigns off |
| Starter | `starterPlanFeatureOff` | BYOK off |
| Growth / Business | all registry ON | empty sparse overrides |
| Enterprise | `is_custom=true` ? custom path | all ON |
| Custom deals (Merkur, …) | `IsCustomPackage` | all ON (create materializes enable-all) |
| **Legacy (A1)** | `legacyFeatureAllowlist` / `03-roles-matrix` | image-nav allow-list only; `processing.monitor` OFF |
| Staff `admin` / `developer` | `DefaultStaffRoleAllows` | all feature keys ON; full `/admin` |
| Staff `support_staff` | denied set in `staff_role_defaults.go` | support queue + assist; no billing mutation routes |
Resolve order for plans: **Legacy ? Custom enable-all ? Free/Starter denials ? Growth+**.
---
## A1 / Local Demo Co
| Piece | Behavior |
|-------|----------|
| Plan name patterns | `Legacy`, `A1*`, contains `legacy` / `a1 slovenija` |
| Company cohort | `legacy_company_id = 97e1a309-3d23-4aa2-b518-8e8d7afdfec7` or name Local Demo Co / A1 |
| Seed | `EnsureLegacyDefaults` upserts plan **Legacy** (Enterprise credit pack, unlimited SKUs, `is_legacy=true`) |
| Assignment | A1 cohort companies get Legacy when missing or on non-legacy profile |
| Demo | `seed-demo` assigns **Legacy** (not Enterprise) so capabilities match limited nav |
| Repair | Full enable-all `plans.features` on legacy-named rows ? `SparseLegacyOverrides` (idempotent) |
**ASSUMPTION:** Empty `{}` still resolves via `DefaultPlanFeatures` by name; seeder writes sparse false keys so admin UIs show an explicit matrix.
---
## Implementation map
| Piece | Path |
|-------|------|
| Legacy detect + allow-list | `apps/api/internal/billing/legacy_plan.go` |
| Legacy plan row + A1 assign | `apps/api/internal/billing/legacy_plan_seed.go` (`EnsureLegacyDefaults`) |
| Sparse legacy helpers | `apps/api/internal/billing/legacy_plan_features.go` |
| Feature seed hook | `EnsureDefaultFeatureSeeds` ? `EnsureLegacyPlanFeatureSeeds`; `EnsureDefaultPlans` ? `EnsureLegacyDefaults` |
| Custom vs legacy | `IsCustomPackage` excludes legacy names |
| Staff role defaults | `apps/api/internal/auth/staff_role_defaults.go` |
| Schema | `apps/api/sql/schema/028_plan_is_legacy.sql` (`plans.is_legacy`) |
| Admin UI profiles | `apps/web/src/lib/admin-plan-permissions.ts` (`LEGACY_FEATURE_ALLOWLIST`, profiles) |
| Billing display | `isLegacyPlan` in `apps/web/src/lib/billing-display.ts` |
---
## Idempotency
- `EnsureDefaultPlans` / `EnsureLegacyDefaults` / `EnsureLegacyPlanFeatureSeeds` are safe to re-run.
- Non-empty **partial** admin feature maps on legacy plans are **not** wiped (only empty or full enable-all).
- Global section gates use `ON CONFLICT DO NOTHING`.
---
## Verification
```bash
cd apps/api
go test ./internal/billing/ ./internal/auth/ -count=1 -run "DefaultPlanFeatures|Legacy|IsCustom|Sparse|StaffRole|Normalize|PlanAllows"
```
Integration (after migrate `028` + `seed-demo`):
- `TestLocalDemoCoLegacyCredits` — Local Demo Co on Legacy with 1M credits
- Capabilities: `processing.monitor` false; `catalog.products` / `feeds.list` true
---
## Coordination notes
- Agent 5 owns overlapping legacy seed docs (`05-legacy-seed.md`); this file is the alignment checklist for agent 19/20.
- Agent 6 wires `staff_role` schema/middleware; defaults here are the ceiling until that lands (`StaffRoleFromPlatformAdmin` bridges the boolean).
- Agent 8 admin profiles should match `LEGACY_FEATURE_ALLOWLIST` ? Go `legacyFeatureAllowlist` (66 ON keys from `03-roles-matrix.json`).
+146
View File
@@ -0,0 +1,146 @@
# Admin roles, legacy plans & support desk
End-to-end work from the **20-agent** `admin-roles-support` swarm: legacy (A1) limited nav, platform staff roles, admin panel polish, support assign/claim + CSAT ratings.
**Runtime rule (tenant features):**
```
effective(feature) = plan_allows(feature)
AND global_section_enabled(section(feature))
AND global_feature_enabled(feature)
```
Staff access is **orthogonal** to plan features: `users.staff_role` + `is_platform_admin` gate `/admin` and the support desk.
---
## Doc map (agents 119)
| Doc | Owner | What it covers |
|-----|-------|----------------|
| [01-ux-research.md](./01-ux-research.md) | 1 | Admin / permissions / support UX patterns |
| [02-current-inventory.md](./02-current-inventory.md) / [02-extension-points.json](./02-extension-points.json) | 2 | Pre-change inventory |
| [03-roles-matrix.md](./03-roles-matrix.md) / [`.json`](./03-roles-matrix.json) | 3 | Legacy allow-list + role→feature matrix |
| [04-contract.md](./04-contract.md) / [`.json`](./04-contract.json) | 4 | Unified plan + staff contract |
| [05-legacy-seed.md](./05-legacy-seed.md) | 5 | Legacy profile seed / A1 detection |
| [06-staff-roles.md](./06-staff-roles.md) | 6 | `admin` / `developer` / `support_staff` backend |
| [07-admin-shell.md](./07-admin-shell.md) | 7 | Admin layout / nav chrome |
| [08-permissions-ui.md](./08-permissions-ui.md) | 8 | Billing → Permissions panel |
| [09-admin-billing-ui.md](./09-admin-billing-ui.md) | 9 | Plans list / assign polish |
| [10-admin-orgs-ui.md](./10-admin-orgs-ui.md) | 10 | Users/companies + staff role grant |
| [11-support-design.md](./11-support-design.md) / [11-support-api-contract.json](./11-support-api-contract.json) | 11 | Support desk design |
| [12-support-backend.md](./12-support-backend.md) | 12 | Tickets / claim / assign API |
| [13-support-ratings.md](./13-support-ratings.md) | 13 | CSAT 15 ratings |
| [14-support-staff-ui.md](./14-support-staff-ui.md) | 14 | Staff inbox + assign UI |
| [15-user-support-ui.md](./15-user-support-ui.md) | 15 | Customer support + rate UI |
| [16-legacy-nav.md](./16-legacy-nav.md) | 16 | A1 image-nav gating |
| [17-security.md](./17-security.md) | 17 | AuthZ / IDOR / CSRF pass |
| [18-performance.md](./18-performance.md) | 18 | Indexes / list bounds |
| [19-defaults-alignment.md](./19-defaults-alignment.md) | 19 | Ladder + Legacy + staff defaults |
Related: [`../plan-permissions/README.md`](../plan-permissions/README.md) (feature keys + admin Permissions API).
---
## Schema (goose)
| Version | File | Purpose |
|---------|------|---------|
| 026 | `apps/api/sql/schema/026_plan_features.sql` | `plans.features` + global gates |
| 027 | `027_capabilities_support_perf.sql` | Capabilities + support queue indexes |
| 028 | `028_plan_is_legacy.sql` | `plans.is_legacy` + name backfill |
| 029 | `029_staff_roles.sql` | `users.staff_role` |
| 030 | `030_support_desk.sql` | CSAT table, claim queue cols, notification kinds |
Apply (Windows):
```powershell
.\scripts\migrate.ps1
# or from apps/api with DATABASE_URL set:
go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 -dir sql/schema postgres $env:DATABASE_URL up
```
Strip UTF-8 BOM from new SQL if goose errors on `\ufeff-- +goose Up`.
---
## Code map
| Concern | Paths |
|---------|--------|
| Legacy detect/seed | `apps/api/internal/billing/legacy_plan*.go` |
| Staff roles | `apps/api/internal/auth/staff*.go`, `httpapi/middleware.go`, `admin_staff_handlers.go` |
| Support desk | `apps/api/internal/support/{desk,ratings,agents,tickets}.go`, `httpapi/support_*.go` |
| Admin shell | `AdminNav.svelte`, `admin-nav-ui.svelte.ts`, root `+layout.svelte` |
| Permissions UI | `PlanPermissionsPanel.svelte`, `admin-plan-permissions.ts` |
| Orgs / staff grant | `routes/admin/users/+page.svelte`, `admin-orgs.ts` |
| Legacy nav | `Nav.svelte`, `plan-capabilities.ts`, `PlanRouteGuard.svelte` |
| Staff inbox | `routes/admin/support/**` |
| User rate | `SupportTicketRating.svelte`, `routes/support/**` |
---
## Manual verify checklist
### A1 / Legacy nav
- [ ] `goose`**30**; `seed-demo` (or `EnsureDefaultPlans`) so **Local Demo Co** is on plan **Legacy** (`is_legacy=true`)
- [ ] Restart API after migrate/seed
- [ ] Login as demo → Local Demo Co → sidebar shows only: Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Usage & Billing, Settings
- [ ] No Background Tasks / Processing, Stores, Campaigns, Brand, SEO (no empty **More** unless Platform admin)
- [ ] `GET /api/billing/capabilities`: `processing.monitor` / `stores.hub` / `marketing.*` → false; nav parents → true
- [ ] Deep link `/processing` → upgrade panel
### Admin panel polish
- [ ] `/admin` shell: sidebar groups Monitor / Manage, dark content, mobile drawer
- [ ] `/admin/billing` → Permissions: profiles (incl. Legacy one-click), section toggles
- [ ] `/admin/users`: companies + assign plan + staff role dialog
### Support assign + rate
- [ ] Customer: create ticket → staff reply → resolve → rate 15 (`SupportTicketRating`)
- [ ] Staff: `/admin/support?scope=unassigned` → Claim → Mine; Unassign returns to queue
- [ ] Full admin: ticket detail → Assign to staff → Apply
- [ ] API: support routes under `RequireSupportDesk`; billing mutations still `RequirePlatformAdmin`
### Staff role access
- [ ] Grant `support_staff` from Users → that user sees Support (+ Overview redirects to Support), not Billing/Settings
- [ ] `GET /api/admin/billing` (or plans) → **403** for support_staff
- [ ] `admin` / `developer` / legacy `is_platform_admin` + NULL role → full console
- [ ] Cannot PATCH own `staff_role`
---
## Verify commands
```powershell
.\scripts\migrate.ps1
cd apps\api
go test ./internal/billing/ ./internal/auth/ ./internal/support/ -count=1
go test ./internal/httpapi/ -count=1 -run "Staff|Support|PlanFeature|PlanGate|AdminAuth|RequirePlatform|CSAT|Csat"
cd ..\web
npm run check
```
Optional demo alignment:
```powershell
cd apps\api
go run ./cmd/seed-demo -postgres $env:DATABASE_URL
```
---
## Integration notes (agent 20)
- Merged duplicate `028_*` legacy SQL into `028_plan_is_legacy.sql`; renumbered staff → **029**, support desk → **030**.
- `/admin` Overview: `support_staff` redirected to `/admin/support` (nav still shows Overview).
- `seed-demo` / `EnsureLegacyDefaults` assigns **Legacy** to Local Demo Co / A1 cohort (not Enterprise all-ON).
### Remaining blockers / non-goals
1. Not every tenant mutation is `AssertFeature`'d (same as plan-permissions agent 10) — UI + critical writes first.
2. Live browser QA of admin polish + CSAT requires a running `npm run dev` session and staff test user.
3. OpenAPI public fragment may still omit some support/staff paths (session docs in plan-permissions / support contract JSON).