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:
@@ -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 (~40–48px rows); optional compact for power users later.
|
||||
- Cap overview KPI cards at **3–5** (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: Descrybe’s primary *is* majorelle blue. “Not purple AI slop” means **don’t 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** (1–5 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 (1–4h) if you need confirmation the fix stuck. Cap frequency (~1 survey / customer / 14–30 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 1–5; 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** — 1–5 (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 3–6):**
|
||||
|
||||
| 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** (ex–A1 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:294–325`:
|
||||
|
||||
```
|
||||
/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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 A1’s 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 3’s `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 3’s 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 3’s 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 → today’s 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.
|
||||
@@ -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 plan’s 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 Free–Enterprise), 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 2–3 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 design’s `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:** Design’s `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 agent’s 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 1–5 + 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` 1–5 |
|
||||
| `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 14–15)
|
||||
- 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 caller’s 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 (1–5 + 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 13’s CSAT route is not mounted yet, `isSupportUnavailable` / 404–503 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 (12–13)
|
||||
- Staff assign / claim UI (14)
|
||||
- Admin CSAT aggregates
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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 1–19)
|
||||
|
||||
| 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 1–5 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 1–5 (`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).
|
||||
Reference in New Issue
Block a user