Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Accessibility notes (Descrybe v2 web)
Critical fixes from the a11y pass (keyboard / focus / ARIA).
## Sidebar (`Nav.svelte`)
- Mobile drawer uses `inert` + `aria-hidden` when closed so off-canvas links are not in the Tab order.
- Open drawer traps focus; **Escape** closes it.
- Hamburger `aria-controls="app-sidebar"` matches the aside `id`; label toggles Open/Close.
- Nav links expose `aria-current="page"`; **More** uses `aria-expanded` / `aria-controls`.
- Primary **Stores** points at `/stores` (`data-tour="nav-stores"`).
## Focus traps
Shared helper: `src/lib/a11y/focus-trap.ts`.
| Surface | Behavior |
|---------|----------|
| `Dialog.svelte` | Tab cycle inside panel; focus restored on close; Escape closes |
| Tutorial popover | Tab cycle = popover controls **+** highlighted `data-tour` target (so forced steps stay keyboard-reachable); Escape pauses |
| Mobile sidebar | Tab cycle while drawer open |
## Store hub (`/stores`)
- Landmark `section` with accessible heading (sr-only).
- Each connector is an `article` with labelled title + CTA `aria-label`.
- Status badges include an accessible name (`Status: …`).
## Remaining (non-blocking)
- Individual feature dialogs that bypass `ui/Dialog` should adopt the same trap.
- Live axe/browser keyboard audit on every route still recommended before production.
+223
View File
@@ -0,0 +1,223 @@
# UX research: Admin, roles/plans, support desk
**Product:** Descrybe — product feed / AI catalog SaaS (Go API + SvelteKit admin).
**Scope:** Clean ops panels, plan/role permission matrices, support queue with assignment + CSAT.
**Date:** 2026-08-05
**Method:** Web research (admin tables, RBAC/entitlements, support SLA/CSAT) + codehelper skim of existing admin UI.
---
## Existing Descrybe surface (baseline)
| Area | What exists today | Paths / symbols |
| --- | --- | --- |
| Admin chrome | Fixed left nav, dark shell (`.dark`), brand “Descrybe” + Admin pill | `AdminNav.svelte` |
| Page frame | `PageShell` → title + description + actions; `max-w-7xl` | `PageShell.svelte` |
| Tables | `TableShell` = bordered `bg-card` + scroll; row hover `muted/40` | `TableShell.svelte` |
| Support queue | Status chip filters, search, Badge status map, click-through rows | `/admin/support`, `listAdminSupportTickets` |
| Ticket model | status, priority, category, `assignee_admin_user_id`, message thread | `support/admin-api.ts` |
| Plan features | Package select + section filter + search; checkbox matrix; package Badges | `PlanPermissionsPanel.svelte` |
| Tokens | Majorelle primary, russian-violet ink, slate-friendly cards; semantic Badge variants | `layout.css` (`--primary` 247°, success/warning Badges) |
**Implication:** Do not invent a second admin visual system. Extend `PageShell` + `Card` + `TableShell` + `Badge` + filter chips already used on Support. Prefer semantic tokens (`border-border`, `bg-primary/15`, `text-muted-foreground`) over one-off `slate-*` where the rest of admin already uses tokens (Support does; PlanPermissions still hardcodes some slate).
---
## A) Clean admin / ops panels
### Patterns to apply
1. **Job-first hierarchy (3 levels)**
- L1: entity/queue purpose + critical alerts + primary actions (Refresh, Assign, Resolve).
- L2: filters + operational table.
- L3: audit / history / advanced settings on demand (drawer or sub-route).
Source pattern: SaaS admin hierarchy guides (Taqwah, PyColors).
2. **Density as a tool, not a vibe**
- Ops tables: comfortable default (~4048px rows); optional compact for power users later.
- Cap overview KPI cards at **35** (admin overview already leans summary + tool grid — keep that restraint).
- Sticky table headers; freeze identity column (subject / company name) when horizontal scroll appears.
3. **Queue-shaped tables**
Useful traits: strong status, workflow filters, clear row entry, stable column order.
Weak traits: ambiguous state, decorative charts instead of work, actions buried in kebab menus for primary work.
4. **Filter UX**
- Removable filter chips + result count (“12 of 240 · status open · priority high”).
- Global search + a few workflow filters (status / assignee / plan tier / SLA risk) — not a filter drawer per column on day one.
- Persist key filters in URL (`?status=open`) — Support already does this; extend to assignee/SLA.
5. **Layout zones for ticket / user detail**
Entity header (state + plan + SLA) → primary thread/table → side sheet for assignment / meta → audit strip.
Prefer split pane or side sheet over full-page form for assignment so the queue context stays visible.
### DO / DON'T — avoiding the “ugly admin” look (Descrybe-native)
| DO | DON'T |
| --- | --- |
| Reuse `PageShell`, `Card`, `TableShell`, Lucide icons at `h-4`/`h-5` | New dashboard kits, glassmorphism, neon charts, multi-shadow cards |
| Use majorelle **sparingly**: active nav, selected filter, primary CTA | Purple-to-indigo hero gradients, glow rings, “AI sparkle” chrome on every control |
| Semantic status via `Badge` (`warning`/`success`/`secondary`) | Rainbow row backgrounds or emoji status columns |
| One composition per page: title → filters → table | KPI strip + chart wall + three card grids above every queue |
| Tabular nums + truncate with tooltip for IDs/emails | Wrapping every cell; monospace walls of UUID |
| Destructive actions visually separate + confirm | Red “Delete” next to “Reply” without consequence copy |
| Match admin dark shell tokens from `layout.css` | Flat gray Bootstrap admin or cream/serif “editorial” admin |
Brand note: Descrybes primary *is* majorelle blue. “Not purple AI slop” means **dont decorate** — use brand as selection/affordance, not as atmosphere.
---
## B) Plan / role permission matrices
### Model (separate two axes)
Research consensus (Stripe-style entitlements + B2B RBAC UX):
| Axis | Question | Descrybe mapping |
| --- | --- | --- |
| **Role** | What can this *person* do? | Platform admin vs company admin vs member; future scoped ops roles |
| **Plan / entitlement** | What has this *account* bought? | Plans + `PlanPermissionsPanel` feature catalog + feature gates |
| **Scope** | Where does it apply? | Company / catalog / feed — keep visible if multi-tenant ops act across companies |
| **Lifecycle** | Trial, past_due, suspended, custom deal | Already surfaced in billing helpers (`billing-display`, package Badges) |
**Never blur** “member can export” (role) with “Advanced export is on Enterprise” (plan). UI should explain *which* layer blocked an action.
### Matrix UX patterns
1. **Read matrix for audit; edit via focused builder**
Full checkbox grids are good for platform ops (current Plan features panel). For end-customer role assignment, prefer named roles + plain-language summary, not a wall of toggles.
2. **Group by task domain**
Sections already in `PLAN_FEATURE_SECTIONS` — keep section headers, global gate Badge (“Globally disabled”), and search. Add “affected companies / seats” count before bulk enable/disable.
3. **Honest gating states**
Hide vs disable vs upgrade CTA vs “ask admin” — pick per feature. Support/billing should show *why* (plan vs role vs lifecycle).
4. **Role chips**
Compact chips: plan name, `is_custom`, trial, platform-admin. Use `Badge variant="outline"` for ladder defaults and `secondary` for custom deals (already started in PlanPermissionsPanel). Avoid inventing 8 near-identical admin role names.
5. **Exceptions expire**
Support overrides and custom deals need owner, reason, expiry, audit — otherwise Support becomes the policy engine.
### DO / DON'T — permissions UI
| DO | DON'T |
| --- | --- |
| Label packages in customer language (Free / Pro / Enterprise / Custom deal) | Internal flags as the only label (`is_custom` alone) |
| Show count enabled / total; section + search filters | Unfiltered 80-row checkbox dump with no scan path |
| Confirm bulk “Disable all” with blast radius | Silent global section flips |
| Log who changed plan features | Toggles with no success/error feedback (panel already alerts — keep it) |
| Separate “AI roles” (provider keys) from user RBAC | Mixing `admin-ai-roles` (LLM slots) into the human permission matrix naming |
---
## C) Support desk: assignment, queues, SLA, CSAT
### Current hooks
- Queue: status filters + search; columns subject/status/priority/company/requester/updated.
- API update already allows `assignee_admin_user_id` / `clear_assignee`.
- Priorities: `low | normal | high`. Categories: `billing | bug | account | other`.
- Gap vs research: no SLA timers, no assignee column/filter, no CSAT fields, no “my queue” / unassigned views.
### Ticket queue patterns
1. **Ownership model**
Default for B2B SaaS: route by **product area / category** + **customer plan tier**, then assign.
Assignment modes to support:
- Unassigned pool → claim
- Round-robin / load-balance for general
- Skill / category for billing vs bug
- VIP / Enterprise bypass for high impact
2. **Queue views (filters, not separate apps)**
- All open · Unassigned · Mine · SLA at risk · Waiting on customer (`pending`)
Keep chip style from Support page (`border-primary bg-primary/15` when active).
3. **Row information hierarchy**
Primary: subject + company. Secondary: status Badge, priority, assignee avatar/initials, last message age. Tertiary: category under subject (already). Add SLA countdown only when defined.
4. **SLA as timers + events**
- Tier by priority (and optionally plan): first response + resolution targets.
- States: on track → **at risk** (e.g. 80% elapsed) → breached.
- Pause when waiting on customer.
- UI: subtle text for on-track; `Badge variant="warning"` at risk; `destructive` only for breach — not red rows.
5. **Assignment UX**
- Inline assignee control on detail header; optional bulk assign from queue.
- Show workload (open count) in assignee picker.
- Reassignment reason for misroutes (feeds quality metrics).
### CSAT patterns
1. **One rating question + optional comment** (15 or Good/Neutral/Bad). Extra questions kill response rate.
2. **Ask in-thread or embed in resolution email** — avoid “click this survey link” as primary path.
3. **Timing:** immediate on resolve for highest response; optional short delay (14h) if you need confirmation the fix stuck. Cap frequency (~1 survey / customer / 1430 days).
4. **Closed loop:** rating ≤2 → auto follow-up task for lead/manager within 24h; do not only score the agent.
5. **Ops dashboard:** weekly CSAT trend, by category and by assignee — not a vanity gauge on every ticket list row (show star only when rated).
### DO / DON'T — support UX
| DO | DON'T |
| --- | --- |
| Explicit assignee + unassigned queue | Tickets that only change status with no owner |
| Concrete priority definitions (impact × urgency) | “High” used for every billing question |
| Pair speed (FRT/SLA) with CSAT / reopen rate | Optimize SLA alone until quality collapses |
| Internal notes distinct from customer replies | Agents accidentally sending notes to customers |
| Macros that sound human; show company plan on ticket | Generic AI purple assist panel auto-replying without gate (`support` AI role stays off until product gate) |
---
## Concrete patterns checklist (build against this)
| Pattern | Apply on |
| --- | --- |
| Density | Support + Users + Billing tables via shared table cell padding |
| Hierarchy | PageShell title → chip filters → TableShell → detail |
| Tables | Sticky header; identity first; Badge for state; truncate + title |
| Filters | Chips + URL params + count; search left-icon Input |
| Role / plan chips | Badge outline vs secondary; plan name humanized |
| Ticket queues | Unassigned / Mine / At risk views; assignee column |
| SLA | Timer + at-risk Badge; pause on `pending` |
| Ratings | Post-resolve 15; optional comment; closed-loop on lows |
---
## Top 8 recommendations
1. **Standardize ops pages on one skeleton**`PageShell` + filter chip bar + `TableShell` + empty/forbidden/unavailable states (copy Support; retire ad-hoc slate-only cards where tokens suffice).
2. **Ship assignee-centric queues next** — column + filter + “Unassigned / Mine”; wire existing `assignee_admin_user_id` before adding SLA or CSAT chrome.
3. **Keep plan entitlements and human roles as two panels** — extend `PlanPermissionsPanel` for packages; separate company/platform RBAC; never merge with `admin-ai-roles` LLM slots in the same matrix.
4. **Permission matrix = grouped sections + search + blast-radius confirms** — preserve section gates and package Badges; add “who is affected” on enable/disable all.
5. **Add SLA as a thin operational layer** — first-response + resolution by priority (optionally × plan tier); show at-risk/breach Badges; pause on `pending`.
6. **CSAT: one click in the resolution path** — 15 (or 3-point) in ticket UI / resolution email; optional comment on low scores only; frequency cap; manager follow-up automation for ≤2.
7. **Visual discipline = brand as signal, not decoration** — majorelle for selection/CTA only; semantic Badges for state; no gradient dashboards, glow, or decorative KPI walls.
8. **Measure ops health with a short metric set** — FRT, SLA at-risk/breach, misroute/reassign rate, reopen rate, CSAT weekly by category — surface on Support overview, not as chart spam on the queue.
---
## Sources (web)
- [SaaS Admin Panel Design — hierarchy & density](https://taqwah.agency/blog/saas-admin-panel-design-guide)
- [Admin tables & queues](https://pycolors.io/guides/saas-admin-panels)
- [Data table density, chips, sticky headers](https://www.setproduct.com/blog/data-table-ui-design)
- [Permission matrix as product surface (roles vs entitlements)](https://0r8it.com/blog/the-permission-matrix-is-a-product-surface)
- [Multi-role B2B SaaS — role / plan / scope](https://dardesign.io/blog/multi-role-b2b-saas-ux-roles-permissions-flows)
- [Support triage, routing, SLA events](https://thinkbot.agency/blog/support-ticket-automation-playbook-triage-routing-slas-knowledge-qa)
- [Queue strategy & SLA milestones](https://www.supportbench.com/support-queue-strategy-triage-routing-ownership/)
- [CSAT timing, one-question surveys, closed loop](https://supp.support/blog/how-to-set-up-csat-surveys)
---
## Open follow-ups (out of scope for this note)
- Exact SLA targets (minutes/hours) per priority × plan — needs ops decision.
- Whether CSAT is in-app only, email-embedded, or both.
- Company-side roles matrix vs platform-admin-only for v1.
@@ -0,0 +1,251 @@
# 02 — Current inventory: admin, roles, plans (A1/legacy), support
**Agent:** 2/20 · **Mode:** read-only inventory (docs only)
**Tools:** codehelper `project_context` (short) → `kickoff``investigate`/`query`/`context`; Read/Grep/Glob fallback where workspace file tools were unavailable.
**Desired target (from parent brief / sibling agents 36):**
| Persona / package | Desired access |
|-------------------|----------------|
| **Legacy plan (A1 + legacy-marked)** | Limited product nav only (Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Billing, Settings). **No** Background Tasks / `processing.monitor`, stores, marketing extras |
| **Platform admin + developer** | Full `/admin` + full ops capabilities |
| **support_staff** | Support ticket queue / assign / reply only — **not** billing/plan mutation |
---
## 1. Executive summary
Today the platform has a **binary** staff gate (`users.is_platform_admin`) and **company membership** roles (`admin` | `member`, plus API-key role `api`). There is **no** `staff_role` / `support_staff` / `developer` enum in code or schema (`is_legacy`, `staff_role`, `support_staff`, `developer`**zero** matches).
**A1** is modeled as a **client deal / custom package** (non-public plan name → `IsCustomPackage` → default **all features ON**), not as a limited “legacy nav” profile. Demo tenant **Local Demo Co** (exA1 Slovenija) is on public **Enterprise** (`is_custom=true`), which also resolves to all-ON features.
Support desk exists (tenant + admin APIs, assignee field, agent replies) but **admin support routes require full platform admin**. No CSAT/rating fields found under `apps/api/internal/support`.
---
## 2. Role model (what exists)
### 2.1 Platform staff — single boolean
| Symbol / field | Path | Notes |
|----------------|------|-------|
| `User.IsPlatformAdmin` | `apps/api/internal/auth/service.go` (`User`) | JSON `is_platform_admin` |
| `Service.IsPlatformAdmin` | `apps/api/internal/auth/invites.go:209` | DB: `SELECT is_platform_admin FROM users WHERE id = $1 AND is_active` |
| `RequirePlatformAdmin` | `apps/api/internal/httpapi/middleware.go:245` | Session + DB check; **not** a client claim |
| `checkPlatformAdmin` | `apps/api/internal/httpapi/middleware.go:262` | Test hook `testPlatformAdmin` or `Auth.IsPlatformAdmin` |
| `requirePlatformAdmin` | `apps/web/src/lib/admin-gate.ts:8` | Client gate via `GET /api/auth/me` |
| `authSession.isPlatformAdmin` | `apps/web/src/lib/auth-session.svelte.ts` | UI session mirror |
| `applyPlatformAdmins` | `apps/api/cmd/migrator/admins.go:13` | Legacy MySQL `admin_users``users.is_platform_admin` |
| `seed-demo` | `apps/api/cmd/seed-demo/main.go` | Forces demo user `is_platform_admin = true` |
**Missing vs desired:** `admin` / `developer` / `support_staff` staff roles; APIs to list/assign staff roles; middleware that allows support-only routes without full platform admin.
### 2.2 Company membership roles
| Concept | Where | Values |
|---------|-------|--------|
| Membership `role` | `memberships.role` (auth/company handlers) | `admin`, `member` (invite normalize rejects `owner`) |
| `CompanyAdminAllowed` | `middleware.go:43` | `admin` \|\| `api` |
| `requireCompanyAdmin` | `middleware.go:48` | 403 `"admin required"` |
| `allowCompanyAdminOrPlatform` | `middleware.go:58` | Company admin **or** platform admin (cutover: all-member tenants) |
| Team promote/demote | `company_handlers.go`, `company_member_role_test.go` | Last-admin guards |
Orthogonal to plan features (also noted in `docs/plan-permissions/02-plans-permissions-current.md` § membership).
### 2.3 Support message “agent” role (not staff RBAC)
Ticket messages use `author_role``user` | `agent` | `system` (`apps/web/src/lib/support/admin-api.ts`). `ReplyAsAgent` (`support/tickets.go:325`) writes `author_role='agent'`. That is **message authorship**, not a user staff role.
---
## 3. Admin panel — web routes
All under `apps/web/src/routes/admin/`. Pages call `requirePlatformAdmin()` (except layout which only shows chrome when `me.user.is_platform_admin`).
| Route | File | In `AdminNav`? | Purpose |
|-------|------|----------------|---------|
| `/admin` | `+page.svelte` | Overview | Analytics summary + tool cards |
| `/admin/users` | `users/+page.svelte` | Yes | User list, set-password emails, **dev** set-password / impersonate |
| `/admin/analytics` | `analytics/+page.svelte` | Yes | Tokens / credits / jobs |
| `/admin/billing` | `billing/+page.svelte` | Yes | Plans, assign, credits, cycles + **PlanPermissionsPanel** |
| `/admin/support` | `support/+page.svelte` | Yes | Ticket queue |
| `/admin/support/[id]` | `support/[id]/+page.svelte` | (via Support) | Ticket detail / reply |
| `/admin/stuck-products` | `stuck-products/+page.svelte` | Yes | Stuck jobs |
| `/admin/settings` | `settings/+page.svelte` | Yes | Platform settings |
| `/admin/tasks-cleanup` | `tasks-cleanup/+page.svelte` | No (overview card only) | Same stuck cleanup |
| `/admin/logs` | `logs/+page.svelte` | No (deep link; nav comment: no logs API) | Placeholder |
| `/admin/bootstrap` | `bootstrap/+page.svelte` | No | Check admin flag; cannot create admins via API |
| `/admin/migrate-organizations` | `migrate-organizations/+page.svelte` | No | Migration helper UI |
**Nav chrome:** `AdminNav``apps/web/src/lib/components/AdminNav.svelte` (`menuItems`).
**Layout wiring:** `apps/web/src/routes/+layout.svelte``showAdminNav = Boolean(me?.user?.is_platform_admin)`; main app `Nav` gets `showAdmin` for “Platform admin” link (`adminOnly: true`).
---
## 4. Admin panel — API routes
Mounted in `Server.Router``apps/api/internal/httpapi/server.go:294325`:
```
/api/admin/* → RequireSession + RequirePlatformAdmin
```
| Method | Path | Handler (approx) |
|--------|------|------------------|
| GET | `/users` | `handleAdminListUsers` |
| POST | `/users/{id}/dev-password` | `handleAdminDevSetPassword` (non-prod) |
| POST | `/users/{id}/impersonate` | `handleAdminDevImpersonate` (non-prod) |
| GET | `/companies` | `handleAdminListCompanies` |
| GET | `/readiness` | `handleAdminReadiness` |
| GET | `/analytics` | `handleAdminAnalytics` |
| GET | `/jobs` | `handleAdminListJobs` |
| POST | `/jobs/stuck-cleanup` | `handleAdminStuckCleanup` |
| GET/PUT | `/settings` | platform settings |
| POST | `/settings/mail/test` | mail probe |
| GET/POST | `/plans` | list / upsert |
| GET/PUT | `/plans/{planID}/features` | plan feature overrides |
| POST | `/plans/{planID}/features/enable-all` | convenience |
| POST | `/plans/{planID}/features/disable-all` | convenience |
| GET/PUT | `/feature-gates` | global gates |
| PUT | `/feature-gates/sections/{section}` | section toggle |
| POST | `/plans/assign` | assign plan to company |
| POST | `/credits` | add credits |
| POST | `/billing/run-cycles` | renewals |
| POST | `/emails/set-password` | cutover emails |
| GET | `/support/tickets` | admin queue |
| GET | `/support/tickets/{id}` | admin get |
| POST | `/support/tickets/{id}/messages` | `ReplyAsAgent` |
| PATCH | `/support/tickets/{id}` | `UpdateAdmin` (status/priority/assignee) |
**Dev-only staff tooling** (not a “developer” role): `admin_dev_handlers.go` — blocked when `Config.IsProduction()`.
---
## 5. PlanPermissionsPanel & plan features
| Piece | Path / symbol |
|-------|----------------|
| UI panel | `apps/web/src/lib/components/admin/PlanPermissionsPanel.svelte` |
| Host page | `/admin/billing` Permissions tab — `billing/+page.svelte` |
| Client API | `apps/web/src/lib/admin-plan-permissions.ts` (`listAdminPlansWithFeatures`, `loadFeatureGates`, enable/disable-all, …) |
| Catalog | `apps/web/src/lib/plan-feature-catalog.ts` (`isDefaultPublicPlanName`, sections) |
| Prior design doc | `docs/plan-permissions/08-admin-ui.md` |
**Package kind badges** (panel `$derived packageKind`):
- `default` — public ladder name + `!is_custom`
- `ladder_custom` — public ladder + `is_custom` (Enterprise)
- `custom``is_custom`
- `deal` — non-ladder name without treating as custom badge path
Stub mode when feature APIs return 404/501 (`isPlanPermissionsApiUnavailable`).
Backend resolve path: `DefaultPlanFeatures``PlanAllowsFeature``ResolveEffectiveFeatures` (`apps/api/internal/billing/plan_features.go`). Custom packages default **all catalog keys ON**.
---
## 6. Plans: `is_custom`, public ladder, A1 / “legacy”
### 6.1 Public vs client deals
| Symbol | Path | Behavior |
|--------|------|----------|
| `IsPublicProductPlan` | `billing/service.go:485` | `free\|starter\|growth\|business\|enterprise` only |
| `ListPublicPlans` | `billing/service.go:600` | Marketing / self-serve; **excludes** client deals |
| Admin `ListPlans` | via `/api/admin/plans` | **All** plan rows (incl. A1, Merkur, …) |
| `EnsureDefaultPlans` | `service.go:496` | Syncs public ladder only; named deals untouched |
Comments explicitly name **A1**, Merkur trial, legacy Basic/Professional as non-public client deals.
### 6.2 `IsCustomPackage` / `is_custom`
| Symbol | Path | Rule |
|--------|------|------|
| `IsCustomPackage(name, isCustom)` | `billing/custom_package_features.go:19` | `isCustom \|\| !IsPublicProductPlan(name)` |
| `prepareCustomPackageCreateFeatures` | same file `:49` | Non-public names force `IsCustom=true`; empty features → all-ON map |
| Enterprise seed | `defaultPublicPlans()` | Public name **with** `IsCustom: true` |
**Tests:** `custom_package_features_test.go``"A1", false, true` (name alone ⇒ custom treatment).
### 6.3 Company A1 / Local Demo Co
| Fact | Source |
|------|--------|
| Legacy MySQL company | `A1 Slovenija` / `97e1a309-3d23-4aa2-b518-8e8d7afdfec7` |
| Postgres id | `ee246275-dec0-4446-9e83-58d0c16c258a` |
| Display name after seed | **Local Demo Co** (`docs/demo-user.md`, `docs/migrate-from-descrybe-new.md`) |
| Demo plan | **Enterprise** (`is_custom=true`, 1M credits) — **not** a plan named `A1` |
| Migrated teammate emails | `…@legacy.local` (dev password / impersonate) |
**Implication:** Even if a DB row named `A1` exists as a client plan, feature defaults are **enable-all**, opposite of desired **legacy limited nav**. There is **no** `is_legacy` column or legacy feature profile in billing today.
### 6.4 Product nav gating (tenant UI)
`Nav.svelte` filters items via `planCapabilities.can(featureKey)`.
`processing.monitor` (Background Tasks / Processing) is a normal feature key — currently ON for custom/Enterprise. Desired legacy matrix would turn it **OFF** (and hide stores/marketing extras).
---
## 7. Support — tenant + admin
### 7.1 Tenant (company-scoped)
| Surface | Path |
|---------|------|
| UI | `/support`, `/support/new`, `/support/[ticketId]` under `apps/web/src/routes/support/` |
| API | `server.go:472+``GET/POST /api/support/tickets`, messages, notifications |
| Handlers | `handleListSupportTickets`, `Create`, `Get`, `Reply` — session + **RequireCompany** |
| Service | `ListForUser` / `Create` / `GetForUser` / `ReplyAsUser` |
Nav item: `/support` gated by feature `support.center`.
### 7.2 Admin / staff queue
| Surface | Path |
|---------|------|
| UI | `/admin/support`, `/admin/support/[id]` |
| Client | `apps/web/src/lib/support/admin-api.ts` |
| API | `/api/admin/support/tickets*` (platform admin only) |
| Service | `ListAdmin`, `GetAdmin`, `ReplyAsAgent`, `UpdateAdmin` |
| Schema | `apps/api/sql/schema/025_support_center.sql` (indexed; includes `assignee_admin_user_id`) |
**Assignee:** `UpdateAdmin` / PATCH body `assignee_admin_user_id` / `clear_assignee`. No separate “support staff inbox” or claim-only policy — any platform admin sees the full queue.
**Ratings / CSAT:** not present in support package types/handlers (gap for agent 11+).
---
## 8. Gaps vs desired state
| Desired | Current | Gap |
|---------|---------|-----|
| Legacy limited nav (A1 / legacy plans) | Custom/non-public → **all features ON**; demo A1 tenant on **Enterprise** all-ON | Need legacy profile / `is_legacy` / seed matrix; map A1 (+ similar) to sparse features; turn off `processing.monitor`, stores, marketing extras |
| Admin + developer full admin | Only `is_platform_admin` boolean | Need staff role enum; map admin+developer → full `/api/admin`; optionally keep prod-safe “dev tools” separate from role |
| support_staff ticket access only | Support admin APIs behind **same** `RequirePlatformAdmin` as billing | Need least-privilege middleware + AdminNav subset (Support only); block plans/settings/credits |
| Role chips / assign staff from orgs UI | Users list shows `is_platform_admin` badge only | No assign APIs; bootstrap page cannot create admins |
| Support assign + staff inbox + CSAT | Assignee field exists; no staff role, no CSAT | Extend UpdateAdmin + UI; add rating schema |
| Differentiate legacy vs `is_custom` | `is_custom` / deal name ⇒ enable-all | Contract must separate **custom deal packaging** from **legacy feature profile** |
---
## 9. Extension points (summary)
Prefer extending existing gates rather than parallel systems:
1. **Staff RBAC** — extend `checkPlatformAdmin` / `RequirePlatformAdmin` (or sibling `RequireStaffRole`) reading a new `users.staff_role` (or replace boolean carefully).
2. **Legacy features** — extend `DefaultPlanFeatures` / `IsCustomPackage` / seed (`default_plan_features_seed.go`) with a `legacy` profile; detection: plan name patterns (`A1`, …) and/or `is_legacy` flag.
3. **Admin nav**`AdminNav.menuItems` filtered by staff capability; support_staff → Support (+ maybe Overview read-only if needed).
4. **Support** — keep `/api/admin/support/*` but authorize `admin|developer|support_staff`; optionally filter `ListAdmin` by assignee for least privilege.
5. **PlanPermissionsPanel** — already the UI to inspect/set matrices; add Legacy badge + apply-legacy-profile action once backend exists.
Machine-readable companion: [`02-extension-points.json`](./02-extension-points.json).
---
## 10. Related docs
- `docs/plan-permissions/02-plans-permissions-current.md` — plans / features / roles orthogonality
- `docs/plan-permissions/08-admin-ui.md` — PlanPermissionsPanel contract
- `docs/demo-user.md` — Local Demo Co / A1 migration
- `docs/migrate-from-descrybe-new.md` — A1-only migrator scope
- Sibling agents will add `03-roles-matrix`, `04-contract`, `05-legacy-seed`, `06-staff-roles`, …
@@ -0,0 +1,340 @@
{
"doc": "02-extension-points",
"agent": "2/20",
"generated_for": "admin-roles-support",
"desired": {
"legacy_limited_nav": [
"dashboard.overview",
"catalog.products",
"feeds.list",
"feeds.export_feeds",
"catalog.categories",
"catalog.attributes",
"catalog.standard_fields",
"billing.overview",
"settings.profile"
],
"legacy_explicitly_off_examples": [
"processing.monitor",
"stores.hub",
"marketing.campaigns",
"marketing.brand_kit",
"marketing.seo",
"marketing.content_calendar",
"marketing.reviews"
],
"staff_roles": ["admin", "developer", "support_staff"],
"staff_access": {
"admin": "full /admin and /api/admin",
"developer": "full /admin and /api/admin (align with admin; keep prod-blocked dev-password/impersonate as env gate)",
"support_staff": "support ticket queue/reply/assign only; no billing/plan/settings mutation"
}
},
"current_role_model": {
"platform": {
"mechanism": "boolean users.is_platform_admin",
"symbols": [
{
"name": "IsPlatformAdmin",
"kind": "method",
"path": "apps/api/internal/auth/invites.go",
"line": 209,
"sym": "sym:descrybe-v2:apps/api/internal/auth/invites.go:209:IsPlatformAdmin"
},
{
"name": "RequirePlatformAdmin",
"kind": "method",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 245,
"sym": "sym:descrybe-v2:apps/api/internal/httpapi/middleware.go:245:RequirePlatformAdmin"
},
{
"name": "checkPlatformAdmin",
"kind": "method",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 262,
"sym": "sym:descrybe-v2:apps/api/internal/httpapi/middleware.go:262:checkPlatformAdmin"
},
{
"name": "requirePlatformAdmin",
"kind": "function",
"path": "apps/web/src/lib/admin-gate.ts",
"line": 8,
"sym": "sym:descrybe-v2:apps/web/src/lib/admin-gate.ts:8:requirePlatformAdmin"
},
{
"name": "applyPlatformAdmins",
"kind": "function",
"path": "apps/api/cmd/migrator/admins.go",
"line": 13,
"sym": "sym:descrybe-v2:apps/api/cmd/migrator/admins.go:13:applyPlatformAdmins"
}
],
"missing": ["staff_role enum", "support_staff", "developer role", "assign-staff APIs"]
},
"company_membership": {
"roles": ["admin", "member"],
"api_key_role": "api",
"symbols": [
{
"name": "CompanyAdminAllowed",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 43
},
{
"name": "allowCompanyAdminOrPlatform",
"path": "apps/api/internal/httpapi/middleware.go",
"line": 58
}
]
},
"support_message_author_roles": ["user", "agent", "system"],
"note": "author_role=agent is message authorship, not users.staff_role"
},
"admin_web_routes": {
"nav_component": {
"name": "AdminNav",
"path": "apps/web/src/lib/components/AdminNav.svelte",
"line": 30,
"menu_items": [
"/admin",
"/admin/users",
"/admin/analytics",
"/admin/billing",
"/admin/support",
"/admin/stuck-products",
"/admin/settings"
]
},
"layout_gate": {
"path": "apps/web/src/routes/+layout.svelte",
"showAdminNav": "me.user.is_platform_admin"
},
"pages": [
{ "href": "/admin", "file": "apps/web/src/routes/admin/+page.svelte", "in_nav": true },
{ "href": "/admin/users", "file": "apps/web/src/routes/admin/users/+page.svelte", "in_nav": true },
{ "href": "/admin/analytics", "file": "apps/web/src/routes/admin/analytics/+page.svelte", "in_nav": true },
{ "href": "/admin/billing", "file": "apps/web/src/routes/admin/billing/+page.svelte", "in_nav": true, "hosts": "PlanPermissionsPanel" },
{ "href": "/admin/support", "file": "apps/web/src/routes/admin/support/+page.svelte", "in_nav": true },
{ "href": "/admin/support/[id]", "file": "apps/web/src/routes/admin/support/[id]/+page.svelte", "in_nav": false },
{ "href": "/admin/stuck-products", "file": "apps/web/src/routes/admin/stuck-products/+page.svelte", "in_nav": true },
{ "href": "/admin/settings", "file": "apps/web/src/routes/admin/settings/+page.svelte", "in_nav": true },
{ "href": "/admin/tasks-cleanup", "file": "apps/web/src/routes/admin/tasks-cleanup/+page.svelte", "in_nav": false },
{ "href": "/admin/logs", "file": "apps/web/src/routes/admin/logs/+page.svelte", "in_nav": false },
{ "href": "/admin/bootstrap", "file": "apps/web/src/routes/admin/bootstrap/+page.svelte", "in_nav": false },
{ "href": "/admin/migrate-organizations", "file": "apps/web/src/routes/admin/migrate-organizations/+page.svelte", "in_nav": false }
]
},
"admin_api": {
"mount": {
"path": "apps/api/internal/httpapi/server.go",
"line": 294,
"middleware": ["RequireSession", "RequirePlatformAdmin"],
"prefix": "/api/admin"
},
"routes": [
"GET /users",
"POST /users/{id}/dev-password",
"POST /users/{id}/impersonate",
"GET /companies",
"GET /readiness",
"GET /analytics",
"GET /jobs",
"POST /jobs/stuck-cleanup",
"GET|PUT /settings",
"POST /settings/mail/test",
"GET|POST /plans",
"GET|PUT /plans/{planID}/features",
"POST /plans/{planID}/features/enable-all",
"POST /plans/{planID}/features/disable-all",
"GET|PUT /feature-gates",
"PUT /feature-gates/sections/{section}",
"POST /plans/assign",
"POST /credits",
"POST /billing/run-cycles",
"POST /emails/set-password",
"GET /support/tickets",
"GET /support/tickets/{id}",
"POST /support/tickets/{id}/messages",
"PATCH /support/tickets/{id}"
],
"dev_handlers": {
"path": "apps/api/internal/httpapi/admin_dev_handlers.go",
"production": "404 / blocked"
}
},
"plan_permissions_panel": {
"component": {
"name": "PlanPermissionsPanel",
"path": "apps/web/src/lib/components/admin/PlanPermissionsPanel.svelte",
"line": 84
},
"client": {
"path": "apps/web/src/lib/admin-plan-permissions.ts",
"symbols": [
"isPlanPermissionsApiUnavailable",
"listAdminPlansWithFeatures",
"loadFeatureGates",
"saveFeatureGates",
"enableAllPlanFeatures",
"disableAllPlanFeatures"
]
},
"catalog": "apps/web/src/lib/plan-feature-catalog.ts",
"host": "apps/web/src/routes/admin/billing/+page.svelte",
"prior_doc": "docs/plan-permissions/08-admin-ui.md"
},
"plans_is_custom_legacy_a1": {
"symbols": [
{
"name": "IsPublicProductPlan",
"path": "apps/api/internal/billing/service.go",
"line": 485,
"public_names": ["Free", "Starter", "Growth", "Business", "Enterprise"]
},
{
"name": "IsCustomPackage",
"path": "apps/api/internal/billing/custom_package_features.go",
"line": 19,
"rule": "isCustom || !IsPublicProductPlan(name)",
"sym": "sym:descrybe-v2:apps/api/internal/billing/custom_package_features.go:19:IsCustomPackage"
},
{
"name": "DefaultPlanFeatures",
"path": "apps/api/internal/billing/plan_features.go",
"line": 71,
"custom_default": "all FeatureCatalogKeys true"
},
{
"name": "prepareCustomPackageCreateFeatures",
"path": "apps/api/internal/billing/custom_package_features.go",
"line": 49
}
],
"a1_as_test_fixture": {
"tests": "apps/api/internal/billing/custom_package_features_test.go",
"behavior": "plan name A1 ⇒ IsCustomPackage true ⇒ all features ON"
},
"company_a1": {
"legacy_mysql_name": "A1 Slovenija",
"legacy_company_id": "97e1a309-3d23-4aa2-b518-8e8d7afdfec7",
"postgres_company_id": "ee246275-dec0-4446-9e83-58d0c16c258a",
"display_name_after_seed": "Local Demo Co",
"seed_plan": "Enterprise",
"seed_is_custom": true,
"docs": ["docs/demo-user.md", "docs/migrate-from-descrybe-new.md"]
},
"is_legacy_flag": false,
"legacy_feature_profile": false
},
"support": {
"tenant": {
"ui": [
"apps/web/src/routes/support/+page.svelte",
"apps/web/src/routes/support/new/+page.svelte",
"apps/web/src/routes/support/[ticketId]/+page.svelte"
],
"api_prefix": "/api/support",
"handlers_file": "apps/api/internal/httpapi/support_handlers.go",
"auth": "RequireSession + RequireCompany"
},
"admin": {
"ui": [
"apps/web/src/routes/admin/support/+page.svelte",
"apps/web/src/routes/admin/support/[id]/+page.svelte"
],
"client": "apps/web/src/lib/support/admin-api.ts",
"api_prefix": "/api/admin/support",
"auth": "RequirePlatformAdmin only",
"service_symbols": [
{
"name": "ListAdmin",
"path": "apps/api/internal/support/tickets.go",
"line": 80
},
{
"name": "GetAdmin",
"path": "apps/api/internal/support/tickets.go",
"line": 158
},
{
"name": "ReplyAsAgent",
"path": "apps/api/internal/support/tickets.go",
"line": 325
},
{
"name": "UpdateAdmin",
"path": "apps/api/internal/support/tickets.go",
"line": 428,
"supports": ["status", "priority", "assignee_admin_user_id", "clear_assignee"]
}
],
"schema": "apps/api/sql/schema/025_support_center.sql"
},
"csat_rating": false,
"staff_inbox_filter": false
},
"tenant_nav_gating": {
"component": "apps/web/src/lib/components/Nav.svelte",
"mechanism": "planCapabilities.can(featureKey); adminOnly uses showAdmin",
"processing_item": {
"href": "/processing",
"feature": "processing.monitor",
"desired_for_legacy": "off"
}
},
"extension_points": [
{
"id": "staff-rbac",
"prefer": "extend RequirePlatformAdmin / checkPlatformAdmin",
"add": "users.staff_role (admin|developer|support_staff) additive migration",
"apis": "list/assign staff roles (admin-only)",
"web": "filter AdminNav + requirePlatformAdmin → requireStaffCapability"
},
{
"id": "legacy-feature-profile",
"prefer": "extend DefaultPlanFeatures + seed/ApplyDefaultMatrix",
"detect": ["plan name patterns (A1, …)", "optional plans.is_legacy", "company flag if needed"],
"do_not_conflate": "is_custom / IsCustomPackage enable-all packaging",
"ui": "PlanPermissionsPanel legacy badge + apply profile"
},
{
"id": "support-staff-least-privilege",
"prefer": "same /api/admin/support/* handlers with role-aware middleware",
"optional": "ListAdmin filter by assignee; claim/unassign UX",
"block_for_support_staff": ["/api/admin/plans*", "/api/admin/credits", "/api/admin/settings", "/api/admin/billing/*"]
},
{
"id": "support-csat",
"status": "absent",
"hook": "after ticket resolved/closed — new columns + tenant UI"
}
],
"gaps": [
{
"desired": "legacy limited nav",
"current": "A1/custom/Enterprise resolve to all features ON",
"severity": "high"
},
{
"desired": "admin/developer full admin",
"current": "single is_platform_admin boolean; no developer role",
"severity": "high"
},
{
"desired": "support_staff ticket access only",
"current": "support admin requires full platform admin (same as billing)",
"severity": "high"
},
{
"desired": "staff assign from orgs UI",
"current": "users list badge only; bootstrap cannot create admins",
"severity": "medium"
},
{
"desired": "CSAT / ratings",
"current": "not in support package",
"severity": "medium"
}
]
}
@@ -0,0 +1,771 @@
{
"agent": "03/20",
"source": [
"docs/plan-permissions/01-feature-keys.json",
"docs/plan-permissions/06-defaults-matrix.json",
"docs/plan-permissions/03-permission-contract.md",
"apps/web/src/lib/components/Nav.svelte",
"apps/web/src/lib/components/AdminNav.svelte",
"docs/migrate-from-descrybe-new.md",
"docs/demo-user.md"
],
"assumptions": [
"legacy_user / standard_user are product-cohort roles derived from plan name patterns (and/or plans.cohort), not company membership admin|member",
"A1 company/plan example maps to legacy profile (including Local Demo Co when it is the renamed A1 tenant)",
"effective(feature) = role_allows AND plan_allows AND global gates",
"standard_user matrix is a ceiling (all registry keys true); Free/Starter denials still apply via 06-defaults-matrix",
"support_staff has no dedicated DB flag yet — design-only until schema lands",
"Platform /admin/* is orthogonal to dashboard feature_keys (is_platform_admin / staff)"
],
"legacy_nav_allowlist": [
{ "label": "Dashboard", "route": "/dashboard", "feature": "dashboard.overview" },
{ "label": "Products", "route": "/products", "feature": "catalog.products" },
{ "label": "Feeds", "route": "/feeds", "feature": "feeds.list" },
{ "label": "Export Feeds", "route": "/export-feeds", "feature": "feeds.export_feeds" },
{ "label": "Categories", "route": "/categories", "feature": "catalog.categories" },
{ "label": "Attributes", "route": "/attributes", "feature": "catalog.attributes" },
{ "label": "Standard Fields", "route": "/standard-fields", "feature": "catalog.standard_fields" },
{ "label": "Usage & Billing", "route": "/billing", "feature": "billing.overview" },
{ "label": "Settings", "route": "/settings", "feature": "settings.profile" }
],
"legacy_nav_excluded": [
"processing.monitor",
"stores.hub",
"stores.woocommerce",
"stores.shopify",
"marketing.campaigns",
"marketing.content_calendar",
"marketing.brand_kit",
"marketing.seo",
"marketing.reviews",
"integrations.ai",
"integrations.email",
"support.center",
"catalog.structured_descriptions",
"catalog.vector_categories"
],
"plan_name_patterns": [
{
"profile": "legacy",
"match": "exact",
"patterns": ["legacy"]
},
{
"profile": "legacy",
"match": "regex",
"patterns": ["(?i)^a1(\\b|[\\s_-])", "(?i)a1\\s*slovenija"],
"examples": ["A1", "A1 Slovenija", "A1-Legacy"]
},
{
"profile": "legacy",
"match": "company_example",
"patterns": ["A1 Slovenija"],
"legacy_company_id": "97e1a309-3d23-4aa2-b518-8e8d7afdfec7",
"note": "Demo may rename display to Local Demo Co; cohort remains legacy when flagged or plan name matches"
},
{
"profile": "standard",
"match": "exact",
"patterns": ["free", "starter", "growth", "business", "enterprise"],
"ladder_ref": "docs/plan-permissions/06-defaults-matrix.json"
},
{
"profile": "custom",
"match": "is_custom_true_non_legacy",
"patterns": [],
"note": "is_custom=true and name does not match legacy patterns → all-on custom defaults"
}
],
"roles": {
"legacy_user": {
"description": "End-user on legacy-pattern company/plan; nav = legacy allow-list only",
"plan_profile": "legacy",
"platform_admin": false,
"company_membership": ["admin", "member"],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": false,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": false,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": false,
"catalog.vector_categories": false,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": false,
"stores.woocommerce": false,
"stores.woocommerce.connection": false,
"stores.woocommerce.categories": false,
"stores.woocommerce.attributes": false,
"stores.woocommerce.orders": false,
"stores.woocommerce.reviews": false,
"stores.woocommerce.settings": false,
"stores.shopify": false,
"stores.shopify.connection": false,
"stores.shopify.orders": false,
"stores.shopify.settings": false,
"processing.monitor": false,
"marketing.campaigns": false,
"marketing.campaigns.create": false,
"marketing.campaigns.generate_ai": false,
"marketing.campaigns.send": false,
"marketing.content_calendar": false,
"marketing.brand_kit": false,
"marketing.brand_ai_apply": false,
"marketing.seo": false,
"marketing.seo.template_fill": false,
"marketing.seo.ai_rewrite": false,
"marketing.reviews": false,
"integrations.ai": false,
"integrations.ai.byok": false,
"integrations.email": false,
"integrations.email.test": false,
"integrations.email.blast": false,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": false,
"support.ticket_create": false,
"support.ticket_thread": false,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": false,
"capability.email_live_send": false,
"capability.brand_ai_apply": false,
"capability.seo_ai_rewrite": false,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": false
}
},
"standard_user": {
"description": "End-user on public ladder; features further restricted by Free→Enterprise plan matrix",
"plan_profile": "standard",
"platform_admin": false,
"company_membership": ["admin", "member"],
"plan_matrix_ref": "docs/plan-permissions/06-defaults-matrix.json",
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": true,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": true,
"catalog.vector_categories": true,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": true,
"stores.woocommerce": true,
"stores.woocommerce.connection": true,
"stores.woocommerce.categories": true,
"stores.woocommerce.attributes": true,
"stores.woocommerce.orders": true,
"stores.woocommerce.reviews": true,
"stores.woocommerce.settings": true,
"stores.shopify": true,
"stores.shopify.connection": true,
"stores.shopify.orders": true,
"stores.shopify.settings": true,
"processing.monitor": true,
"marketing.campaigns": true,
"marketing.campaigns.create": true,
"marketing.campaigns.generate_ai": true,
"marketing.campaigns.send": true,
"marketing.content_calendar": true,
"marketing.brand_kit": true,
"marketing.brand_ai_apply": true,
"marketing.seo": true,
"marketing.seo.template_fill": true,
"marketing.seo.ai_rewrite": true,
"marketing.reviews": true,
"integrations.ai": true,
"integrations.ai.byok": true,
"integrations.email": true,
"integrations.email.test": true,
"integrations.email.blast": true,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": true,
"capability.email_live_send": true,
"capability.brand_ai_apply": true,
"capability.seo_ai_rewrite": true,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": true
}
},
"admin": {
"description": "Platform admin (users.is_platform_admin); full dashboard + AdminNav",
"plan_profile": null,
"platform_admin": true,
"admin_nav": [
"/admin",
"/admin/users",
"/admin/analytics",
"/admin/billing",
"/admin/support",
"/admin/stuck-products",
"/admin/settings"
],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": true,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": true,
"catalog.vector_categories": true,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": true,
"stores.woocommerce": true,
"stores.woocommerce.connection": true,
"stores.woocommerce.categories": true,
"stores.woocommerce.attributes": true,
"stores.woocommerce.orders": true,
"stores.woocommerce.reviews": true,
"stores.woocommerce.settings": true,
"stores.shopify": true,
"stores.shopify.connection": true,
"stores.shopify.orders": true,
"stores.shopify.settings": true,
"processing.monitor": true,
"marketing.campaigns": true,
"marketing.campaigns.create": true,
"marketing.campaigns.generate_ai": true,
"marketing.campaigns.send": true,
"marketing.content_calendar": true,
"marketing.brand_kit": true,
"marketing.brand_ai_apply": true,
"marketing.seo": true,
"marketing.seo.template_fill": true,
"marketing.seo.ai_rewrite": true,
"marketing.reviews": true,
"integrations.ai": true,
"integrations.ai.byok": true,
"integrations.email": true,
"integrations.email.test": true,
"integrations.email.blast": true,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": true,
"capability.email_live_send": true,
"capability.brand_ai_apply": true,
"capability.seo_ai_rewrite": true,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": true
}
},
"developer": {
"description": "Engineering/ops; full feature ceiling including debug catalog extras",
"plan_profile": null,
"platform_admin": true,
"admin_nav": [
"/admin",
"/admin/users",
"/admin/analytics",
"/admin/billing",
"/admin/support",
"/admin/stuck-products",
"/admin/settings"
],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": true,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": true,
"catalog.vector_categories": true,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": true,
"stores.woocommerce": true,
"stores.woocommerce.connection": true,
"stores.woocommerce.categories": true,
"stores.woocommerce.attributes": true,
"stores.woocommerce.orders": true,
"stores.woocommerce.reviews": true,
"stores.woocommerce.settings": true,
"stores.shopify": true,
"stores.shopify.connection": true,
"stores.shopify.orders": true,
"stores.shopify.settings": true,
"processing.monitor": true,
"marketing.campaigns": true,
"marketing.campaigns.create": true,
"marketing.campaigns.generate_ai": true,
"marketing.campaigns.send": true,
"marketing.content_calendar": true,
"marketing.brand_kit": true,
"marketing.brand_ai_apply": true,
"marketing.seo": true,
"marketing.seo.template_fill": true,
"marketing.seo.ai_rewrite": true,
"marketing.reviews": true,
"integrations.ai": true,
"integrations.ai.byok": true,
"integrations.email": true,
"integrations.email.test": true,
"integrations.email.blast": true,
"billing.overview": true,
"billing.customer_portal": true,
"billing.quick_upgrade": true,
"billing.plans_compare": true,
"billing.checkout": true,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": true,
"settings.team": true,
"settings.team_invite": true,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": true,
"capability.email_live_send": true,
"capability.brand_ai_apply": true,
"capability.seo_ai_rewrite": true,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": true,
"capability.byok": true
}
},
"support_staff": {
"description": "Support agents; support queue + assisted tenant context; no platform settings/billing writes",
"plan_profile": null,
"platform_admin": false,
"staff_flag": "is_support_staff [UNCERTAIN — not in schema yet]",
"admin_nav": [
"/admin/support",
"/admin/users",
"/admin/stuck-products"
],
"admin_nav_denied": [
"/admin/settings",
"/admin/billing"
],
"features": {
"shell.navigation": true,
"shell.command_palette": true,
"shell.company_switcher": true,
"shell.support_notifications": true,
"shell.tutorial": true,
"shell.account_menu": true,
"shell.billing_recovery_banner": true,
"dashboard.overview": true,
"dashboard.stats": true,
"dashboard.quick_links": true,
"dashboard.recent_jobs": true,
"dashboard.news_feed": true,
"dashboard.activation_checklist": true,
"dashboard.migrated_checklist": true,
"dashboard.etl_gaps": true,
"dashboard.store_reconnect": false,
"dashboard.upgrade_banners": true,
"catalog.products": true,
"catalog.products.tab_processed": true,
"catalog.products.tab_needs_review": true,
"catalog.products.tab_error": true,
"catalog.products.tab_processing": true,
"catalog.products.tab_unprocessed": true,
"catalog.products.process_categories": true,
"catalog.products.process_attributes": true,
"catalog.products.process_ai_titles": true,
"catalog.products.process_ai_descriptions": true,
"catalog.products.enrichment_review": true,
"catalog.products.export_selection": true,
"catalog.products.upgrade_prompt": true,
"catalog.categories": true,
"catalog.categories.title_formula": true,
"catalog.categories.description_formula": true,
"catalog.attributes": true,
"catalog.attributes.bulk_import": true,
"catalog.standard_fields": true,
"catalog.standard_fields.groups": true,
"catalog.structured_descriptions": false,
"catalog.vector_categories": false,
"feeds.list": true,
"feeds.add_url": true,
"feeds.add_csv": true,
"feeds.sync": true,
"feeds.mapping": true,
"feeds.mapping.select_item": true,
"feeds.mapping.map_fields": true,
"feeds.export_feeds": true,
"feeds.export_feeds.create": true,
"feeds.export_feeds.generate": true,
"feeds.uploads": true,
"stores.hub": false,
"stores.woocommerce": false,
"stores.woocommerce.connection": false,
"stores.woocommerce.categories": false,
"stores.woocommerce.attributes": false,
"stores.woocommerce.orders": false,
"stores.woocommerce.reviews": false,
"stores.woocommerce.settings": false,
"stores.shopify": false,
"stores.shopify.connection": false,
"stores.shopify.orders": false,
"stores.shopify.settings": false,
"processing.monitor": true,
"marketing.campaigns": false,
"marketing.campaigns.create": false,
"marketing.campaigns.generate_ai": false,
"marketing.campaigns.send": false,
"marketing.content_calendar": false,
"marketing.brand_kit": false,
"marketing.brand_ai_apply": false,
"marketing.seo": false,
"marketing.seo.template_fill": false,
"marketing.seo.ai_rewrite": false,
"marketing.reviews": false,
"integrations.ai": false,
"integrations.ai.byok": false,
"integrations.email": false,
"integrations.email.test": false,
"integrations.email.blast": false,
"billing.overview": true,
"billing.customer_portal": false,
"billing.quick_upgrade": false,
"billing.plans_compare": true,
"billing.checkout": false,
"settings.profile": true,
"settings.company": true,
"settings.alerts": true,
"settings.api_keys": false,
"settings.team": true,
"settings.team_invite": false,
"support.center": true,
"support.ticket_create": true,
"support.ticket_thread": true,
"capability.sku_cap": true,
"capability.ai_credits": true,
"capability.ai_processing": true,
"capability.eprel": true,
"capability.normalize_specs_fill": true,
"capability.campaign_ai": false,
"capability.email_live_send": false,
"capability.brand_ai_apply": false,
"capability.seo_ai_rewrite": false,
"capability.feed_source_limit": true,
"capability.export_feed_limit": true,
"capability.storage_limit": true,
"capability.api_access": false,
"capability.byok": false
}
}
},
"plan_profiles": {
"legacy": {
"description": "A1 / Legacy package — same feature map as legacy_user",
"features_ref_role": "legacy_user",
"on_keys": [
"shell.navigation",
"shell.command_palette",
"shell.company_switcher",
"shell.tutorial",
"shell.account_menu",
"shell.billing_recovery_banner",
"dashboard.overview",
"dashboard.stats",
"dashboard.quick_links",
"dashboard.recent_jobs",
"dashboard.news_feed",
"dashboard.activation_checklist",
"dashboard.migrated_checklist",
"dashboard.etl_gaps",
"dashboard.upgrade_banners",
"catalog.products",
"catalog.products.tab_processed",
"catalog.products.tab_needs_review",
"catalog.products.tab_error",
"catalog.products.tab_processing",
"catalog.products.tab_unprocessed",
"catalog.products.process_categories",
"catalog.products.process_attributes",
"catalog.products.process_ai_titles",
"catalog.products.process_ai_descriptions",
"catalog.products.enrichment_review",
"catalog.products.export_selection",
"catalog.products.upgrade_prompt",
"catalog.categories",
"catalog.categories.title_formula",
"catalog.categories.description_formula",
"catalog.attributes",
"catalog.attributes.bulk_import",
"catalog.standard_fields",
"catalog.standard_fields.groups",
"feeds.list",
"feeds.add_url",
"feeds.add_csv",
"feeds.sync",
"feeds.mapping",
"feeds.mapping.select_item",
"feeds.mapping.map_fields",
"feeds.export_feeds",
"feeds.export_feeds.create",
"feeds.export_feeds.generate",
"feeds.uploads",
"billing.overview",
"billing.customer_portal",
"billing.quick_upgrade",
"billing.plans_compare",
"billing.checkout",
"settings.profile",
"settings.company",
"settings.alerts",
"settings.api_keys",
"settings.team",
"settings.team_invite",
"capability.sku_cap",
"capability.ai_credits",
"capability.ai_processing",
"capability.eprel",
"capability.normalize_specs_fill",
"capability.feed_source_limit",
"capability.export_feed_limit",
"capability.storage_limit",
"capability.api_access"
],
"off_sections": [
"stores",
"processing",
"marketing",
"integrations",
"support"
]
},
"standard": {
"description": "Public ladder Free→Enterprise; use 06-defaults-matrix per plan name",
"matrix_ref": "docs/plan-permissions/06-defaults-matrix.json"
},
"custom": {
"description": "is_custom=true non-legacy deals; all registry keys ON by default",
"all_features_on": true
}
}
}
+294
View File
@@ -0,0 +1,294 @@
# 03 — Roles & legacy plan matrix (agent 3/20)
**Status:** Design contract for admin roles + legacy cohort packaging.
**Machine-readable twin:** [`03-roles-matrix.json`](./03-roles-matrix.json).
**Feature keys:** align with [`docs/plan-permissions/01-feature-keys.json`](../plan-permissions/01-feature-keys.json).
**Plan defaults (public ladder):** [`docs/plan-permissions/06-defaults-matrix.json`](../plan-permissions/06-defaults-matrix.json).
---
## PROBLEM
Migrated / legacy tenants (example: **A1** → Local Demo Co catalog) must see a **narrow product surface** matching the legacy nav only — not the full v2 Operate/Setup marketing suite. Separately, platform roles need a clear permission ceiling that composes with plan feature keys.
---
## Effective resolution
```
effective(feature) =
role_allows(role, feature)
AND plan_allows(company.plan, feature)
AND global_section_enabled(section(feature))
AND global_feature_enabled(feature)
```
- **Company end-users** (`legacy_user`, `standard_user`): plan matrix is the primary gate; role is the ceiling.
- **Staff roles** (`admin`, `developer`, `support_staff`): role ceiling is wide; company plan still applies when acting *as* a tenant unless impersonation / platform override is explicit.
- Platform admin routes (`/admin/*`) are **orthogonal** to dashboard `feature_key`s — gated by `users.is_platform_admin` (and future staff flags), not by plan JSON.
**ASSUMPTION:** `legacy_user` / `standard_user` are product-cohort roles derived from plan name patterns (and/or an explicit `plans.cohort = legacy|standard` when implemented), not company membership `admin|member`.
**ASSUMPTION:** A1 company / plan example maps to the **legacy** plan profile (see plan name patterns below).
---
## Legacy nav allow-list (ONLY)
These are the **only** user-facing nav destinations for the legacy cohort. Everything else is OFF.
| Nav label | Route | Parent feature key(s) |
|-----------|-------|------------------------|
| Dashboard | `/dashboard` | `dashboard.overview` |
| Products | `/products` | `catalog.products` |
| Feeds | `/feeds` | `feeds.list` (+ mapping) |
| Export Feeds | `/export-feeds` | `feeds.export_feeds` |
| Categories | `/categories` | `catalog.categories` |
| Attributes | `/attributes` | `catalog.attributes` |
| Standard Fields | `/standard-fields` | `catalog.standard_fields` |
| Usage & Billing | `/billing` | `billing.overview` |
| Settings | `/settings` | `settings.profile` (+ settings tabs) |
### Explicitly excluded from legacy
| Surface | Reason |
|---------|--------|
| Background Tasks / Processing (`processing.monitor`) | Out of legacy nav |
| Stores / Woo / Shopify | Out of scope |
| Marketing campaigns, content calendar | Out of scope |
| Brand kit / brand AI | Out of scope |
| SEO | Out of scope |
| Reviews | Out of scope |
| AI integrations / Email sending | Integration extras beyond feeds |
| Support center (tickets) | Support-heavy; not essential for legacy nav |
| Structured descriptions / vector categories | Extra catalog tools, no legacy nav |
| Platform admin chrome | Staff-only (`admin` / `developer`) |
---
## Legacy feature allow-list (ON keys)
Canonical ON set for plan profile **`legacy`** (and role **`legacy_user`**). All other keys in `01-feature-keys.json` are **OFF**.
### Shell
- `shell.navigation`
- `shell.command_palette`
- `shell.company_switcher`
- `shell.tutorial`
- `shell.account_menu`
- `shell.billing_recovery_banner`
OFF: `shell.support_notifications`
### Dashboard
- `dashboard.overview`
- `dashboard.stats`
- `dashboard.quick_links`
- `dashboard.recent_jobs` *(informational only; no Processing nav)*
- `dashboard.news_feed`
- `dashboard.activation_checklist`
- `dashboard.migrated_checklist`
- `dashboard.etl_gaps`
- `dashboard.upgrade_banners`
OFF: `dashboard.store_reconnect`
### Catalog
- `catalog.products` (+ all product tabs)
- `catalog.products.tab_processed`
- `catalog.products.tab_needs_review`
- `catalog.products.tab_error`
- `catalog.products.tab_processing`
- `catalog.products.tab_unprocessed`
- `catalog.products.process_categories`
- `catalog.products.process_attributes`
- `catalog.products.process_ai_titles`
- `catalog.products.process_ai_descriptions`
- `catalog.products.enrichment_review`
- `catalog.products.export_selection`
- `catalog.products.upgrade_prompt`
- `catalog.categories`
- `catalog.categories.title_formula`
- `catalog.categories.description_formula`
- `catalog.attributes`
- `catalog.attributes.bulk_import`
- `catalog.standard_fields`
- `catalog.standard_fields.groups`
OFF: `catalog.structured_descriptions`, `catalog.vector_categories`
### Feeds (import + export + CSV uploads needed for feeds)
- `feeds.list`
- `feeds.add_url`
- `feeds.add_csv`
- `feeds.sync`
- `feeds.mapping`
- `feeds.mapping.select_item`
- `feeds.mapping.map_fields`
- `feeds.export_feeds`
- `feeds.export_feeds.create`
- `feeds.export_feeds.generate`
- `feeds.uploads`
### Billing (Usage & Billing)
- `billing.overview`
- `billing.customer_portal`
- `billing.quick_upgrade`
- `billing.plans_compare`
- `billing.checkout`
### Settings
- `settings.profile`
- `settings.company`
- `settings.alerts`
- `settings.api_keys`
- `settings.team`
- `settings.team_invite`
### Capabilities (core catalog/feeds only)
- `capability.sku_cap`
- `capability.ai_credits`
- `capability.ai_processing`
- `capability.eprel`
- `capability.normalize_specs_fill`
- `capability.feed_source_limit`
- `capability.export_feed_limit`
- `capability.storage_limit`
- `capability.api_access`
OFF: `capability.campaign_ai`, `capability.email_live_send`, `capability.brand_ai_apply`, `capability.seo_ai_rewrite`, `capability.byok`
### Entire sections OFF for legacy
- **stores.*** — all keys
- **processing.*** — `processing.monitor`
- **marketing.*** — all keys
- **integrations.*** — all keys
- **support.*** — all keys
---
## Plan name patterns → profile
| Pattern (case-insensitive) | Profile | Notes |
|----------------------------|---------|-------|
| exact `legacy` | `legacy` | Explicit legacy package |
| `^a1(\b\|[\s_-])` / contains `a1 slovenija` | `legacy` | A1 company / plan example |
| company display historically **A1 Slovenija** (MySQL `97e1a309-…`) | `legacy` | Even if renamed to Local Demo Co for demo |
| exact `free` \| `starter` \| `growth` \| `business` \| `enterprise` | `standard` (+ ladder tier) | Public ladder |
| `is_custom=true` and name does **not** match legacy patterns | `custom` | All-on defaults per plan-permissions contract |
| unknown / empty | `standard` / Free | Fail closed to Free matrix |
**A1 example = legacy.** Treat A1s assigned plan (or a dedicated `Legacy` plan row seeded for that tenant) as the legacy profile.
---
## Roles
| Role key | Who | Plan / feature source | Ceiling |
|----------|-----|----------------------|---------|
| `legacy_user` | End-user on a legacy-pattern company/plan | **legacy** matrix | Legacy allow-list only |
| `standard_user` | End-user on public ladder | Plan matrix from `06-defaults-matrix` (Free→Enterprise) | Full catalog; plan denies Free/Starter AI/BYOK/etc. |
| `admin` | Platform admin (`users.is_platform_admin`) | N/A for `/admin/*`; full dashboard keys ON | All feature keys + AdminNav |
| `developer` | Engineering / ops with platform access | Same as admin + debug catalog extras always ON | All keys; intended for non-prod diagnostics |
| `support_staff` | Support agents | Support + read-heavy tenant assist | Support keys ON; marketing/integrations mostly OFF; no destructive platform settings |
### Role notes
#### `legacy_user`
- Sees only the legacy nav allow-list.
- AI titles/descriptions + credits remain ON (legacy customers processed catalog data).
- No stores, processing monitor, marketing, brand, SEO, integrations extras, or support ticket UI.
#### `standard_user`
- Full v2 dashboard surface as allowed by their **public plan** (see `06-defaults-matrix.json`).
- Role matrix in JSON is the **ceiling** (all registry keys `true`); Free/Starter denials still apply via plan.
#### `admin` (platform)
- `is_platform_admin = true`.
- All dashboard feature keys ON.
- Platform surfaces: Overview, Users, Analytics, Platform billing, Support, Stuck Products, Platform settings (`AdminNav.svelte`).
- May impersonate users (non-prod / gated admin APIs).
#### `developer`
- Same feature ceiling as `admin`.
- Explicitly keeps debug/extra catalog keys ON (`catalog.vector_categories`, `catalog.structured_descriptions`).
- Expected to use platform admin + API tooling; not a customer-facing role.
#### `support_staff`
- Dashboard: shell + dashboard overview/stats + support.* ON.
- Catalog/feeds/billing: **read-assist** — parent keys ON so staff can open customer context when impersonating; write-heavy marketing/integrations OFF.
- Platform: Support queue (`/admin/support`), Users (limited), Stuck Products; **not** Platform settings or Platform billing write.
- [UNCERTAIN] No dedicated `is_support_staff` column today — design assumes a future staff flag or group; until then map to a subset of platform-admin users.
---
## Role × section summary
| Section | legacy_user | standard_user | admin | developer | support_staff |
|---------|:-----------:|:-------------:|:-----:|:---------:|:-------------:|
| shell (core) | ON | ON | ON | ON | ON |
| shell.support_notifications | OFF | ON | ON | ON | ON |
| dashboard (core) | ON | ON | ON | ON | ON |
| dashboard.store_reconnect | OFF | ON | ON | ON | OFF |
| catalog (products/categories/attributes/standard fields) | ON | ON | ON | ON | ON† |
| catalog extras (structured/vector) | OFF | ON | ON | ON | OFF |
| feeds (+ export + uploads) | ON | ON | ON | ON | ON† |
| stores | OFF | ON | ON | ON | OFF |
| processing.monitor | OFF | ON | ON | ON | ON† |
| marketing | OFF | ON | ON | ON | OFF |
| integrations | OFF | ON | ON | ON | OFF |
| billing | ON | ON | ON | ON | ON† |
| settings | ON | ON | ON | ON | ON† |
| support | OFF | ON | ON | ON | ON |
| capabilities (core AI/SKU/feeds) | ON | plan | ON | ON | ON† |
| capabilities (campaign/email/brand/seo/byok) | OFF | plan | ON | ON | OFF |
| `/admin/*` | OFF | OFF | ON | ON | partial |
† support_staff: intended for assisted sessions / impersonation; not for self-serve marketing ops.
---
## COMPOSITION with company membership
Existing company membership roles (`admin` \| `member`) stay orthogonal:
| Concern | Gate |
|---------|------|
| Invite / team / API keys / Stripe portal | company `admin` (or platform admin) |
| Which nav/features appear | plan profile + product role (`legacy_user` / `standard_user`) |
| Platform console | `is_platform_admin` / staff role |
Do **not** overload company membership `admin` with platform `admin`.
---
## IMPLEMENTATION HOOKS (non-binding)
| Need | Likely home |
|------|-------------|
| Resolve legacy profile from plan name | `DefaultPlanFeatures` / plan name normalizer beside public ladder |
| Seed Legacy plan row | `EnsureDefaultPlans` + assign to A1/demo migrated tenant |
| Staff role flag | users column or staff group — [UNCERTAIN] until agent/schema decides |
| Nav filter | existing `feature` on `Nav.svelte` items + ResolveFeatures |
---
## VERIFICATION checklist
- [ ] Legacy matrix ON keys ⊆ `01-feature-keys.json`
- [ ] No `processing.monitor`, `stores.*`, `marketing.*`, `integrations.*`, `support.*` ON for legacy
- [ ] A1 / `legacy` name patterns resolve to legacy profile
- [ ] `standard_user` ceiling does not bypass Free/Starter denials in `06-defaults-matrix`
- [ ] Platform `admin` retains `/admin/*` independent of plan JSON
+276
View File
@@ -0,0 +1,276 @@
{
"agent": "04/20",
"title": "Unified contract: plan features + staff roles + legacy",
"status": "design_only",
"version": "1.0.0",
"coordinates_with": [
"docs/admin-roles-support/01-ux-research.md",
"docs/admin-roles-support/02-current-inventory.md",
"docs/admin-roles-support/03-roles-matrix.md",
"docs/admin-roles-support/03-roles-matrix.json",
"docs/plan-permissions/03-permission-contract.md",
"docs/plan-permissions/03-permission-contract.json",
"docs/plan-permissions/01-feature-keys.json",
"docs/plan-permissions/06-defaults-matrix.json"
],
"assumptions": [
"Product cohort (legacy/standard/custom) is a plan feature_profile, not a stored end-user RBAC role; legacy_user/standard_user are derived labels",
"support_staff is least privilege: ticket queue assign/reply only — narrower than optional read-assist in 03",
"packages ≡ plans rows; no parallel permission service outside billing + auth staff flags",
"is_platform_admin retained; staff_role additive with null+admin-flag ⇒ staff_role admin back-compat",
"A1/Local Demo Co Enterprise → Legacy assign is explicit ops step, not silent migration"
],
"axes": {
"plan_features": {
"subject": "company.active_plan",
"storage": ["plans.features", "plans.feature_profile", "platform_feature_gates"],
"resolver": "ResolveEffectiveFeatures / CapabilitiesForCompany",
"failure": { "http": 402, "code": "plan_gate" }
},
"company_membership": {
"subject": "user in company",
"storage": "memberships.role",
"values": ["admin", "member"],
"notes": "Orthogonal; do not overload with platform admin"
},
"platform_staff": {
"subject": "user",
"storage": ["users.is_platform_admin", "users.staff_role"],
"resolver": "resolve_staff_role + staff_allows(capability)",
"failure": { "http": 403, "error": "staff capability required" }
}
},
"runtime_formulas": {
"effective_feature": "plan_allows(key) AND global_section_enabled(section(key)) AND global_feature_enabled(key)",
"plan_allows": [
"if key in plans.features -> plans.features[key]",
"else if resolve_plan_profile == legacy -> LegacyMatrix[key] (missing -> false)",
"else if resolve_plan_profile == custom -> true",
"else -> DefaultPlanFeatures(name, false)[key]"
],
"resolve_plan_profile_priority": [
"plans.feature_profile if set (legacy|ladder|custom)",
"legacy name patterns from 03-roles-matrix",
"IsCustomPackage -> custom",
"else ladder"
],
"staff_allows": "user.is_active AND resolve_staff_role(user) in allowed_roles(capability)",
"resolve_staff_role": [
"inactive -> none",
"staff_role if not null",
"else if is_platform_admin -> admin",
"else none"
]
},
"legacy": {
"profile": "legacy",
"shipping_modes": [
{
"id": "named_profile",
"preferred": true,
"mechanism": "plans.feature_profile='legacy' or name-pattern derive; DefaultPlanFeatures/LegacyMatrix"
},
{
"id": "sparse_or_dense_features",
"preferred": false,
"mechanism": "plans.features explicit map; requires profile precedence so missing keys are not custom-all-on"
}
],
"must_not": "Fall through IsCustomPackage all-ON for A1/legacy names",
"allowlist_authority": "docs/admin-roles-support/03-roles-matrix.md",
"excluded_sections": [
"processing.monitor",
"stores.*",
"marketing.*",
"integrations.*",
"support.*"
],
"name_patterns_ref": "docs/admin-roles-support/03-roles-matrix.json#plan_name_patterns",
"a1_example": {
"legacy_company_id": "97e1a309-3d23-4aa2-b518-8e8d7afdfec7",
"demo_note": "Local Demo Co may still be on Enterprise until explicit Legacy assign"
}
},
"staff_roles": {
"values": ["admin", "developer", "support_staff"],
"storage": {
"keep": "users.is_platform_admin",
"add": {
"column": "users.staff_role",
"type": "TEXT NULL CHECK IN (admin, developer, support_staff)",
"invariant": "staff_role set implies is_platform_admin=true"
}
},
"backfill": "UPDATE users SET staff_role='admin' WHERE is_platform_admin AND staff_role IS NULL",
"capabilities": {
"staff.admin_shell": ["admin", "developer", "support_staff"],
"staff.support.queue": ["admin", "developer", "support_staff"],
"staff.support.reply": ["admin", "developer", "support_staff"],
"staff.support.assign": ["admin", "developer", "support_staff"],
"staff.users.read": ["admin", "developer"],
"staff.users.write": ["admin", "developer"],
"staff.analytics": ["admin", "developer"],
"staff.billing": ["admin", "developer"],
"staff.plans_features": ["admin", "developer"],
"staff.feature_gates": ["admin", "developer"],
"staff.settings": ["admin", "developer"],
"staff.jobs_stuck": ["admin", "developer"],
"staff.impersonate": ["admin", "developer"],
"staff.dev_password": ["admin", "developer"]
},
"support_staff_least_privilege": {
"allowed_web": ["/admin/support", "/admin/support/[id]"],
"allowed_api": [
"GET /api/admin/support/tickets",
"GET /api/admin/support/tickets/{id}",
"POST /api/admin/support/tickets/{id}/messages",
"PATCH /api/admin/support/tickets/{id}"
],
"denied": [
"billing",
"plans",
"feature_gates",
"credits",
"settings",
"users_write",
"impersonate",
"stuck_cleanup",
"self_escalate_staff_role"
],
"deferred": "ticket-side read-only company context without tenant write APIs"
},
"middleware": {
"prefer": ["RequireStaff", "RequireStaffCapability"],
"extend": [
"apps/api/internal/httpapi/middleware.go#RequirePlatformAdmin",
"apps/api/internal/httpapi/middleware.go#checkPlatformAdmin",
"apps/api/internal/auth/invites.go#IsPlatformAdmin"
],
"router_today": "apps/api/internal/httpapi/server.go /api/admin RequireSession+RequirePlatformAdmin",
"ui": [
"apps/web/src/lib/admin-gate.ts",
"apps/web/src/lib/components/AdminNav.svelte"
]
}
},
"security": {
"session": "scs cookie + RequireSession; staff flags from DB not client claims",
"csrf": "double-submit X-CSRF-Token on dashboard /api including /api/admin; v1/public/webhooks exempt",
"company_isolation": [
"tenant APIs RequireCompany scoped",
"admin support cross-company ticket view does not open tenant write APIs",
"impersonate admin|developer non-prod only; never support_staff",
"allowCompanyAdminOrPlatform: only admin|developer count as platform bypass — not support_staff"
],
"no_privilege_escalation": [
"capability middleware on admin routes",
"no self-service staff_role",
"plan features never grant /admin",
"staff never granted by plan JSON",
"fail closed on missing role/capability"
]
},
"performance": {
"existing": {
"symbol": "featureETag",
"path": "apps/api/internal/billing/plan_features.go",
"semantics": "sha256 of sorted enabled feature keys"
},
"rules": [
"Do not fold staff_role into feature_etag",
"Keep company capabilities cache keyed by company_id + feature_etag",
"Invalidate on plan assign, plans.features write, global gates write",
"etag remains pure function of effective enabled-key set (profile already reflected in map)",
"Resolve staff role once per request in middleware"
],
"me_payload_additive": {
"user.staff_role": "string|null",
"staff_capabilities": "string[]",
"credits.feature_profile": "legacy|ladder|custom|null",
"credits.feature_etag": "unchanged semantics"
}
},
"migration": {
"file_suggestion": "apps/api/sql/schema/027_staff_roles_legacy_profile.sql",
"idempotent": true,
"destructive": false,
"steps": [
"ALTER users ADD staff_role NULL CHECK",
"ALTER plans ADD feature_profile NULL CHECK",
"Backfill staff_role=admin for existing platform admins",
"Ensure Legacy plan row with feature_profile=legacy",
"Optional sparse plans.features from LegacyMatrix",
"Explicit A1/demo assign — no silent Enterprise downgrade",
"EnsureDefaultPlans must not clobber features or feature_profile"
],
"optional_flag": "STAFF_RBAC=1 to enforce capability middleware",
"breaking_changes": [],
"preserve": [
"is_platform_admin",
"public ladder names",
"Stripe public-ladder checkout",
"402 plan_gate shapes",
"support assignee_admin_user_id schema",
"CSRF session behavior"
]
},
"reuse_symbols": {
"billing": [
"DefaultPlanFeatures",
"SparseDefaultOverrides",
"IsCustomPackage",
"PlanAllowsFeature",
"ResolveEffectiveFeatures",
"CapabilitiesForCompany",
"featureETag",
"EntitlementsForCompany"
],
"auth_http": [
"IsPlatformAdmin",
"RequirePlatformAdmin",
"checkPlatformAdmin",
"CSRF",
"RequireSession",
"allowCompanyAdminOrPlatform"
],
"support": [
"ListAdmin",
"GetAdmin",
"ReplyAsAgent",
"UpdateAdmin",
"handleAdminListSupportTickets"
],
"web": [
"requirePlatformAdmin",
"AdminNav",
"PlanPermissionsPanel",
"planCapabilities",
"Nav.svelte"
]
},
"do_not_add": [
"parallel permissions package outside billing/auth",
"packages table",
"tenant feature_keys for /admin/*",
"mixing LLM admin-ai-roles into staff RBAC",
"staff fields inside feature_etag"
],
"verification": [
"legacy excludes processing/stores/marketing/integrations/support",
"A1/legacy profile not custom-all-on",
"custom non-legacy still all-on",
"ladder Free/Starter denials unchanged",
"null staff_role + is_platform_admin => admin",
"support_staff 403 on billing/plans/settings",
"support_staff AdminNav Support-only",
"CSRF still on admin mutations",
"feature_etag stable across staff_role-only changes",
"tenant APIs remain company-scoped for support_staff"
],
"open_questions_defaults": {
"a1_demo_cutover": "explicit_ops_assign",
"support_staff_sets_is_platform_admin": true,
"company_admin_platform_bypass": "admin_and_developer_only",
"legacy_seed_density": "profile_first_sparse_optional"
}
}
+405
View File
@@ -0,0 +1,405 @@
# 04 — Unified contract: plan features + staff roles + legacy
**Status:** Design only — no app code in this doc. Implementers own schema/API/UI.
**Agent:** 4/20
**Machine-readable twin:** [`04-contract.json`](./04-contract.json)
**Coordinates with:** [`01-ux-research.md`](./01-ux-research.md), [`02-current-inventory.md`](./02-current-inventory.md), [`03-roles-matrix.md`](./03-roles-matrix.md) / [`.json`](./03-roles-matrix.json), [`docs/plan-permissions/03-permission-contract.md`](../plan-permissions/03-permission-contract.md)
---
## PROBLEM
Three concerns are blurred in production today:
1. **Legacy / A1 cohort** should see a **limited** dashboard (nav allow-list in `03-roles-matrix`) but currently resolves like **custom all-ON** via `IsCustomPackage`.
2. **Platform staff** is a single boolean (`users.is_platform_admin`) — no `admin` / `developer` / `support_staff` split; support queue sits behind full admin.
3. Implementers need one **additive** contract that extends Entitlements / plan features / platform admin flags — **not** a parallel permission service.
---
## CONTEXT (tools / sources)
| Source | Finding |
|--------|---------|
| Agent 1 UX | Keep plan entitlements and human/staff roles as **two axes**; never merge with LLM `admin-ai-roles` |
| Agent 2 inventory | Binary `is_platform_admin`; membership `admin\|member`; support admin under `RequirePlatformAdmin`; no legacy profile |
| Agent 3 matrix | Legacy ON keys + plan name patterns; staff role ceilings; company membership stays orthogonal |
| Plan-permissions 03 | `plans.features` sparse + globals + `ResolveEffectiveFeatures` / `featureETag` / `CapabilitiesForCompany` |
| Code | `DefaultPlanFeatures`, `IsCustomPackage`, `RequirePlatformAdmin`, `CSRF` + session on dashboard `/api/*` |
**ASSUMPTION:** Product cohort (`legacy` vs public ladder vs custom deal) is expressed as a **plan feature profile**, not a second end-user RBAC table. Agent 3s `legacy_user` / `standard_user` are **derived labels** for docs/UI, not stored membership roles.
**ASSUMPTION:** `support_staff` is **least privilege**: ticket queue (+ assign/reply) only — narrower than agent 3s optional read-assist/impersonation notes.
**ASSUMPTION:** Packages remain `plans` rows (no packages table) — same as plan-permissions contract.
---
## 1. Three orthogonal axes (normative)
| Axis | Question | Storage / resolver | Gates |
|------|----------|--------------------|-------|
| **A. Plan features** | What did this *company* buy / inherit? | `plans.features` + profile + globals | Tenant nav, `AssertFeature`, meters compose |
| **B. Company membership** | What can this *person* do *in that company*? | `memberships.role` (`admin` \| `member`) | Invites, team, API keys, Stripe portal |
| **C. Platform staff** | What can this *person* do on the *platform console*? | `users.is_platform_admin` + `users.staff_role` | `/admin/*`, `/api/admin/*` |
**Never:**
- Put `/admin/*` keys into the tenant `feature_key` registry.
- Use company `memberships.role = admin` as platform admin.
- Treat `is_custom` / client-deal packaging as the same thing as **legacy limited nav**.
- Mix LLM provider “AI roles” into this matrix.
```
# Tenant dashboard / tenant APIs (company-scoped)
effective_feature(key) =
plan_allows(company.active_plan, key)
AND global_section_enabled(section(key))
AND global_feature_enabled(key)
# Platform console (user-scoped, company plan irrelevant)
staff_allows(user, capability) =
user.is_active
AND resolve_staff_role(user) IN allowed_roles(capability)
```
Company membership checks stay as today (`requireCompanyAdmin`, `allowCompanyAdminOrPlatform`) and **compose** with `effective_feature` for tenant mutations (e.g. invite requires company admin **and** `settings.team_invite` when enforced).
---
## 2. Plan feature profiles (legacy + ladder + custom)
### 2.1 Profile enum
| Profile | Meaning | Default matrix source |
|---------|---------|------------------------|
| `legacy` | Migrated/limited cohort (A1 + explicit Legacy) | Allow-list in `03-roles-matrix` — all other registry keys **OFF** |
| `ladder` | Public Free→Enterprise by plan name | `06-defaults-matrix` / `DefaultPlanFeatures` |
| `custom` | Client deal / enable-all package | All registry keys ON unless override `false` |
### 2.2 How to select profile (priority)
```
resolve_plan_profile(plan) =
1. if plans.feature_profile IN ('legacy','ladder','custom') -> that value # preferred additive column
2. else if normalize(name) matches legacy patterns (03) -> 'legacy'
3. else if IsCustomPackage(name, is_custom) -> 'custom'
4. else -> 'ladder'
```
Legacy patterns (from agent 3): exact `legacy`; `(?i)^a1(\b|[\s_-])`; `(?i)a1\s*slovenija`; known A1 company id `97e1a309-…` when ops assign a Legacy plan to that tenant.
**Critical fix vs today:** Profile `legacy` **must not** fall through `IsCustomPackage` → all-ON. Name `A1` alone currently yields custom treatment (`custom_package_features_test`); the contract requires legacy detection **before** custom all-ON.
### 2.3 `plan_allows` (extends plan-permissions 03)
```
plan_allows(key) =
if key present in plans.features -> plans.features[key] # sparse or dense override wins
else if resolve_plan_profile(plan) == 'legacy' -> LegacyMatrix[key] # missing key -> false
else if resolve_plan_profile(plan) == 'custom' -> true
else -> DefaultPlanFeatures(name, false)[key] # ladder; unknown -> false
```
Globals unchanged:
```
effective_feature(key) = plan_allows(key)
AND global_section_enabled(section(key))
AND global_feature_enabled(key)
```
### 2.4 Two equivalent ways to ship legacy limited matrix
Both are valid; prefer (a) for clarity, (b) works without waiting on every resolver caller:
| Mode | Mechanism | When |
|------|-----------|------|
| **(a) Named profile `legacy`** | `plans.feature_profile = 'legacy'` (or name-pattern derive) + `DefaultPlanFeatures` / `LegacyMatrix` | Steady state; PlanPermissionsPanel badge “Legacy” |
| **(b) Sparse / dense `plans.features`** | Store explicit map: ON keys `true`, and either all OFF keys `false` **or** rely on profile so missing ≠ custom-all-on | Migration seed / one-off admin fix |
**Seed recommendation:** create/ensure a public-or-deal plan row named `Legacy` with `feature_profile='legacy'` and optionally materialize sparse overrides (false keys only) for audit in admin UI. Assign A1 / Local Demo Co **away from Enterprise all-ON** when the product intent is legacy nav (ops decision — see migration).
### 2.5 Symbols to extend (no parallel system)
| Concern | Extend |
|---------|--------|
| Defaults | `DefaultPlanFeatures`, `SparseDefaultOverrides`, `IsCustomPackage` (legacy short-circuit) |
| Resolve | `PlanAllowsFeature`, `ResolveEffectiveFeatures`, `CapabilitiesForCompany` |
| Admin | `PlanPermissionsPanel` + plan feature APIs — Legacy badge / “apply legacy profile” |
| Nav | Existing `planCapabilities.can` / `Nav.svelte` feature keys |
---
## 3. Platform staff roles
### 3.1 Role keys
| `staff_role` | Who | Ceiling |
|--------------|-----|---------|
| `admin` | Full platform operators | All staff capabilities |
| `developer` | Engineering / ops | Same as `admin` for console; **plus** non-prod debug tools (existing `admin_dev_handlers` stay env-gated) |
| `support_staff` | Support agents | **Ticket queue only** (list/get/reply/assign/status) |
### 3.2 Additive storage
Prefer extending users — **do not** replace `is_platform_admin` in v1:
```text
users.is_platform_admin -- keep: “has any platform console access”
users.staff_role TEXT NULL
CHECK (staff_role IS NULL OR staff_role IN ('admin','developer','support_staff'))
```
**Resolution:**
```
resolve_staff_role(user) =
if not user.is_active -> none
if user.staff_role IS NOT NULL -> user.staff_role
else if user.is_platform_admin -> 'admin' # back-compat for migrated admin_users
else -> none
```
**Invariant:** `staff_role` set ⇒ `is_platform_admin = true` (writers enforce). Clearing staff access sets both off / null.
**Migrator:** existing `applyPlatformAdmins` continues to set `is_platform_admin`; backfill `staff_role='admin'` where admin and role null.
### 3.3 Staff capability catalog (platform — not feature_keys)
| Capability | admin | developer | support_staff |
|------------|:-----:|:---------:|:-------------:|
| `staff.admin_shell` | ✓ | ✓ | ✓ (Support-only nav) |
| `staff.support.queue` | ✓ | ✓ | ✓ |
| `staff.support.reply` | ✓ | ✓ | ✓ |
| `staff.support.assign` | ✓ | ✓ | ✓ |
| `staff.users.read` | ✓ | ✓ | ✗ |
| `staff.users.write` | ✓ | ✓ | ✗ |
| `staff.analytics` | ✓ | ✓ | ✗ |
| `staff.billing` | ✓ | ✓ | ✗ |
| `staff.plans_features` | ✓ | ✓ | ✗ |
| `staff.feature_gates` | ✓ | ✓ | ✗ |
| `staff.settings` | ✓ | ✓ | ✗ |
| `staff.jobs_stuck` | ✓ | ✓ | ✗ |
| `staff.impersonate` | ✓* | ✓* | ✗ |
| `staff.dev_password` | ✓* | ✓* | ✗ |
\* Existing non-prod / env gates remain (`Config.IsProduction()`).
### 3.4 HTTP / UI mapping
| Surface | Gate |
|---------|------|
| `GET/PATCH /api/admin/support/tickets*`, `POST …/messages` | `staff.support.*` (`admin` \| `developer` \| `support_staff`) |
| All other `/api/admin/*` | `admin` \| `developer` only |
| Web `/admin/support/**` | support_staff allowed |
| Web `/admin/**` (billing, settings, users, …) | admin \| developer; support_staff → 403 / redirect |
| `AdminNav` | Filter `menuItems` by staff capability (Support only for `support_staff`) |
**Middleware shape (preferred):** keep `RequireSession` on `/api/admin`; replace blanket-only `RequirePlatformAdmin` with:
1. `RequireStaff``resolve_staff_role ≠ none` (replaces binary admin check for “any staff”).
2. `RequireStaffCapability(cap)` — per-route or route-group allow-list.
Until split lands, **do not** grant support_staff by setting `is_platform_admin` alone without capability middleware — that would privilege-escalate to billing.
### 3.5 Difference from plan features (explicit)
| | Plan features | Staff roles |
|-|---------------|-------------|
| Subject | Company (active plan) | User |
| Payload | `features` / `feature_etag` on credits & capabilities | `staff_role` / `staff_capabilities` on `/api/auth/me` |
| Failure | 402 `plan_gate` / `feature_disabled` | 403 `platform admin required` / `staff capability required` |
| Cache | Company-scoped capabilities cache | User session / me payload — **not** folded into `feature_etag` |
---
## 4. Support staff — least privilege
Normative for `support_staff`:
**Allowed**
- Open `/admin/support` and ticket detail.
- List / filter / search admin ticket queue.
- Reply as agent; update status / priority / `assignee_admin_user_id`.
**Denied**
- Platform billing, plan upsert, feature matrices, global feature gates, credits, cycle runs.
- Platform settings (mail, AI configs, etc.).
- User list mutations, set-password blast, impersonation, stuck-job cleanup.
- Tenant session switching without a future audited impersonation flow (out of scope for support_staff).
- Expanding own `staff_role` or `is_platform_admin` via any API.
**Optional later (not v1 contract):** read-only company context cards on the ticket — still **no** tenant write APIs. Agent 3s broader “read-assist” ceiling is **deferred**; least privilege wins.
Tenant `/support` (company-scoped tickets) remains gated by plan feature `support.center` and `RequireCompany` — unrelated to staff queue.
---
## 5. Security
### 5.1 Session
- Staff and tenant routes use the same cookie session (`scs`) + `RequireSession`.
- Staff flags loaded from **DB** (`IsPlatformAdmin` / future `staff_role`) — never trust client claims alone (`admin-gate.ts` is UX only).
- Inactive users (`is_active=false`) fail all staff checks.
### 5.2 CSRF
- Dashboard `/api/*` (including `/api/admin/*`) stays behind existing double-submit `CSRF` middleware (`X-CSRF-Token`).
- Public `/api/v1`, `/api/public/`, `/api/webhooks/` remain CSRF-exempt (API key / signature).
- No new CSRF bypass for staff tools.
### 5.3 Company isolation
- Tenant handlers keep `RequireCompany` + company-scoped queries.
- Admin support `ListAdmin` / `GetAdmin` may return cross-company ticket metadata; that does **not** open tenant data APIs.
- Impersonation (when enabled) is `admin`/`developer` + non-prod only; must audit; support_staff **cannot** impersonate.
- `allowCompanyAdminOrPlatform` must not treat `support_staff` as company admin for invites/billing unless an explicit product decision says otherwise — **default: platform staff ≠ company admin**. Prefer checking `resolve_staff_role ∈ {admin, developer}` if platform bypass remains for team cutover.
### 5.4 No privilege escalation
| Risk | Control |
|------|---------|
| support_staff hits `/api/admin/plans` | Capability middleware 403 |
| User sets `is_platform_admin` via profile API | No self-service; only admin/developer staff APIs (future) or DB/migrator |
| Plan feature ON does not grant `/admin` | Staff axis independent |
| Custom/legacy plan does not grant staff | Staff axis independent |
| Client sends `staff_role` on `/me` POST | Ignore; server is SOT |
| Feature override JSON includes admin keys | Reject unknown / non-registry keys on upsert (existing) |
Fail closed on missing staff role / capability.
---
## 6. Performance — capabilities + etag
Existing:
- `CapabilitiesForCompany` resolves plan ∩ globals.
- `featureETag(features)` = `sha256:` of sorted **enabled** feature keys (`plan_features.go`).
**Contract for extension:**
1. **Do not** put `staff_role` into `feature_etag` — staff is per-user; features are per-company. Mixing forces every staff login to invalidate tenant feature caches.
2. Optional additive fields on `/api/auth/me`:
```json
{
"user": {
"is_platform_admin": true,
"staff_role": "support_staff"
},
"credits": {
"features": { "...": true },
"feature_etag": "sha256:…",
"feature_profile": "legacy"
},
"staff_capabilities": ["staff.support.queue", "staff.support.reply", "staff.support.assign"]
}
```
3. If caching capabilities by `(company_id, feature_etag)`, keep that key. Invalidate on plan assign, `plans.features` write, global gate write — **unchanged**.
4. If `feature_profile` is exposed, include it in etag **input** only when it changes effective features (hash profile name + enabled keys, or keep hashing enabled keys alone since profile is already reflected in the map). Prefer **etag remains a pure function of the effective enabled-key set** so clients need not special-case profile.
5. Avoid N+1: resolve staff role once per request in middleware; do not re-query per admin handler beyond existing patterns.
---
## 7. Migration strategy (additive)
Suggested goose file: `027_staff_roles_legacy_profile.sql` (number may shift — after `026_plan_features`).
### 7.1 Schema (idempotent)
```sql
ALTER TABLE users
ADD COLUMN IF NOT EXISTS staff_role TEXT NULL
CHECK (staff_role IS NULL OR staff_role IN ('admin', 'developer', 'support_staff'));
ALTER TABLE plans
ADD COLUMN IF NOT EXISTS feature_profile TEXT NULL
CHECK (feature_profile IS NULL OR feature_profile IN ('legacy', 'ladder', 'custom'));
-- Optional helpful indexes
CREATE INDEX IF NOT EXISTS users_staff_role_idx ON users (staff_role)
WHERE staff_role IS NOT NULL;
```
No drops; no rewriting meters; ETL ignores unknown columns safely.
### 7.2 Data backfill (safe order)
1. `UPDATE users SET staff_role = 'admin' WHERE is_platform_admin = true AND staff_role IS NULL;`
2. Ensure `Legacy` plan row: `feature_profile='legacy'`, meters as ops decide; **do not** set `is_custom` in a way that bypasses legacy (resolver must honor profile first).
3. Optionally seed `plans.features` sparse false-map from LegacyMatrix for admin visibility.
4. **A1 / Local Demo Co:** do **not** auto-downgrade Enterprise without ops confirmation — document a one-shot assign script (`05-legacy-seed` agent). Default contract: *tools exist*; cutover is explicit.
5. `EnsureDefaultPlans` / feature seeders: never clobber non-empty `features` or explicit `feature_profile`.
### 7.3 Rollout flags (optional)
| Flag | Purpose |
|------|---------|
| (none required) | Additive columns default null → todays behavior via back-compat resolution |
| `STAFF_RBAC=1` | Enforce capability middleware (vs temporary “any platform admin”) |
| existing `FEATURES_ENFORCE` | Unrelated; keep for tenant AssertFeature |
### 7.4 BREAKING
**None preferred.** Additive columns and fields only. Preserve:
- `is_platform_admin` semantics for existing admins.
- Public ladder names, Stripe checkout rules, 402 `plan_gate` shapes.
- Support ticket schema (`assignee_admin_user_id`, etc.).
---
## 8. API / me contract (additive fields)
| Endpoint | Add |
|----------|-----|
| `GET /api/auth/me` | `user.staff_role`; optional `staff_capabilities[]`; `credits.feature_profile` |
| `GET /api/billing/capabilities` | `feature_profile` alongside existing `features` / `feature_etag` |
| `GET/POST /api/admin/plans` | `feature_profile` read/write for admin\|developer |
| Future | `PATCH /api/admin/users/{id}/staff-role` — admin\|developer only; validate escalation rules |
Errors:
```json
{ "error": "staff capability required", "capability": "staff.billing" }
```
HTTP 403 (not 402 — not a plan gate).
---
## 9. Verification checklist
- [ ] Legacy profile: `processing.monitor`, `stores.*`, `marketing.*`, `integrations.*`, `support.*` effective false
- [ ] Name `A1` / `feature_profile=legacy` does **not** all-ON via `IsCustomPackage`
- [ ] Custom non-legacy deal still all-ON
- [ ] Free/Starter ladder denials unchanged (`06-defaults-matrix`)
- [ ] `is_platform_admin` + null `staff_role` ⇒ behaves as `admin`
- [ ] `support_staff` can hit support admin APIs; 403 on plans/settings/credits
- [ ] support_staff AdminNav shows Support only
- [ ] CSRF still required on admin POST/PATCH
- [ ] `feature_etag` unchanged when only `staff_role` changes
- [ ] Company tenant APIs still company-scoped for support_staff sessions
- [ ] Migrator admin backfill sets `staff_role='admin'`
---
## 10. Open questions (defaults chosen)
1. **A1 demo plan cutover** — Local Demo Co is on Enterprise today; legacy assign is explicit ops step (default: do not silent-migrate).
2. **support_staff + `is_platform_admin`** — both true when role set (default).
3. **Company-admin bypass for platform staff** — only `admin`/`developer`, not `support_staff` (default).
4. **Dense vs sparse legacy seed** — profile + LegacyMatrix sufficient; sparse false keys optional for UI (default: profile-first).
---
## Related implementer docs (downstream agents)
| Doc | Expected owner |
|-----|----------------|
| `05-legacy-seed` | Seed Legacy plan + A1 assign runbook |
| `06-staff-roles` | Schema + middleware + AdminNav filter |
| Plan-permissions enforcement | Keep AssertFeature on tenant mutations |
@@ -0,0 +1,65 @@
# 05 — Legacy plan feature seed (agent 5/20)
**Status:** Implemented in `apps/api/internal/billing`.
**Coordinates with:** [`03-roles-matrix.md`](./03-roles-matrix.md), [`04-contract.md`](./04-contract.md), [`docs/plan-permissions/06-defaults-matrix.md`](../plan-permissions/06-defaults-matrix.md).
---
## PROBLEM
A1 / migrated packages previously resolved as **custom all-ON**, which exposed Background Tasks (`processing.monitor`), stores, marketing, etc. Legacy tenants must get the **image-nav** matrix only.
---
## Behavior
| Concern | Implementation |
|---------|----------------|
| Profile | `PlanProfileLegacy` / `ResolvePlanProfile` |
| Allow-list | `LegacyFeatureAllowed` + `legacyFeatureAllowlist` in `legacy_plan.go` |
| Defaults | `DefaultPlanFeatures` / `DefaultPlanFeaturesEx` check legacy **before** custom all-ON |
| Sparse store | `SparseLegacyOverrides` / `SparseDefaultOverridesEx` |
| Detect | `IsLegacyPlanName` (exact `legacy`, `A1` / `A1 …`), `IsLegacyPlan(name, is_legacy)`, `IsLegacyCompanyID` |
| Column | `plans.is_legacy` via `028_plan_is_legacy.sql` (additive; code falls back if missing) |
| Seed | `EnsureDefaultFeatureSeeds``EnsureLegacyPlanFeatureSeeds``EnsureLegacyDefaults` |
| Reset | `ApplyDefaultMatrix` writes sparse legacy for legacy plans |
| Create | `prepareCustomPackageCreateFeatures` seeds sparse legacy for A1-named plans |
### Seed / backfill rules (`EnsureLegacyDefaults`)
1. Upsert plan row named **Legacy** (`is_legacy=true`).
2. For each plan matching name or `is_legacy`:
- **empty** `features` → write `SparseLegacyOverrides`
- **full enable-all** map (prior custom create) → repair to sparse legacy
- **`is_legacy=true`** → re-apply sparse legacy
- **non-empty customized** (not enable-all, not flagged) → **leave alone**
3. Assign Legacy plan to A1 cohort companies (`legacy_company_id` / A1 name / Local Demo Co stand-in).
### Explicitly OFF (examples)
- `processing.monitor` (Background Tasks)
- all `stores.*`, `marketing.*`, `integrations.*`, `support.*`
- `catalog.structured_descriptions`, `catalog.vector_categories`
- `shell.support_notifications`, `dashboard.store_reconnect`
- `capability.byok`, campaign/email/brand/seo capabilities
### Explicitly ON (image-nav)
Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Usage & Billing, Settings — plus required shell/capabilities for catalog AI.
---
## Verification
```bash
cd apps/api
go test ./internal/billing/ -count=1 -run "Legacy|DefaultPlanFeatures|SparseDefault|IsCustomPackage|Prepare"
```
---
## ASSUMPTIONS
1. Name patterns from agent 3 win over `is_custom` for matrix resolution.
2. Non-legacy client deals (e.g. Merkur) remain enable-all custom.
3. Local Demo Co may be treated as A1 stand-in during demo seed when remapped.
+110
View File
@@ -0,0 +1,110 @@
# 06 — Platform staff roles
**Agent:** 6/20
**Contract:** [`04-contract.md`](./04-contract.md) §3
**Status:** Implemented (additive schema + middleware + admin APIs + tests)
---
## PROBLEM
Platform access was a single boolean (`users.is_platform_admin`). Support desk and billing/plan mutation shared the same gate. Contract 04 requires three staff roles with least privilege for `support_staff`.
---
## Model
| Column | Meaning |
|--------|---------|
| `users.is_platform_admin` | Retained. Any platform console access (including support_staff). |
| `users.staff_role` | `admin` \| `developer` \| `support_staff` \| NULL |
**Resolution** (`ResolveStaffRole` / `ResolveStaffAccess` in `apps/api/internal/auth/staff.go`):
1. Inactive → none
2. `staff_role` if set
3. Else `is_platform_admin` → treat as `admin` (legacy back-compat)
4. Else none
**Capabilities:**
| Role | Full `/api/admin/*` | Support desk | Notes |
|------|:-------------------:|:------------:|-------|
| `admin` | yes | yes | Full console |
| `developer` | yes | yes | Same as admin; env-gated dev tools unchanged |
| `support_staff` | **no** | yes | Tickets only — no plans/billing/settings/credits |
| legacy `is_platform_admin` + NULL role | yes | yes | Migrated admins |
**Invariant:** assigning a non-empty `staff_role` sets `is_platform_admin=true`. Clearing role clears both.
---
## Schema
Migration: `apps/api/sql/schema/029_staff_roles.sql`
- Additive `staff_role` CHECK + partial index
- Idempotent backfill: `staff_role='admin'` where `is_platform_admin` and role NULL
- Migrator `applyPlatformAdmins` also `COALESCE(staff_role, 'admin')`
---
## Middleware (extends existing — no parallel auth)
| Middleware | Allows |
|------------|--------|
| `RequirePlatformAdmin` | `FullAdmin` (admin/developer/legacy) — **excludes** support_staff |
| `RequireSupportDesk` | `SupportDesk` (admin/developer/support_staff/legacy) |
| `IsPlatformAdmin` / `checkPlatformAdmin` | Now resolves via `GetStaffAccess().FullAdmin` |
Router (`server.go`):
- `/api/admin/support/tickets*``RequireSupportDesk`
- All other `/api/admin/*``RequirePlatformAdmin`
- Staff assign APIs sit under full admin group
`StaffRoleAllowsAdminRoute`: support_staff → `/admin/support*` only (contract 04).
---
## APIs (admin \| developer only)
| Method | Path | Body | Behavior |
|--------|------|------|----------|
| `GET` | `/api/admin/staff` | — | List users with staff access |
| `PATCH` | `/api/admin/users/{id}/staff-role` | `{"staff_role":"admin"\|"developer"\|"support_staff"\|null}` | Assign/clear; **cannot change own role** |
| `GET` | `/api/admin/users` | — | Includes `staff_role` |
| `GET` | `/api/auth/me` | — | Additive `staff_access`, `staff_capabilities` when staff |
Errors: 400 invalid role, 403 self-change / capability, 404 unknown user. CSRF still required on mutating admin routes.
Support convenience (also full-admin): `PUT /api/admin/support/agents/{id}` grants/revokes `support_staff` only (does not demote admin/developer).
---
## Tests
- `internal/auth`: `TestResolveStaffAccess`, `TestNormalizeStaffRole`, `TestStaffCapabilities`, `TestStaffRoleAllowsAdminRouteContract`
- `internal/httpapi`: `TestRequirePlatformAdmin*` (incl. support_staff forbidden), `TestRequireSupportDesk`, `TestHandleAdminSetStaffRoleRejectsSelf`
```text
go test ./internal/auth/ ./internal/httpapi/ -count=1
```
---
## Files touched
- `apps/api/sql/schema/029_staff_roles.sql`
- `apps/api/internal/auth/staff.go`, `staff_test.go`, `staff_role_defaults.go`, `service.go`, `invites.go`
- `apps/api/internal/httpapi/middleware.go` (existing Require*), `server.go`, `admin_staff_handlers.go`, `admin_handlers.go`, `auth_handlers.go`, `admin_authz_test.go`
- `apps/api/cmd/migrator/admins.go`
- `apps/api/internal/support/agents.go` (platform_admin invariant on grant/revoke)
---
## Out of scope / follow-ups
- AdminNav UI filter (agent 7/10/14) — consume `staff_access` / `staff_capabilities` from `/me`
- Ticket queue assignee filtering (agent 12/14)
- Do not conflate with company `memberships.role` or LLM AI roles
@@ -0,0 +1,61 @@
# 07 — Admin shell (layout / nav / chrome)
Platform admin UI lives under `/admin/*` with a **dedicated shell** (not the tenant dashboard layout). This doc covers the shared chrome only — page bodies own their own data/actions.
Stack: **Go chi API + SvelteKit 5 / Svelte / Tailwind** (not Laravel).
## Goals
- Brand sidebar via semantic `--sidebar-*` tokens (russian-violet light / deepened dark).
- **Both themes:** `html.dark` + shared `$lib/theme.svelte`; content uses `bg-background` / `text-foreground` / `bg-card` — not a light-only paint.
- Chart tokens in `layout.css` stay preserved for analytics.
- No sticky `AdminHeader`. Theme via `ThemeToggle` in sidebar footer + mobile chrome.
- No Migration readiness / cutover banner in the shell.
- `/admin` is the **command center** (signals + curated shortcuts) — not a flat tool-card grid.
- Responsive: persistent sidebar ≥ `lg`; off-canvas drawer below; mobile menu hook only in layout.
- Accessible: skip link, `aria-current`, focus-visible rings, mobile focus trap + Escape.
## Files
| Piece | Path |
| --- | --- |
| Admin branch | `apps/web/src/routes/+layout.svelte` (`#admin-shell`) |
| Nav IA catalog | `apps/web/src/lib/admin-nav.ts` |
| Sidebar | `apps/web/src/lib/components/AdminNav.svelte` |
| Drawer state | `apps/web/src/lib/admin-nav-ui.svelte.ts` |
| Theme | `apps/web/src/lib/theme.svelte.ts` + `ThemeToggle.svelte` |
| Command center | `apps/web/src/routes/admin/+page.svelte` |
| Tokens | `apps/web/src/routes/layout.css` |
## Nav IA
Source: `ADMIN_NAV_ROUTES` / `ADMIN_NAV_SECTIONS` in `$lib/admin-nav.ts`.
| Section | Links | Gate |
| --- | --- | --- |
| **Overview** | Command center (`/admin`), Analytics | Analytics: full admin |
| **Directory** | Users & orgs | full admin |
| **Support** | Tickets, Knowledge | Knowledge: full admin |
| **Operations** | Diagnostics, Stuck products | full admin |
| **Commerce** | Billing | full admin |
| **System** | Settings | full admin |
Support-only: Tickets (+ Command center in nav; home redirects to `/admin/support`).
Footer: staff chip · ThemeToggle · Back to app · Sign out.
## Structure
```
[SkipLink]
[#admin-shell] /* inherits html.dark tokens */
[AdminNav #admin-sidebar] w-[15.5rem], bg-sidebar, sidebar-* tokens
[content lg:ml-[15.5rem]]
SystemModeBanner / BillingRecoveryBanner
[mobile] menu + adminPageTitle + ThemeToggle
<main data-admin-shell="main">
```
## Page chrome contract
Use `PageShell`. Slots: `data-admin-shell="main"`, `data-admin-slot="diagnostics-panels"`, `data-admin-chrome="staff"`.
@@ -0,0 +1,96 @@
# 08 — Admin Permissions UI (agent 8/20)
**Owns:** Admin → Billing → **Permissions** tab (`PlanPermissionsPanel` + `admin-plan-permissions` client).
**Entry:** `/admin/billing` → Permissions.
**Docs home:** [`README.md`](./README.md) (when present) · prior matrix: [`../plan-permissions/`](../plan-permissions/).
---
## PROBLEM
Make plan feature editing clean and usable: named profiles (Legacy / Free / Starter…), bulk section toggles, search/filters, clear default vs custom, and one-click Legacy for A1-like plans — wired to real admin APIs (no stub mode).
---
## Features delivered
1. **Plan profiles** — Legacy, Free, Starter, Growth, Business, Enterprise/all-on. Apply writes a full override matrix via `PUT /api/admin/plans/{id}/features`.
2. **Legacy (A1)** — Allowlist nav (Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Billing, Settings). `processing.monitor` and stores/marketing/integrations/support extras stay **OFF**. Prominent **Apply Legacy (A1)** when the selected plan is A1-like (`A1*`, name contains `legacy`, or `is_legacy`).
3. **Bulk section toggles** — Per-plan Enable/Disable section (all keys in that section). Global section masters stay in the right rail (optional “+ features” updates global feature-gate rows).
4. **Search / filters** — Text (key or label), section filter, state filter (all / enabled / disabled / differs from plan-name default).
5. **Default vs custom** — Badges: Legacy / Default ladder / Custom deal; **Defaults** vs **Custom overrides**; profile chip shows ✓ when the resolved matrix matches a named profile; **Clear overrides** sends `{}`.
6. **Real APIs** — No 404 stub / in-session-only path. Mutations use feature endpoints; errors surface in alerts.
---
## Source files
| File | Intent |
|------|--------|
| `apps/web/src/lib/components/admin/PlanPermissionsPanel.svelte` | Permissions checklist UI |
| `apps/web/src/lib/admin-plan-permissions.ts` | Profiles, Legacy detect, API client |
| `apps/web/src/lib/plan-feature-catalog.ts` | Catalog / sections (shared) |
| `apps/web/src/routes/admin/billing/+page.svelte` | Hosts Permissions tab |
---
## API contract used
| Method | Path | Use |
|--------|------|-----|
| `GET` | `/api/admin/plans` | List packages |
| `GET\|PUT` | `/api/admin/plans/{id}/features` | Load / replace overrides |
| `POST` | `/api/admin/plans/{id}/features/enable-all` | Full ON |
| `POST` | `/api/admin/plans/{id}/features/disable-all` | Full OFF |
| `GET\|PUT` | `/api/admin/feature-gates` | Global masters |
| `PUT` | `/api/admin/feature-gates/sections/{section}` | Global section switch |
Auth: session + platform admin + CSRF.
---
## Legacy allowlist (summary)
Canonical ON set matches [`03-roles-matrix.md`](./03-roles-matrix.md) / `legacy_user` — including AI titles/descriptions, API keys, and `capability.ai_processing` / `api_access`. Shell skips `shell.support_notifications`; dashboard skips `dashboard.store_reconnect`.
OFF (among others): `processing.monitor`, all `stores.*`, all `marketing.*`, integrations, support tickets, campaign/email/brand/SEO/BYOK capabilities.
Machine source: `LEGACY_FEATURE_ALLOWLIST` in `admin-plan-permissions.ts`. Backend seed (agent 5) should align; UI Apply Legacy persists even if seed lags.
---
## Visual language
Uses admin shell tokens (`border-border`, `bg-card`, `text-muted-foreground`, `bg-primary/15` active chips) — same vocabulary as `AdminNav` / agent 7 shell — not hard-coded slate cards.
---
## Manual verify
1. Platform admin → `/admin/billing`**Permissions**.
2. Select **Free** → profile chip should show Free ✓ (or Apply Free). Toggle one key → **Custom overrides** + Differs badge.
3. Select **A1** (or create an A1-named deal) → **Apply Legacy (A1)**`processing.monitor` off; Products/Feeds on. Reload → persisted.
4. Section **Enable section** / **Disable section** on Catalog for the selected plan only.
5. Flip a **Global section** switch; confirm tenant capabilities respect it (agent 9/16).
6. **Clear overrides** → Defaults badge; resolved view matches plan-name defaults (Legacy-like names still resolve Legacy on the client when empty).
---
## Coordination
| Agent | Touchpoint |
|-------|------------|
| 3 / 4 | Roles matrix + contract — profiles follow Legacy allow-nav |
| 5 | Backend Legacy seed / `is_legacy` — UI already detects A1 + `is_legacy` |
| 7 | Admin shell tokens — Permissions uses the same |
| 9 | Plans table → Edit features deep-link (unchanged prop `selectedPlanId`) |
| 16 | User nav gating consumes capabilities after Legacy apply |
| 19 | Defaults alignment — keep `LEGACY_FEATURE_ALLOWLIST` in sync with seeds |
---
## ASSUMPTIONS
1. **ASSUMPTION:** Empty `plans.features` means “use defaults”; Apply Profile always writes an explicit full map.
2. **ASSUMPTION:** Until agent 5 seeds Legacy on the API, A1 empty overrides may resolve all-ON server-side; **Apply Legacy** is the reliable admin path.
3. Global “Off + features” updates **global feature masters**, not every plans overrides.
@@ -0,0 +1,70 @@
# 09 — Admin billing plans UI (agent 9/20)
**Owns:** Admin → Billing → **Plans** tab (table, filters, badges), create/edit plan dialogs, assign-plan UX, companies-tab assign shortcuts.
**Does not own:** Permissions tab / `PlanPermissionsPanel` (agent 8).
**Entry:** `/admin/billing` (gate: `requirePlatformAdmin`).
---
## Delivered
1. **Plans table** — search + visibility filters (All / Public / Legacy / Custom) with counts.
2. **Badges** — Public (ladder FreeEnterprise), Legacy (A1, Basic, Professional, Mini, Merkur…), Custom (other client deals). Public + `is_custom` also shows “Custom package flag” (Enterprise).
3. **Create / edit** — polished dialog: name, description, monthly/yearly credits, max products, term, custom flag; preview of visibility kind. Wired to `POST /api/admin/plans` (upsert with `id` for edit).
4. **Assign plan** — dialog with company + plan selects, optional trial + trial credits; row actions from Plans and Companies. `POST /api/admin/plans/assign`.
5. **Link to permissions** — Plans row → Permissions tab with that plan selected (`PlanPermissionsPanel`).
6. **Companies polish** — search, has/no active plan filter, Assign plan + Add credits actions; summary card for companies without a plan.
---
## Source files
| File | Intent |
|------|--------|
| `apps/web/src/routes/admin/billing/+page.svelte` | Host page: stats, tabs, dialogs, companies table |
| `apps/web/src/lib/components/admin/AdminPlansPanel.svelte` | Plans table + filters + row actions |
| `apps/web/src/lib/admin-billing-plans.ts` | Visibility classify/filter, upsert/assign API helpers |
---
## API contract (existing)
| Method | Path | Use |
|--------|------|-----|
| `GET` | `/api/admin/plans` | List all plans (ladder + deals) |
| `POST` | `/api/admin/plans` | Upsert plan (`id` optional); body: name, description, monthly_credits, yearly_credits, max_products, is_custom, term |
| `POST` | `/api/admin/plans/assign` | `{ company_id, plan_id, is_trial?, trial_credits? }` |
| `GET` | `/api/admin/companies` | Companies + `has_active_plan` |
| `POST` | `/api/admin/credits` | Adjust balance |
| `POST` | `/api/admin/billing/run-cycles` | Due renewals |
---
## Visibility rules (UI)
| Kind | Rule |
|------|------|
| **Public** | Name ∈ Free, Starter, Growth, Business, Enterprise (`isDefaultPublicPlanName`) |
| **Legacy** | Known migrated names: A1 (+ `a1 …` prefix), Basic, Professional, Mini, Merkur*, Meur, or name contains `legacy` |
| **Custom** | Everything else (client deals / `is_custom` non-ladder) |
ASSUMPTION: Backend may later expose an explicit `is_legacy` / profile field; until then UI classifies by name (aligned with `IsPublicProductPlan` + migrated deal set).
---
## Manual verify
1. Platform admin → `/admin/billing` → Plans.
2. Filter Public / Legacy / Custom; search by name.
3. Create a custom plan; edit credits/description; confirm row badges.
4. Assign plan to a company (from Plans row or Companies → Assign plan); optional trial.
5. Click Permissions on a plan → Permissions tab opens with that plan selected.
6. Companies filter “No active plan”; Add credits still works.
---
## Coordination
- Agent 8 owns Permissions panel styling/profiles — do not rewrite `PlanPermissionsPanel`.
- Agent 5/19 may add explicit legacy profile on plans; when present, prefer API flag over name heuristics (additive).
- Agent 10 may deepen org/company management; this page only adds assign/credits filters needed for billing ops.
@@ -0,0 +1,77 @@
# 10 — Admin users & organizations UI (agent 10/20)
**Owns:** `/admin/users` — users directory, company list with plan view/assign, platform staff role assignment.
**Does not own:** Billing plans table (agent 9), Permissions panel (agent 8), staff middleware/model (agent 6), admin shell chrome (agent 7 — nav label only).
**Gate:** `requirePlatformAdmin` (full admin / developer). `support_staff` is excluded.
---
## PROBLEM
`/admin/users` was a thin client-filtered user list (no companies, no plan view, no staff role edits, no server pagination). Operators needed one place to manage orgs and staff after cutover.
---
## Delivered
1. **Users tab** — server-paginated (`limit`/`offset`, page size 25), search (`q`), staff-only filter, set-password invite + local dev tools preserved.
2. **Staff roles** — dialog → `PATCH /api/admin/users/{id}/staff-role` with `admin` | `developer` | `support_staff` | clear. Uses agent 6 API; self-change blocked server-side.
3. **Companies tab** — paginated list with active plan name, legacy/public/custom badge, credits remaining/total, “without plan” filter.
4. **Assign plan** — dialog lists all admin plans (ladder + legacy + custom) → `POST /api/admin/plans/assign`.
5. **API enrichments**`GET /api/admin/users` returns `total`, `staff_role`, `resolved_role`, search/filters; `GET /api/admin/companies` returns `total`, `plan_id` / `plan_name` / `plan_is_*`, search.
---
## Source files
| File | Intent |
|------|--------|
| `apps/web/src/routes/admin/users/+page.svelte` | Orgs UI (users + companies tabs) |
| `apps/web/src/lib/admin-orgs.ts` | Client helpers + types |
| `apps/api/internal/httpapi/admin_orgs_handlers.go` | Paginated list handlers |
| `apps/api/internal/httpapi/admin_staff_handlers.go` | Staff role PATCH (agent 6) |
| `apps/web/src/lib/components/AdminNav.svelte` | Nav label “Users & orgs” |
---
## API contract
| Method | Path | Use |
|--------|------|-----|
| `GET` | `/api/admin/users?limit&offset&q&staff_only&active_only&inactive_only` | User directory |
| `PATCH` | `/api/admin/users/{id}/staff-role` | Body `{ "staff_role": "admin"\|"developer"\|"support_staff"\|null }` |
| `GET` | `/api/admin/companies?limit&offset&q&without_active_plan` | Companies + plan summary |
| `GET` | `/api/admin/plans` | Plan picker |
| `POST` | `/api/admin/plans/assign` | `{ company_id, plan_id }` |
| `POST` | `/api/admin/emails/set-password` | Existing invite tooling |
All under `RequireSession` + `RequirePlatformAdmin` (full admin only).
---
## Security / performance
- AuthZ enforced server-side; UI gate is UX only.
- Cannot change own staff role (agent 6 handler).
- Lists use SQL `LIMIT`/`OFFSET` + `COUNT(*)` — no full-table client filter.
- Company plan join is a single query (no N+1).
**ASSUMPTION:** `users.staff_role` migration (`029_staff_roles.sql`) applied. UI shows an info banner if PATCH returns 404/501.
---
## Manual verify
1. Platform admin → `/admin/users`.
2. Search users; toggle Staff only; page Next/Previous.
3. Assign staff role (admin / developer / support_staff); confirm badge.
4. Companies tab → see plan badge; filter Without plan; Assign plan (legacy + public).
5. As `support_staff`, confirm `/admin/users` is forbidden (full-admin-only nav).
---
## Coordination
- Agent 6: staff model + `PATCH …/staff-role` + `RequirePlatformAdmin` excluding support_staff.
- Agent 9: billing assign UX remains; orgs page is the directory-focused assign path.
- Agent 14: support_staff queue access after role grant from this UI.
@@ -0,0 +1,521 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Descrybe v2 Support Desk API Contract",
"version": "11.0.0-design",
"status": "design-only",
"related": "docs/admin-roles-support/11-support-design.md",
"conventions": {
"pagination": {
"query": ["limit", "offset"],
"defaults": { "limit": 50, "max_limit": 200 },
"response": { "total": "number", "limit": "number", "offset": "number" }
},
"timestamps": "RFC3339 UTC",
"ids": "UUID string",
"errors": {
"shape": { "error": "string", "code": "string?" },
"auth": {
"401": "unauthorized",
"403": "forbidden (capability or scope)",
"404": "not found (also used for agent cross-assignee hide)"
}
}
},
"enums": {
"ticket_status": ["open", "pending", "resolved", "closed"],
"ticket_category": ["billing", "bug", "account", "other"],
"ticket_priority": ["low", "normal", "high"],
"author_role": ["user", "agent", "system"],
"notification_kind": [
"ticket_created",
"agent_reply",
"status_changed",
"user_reply",
"ticket_claimed",
"csat_requested"
],
"staff_list_scope": ["inbox", "mine", "unassigned", "all"],
"email_stub_event": [
"ticket_created",
"agent_reply",
"status_resolved",
"ticket_claimed",
"user_reply"
]
},
"types": {
"SupportMessage": {
"type": "object",
"required": ["id", "ticket_id", "author_role", "body", "is_internal_note", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"ticket_id": { "type": "string", "format": "uuid" },
"author_user_id": { "type": ["string", "null"], "format": "uuid" },
"author_role": { "$ref": "#/enums/author_role" },
"body": { "type": "string", "maxLength": 10000 },
"is_internal_note": { "type": "boolean" },
"created_at": { "type": "string", "format": "date-time" }
}
},
"SupportCsat": {
"type": "object",
"required": ["score", "created_at"],
"properties": {
"score": { "type": "integer", "minimum": 1, "maximum": 5 },
"comment": { "type": "string", "maxLength": 2000 },
"created_at": { "type": "string", "format": "date-time" }
}
},
"SupportTicket": {
"type": "object",
"required": [
"id",
"company_id",
"created_by_user_id",
"subject",
"category",
"status",
"priority",
"created_at",
"updated_at"
],
"properties": {
"id": { "type": "string", "format": "uuid" },
"company_id": { "type": "string", "format": "uuid" },
"created_by_user_id": { "type": "string", "format": "uuid" },
"subject": { "type": "string", "maxLength": 200 },
"category": { "$ref": "#/enums/ticket_category" },
"status": { "$ref": "#/enums/ticket_status" },
"priority": { "$ref": "#/enums/ticket_priority" },
"assignee_admin_user_id": { "type": ["string", "null"], "format": "uuid" },
"resolved_by_user_id": { "type": ["string", "null"], "format": "uuid" },
"last_message_at": { "type": ["string", "null"], "format": "date-time" },
"last_customer_message_at": { "type": ["string", "null"], "format": "date-time" },
"last_agent_message_at": { "type": ["string", "null"], "format": "date-time" },
"resolved_at": { "type": ["string", "null"], "format": "date-time" },
"closed_at": { "type": ["string", "null"], "format": "date-time" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" },
"messages": {
"type": "array",
"items": { "$ref": "#/types/SupportMessage" },
"description": "Present on GET detail only. Customer GETs omit is_internal_note=true."
},
"company_name": { "type": "string", "description": "Staff detail/list only" },
"created_by_email": { "type": "string", "description": "Staff detail/list only" },
"csat": {
"oneOf": [
{ "$ref": "#/types/SupportCsat" },
{ "type": "null" }
],
"description": "Present when rated; null/omitted when eligible but unrated"
},
"csat_eligible": {
"type": "boolean",
"description": "Customer detail: true when status resolved|closed and no rating yet"
}
}
},
"SupportNotification": {
"type": "object",
"required": ["id", "user_id", "ticket_id", "kind", "created_at"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"user_id": { "type": "string", "format": "uuid" },
"ticket_id": { "type": "string", "format": "uuid" },
"message_id": { "type": ["string", "null"], "format": "uuid" },
"kind": { "$ref": "#/enums/notification_kind" },
"read_at": { "type": ["string", "null"], "format": "date-time" },
"created_at": { "type": "string", "format": "date-time" },
"subject": { "type": "string" }
}
},
"SupportAgent": {
"type": "object",
"required": ["id", "email", "is_support_agent", "is_platform_admin"],
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string" },
"name": { "type": "string" },
"is_support_agent": { "type": "boolean" },
"is_platform_admin": { "type": "boolean" },
"is_active": { "type": "boolean" }
}
}
},
"auth": {
"customer": ["RequireSession", "RequireCompany"],
"support_desk": ["RequireSession", "RequireSupportDesk"],
"platform_admin": ["RequireSession", "RequirePlatformAdmin"],
"public_csat": ["none", "rate_limited"],
"RequireSupportDesk": "DB check: users.is_platform_admin OR users.is_support_agent",
"visibility_policy": "queue_plus_claim",
"visibility_rules": {
"customer": "created_by_user_id = me AND company_id = selected company; never internal notes",
"support_agent_list": "scope inbox|mine|unassigned only; never other assignees",
"support_agent_get": "assignee=me OR (unassigned AND status in open|pending); else 404",
"platform_admin": "unrestricted desk access; scope=all default"
}
},
"endpoints": [
{
"id": "customer.list_tickets",
"method": "GET",
"path": "/api/support/tickets",
"auth": "customer",
"query": {
"status": { "enum_ref": "ticket_status", "optional": true },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"tickets": { "type": "array", "items": "SupportTicket" },
"total": "number",
"limit": "number",
"offset": "number"
}
},
"notes": "Existing. No messages embedded."
},
{
"id": "customer.create_ticket",
"method": "POST",
"path": "/api/support/tickets",
"auth": "customer",
"body": {
"subject": { "type": "string", "required": true, "maxLength": 200 },
"category": { "enum_ref": "ticket_category", "default": "other" },
"priority": { "enum_ref": "ticket_priority", "default": "normal" },
"body": { "type": "string", "required": true, "maxLength": 10000 }
},
"response": { "201": "SupportTicket with first message" },
"side_effects": ["status=open", "email_stub:ticket_created optional"]
},
{
"id": "customer.get_ticket",
"method": "GET",
"path": "/api/support/tickets/{id}",
"auth": "customer",
"response": {
"200": "SupportTicket + public messages + csat/csat_eligible",
"404": "not found"
}
},
{
"id": "customer.reply",
"method": "POST",
"path": "/api/support/tickets/{id}/messages",
"auth": "customer",
"body": {
"body": { "type": "string", "required": true, "maxLength": 10000 }
},
"response": {
"200": "SupportTicket",
"400": "closed or validation",
"404": "not found"
},
"side_effects": [
"reopens to open; clears resolved_at",
"notifies assignee if set",
"email_stub:user_reply optional"
]
},
{
"id": "customer.submit_csat",
"method": "POST",
"path": "/api/support/tickets/{id}/csat",
"auth": "customer",
"status": "new",
"body": {
"score": { "type": "integer", "required": true, "minimum": 1, "maximum": 5 },
"comment": { "type": "string", "optional": true, "maxLength": 2000 }
},
"response": {
"201": "SupportCsat",
"400": "not eligible status",
"404": "not found",
"409": "already rated"
},
"rules": [
"only ticket owner",
"status must be resolved or closed",
"one rating per ticket"
]
},
{
"id": "customer.list_notifications",
"method": "GET",
"path": "/api/support/notifications",
"auth": "customer",
"query": {
"unread": { "type": "boolean", "optional": true },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"notifications": { "type": "array", "items": "SupportNotification" },
"total": "number",
"unread": "number",
"limit": "number",
"offset": "number"
}
}
},
{
"id": "customer.mark_notification_read",
"method": "POST",
"path": "/api/support/notifications/{id}/read",
"auth": "customer",
"response": { "200": { "status": "ok" }, "404": "not found" }
},
{
"id": "customer.mark_all_notifications_read",
"method": "POST",
"path": "/api/support/notifications/read-all",
"auth": "customer",
"response": { "200": { "status": "ok", "updated": "number" } }
},
{
"id": "staff.list_tickets",
"method": "GET",
"path": "/api/admin/support/tickets",
"auth": "support_desk",
"query": {
"scope": {
"enum_ref": "staff_list_scope",
"default_agent": "inbox",
"default_platform_admin": "all"
},
"status": { "enum_ref": "ticket_status", "optional": true },
"company_id": { "type": "uuid", "optional": true },
"assignee_id": {
"type": "uuid",
"optional": true,
"platform_admin_only": true
},
"q": { "type": "string", "optional": true, "minLength_recommended": 2 },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"tickets": { "type": "array", "items": "SupportTicket" },
"total": "number",
"limit": "number",
"offset": "number"
},
"403": "agent requested scope=all or admin-only filter"
},
"indexes": [
"support_tickets_assignee_queue_idx",
"support_tickets_unassigned_queue_idx",
"support_tickets_admin_queue_idx"
]
},
{
"id": "staff.get_ticket",
"method": "GET",
"path": "/api/admin/support/tickets/{id}",
"auth": "support_desk",
"response": {
"200": "SupportTicket including internal notes + csat",
"404": "missing or not visible to agent"
}
},
{
"id": "staff.reply",
"method": "POST",
"path": "/api/admin/support/tickets/{id}/messages",
"auth": "support_desk",
"body": {
"body": { "type": "string", "required": true, "maxLength": 10000 },
"is_internal_note": { "type": "boolean", "default": false },
"status": { "enum_ref": "ticket_status", "optional": true }
},
"response": {
"200": "SupportTicket",
"404": "not visible",
"409": "assigned to another agent"
},
"side_effects": [
"public reply on unassigned auto-claims actor if still NULL",
"default status open|resolved -> pending on public reply",
"customer notification + email_stub:agent_reply when public",
"sets resolved_by_user_id when transitioning to resolved"
]
},
{
"id": "staff.update_ticket",
"method": "PATCH",
"path": "/api/admin/support/tickets/{id}",
"auth": "support_desk",
"body": {
"status": { "enum_ref": "ticket_status", "optional": true },
"priority": { "enum_ref": "ticket_priority", "optional": true },
"assignee_admin_user_id": {
"type": "uuid",
"optional": true,
"platform_admin_only": true
},
"clear_assignee": {
"type": "boolean",
"optional": true,
"platform_admin_only": true
}
},
"response": {
"200": "SupportTicket",
"403": "agent attempted assign fields or unowned ticket",
"404": "not visible"
},
"side_effects": [
"resolved -> csat invite notification + email_stub:status_resolved",
"status_changed notification to customer"
]
},
{
"id": "staff.claim",
"method": "POST",
"path": "/api/admin/support/tickets/{id}/claim",
"auth": "support_desk",
"status": "new",
"body": {},
"response": {
"200": "SupportTicket",
"404": "not found / not claimable visibility",
"409": { "code": "already_claimed|not_claimable" }
},
"sql": "UPDATE … SET assignee=$actor WHERE id=$id AND assignee IS NULL AND status IN ('open','pending') RETURNING id",
"side_effects": ["email_stub:ticket_claimed optional", "notification ticket_claimed"]
},
{
"id": "staff.release",
"method": "POST",
"path": "/api/admin/support/tickets/{id}/release",
"auth": "support_desk",
"status": "new",
"body": {},
"response": {
"200": "SupportTicket",
"403": "not assignee (unless platform admin)",
"404": "not found"
},
"rules": ["agent may release only when assignee=me", "platform admin may force-release via PATCH clear_assignee"]
},
{
"id": "admin.list_agents",
"method": "GET",
"path": "/api/admin/support/agents",
"auth": "platform_admin",
"status": "new",
"query": {
"include_platform_admins": { "type": "boolean", "default": true },
"limit": { "type": "integer" },
"offset": { "type": "integer" }
},
"response": {
"200": {
"agents": { "type": "array", "items": "SupportAgent" },
"total": "number",
"limit": "number",
"offset": "number"
}
}
},
{
"id": "admin.set_agent",
"method": "PUT",
"path": "/api/admin/support/agents/{userId}",
"auth": "platform_admin",
"status": "new",
"body": {
"is_support_agent": { "type": "boolean", "required": true }
},
"response": {
"200": "SupportAgent",
"404": "user not found"
},
"notes": "Does not grant is_platform_admin. Revoking does not clear open ticket assignees (admin should reassign)."
},
{
"id": "public.submit_csat_token",
"method": "POST",
"path": "/api/public/support/csat",
"auth": "public_csat",
"status": "new_optional",
"body": {
"token": { "type": "string", "required": true },
"score": { "type": "integer", "required": true, "minimum": 1, "maximum": 5 },
"comment": { "type": "string", "optional": true, "maxLength": 2000 }
},
"response": {
"201": "SupportCsat",
"400": "invalid token or score",
"409": "already rated",
"429": "rate limited"
},
"security": [
"compare SHA-256(token) to support_tickets.csat_token_hash",
"clear hash after success",
"no ticket id in response beyond generic ok/csat"
]
}
],
"session_me_extension": {
"path": "/api/auth/me",
"additive_fields": {
"is_support_agent": {
"type": "boolean",
"description": "Expose so web nav can show /admin/support for agents without platform admin"
}
}
},
"email_stubs": {
"transport": "apps/api/internal/mail.Mailer",
"gate": "SUPPORT_EMAIL_ENABLED=true AND Mailer.Enabled()",
"pii": "never log To or Body",
"events": {
"ticket_created": { "to": "configured support inbox or all is_support_agent emails" },
"agent_reply": { "to": "ticket owner" },
"status_resolved": { "to": "ticket owner", "includes": "CSAT deep link" },
"ticket_claimed": { "to": "claiming agent" },
"user_reply": { "to": "assignee if set" }
},
"failure_mode": "log and continue; never fail the HTTP mutating request"
},
"indexes_required": [
{
"name": "support_tickets_assignee_queue_idx",
"sql": "CREATE INDEX support_tickets_assignee_queue_idx ON support_tickets (assignee_admin_user_id, status, last_message_at DESC NULLS LAST)"
},
{
"name": "support_tickets_unassigned_queue_idx",
"sql": "CREATE INDEX support_tickets_unassigned_queue_idx ON support_tickets (status, last_message_at DESC NULLS LAST) WHERE assignee_admin_user_id IS NULL"
},
{
"name": "support_csat_ratings_created_idx",
"sql": "CREATE INDEX support_csat_ratings_created_idx ON support_csat_ratings (created_at DESC)"
}
],
"breaking_changes": [
{
"audience": "support_agent_future",
"change": "Staff without platform admin cannot use scope=all or read other agents' tickets",
"migration": "N/A today — all desk users are platform admins; document when agents ship"
}
],
"test_matrix": [
{ "actor": "customer", "action": "get other user ticket", "expect": 404 },
{ "actor": "customer", "action": "see internal note", "expect": "filtered out" },
{ "actor": "customer", "action": "csat twice", "expect": 409 },
{ "actor": "agent_a", "action": "get agent_b assigned ticket", "expect": 404 },
{ "actor": "agent_a", "action": "claim unassigned", "expect": 200 },
{ "actor": "agent_b", "action": "claim same after a", "expect": 409 },
{ "actor": "agent_a", "action": "scope=all", "expect": 403 },
{ "actor": "platform_admin", "action": "scope=all + assign", "expect": 200 },
{ "actor": "platform_admin", "action": "PUT agents grant", "expect": 200 },
{ "actor": "support_agent", "action": "PUT agents", "expect": 403 }
]
}
@@ -0,0 +1,464 @@
# 11 — Support desk design (complete)
**Status:** Design only — no implementation in this document.
**Agent:** 11/20 (`admin-roles-support`)
**Date:** 2026-08-05
**Repo:** `F:/laragon/www/_MY/descrybe-v2`
**API contract:** [11-support-api-contract.json](./11-support-api-contract.json)
---
## 0. North star
Extend the **existing** Support Center (`025_support_center.sql` + `internal/support`) into a least-privilege staff desk:
- Customers open/reply on **their** tickets.
- Platform admins **grant** support-agent capability and may **assign** tickets.
- Support agents work a **shared unassigned queue** + **claim** model; they only see assigned or claimable tickets.
- After **resolve**, customers may leave a **CSAT** rating (once).
- Outbound email is **optional stubs** on top of `internal/mail` (noop when SMTP disabled).
```
Customer (/support)
│ create / reply / rate (CSAT)
support_tickets ──◄── support_messages (public + internal notes)
├── unassigned queue ── claim ──► assignee inbox
└── platform admin: assign / reassign / revoke staff
```
**Principle:** Prefer extending `ListAdmin` / `GetAdmin` / `UpdateAdmin` / `ReplyAsAgent` with **actor-scoped filters** over a parallel ticket system.
---
## 1. Inventory — what exists today
### 1.1 Schema (`apps/api/sql/schema/025_support_center.sql`)
| Table | Purpose |
|-------|---------|
| `support_tickets` | Subject, category, status, priority, `assignee_admin_user_id`, message timestamps, resolved/closed |
| `support_messages` | Thread; `author_role``user\|agent\|system`; `is_internal_note` |
| `support_notifications` | In-app bell; kinds: `ticket_created`, `agent_reply`, `status_changed`, `user_reply` |
**Statuses:** `open``pending``resolved``closed` (CHECK).
**Categories:** `billing`, `bug`, `account`, `other`.
**Priorities:** `low`, `normal`, `high`.
**Indexes today:**
- `support_tickets_admin_queue_idx (status, last_message_at DESC)`
- `support_tickets_company_user_idx (company_id, created_by_user_id, updated_at DESC)`
- `support_tickets_company_status_idx (company_id, status)`
- `support_messages_ticket_idx (ticket_id, created_at)`
- notification user unread / created indexes
**Missing for this design:** assignee queue indexes, CSAT table, support-agent capability, claim audit, email event stubs.
### 1.2 API (Go)
| Surface | Mount | Auth today |
|---------|-------|------------|
| User tickets | `/api/support/tickets` (+ messages) | `RequireSession` + `RequireCompany` |
| Notifications | `/api/support/notifications*` | same |
| Admin desk | `/api/admin/support/tickets*` | `RequireSession` + **`RequirePlatformAdmin`** |
Handlers: `apps/api/internal/httpapi/support_handlers.go`.
Domain: `apps/api/internal/support/{tickets,types,validate,notifications,ai_auto_reply}.go`.
Notable behavior already shipped:
- `GetForUser` / `ListForUser` — owner-only; **strips internal notes**.
- `GetAdmin` / `ListAdmin`**all companies**, includes internal notes; no assignee filter.
- `ReplyAsAgent` — auto-sets assignee via `COALESCE(assignee, actor)` on public reply; defaults status `open|resolved``pending`.
- `UpdateAdmin` — patch status / priority / assignee / `clear_assignee`.
- `TryAutoReplyLLM`**safe stub** (`ErrAIAutoReplyDisabled`); keep unused for human desk.
Pagination: `ParseLimitOffset` — default **50**, max **200**.
### 1.3 Web UI
| Route | Client |
|-------|--------|
| `/support`, `/support/new`, `/support/[ticketId]` | `$lib/support/api.ts``/api/support/tickets` |
| `/admin/support`, `/admin/support/[id]` | `$lib/support/admin-api.ts``/api/admin/support/tickets` |
Plan gates (catalog): `support.center`, `support.ticket_create`, `support.ticket_thread` — fail-open for Free (`docs/plan-permissions`).
### 1.4 Gaps vs goal
| Goal | Today |
|------|-------|
| Support staff ≠ full platform admin | Only `users.is_platform_admin`; admin support routes are platform-admin-only |
| Staff inbox (assigned + queue) | Flat global list for every platform admin |
| Claim / least privilege | Assignee column exists; no claim race, no visibility rules |
| CSAT after resolve | None |
| Email on events | In-app notifications only; platform SMTP (`internal/mail`) unused by support |
| Admin assigns staff | Can set `assignee_admin_user_id` on a ticket, but cannot grant a support-only role |
---
## 2. Roles & capability model
### 2.1 Capabilities (additive flags)
Keep `users.is_platform_admin`. Add:
```sql
ALTER TABLE users
ADD COLUMN is_support_agent BOOLEAN NOT NULL DEFAULT false;
```
| Actor | Capability |
|-------|------------|
| **Customer** (company member) | Own tickets only (`created_by_user_id` + `company_id`) |
| **Support agent** (`is_support_agent`) | Queue + claim + own inbox; internal notes; reply/status on allowed tickets |
| **Platform admin** (`is_platform_admin`) | Everything agents can do **plus** global list, force-assign/reassign, grant/revoke `is_support_agent`, CSAT aggregates |
**ASSUMPTION:** `is_platform_admin` implies support access (no need to also set `is_support_agent`). Middleware: `is_platform_admin OR is_support_agent`.
**ASSUMPTION:** Support agents are Descrybe platform users (not tenant company roles). They may still belong to a company for login, but support desk is platform-scoped.
### 2.2 Auth middleware
| Middleware | Gate |
|------------|------|
| `RequirePlatformAdmin` | unchanged — billing, plans, impersonation, staff grants |
| **`RequireSupportDesk`** (new) | session + (`is_platform_admin` OR `is_support_agent`) from DB |
Mount **desk** routes under `/api/admin/support/*` with `RequireSupportDesk` (not full admin).
Mount **staff management** under `/api/admin/support/agents*` with `RequirePlatformAdmin` only.
---
## 3. Visibility policy — **queue + claim** (chosen)
Least-privilege default: agents never list or open tickets assigned to someone else.
### 3.1 List scopes
Query param `scope` on staff list:
| `scope` | Who | Filter |
|---------|-----|--------|
| `inbox` (default for agents) | agent | `assignee_admin_user_id = me OR assignee IS NULL` and `status IN ('open','pending')` |
| `mine` | agent | `assignee = me` |
| `unassigned` | agent | `assignee IS NULL` and status open/pending |
| `all` | **platform admin only** | optional filters: status, company_id, assignee_id, q |
Agents requesting `scope=all`**403**.
### 3.2 Get / reply / update
| Action | Agent allowed when |
|--------|--------------------|
| GET ticket | assignee = me **OR** (unassigned AND status ∈ open/pending) |
| Public reply / internal note | assignee = me **OR** claim-first on unassigned |
| PATCH status/priority | assignee = me |
| Force assign / clear / reassign | **platform admin only** |
| Claim | unassigned + open/pending; atomic |
| Release | assignee = me → set NULL (optional system note) |
**Get on another agent's ticket → 404** (not 403) to avoid ticket-id probing. Platform admin gets 200 always.
### 3.3 Claim (atomic)
```sql
UPDATE support_tickets
SET assignee_admin_user_id = $actor,
updated_at = now()
WHERE id = $id
AND assignee_admin_user_id IS NULL
AND status IN ('open', 'pending')
RETURNING id;
```
- 0 rows → `409 conflict` (`already_claimed` or `not_claimable`).
- On success: optional internal system message + notification to claimant; email stub `ticket_claimed`.
**First public agent reply** may continue to auto-claim via existing `COALESCE(assignee, actor)` **only if** still unassigned; if assigned to another agent → **403/404**.
### 3.4 Alternatives considered (rejected for MVP)
| Model | Why not |
|-------|---------|
| Shared team inbox (all agents see all) | Violates least privilege; leaks tenant PII across staff |
| Assigned-only (no queue) | Admin must hand-assign every ticket; high latency |
| Round-robin auto-assign | Needs fair staffing metrics; defer |
---
## 4. Status workflow
Keep existing enum; tighten **transitions** in service layer (today: mostly free-form normalize).
```
┌──────────────┐
│ open │◄──── customer reply (from pending/resolved)
└──────┬───────┘
│ agent public reply (default)
┌──────────────┐
│ pending │ waiting on customer
└──────┬───────┘
agent resolve │
┌──────────────┐
│ resolved │ CSAT window opens
└──────┬───────┘
close / idle │
┌──────────────┐
│ closed │ no customer replies
└──────────────┘
```
| From → To | Who |
|-----------|-----|
| * → `pending` | agent (public reply default) |
| * → `resolved` | agent assignee or platform admin |
| `resolved``closed` | agent assignee, platform admin, or optional job after N days |
| `resolved\|pending``open` | customer reply (existing) |
| `closed` → * | platform admin only (reopen); customer blocked (`ErrTicketClosed`) |
Set `resolved_at` / `closed_at` as today. On resolve: enqueue CSAT invite (in-app + email stub).
Internal notes **must not** change status unless `status` is explicitly sent (existing `ReplyAsAgent` behavior).
---
## 5. CSAT / rating
### 5.1 Schema
```sql
CREATE TABLE support_csat_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL UNIQUE REFERENCES support_tickets(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5),
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX support_csat_ratings_created_idx
ON support_csat_ratings (created_at DESC);
```
Optional opaque token for email deep-link (hash-at-rest):
```sql
-- on support_tickets
csat_token_hash BYTEA, -- NULL until resolved
csat_invite_sent_at TIMESTAMPTZ
```
### 5.2 Rules
- Only `created_by_user_id` may rate.
- Ticket status must be `resolved` or `closed`.
- One rating per ticket (`UNIQUE ticket_id`); duplicate → `409`.
- Comment max 2000 runes; strip NUL; optional.
- Rating is **customer-visible** on own ticket; staff see score on GET (admin/agent).
- No edits after submit (MVP).
### 5.3 UX
- Banner on `/support/[ticketId]` when resolved and unrated.
- Admin analytics later: avg score by agent (join assignee at resolve time — optional `resolved_by_user_id` column if needed for fairness after reassignment).
**ASSUMPTION:** Store `resolved_by_user_id` (nullable) set when transitioning to `resolved` so CSAT attributes to the resolver, not a later reassignment.
---
## 6. Internal notes
Already modeled: `support_messages.is_internal_note` + `author_role='agent'`.
Preserve:
- Never return internal notes from `GetForUser` / user list serializers.
- Staff GET includes notes.
- Notes do not trigger `agent_reply` customer notifications (existing).
- Add notification kind `internal_note` for **other staff on same ticket**? **Defer** — with claim model only assignee (+ admins) see the ticket.
---
## 7. Email — optional stubs
Reuse platform `internal/mail.Mailer` (SMTP or noop). Do **not** use tenant marketing email (`internal/email`).
| Event | Recipient | Stub behavior |
|-------|-----------|---------------|
| `ticket_created` | support agents (or shared inbox address) | noop logs subject if SMTP off |
| `agent_reply` | ticket owner | link to `/support/{id}` |
| `status_resolved` | ticket owner | CSAT CTA + tokenized link |
| `ticket_claimed` | claiming agent | optional |
| `user_reply` | assignee | if assigned |
Implementation sketch:
```go
// internal/support/notify_email.go
func (s *Service) enqueueSupportMail(ctx, kind, ticketID) {
if s.Mail == nil || !s.Mail.Enabled() { return }
// build Message; never log To/Body PII
}
```
Feature flag: `SUPPORT_EMAIL_ENABLED` (default false) **or** rely solely on `Mailer.Enabled()`. Prefer explicit flag so ops can enable SMTP for invites without support mail spam.
River/async: optional later; MVP sync-best-effort after commit (errors logged, not failed request).
---
## 8. Schema deltas (implementation later)
```sql
-- +goose Up (sketch — new migration, e.g. 0xx_support_desk.sql)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_support_agent BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE support_tickets
ADD COLUMN IF NOT EXISTS resolved_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS csat_token_hash BYTEA,
ADD COLUMN IF NOT EXISTS csat_invite_sent_at TIMESTAMPTZ;
CREATE INDEX support_tickets_assignee_queue_idx
ON support_tickets (assignee_admin_user_id, status, last_message_at DESC NULLS LAST);
CREATE INDEX support_tickets_unassigned_queue_idx
ON support_tickets (status, last_message_at DESC NULLS LAST)
WHERE assignee_admin_user_id IS NULL;
-- support_csat_ratings as in §5.1
ALTER TABLE support_notifications
DROP CONSTRAINT IF EXISTS support_notifications_kind_check;
-- recreate CHECK to include: ticket_claimed, csat_requested (if used)
```
---
## 9. API surface (summary)
Full OpenAPI-ish shapes: [11-support-api-contract.json](./11-support-api-contract.json).
### 9.1 Customer (existing + CSAT)
| Method | Path | Change |
|--------|------|--------|
| GET/POST | `/api/support/tickets` | unchanged contract |
| GET | `/api/support/tickets/{id}` | add optional `csat` object |
| POST | `/api/support/tickets/{id}/messages` | unchanged |
| **POST** | **`/api/support/tickets/{id}/csat`** | **new**`{ score, comment? }` |
| GET/POST | `/api/support/notifications*` | unchanged |
### 9.2 Staff desk (`RequireSupportDesk`)
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/admin/support/tickets` | add `scope`, `assignee_id`; enforce §3 |
| GET | `/api/admin/support/tickets/{id}` | visibility §3.2 |
| POST | `/api/admin/support/tickets/{id}/messages` | visibility + auto-claim rules |
| PATCH | `/api/admin/support/tickets/{id}` | agent: status/priority only if assigned; assign fields admin-only |
| **POST** | **`/api/admin/support/tickets/{id}/claim`** | atomic claim |
| **POST** | **`/api/admin/support/tickets/{id}/release`** | assignee self-clear |
### 9.3 Staff directory (`RequirePlatformAdmin`)
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/admin/support/agents` | list users with `is_support_agent` (+ admins optional) |
| PUT | `/api/admin/support/agents/{userId}` | `{ is_support_agent: bool }` |
### 9.4 Public CSAT token (optional)
| Method | Path | Notes |
|--------|------|-------|
| POST | `/api/public/support/csat` | `{ token, score, comment? }` — rate-limited; no session |
---
## 10. Web UI (later)
| Route | Audience |
|-------|----------|
| `/support*` | customer — add CSAT panel |
| `/admin/support` | rename mentally to “desk”; agents see Inbox tabs: Unassigned / Mine |
| `/admin/support/[id]` | hide assign controls for agents; show Claim if unassigned |
| `/admin/support/agents` | platform admin — grant/revoke agents |
Nav: show Support desk link if `is_platform_admin || is_support_agent` (session `/api/auth/me` must expose `is_support_agent`).
---
## 11. Security notes
1. **AuthZ server-side** — never trust client `scope` / assignee; re-check on every GET/PATCH/reply.
2. **404 vs 403** on cross-assignee reads for agents (anti-enumeration).
3. **Internal notes** — filter at SQL (`is_internal_note = false`) for customer paths; double-filter in TS serializers.
4. **CSAT token** — store only SHA-256 hash; single-use invalidate on submit; rate-limit public endpoint.
5. **Staff grant** — platform admin only; audit log recommended (who granted whom).
6. **PII** — list search already ILIKE email/company; keep limit/offset caps; no unbounded export in MVP.
7. **Claim races** — single `UPDATE … WHERE assignee IS NULL` in a transaction; no read-modify-write.
8. **Closed tickets** — customers cannot reply; agents notes-only or admin reopen.
9. **AI** — keep `TryAutoReplyLLM` disabled; human desk only.
10. **Email** — no logging of To/Body; header-break validation via existing mailer.
---
## 12. Performance notes
| Path | Guidance |
|------|----------|
| Customer list | existing `(company_id, created_by_user_id, updated_at)` |
| Agent inbox | **partial** `WHERE assignee IS NULL` + `(assignee, status, last_message_at)` |
| Admin global | existing status queue idx; avoid `ILIKE %q%` without length floor (min 23 chars) |
| Messages | always by `ticket_id` + order; do not load all messages for list endpoints |
| Notifications | existing unread partial-friendly indexes |
| CSAT aggregates | index `created_at`; optional later `(resolved_by_user_id)` |
| Pagination | keep default 50 / max 200; return `total` via count query (already) |
| N+1 | list endpoints return ticket rows only — no nested messages (already) |
Claim and resolve paths use `FOR UPDATE` on the ticket row (already in Reply/Update) — keep that for status transitions.
---
## 13. Implementation phasing (for later agents)
1. Migration: `is_support_agent`, assignee indexes, CSAT table, `resolved_by_user_id`.
2. Middleware `RequireSupportDesk`; expose flag on `/me`.
3. Service visibility + claim/release; tighten admin list filters.
4. CSAT POST + ticket payload field.
5. Staff agents admin API + UI.
6. Email stubs behind flag.
7. Tests: authz matrix (customer / agent A / agent B / platform admin), claim conflict, CSAT once, internal notes leak check.
**Out of scope for this design doc:** SLA timers, macros, attachments, full-text search, multi-assignee, customer-visible agent names beyond email, AI auto-reply enablement.
---
## 14. Contracts preserved
- Customer ticket JSON fields remain stable; additive `csat` only.
- Existing admin paths stay; new claim/release/agents/csat endpoints are additive.
- Status/category/priority enums unchanged.
- `TryAutoReplyLLM` stub remains refuse-by-default.
**BREAKING (intentional, staff-only):** agents lose unrestricted `ListAdmin`/`GetAdmin` visibility — platform admins unaffected. Document in release notes when implementing.
---
## 15. Coordination with sibling agents
| Doc | Alignment |
|-----|-----------|
| [02-current-inventory.md](./02-current-inventory.md) | Confirms gaps this design fills (CSAT, staff-only gate, claim inbox). |
| [03-roles-matrix.md](./03-roles-matrix.md) | Staff role name **`support_staff`**. This designs `users.is_support_agent` is the **MVP column** that implements that role until a unified `staff_role` enum lands. Treat names as aliases. |
| [18-performance.md](./18-performance.md) | Prefers assignee/activity indexes in `027_capabilities_support_perf.sql`. **Implementers:** reuse those index names if 027 ships first; do not duplicate. Partial unassigned index from §8 remains additive if not covered. |
**ASSUMPTION:** If agent 03 later ships `staff_role TEXT` (`admin|developer|support_staff`), migrate `is_support_agent``staff_role = 'support_staff'` and keep `RequireSupportDesk` as `is_platform_admin OR staff_role IN ('support_staff','admin','developer')` per final matrix — do not invent a second parallel flag.
@@ -0,0 +1,88 @@
# 12 — Support tickets backend
**Status:** Implemented (extends Support Center)
**Agent:** 12/20
**Date:** 2026-08-05
**Design:** [11-support-design.md](./11-support-design.md) · [11-support-api-contract.json](./11-support-api-contract.json)
## Summary
Additive support-desk backend on top of `025_support_center` + `029_staff_roles`:
- Customers: create / list / reply with **company + owner isolation** (unchanged contract).
- Staff desk (`RequireSupportDesk`): queue + claim visibility for `staff_role=support_staff`; full list for platform admins.
- Claim / release endpoints; assign force-fields remain admin-oriented.
- Agents directory via `staff_role` (maps API `is_support_agent` without a second boolean column).
- Migration `030_support_desk.sql`: `resolved_by_user_id`, CSAT table (ratings owned by agent 13), unassigned index, notification kinds.
## Schema
| Artifact | Purpose |
|----------|---------|
| `029_staff_roles.sql` | `users.staff_role``admin\|developer\|support_staff` |
| `027_capabilities_support_perf.sql` | assignee / status activity indexes |
| `030_support_desk.sql` | `resolved_by_user_id`, CSAT table + token cols, unassigned partial index, `ticket_claimed` / `csat_requested` notification kinds |
**ASSUMPTION:** Designs `is_support_agent` boolean is implemented as `staff_role='support_staff'` (roles agents landed first). API still exposes `is_support_agent` on agent DTOs / `/me` staff block.
## Auth
| Middleware | Who |
|------------|-----|
| `RequireSession` + `RequireCompany` | Customer ticket + notification routes |
| `RequireSupportDesk` | `/api/admin/support/tickets*` claim/release |
| `RequirePlatformAdmin` | `/api/admin/support/agents*` (+ other admin) |
`auth.ResolveStaffAccess`: `support_staff` → desk only; `admin`/`developer`/legacy `is_platform_admin` → full admin + desk.
## Visibility (queue + claim)
| Scope | Filter |
|-------|--------|
| `inbox` (agent default) | assignee IS NULL OR me; status ∈ open\|pending |
| `mine` | assignee = me |
| `unassigned` | assignee IS NULL; open\|pending |
| `all` (admin default) | optional status / company_id / assignee_id / q |
Agent `scope=all`**403**. Cross-assignee GET → **404**. Reply on another agents ticket → **409 already_claimed**.
Claim SQL (atomic):
```sql
UPDATE support_tickets
SET assignee_admin_user_id = $actor, updated_at = now()
WHERE id = $id AND assignee_admin_user_id IS NULL AND status IN ('open','pending');
```
## Key packages
| Path | Role |
|------|------|
| `internal/support/tickets.go` | CRUD, reply, update (+ `resolved_by_user_id` on resolve) |
| `internal/support/desk.go` | scopes, claim, release, `GetAdminForActor` |
| `internal/support/agents.go` | list/set support agents via `staff_role` |
| `internal/httpapi/support_handlers.go` | HTTP + staff visibility helpers |
| `internal/httpapi/admin_staff_handlers.go` | set agent |
| `internal/auth/staff.go` | StaffAccess resolution |
## Tests
```bash
cd apps/api
go test ./internal/support/ -count=1
# with DATABASE_URL:
go test ./internal/support/ -run 'TicketCRUDAuthOwnership|StaffQueueClaimRelease' -count=1
go test ./internal/httpapi/ -run Support -count=1
```
## Out of scope here
- CSAT submit/aggregate HTTP (agent 13 — schema prepared in `029`).
- Staff/customer UI (later agents).
- Support email stubs (optional; mailer unused by support MVP).
## Verification notes
- Customer list/get still require `company_id` + `created_by_user_id`.
- Internal notes never returned on customer GET.
- Pagination clamped 50 default / 200 max (`clampListBounds`).
@@ -0,0 +1,90 @@
# 13 — Support ticket ratings / CSAT
**Owner:** agent 13
**Depends on:** agent 12 schema (`030_support_desk.sql``support_csat_ratings`)
**Status:** implemented (API + tests)
## Goal
Customers rate **resolved** or **closed** tickets once (score 15 + optional comment). Platform admins see aggregate CSAT (no PII).
## Schema (agent 12)
Table `support_csat_ratings`:
| Column | Notes |
|--------|--------|
| `ticket_id` | `UNIQUE` — one rating per ticket |
| `company_id`, `user_id` | owner + tenant |
| `score` | `SMALLINT` 15 |
| `comment` | optional, max 2000 runes (truncated) |
| `created_at` | UTC |
Ticket columns `csat_token_hash` / `csat_invite_sent_at` exist for optional public token flow (not wired in this agent).
## Rules
1. Only `created_by_user_id` may rate (others → `404`).
2. Status must be `resolved` or `closed` else `400` (`ticket not eligible for rating`).
3. Duplicate insert → `409` (`already rated`).
4. Logs: `ticket_id` + `score` only — **never** comment, email, or names.
## APIs
| Method | Path | Auth | Response |
|--------|------|------|----------|
| `POST` | `/api/support/tickets/{id}/csat` | session + company | `201` `SupportCsat` |
| `GET` | `/api/support/tickets/{id}` | customer | additive `csat` / `csat_eligible` |
| `GET` | `/api/admin/support/tickets/{id}` | support desk | includes `csat` when present |
| `GET` | `/api/admin/support/csat?from=&to=` | platform admin | aggregate |
### Customer submit body
```json
{ "score": 4, "comment": "optional" }
```
### Admin aggregate
```json
{
"total": 12,
"average": 4.25,
"distribution": { "1": 0, "2": 1, "3": 2, "4": 4, "5": 5 },
"from": null,
"to": null
}
```
`from` / `to` are optional RFC3339 bounds on `created_at` (`to` exclusive).
## Code map
| Piece | Path |
|-------|------|
| Service | `apps/api/internal/support/ratings.go` |
| Types | `CSATInput`, `CSATRating`, `CSATAggregate` in `types.go` |
| Handlers | `apps/api/internal/httpapi/support_csat_handlers.go` |
| Routes | customer POST csat; admin GET `/api/admin/support/csat` |
| Unit tests | `ratings_test.go`, `support_csat_auth_test.go` |
| Integration | `ratings_integration_test.go` (needs `DATABASE_URL` + goose) |
## Verification
```bash
cd apps/api
go test ./internal/support/ -count=1 -run 'CSAT|NormalizeCSAT|ClientErrorCSAT'
go test ./internal/httpapi/ -count=1 -run 'SupportCSAT|SupportTicketCRUDAuth'
```
With DB migrated:
```bash
go test ./internal/support/ -count=1 -run TestSubmitCSATOwnershipAndOnce
```
## Out of scope (this agent)
- Public token CSAT (`POST /api/public/support/csat`)
- User/staff UI panels (agents 1415)
- Email CSAT invite stubs
@@ -0,0 +1,91 @@
# 14 — Support staff UI
**Agent:** 14/20
**Status:** Implemented
**Coordinates with:** `06-staff-roles` (middleware + `staff_role`), `11-support-design` / `12-support-backend` (claim/scope/agents), `07-admin-shell`, `10-admin-orgs-ui` (grant role)
---
## PROBLEM
Platform support work lived behind full `is_platform_admin`. Staff need an inbox with claim/unassign, assignment, and filters — without billing/settings access. Platform admins must be able to grant `support_staff` from the orgs/users UI.
---
## WHAT SHIPPED
### Access model
| Actor | Gate | Surfaces |
|-------|------|----------|
| Full admin (`admin` / `developer` / legacy platform admin) | `RequirePlatformAdmin` / `staff.full_admin` | Full `/admin/*` |
| `support_staff` | `RequireSupportDesk` / `staff.support_desk` | `/admin/support/**` (+ Overview in nav) |
| Customers | company session | `/support/**` (unchanged) |
- API: `/api/admin/support/*` mounted under `RequireSupportDesk` (not full admin).
- Web: `requireSupportDesk()` on support pages; `requirePlatformAdmin()` elsewhere.
- `AdminNav` hides billing/settings/users/analytics for support-only staff.
- Layout shows admin shell when `staff_access.support_desk` or `full_admin`.
### Queue + ticket UI (`/admin/support`)
- **Scopes:** Inbox · Mine · Unassigned · All (All = full admin only). Persisted in `?scope=`.
- **Status chips** + search (subject / email / company).
- **Claim** on unassigned open/pending rows and on ticket detail.
- **Unassign / release** when assignee is self (or force for full admin).
- **Assign to staff** (full admin): agent picker from `GET /api/admin/support/agents`.
- Assignee column shows email / “You” / Unassigned.
- Visual language matches reworked admin shell (`PageShell`, `Card`, `TableShell`, filter chips).
### Role grant coordination (orgs UI)
Platform admins grant roles via:
- `PATCH /api/admin/users/{id}/staff-role` body `{ "staff_role": "support_staff" | "admin" | "developer" | null }`
- Admin Users UI (`apps/web/src/routes/admin/users/+page.svelte` + `$lib/admin-orgs.ts`) — role dialog owned with agent 10.
- Optional alias: `PUT /api/admin/support/agents/{id}` `{ "is_support_agent": true }` → sets `staff_role=support_staff`.
`/api/auth/me` returns `staff_access` + `staff_capabilities` for client gating.
---
## FILES (primary)
| Area | Path |
|------|------|
| Client API | `apps/web/src/lib/support/admin-api.ts` |
| Gates | `apps/web/src/lib/admin-gate.ts` (`requireSupportDesk`) |
| Types / session | `apps/web/src/lib/types.ts`, `auth-session.svelte.ts` |
| Nav / layout | `AdminNav.svelte`, `routes/+layout.svelte` |
| Queue / detail | `routes/admin/support/+page.svelte`, `[id]/+page.svelte` |
| Handlers | `apps/api/internal/httpapi/support_handlers.go` (scope, claim, release, agents) |
| Desk domain | `apps/api/internal/support/desk.go`, `agents.go` |
---
## VERIFICATION
```text
cd apps/api && go test ./internal/support/ ./internal/auth/ ./internal/httpapi/ -count=1
```
Manual:
1. As platform admin: Users → set a user `staff_role=support_staff`.
2. Sign in as that user → admin shell shows Support (not billing).
3. Open `/admin/support?scope=unassigned` → Claim → appears under Mine.
4. Unassign → returns to Unassigned.
5. As full admin: open ticket → Assign to staff → Apply.
6. Confirm `/admin/billing` returns 403 for support_staff (API + UI).
---
## ASSUMPTIONS
- ASSUMPTION: Agent 6 `ResolveStaffAccess``support_staff` never gets `full_admin` even if `is_platform_admin` column is true.
- ASSUMPTION: Least-privilege visibility for agents (inbox / claim) is enforced server-side in list/get/update (agent 12).
- CSAT ratings UI is owned by sibling agents; this doc does not cover rating widgets.
## ROLLBACK
Revert support route UI/gate/nav changes and remount support APIs under `RequirePlatformAdmin` only if needed. Staff role column (`029_staff_roles`) is additive — leave in place.
@@ -0,0 +1,96 @@
# 15 — User-facing Support Center UI
**Agent:** 15/20
**Date:** 2026-08-05
**Scope:** Customer `/support` surfaces — list, create, thread, CSAT rating.
**Contract:** [11-support-design.md](./11-support-design.md) §5 / §9.1 (`POST /api/support/tickets/{id}/csat`).
---
## Outcome
Polished, accessible user Support Center that:
1. Lists the callers tickets with status chips + URL `?status=` persistence.
2. Creates tickets (subject, category, priority, body) when `support.ticket_create` allows.
3. Shows a threaded conversation with quiet polling (visibility-aware).
4. Prompts CSAT (15 + optional comment) when status is `resolved` or `closed` and no rating yet.
5. Respects plan feature keys via existing `PlanRouteGuard` + `FeatureGate` / `planCapabilities.can`.
---
## Surfaces
| Route | Feature key | Notes |
|-------|-------------|-------|
| `/support` | `support.center` | Status filter chips, clickable rows, count |
| `/support/new` | `support.ticket_create` | Priority + char counters; upgrade panel if denied |
| `/support/[ticketId]` | `support.ticket_thread` | Thread + CSAT banner/form |
Legacy plans: if the matrix turns `support.*` off (see `03-roles-matrix`), `PlanRouteGuard` shows the upgrade panel. When keys are absent (pre-cutover), `CORE_ALWAYS_ON` keeps basic support available.
CSAT is **not** a separate feature key — it rides on `support.ticket_thread` once the ticket is resolved/closed (design §5).
---
## Client API
| Helper | Path |
|--------|------|
| `listSupportTickets` / `getSupportTicket` / `createSupportTicket` / `replySupportTicket` | `apps/web/src/lib/support/api.ts` |
| `submitSupportCsat``POST …/csat` `{ score, comment? }` | same |
| `canRateTicket` / `canReplyToTicket` | same |
| Types `SupportCsat`, `SubmitSupportCsatInput` | `apps/web/src/lib/support/types.ts` |
| UI | `SupportTicketRating.svelte` |
**Graceful backend lag:** if agent 13s CSAT route is not mounted yet, `isSupportUnavailable` / 404503 surfaces a clear message without breaking the thread. Duplicate ratings (`409`) show “already rated”.
**ASSUMPTION:** Response may be a full ticket (with `csat`) or a CSAT object; UI merges `csat` onto the in-memory ticket either way.
---
## UX / a11y / perf
- Status filters: `aria-pressed` chip group (matches admin queue pattern).
- Rows: keyboard activatable (`Enter` / `Space`), `role="link"`.
- CSAT: `role="radiogroup"` star buttons with explicit `aria-label`s; comment encouraged for scores ≤2.
- Thread: `role="log"` + `aria-live="polite"`; capped scroll region; auto-scroll on new messages.
- Polling: 20s interval skipped when `document.visibilityState === "hidden"`; refresh on focus.
- No extra cards in hero chrome — reuse `PageShell` + existing tokens.
---
## Files touched
- `apps/web/src/lib/support/types.ts`
- `apps/web/src/lib/support/api.ts`
- `apps/web/src/lib/components/SupportTicketRating.svelte` (new)
- `apps/web/src/routes/support/+page.svelte`
- `apps/web/src/routes/support/new/+page.svelte`
- `apps/web/src/routes/support/[ticketId]/+page.svelte`
- `apps/web/src/lib/plan-capabilities.ts` (upgrade titles for create/thread)
---
## Verification
```bash
cd apps/web && npm run check
```
Manual:
1. `/support` — filter chips update URL; empty + list states.
2. `/support/new` — create with priority; gated plan shows upgrade.
3. Resolved ticket — CSAT form appears; after submit, “Your rating” card; reply still reopens when allowed.
4. Legacy matrix with `support.center=false` — route guard upgrade panel.
**Depends on:** agent 13 mounting `POST /api/support/tickets/{id}/csat` and optional `csat` on GET. UI is ready ahead of that mount.
---
## Out of scope (other agents)
- Schema / CSAT backend (1213)
- Staff assign / claim UI (14)
- Admin CSAT aggregates
+104
View File
@@ -0,0 +1,104 @@
# 16 — Legacy (A1) user dashboard nav (agent 16/20)
**Status:** Frontend nav + route guards aligned to the legacy image allow-list.
**Coordinates with:** [`03-roles-matrix.md`](./03-roles-matrix.md), [`05-legacy-seed.md`](./05-legacy-seed.md) (agent 5), [`../plan-permissions/09-dashboard-gating.md`](../plan-permissions/09-dashboard-gating.md).
---
## PROBLEM
Legacy tenants (A1 / Local Demo Co cohort) must see a flat primary nav matching the product screenshot — **without** Background Tasks / Processing, stores, or marketing suite chrome.
---
## Target nav (image order)
| # | Label | Route | Feature key |
|---|-------|-------|-------------|
| 1 | Dashboard | `/dashboard` | `dashboard.overview` |
| 2 | Products | `/products` | `catalog.products` |
| 3 | Feeds | `/feeds` | `feeds.list` |
| 4 | Export Feeds | `/export-feeds` | `feeds.export_feeds` |
| 5 | Categories | `/categories` | `catalog.categories` |
| 6 | Attributes | `/attributes` | `catalog.attributes` |
| 7 | Standard Fields | `/standard-fields` | `catalog.standard_fields` |
| 8 | Usage & Billing | `/billing` | `billing.overview` |
| 9 | Settings | `/settings` | `settings.profile` |
**Must be OFF (no nav link):** `processing.monitor` (Background Tasks), `stores.*`, `marketing.*`, `integrations.*`, `support.center`, catalog extras.
When only the allow-list is ON, the **More** panel is omitted entirely (no empty More control).
---
## Implementation (this agent)
| Piece | Path | Change |
|-------|------|--------|
| Primary nav order + labels | `apps/web/src/lib/components/Nav.svelte` | Flat primary = image list; Stores/Processing/marketing under More; hide More when empty |
| Constants + upgrade titles | `apps/web/src/lib/plan-capabilities.ts` | `LEGACY_PRIMARY_NAV`, `LEGACY_NAV_DENIED_FEATURES`; upgrade copy for processing/stores/support/… |
| Command palette | `apps/web/src/lib/components/CommandPalette.svelte` | Same labels; destinations filtered by `planCapabilities.can` |
| Route guard (existing) | `PlanRouteGuard.svelte` | Deep-link to denied route → `PlanUpgradePanel` |
| Dashboard chrome | `DashboardStats.svelte`, `dashboard/+page.svelte` | Hide processing quick link / stats / “View all” when `processing.monitor` OFF; gate store reconnect |
**ASSUMPTION:** Nav visibility is driven solely by `GET /api/billing/capabilities` (and `/me` credits.features) — no client-side plan-name hardcoding. Backend legacy seed (agent 5) must return the deny map.
---
## API verification (after legacy seed)
### Expected for an A1 / Legacy company session
```http
GET /api/billing/capabilities
```
| Key | Expected |
|-----|----------|
| `dashboard.overview``settings.profile` (nav parents) | `true` |
| `processing.monitor` | `false` |
| `stores.hub` | `false` |
| `marketing.campaigns` / `brand_kit` / `seo` / … | `false` |
| `integrations.ai` / `email` | `false` |
| `support.center` | `false` |
### Live check (2026-08-05, local)
| Check | Result |
|-------|--------|
| Demo login → Local Demo Co | Active plan resolved as **Enterprise** (`is_custom=true`) via capabilities |
| Enterprise features | All nav parents **ON** including `processing.monitor` / `stores.hub` / marketing |
| Admin `GET /api/admin/plans/5/features` (plan **A1**) | Name matches legacy pattern, but stored `features` JSON is a **partial** sparse map (shell/dashboard extras OFF only). Resolved still had `processing.monitor=true` on the **running** API process at verify time |
| Company assignment | Local Demo Co was **not** on plan A1 / Legacy — still Enterprise |
**Blocker for end-to-end image match on demo:** agent 5 seed must (1) apply full `SparseLegacyOverrides` to plan **A1** (or mark `is_legacy` and re-seed), (2) assign Local Demo Co / A1 tenant to that plan, (3) restart API so `DefaultPlanFeaturesEx` / `EnsureLegacyPlanFeatureSeeds` are live. Frontend is ready once capabilities return the legacy map.
### Manual UI checklist (once capabilities correct)
- [ ] Sidebar shows exactly the 9 labels above (order + casing)
- [ ] No “Processing” / Background Tasks, Stores, Campaigns, Brand, SEO, Support
- [ ] No **More** panel (unless platform admin — then only Platform admin)
- [ ] Direct `/processing`, `/stores`, `/campaigns` → upgrade panel, not empty shell
- [ ] Command palette omits Processing / Support when denied
---
## Verification commands
```bash
cd apps/web && npm run check
```
Go (seed / matrix; agent 5):
```bash
cd apps/api && go test ./internal/billing/ -run Legacy -count=1
```
---
## CONTRACT
- Public feature keys unchanged.
- `PlanRouteGuard` behavior unchanged (in-place upgrade panel).
- Platform admin (`showAdmin`) may still see Platform admin under More — orthogonal to plan matrix.
+95
View File
@@ -0,0 +1,95 @@
# 17 — Security pass: staff roles, plan features, support tickets
**Agent:** 17/20 · **Scope:** API handlers / middleware security fixes (no git).
## Goal
Close IDOR, missing authz, mass-assignment, and CSRF gaps around platform staff, plan-feature admin APIs, and support tickets. Enforce **least privilege** for `support_staff`. Keep secrets out of logs. Add forbidden-access tests.
## Threat model (in scope)
| Actor | May access | Must not |
|-------|------------|----------|
| Customer (company member) | Own tickets in active company | Other users' tickets; admin APIs; internal notes |
| `support_staff` | Support desk queue (unassigned + own) | Plan features, feature gates, billing, settings, users list, credits |
| `admin` / `developer` / legacy `is_platform_admin` | Full `/api/admin/*` | N/A (full staff) |
| Unauthenticated | Public / CSRF cookie seed on GET | Mutating `/api/*` without CSRF |
## Controls implemented
### 1. Staff roles (`users.staff_role`)
- Migration: `apps/api/sql/schema/029_staff_roles.sql`
- Values: `admin` \| `developer` \| `support_staff` (NULL allowed)
- Resolver: `auth.ResolveStaffAccess` / `auth.GetStaffAccess`
- `support_staff`**support desk only** (even if `is_platform_admin=true`)
- `admin` / `developer` → full admin + support desk
- NULL + `is_platform_admin`**legacy full admin** (backward compatible)
- Missing column (pre-migration) → boolean-only fallback
### 2. Middleware least privilege
| Middleware | Allows |
|------------|--------|
| `RequirePlatformAdmin` | Full admin only (`FullAdmin`) |
| `RequireSupportDesk` | Full admin **or** `support_staff` |
Route split in `Server.Router` (`server.go`):
- `/api/admin/support/*``RequireSession` + `RequireSupportDesk`
- All other `/api/admin/*` (plans, features, gates, billing, settings, users, …) → `RequireSession` + `RequirePlatformAdmin`
CSRF remains on the outer session group (double-submit cookie + `X-CSRF-Token`). Admin mutations are not CSRF-exempt.
### 3. Support ticket IDOR / visibility
- Customer paths always bind `company_id` + `user_id` from **session context** (not JSON body). Handlers fail closed if context missing.
- `GetForUser` / `ReplyAsUser` already filter by company + creator; internal notes excluded at SQL.
- Customer reply uses `UserReplyInput` (`body` only) — **mass assignment** of `is_internal_note` / `status` rejected via `DisallowUnknownFields`.
- `support_staff` list forced to `UnassignedOrSelf` (client `assignee_id` ignored).
- `support_staff` get/reply/update of another agent's ticket → **404** (anti-enumeration).
- Assignee updates: zero UUID rejected; assignee must be support-capable; `support_staff` may only assign self (or clear).
### 4. Plan feature admin APIs
- Remain behind `RequirePlatformAdmin` (support_staff → 403).
- Feature keys / sections already allowlisted in billing (`validateFeatureOverrides` / `validateGatesUpdate`).
- Unknown JSON fields rejected by `DecodeJSON`.
### 5. Secrets in logs
- `LogAndError` redacts password/secret/api_key/token/`sk_live`/`whsec_` patterns via `redactForLog`.
- Request logger continues to log method/path/status only (no bodies).
## Tests
| Test | Asserts |
|------|---------|
| `auth.TestResolveStaffAccess` | Capability matrix |
| `TestRequireSupportDeskForbiddenAndAllow` | 401 / member 403 / support_staff 204 |
| `TestRequirePlatformAdminExcludesSupportStaff` | support_staff blocked from full admin |
| `TestSupportStaffForbiddenOnPlanFeatures` | plan/gate routes 403 for support_staff |
| `TestMemberForbiddenOnAdminSupportAndPlanRoutes` | plain users 403 |
| `TestUserReplyMassAssignmentRejected` | unknown fields on customer reply |
| `TestStaffMayAccessTicket` | visibility rules |
| `TestRedactForLog` | secrets scrubbed |
| Existing `TestTicketCRUDAuthOwnership` | customer IDOR ownership |
| Existing CSRF tests | mutating requests need token |
Run:
```bash
cd apps/api
go test ./internal/auth ./internal/httpapi -count=1 -run "Staff|SupportDesk|PlatformAdmin|Redact|UserReply|MemberForbidden"
```
## Residual / handoff
- Staff **assignment APIs** (grant/revoke `staff_role`) owned by agent 6 — must call `NormalizeStaffRole` and remain `RequirePlatformAdmin`.
- Claim race / CSAT / email stubs owned by support backend agents; keep visibility checks on every new staff handler.
- Web UI gates should mirror `SupportDesk` vs `FullAdmin` (do not trust client-only hides).
- Apply migration `029_staff_roles` before relying on `staff_role` in production.
## Rollback
Revert middleware/route split + `029_staff_roles` down migration; restore prior `RequirePlatformAdmin` on all `/api/admin/*`.
@@ -0,0 +1,78 @@
# 18 — Performance (capabilities, permission matrices, support queues)
Date: 2026-08-05
Owner: agent 18/20
## Scope
Concrete query/index/cache fixes for:
1. **Capabilities resolution** (`GET /api/billing/capabilities`)
2. **Admin permission matrices** (`GET /api/admin/plans`, plan features, feature gates)
3. **Support queues** (user list + admin/staff inbox)
No million-SKU product-list work (already covered in `docs/perf-notes.md`).
## Findings (before)
| Path | Issue | Severity |
|------|--------|----------|
| `CapabilitiesForCompany` | Active plan lookup used only `company_plans(company_id)` | Medium |
| `handleGetCapabilities` | Body had `feature_etag` but no HTTP `ETag` / 304 (OpenAPI already patterned) | Medium |
| Admin `ListPlans` | Already one query + in-memory `resolved_features` (no N+1) | OK |
| Admin matrices | No `Cache-Control` — risk of intermediary caching | Low |
| `ListAdmin` count | Always joined `users` + `companies` even without search | Medium |
| Support indexes | Missing assignee + COALESCE(activity) indexes for queue sorts | Medium |
| Pagination | Handlers already use `ParseLimitOffset` (default 50, max 200) | OK |
## Changes
### 1. Indexes — goose `027_capabilities_support_perf.sql`
- `company_plans_company_active_created_idx` — partial `(company_id, created_at DESC) WHERE is_active`
- `support_tickets_status_activity_idx``(status, COALESCE(last_message_at, updated_at) DESC)`
- `support_tickets_assignee_activity_idx` — staff inbox partial on assignee
- `support_tickets_user_activity_idx` — user ticket list activity sort
Apply via existing migrate script after deploy.
### 2. Capabilities HTTP cache headers
`handleGetCapabilities` now sets:
- `Cache-Control: private, max-age=30, must-revalidate`
- `ETag: "sha256:…"` via `billing.CapabilitiesResponseETag` (features **plus** plan id / name / remaining credits)
- `304 Not Modified` when `If-None-Match` matches
Body `feature_etag` is unchanged (feature-map only) for the web client.
### 3. Admin permission matrices
- `ListPlans` already selects `features` once and resolves matrices in-process (no per-plan round-trip).
- Added `Cache-Control: private, no-store` on:
- `GET /api/admin/plans`
- `GET /api/admin/plans/{id}/features`
- `GET /api/admin/feature-gates`
### 4. Support queues
- Count query skips user/company joins unless `search`/`q` is set.
- Service-layer `clampListBounds` (default 50 / max 200) as defense in depth.
- Admin list accepts `assignee_id` filter (uses new assignee index).
- Handlers continue to use `ParseLimitOffset`.
## Tests
- `apps/api/internal/billing/capabilities_etag_test.go`
- `apps/api/internal/support/list_bounds_test.go`
```powershell
cd apps/api
go test ./internal/billing/ ./internal/support/ ./internal/httpapi/ -count=1
```
## Out of scope / deferred
- In-process TTL cache for `GetFeatureGates` (tiny table; premature)
- Keyset pagination for support (volume still small)
- Changing product-list OFFSET pagination (see `docs/perf-notes.md` P0)
@@ -0,0 +1,88 @@
# 19 — Plan / role defaults alignment (agent 19/20)
**Status:** Implemented end-to-end defaults for public ladder, custom enable-all, Legacy (A1), and staff role ceilings.
**Sources:** `03-roles-matrix.md` / `.json`, `docs/plan-permissions/06-defaults-matrix.md`.
---
## PROBLEM
Public ladder defaults, custom enable-all, and Legacy (A1) limited nav must agree across API resolve, seeds, admin UI profiles, and demo assignment — without treating A1 as custom all-ON.
---
## Effective matrices
| Profile | Source of truth | Default behavior |
|---------|-----------------|------------------|
| Free | `freePlanFeatureOff` | AI / API keys / BYOK / campaigns off |
| Starter | `starterPlanFeatureOff` | BYOK off |
| Growth / Business | all registry ON | empty sparse overrides |
| Enterprise | `is_custom=true` ? custom path | all ON |
| Custom deals (Merkur, …) | `IsCustomPackage` | all ON (create materializes enable-all) |
| **Legacy (A1)** | `legacyFeatureAllowlist` / `03-roles-matrix` | image-nav allow-list only; `processing.monitor` OFF |
| Staff `admin` / `developer` | `DefaultStaffRoleAllows` | all feature keys ON; full `/admin` |
| Staff `support_staff` | denied set in `staff_role_defaults.go` | support queue + assist; no billing mutation routes |
Resolve order for plans: **Legacy ? Custom enable-all ? Free/Starter denials ? Growth+**.
---
## A1 / Local Demo Co
| Piece | Behavior |
|-------|----------|
| Plan name patterns | `Legacy`, `A1*`, contains `legacy` / `a1 slovenija` |
| Company cohort | `legacy_company_id = 97e1a309-3d23-4aa2-b518-8e8d7afdfec7` or name Local Demo Co / A1 |
| Seed | `EnsureLegacyDefaults` upserts plan **Legacy** (Enterprise credit pack, unlimited SKUs, `is_legacy=true`) |
| Assignment | A1 cohort companies get Legacy when missing or on non-legacy profile |
| Demo | `seed-demo` assigns **Legacy** (not Enterprise) so capabilities match limited nav |
| Repair | Full enable-all `plans.features` on legacy-named rows ? `SparseLegacyOverrides` (idempotent) |
**ASSUMPTION:** Empty `{}` still resolves via `DefaultPlanFeatures` by name; seeder writes sparse false keys so admin UIs show an explicit matrix.
---
## Implementation map
| Piece | Path |
|-------|------|
| Legacy detect + allow-list | `apps/api/internal/billing/legacy_plan.go` |
| Legacy plan row + A1 assign | `apps/api/internal/billing/legacy_plan_seed.go` (`EnsureLegacyDefaults`) |
| Sparse legacy helpers | `apps/api/internal/billing/legacy_plan_features.go` |
| Feature seed hook | `EnsureDefaultFeatureSeeds` ? `EnsureLegacyPlanFeatureSeeds`; `EnsureDefaultPlans` ? `EnsureLegacyDefaults` |
| Custom vs legacy | `IsCustomPackage` excludes legacy names |
| Staff role defaults | `apps/api/internal/auth/staff_role_defaults.go` |
| Schema | `apps/api/sql/schema/028_plan_is_legacy.sql` (`plans.is_legacy`) |
| Admin UI profiles | `apps/web/src/lib/admin-plan-permissions.ts` (`LEGACY_FEATURE_ALLOWLIST`, profiles) |
| Billing display | `isLegacyPlan` in `apps/web/src/lib/billing-display.ts` |
---
## Idempotency
- `EnsureDefaultPlans` / `EnsureLegacyDefaults` / `EnsureLegacyPlanFeatureSeeds` are safe to re-run.
- Non-empty **partial** admin feature maps on legacy plans are **not** wiped (only empty or full enable-all).
- Global section gates use `ON CONFLICT DO NOTHING`.
---
## Verification
```bash
cd apps/api
go test ./internal/billing/ ./internal/auth/ -count=1 -run "DefaultPlanFeatures|Legacy|IsCustom|Sparse|StaffRole|Normalize|PlanAllows"
```
Integration (after migrate `028` + `seed-demo`):
- `TestLocalDemoCoLegacyCredits` — Local Demo Co on Legacy with 1M credits
- Capabilities: `processing.monitor` false; `catalog.products` / `feeds.list` true
---
## Coordination notes
- Agent 5 owns overlapping legacy seed docs (`05-legacy-seed.md`); this file is the alignment checklist for agent 19/20.
- Agent 6 wires `staff_role` schema/middleware; defaults here are the ceiling until that lands (`StaffRoleFromPlatformAdmin` bridges the boolean).
- Agent 8 admin profiles should match `LEGACY_FEATURE_ALLOWLIST` ? Go `legacyFeatureAllowlist` (66 ON keys from `03-roles-matrix.json`).
+146
View File
@@ -0,0 +1,146 @@
# Admin roles, legacy plans & support desk
End-to-end work from the **20-agent** `admin-roles-support` swarm: legacy (A1) limited nav, platform staff roles, admin panel polish, support assign/claim + CSAT ratings.
**Runtime rule (tenant features):**
```
effective(feature) = plan_allows(feature)
AND global_section_enabled(section(feature))
AND global_feature_enabled(feature)
```
Staff access is **orthogonal** to plan features: `users.staff_role` + `is_platform_admin` gate `/admin` and the support desk.
---
## Doc map (agents 119)
| Doc | Owner | What it covers |
|-----|-------|----------------|
| [01-ux-research.md](./01-ux-research.md) | 1 | Admin / permissions / support UX patterns |
| [02-current-inventory.md](./02-current-inventory.md) / [02-extension-points.json](./02-extension-points.json) | 2 | Pre-change inventory |
| [03-roles-matrix.md](./03-roles-matrix.md) / [`.json`](./03-roles-matrix.json) | 3 | Legacy allow-list + role→feature matrix |
| [04-contract.md](./04-contract.md) / [`.json`](./04-contract.json) | 4 | Unified plan + staff contract |
| [05-legacy-seed.md](./05-legacy-seed.md) | 5 | Legacy profile seed / A1 detection |
| [06-staff-roles.md](./06-staff-roles.md) | 6 | `admin` / `developer` / `support_staff` backend |
| [07-admin-shell.md](./07-admin-shell.md) | 7 | Admin layout / nav chrome |
| [08-permissions-ui.md](./08-permissions-ui.md) | 8 | Billing → Permissions panel |
| [09-admin-billing-ui.md](./09-admin-billing-ui.md) | 9 | Plans list / assign polish |
| [10-admin-orgs-ui.md](./10-admin-orgs-ui.md) | 10 | Users/companies + staff role grant |
| [11-support-design.md](./11-support-design.md) / [11-support-api-contract.json](./11-support-api-contract.json) | 11 | Support desk design |
| [12-support-backend.md](./12-support-backend.md) | 12 | Tickets / claim / assign API |
| [13-support-ratings.md](./13-support-ratings.md) | 13 | CSAT 15 ratings |
| [14-support-staff-ui.md](./14-support-staff-ui.md) | 14 | Staff inbox + assign UI |
| [15-user-support-ui.md](./15-user-support-ui.md) | 15 | Customer support + rate UI |
| [16-legacy-nav.md](./16-legacy-nav.md) | 16 | A1 image-nav gating |
| [17-security.md](./17-security.md) | 17 | AuthZ / IDOR / CSRF pass |
| [18-performance.md](./18-performance.md) | 18 | Indexes / list bounds |
| [19-defaults-alignment.md](./19-defaults-alignment.md) | 19 | Ladder + Legacy + staff defaults |
Related: [`../plan-permissions/README.md`](../plan-permissions/README.md) (feature keys + admin Permissions API).
---
## Schema (goose)
| Version | File | Purpose |
|---------|------|---------|
| 026 | `apps/api/sql/schema/026_plan_features.sql` | `plans.features` + global gates |
| 027 | `027_capabilities_support_perf.sql` | Capabilities + support queue indexes |
| 028 | `028_plan_is_legacy.sql` | `plans.is_legacy` + name backfill |
| 029 | `029_staff_roles.sql` | `users.staff_role` |
| 030 | `030_support_desk.sql` | CSAT table, claim queue cols, notification kinds |
Apply (Windows):
```powershell
.\scripts\migrate.ps1
# or from apps/api with DATABASE_URL set:
go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 -dir sql/schema postgres $env:DATABASE_URL up
```
Strip UTF-8 BOM from new SQL if goose errors on `\ufeff-- +goose Up`.
---
## Code map
| Concern | Paths |
|---------|--------|
| Legacy detect/seed | `apps/api/internal/billing/legacy_plan*.go` |
| Staff roles | `apps/api/internal/auth/staff*.go`, `httpapi/middleware.go`, `admin_staff_handlers.go` |
| Support desk | `apps/api/internal/support/{desk,ratings,agents,tickets}.go`, `httpapi/support_*.go` |
| Admin shell | `AdminNav.svelte`, `admin-nav-ui.svelte.ts`, root `+layout.svelte` |
| Permissions UI | `PlanPermissionsPanel.svelte`, `admin-plan-permissions.ts` |
| Orgs / staff grant | `routes/admin/users/+page.svelte`, `admin-orgs.ts` |
| Legacy nav | `Nav.svelte`, `plan-capabilities.ts`, `PlanRouteGuard.svelte` |
| Staff inbox | `routes/admin/support/**` |
| User rate | `SupportTicketRating.svelte`, `routes/support/**` |
---
## Manual verify checklist
### A1 / Legacy nav
- [ ] `goose`**30**; `seed-demo` (or `EnsureDefaultPlans`) so **Local Demo Co** is on plan **Legacy** (`is_legacy=true`)
- [ ] Restart API after migrate/seed
- [ ] Login as demo → Local Demo Co → sidebar shows only: Dashboard, Products, Feeds, Export Feeds, Categories, Attributes, Standard Fields, Usage & Billing, Settings
- [ ] No Background Tasks / Processing, Stores, Campaigns, Brand, SEO (no empty **More** unless Platform admin)
- [ ] `GET /api/billing/capabilities`: `processing.monitor` / `stores.hub` / `marketing.*` → false; nav parents → true
- [ ] Deep link `/processing` → upgrade panel
### Admin panel polish
- [ ] `/admin` shell: sidebar groups Monitor / Manage, dark content, mobile drawer
- [ ] `/admin/billing` → Permissions: profiles (incl. Legacy one-click), section toggles
- [ ] `/admin/users`: companies + assign plan + staff role dialog
### Support assign + rate
- [ ] Customer: create ticket → staff reply → resolve → rate 15 (`SupportTicketRating`)
- [ ] Staff: `/admin/support?scope=unassigned` → Claim → Mine; Unassign returns to queue
- [ ] Full admin: ticket detail → Assign to staff → Apply
- [ ] API: support routes under `RequireSupportDesk`; billing mutations still `RequirePlatformAdmin`
### Staff role access
- [ ] Grant `support_staff` from Users → that user sees Support (+ Overview redirects to Support), not Billing/Settings
- [ ] `GET /api/admin/billing` (or plans) → **403** for support_staff
- [ ] `admin` / `developer` / legacy `is_platform_admin` + NULL role → full console
- [ ] Cannot PATCH own `staff_role`
---
## Verify commands
```powershell
.\scripts\migrate.ps1
cd apps\api
go test ./internal/billing/ ./internal/auth/ ./internal/support/ -count=1
go test ./internal/httpapi/ -count=1 -run "Staff|Support|PlanFeature|PlanGate|AdminAuth|RequirePlatform|CSAT|Csat"
cd ..\web
npm run check
```
Optional demo alignment:
```powershell
cd apps\api
go run ./cmd/seed-demo -postgres $env:DATABASE_URL
```
---
## Integration notes (agent 20)
- Merged duplicate `028_*` legacy SQL into `028_plan_is_legacy.sql`; renumbered staff → **029**, support desk → **030**.
- `/admin` Overview: `support_staff` redirected to `/admin/support` (nav still shows Overview).
- `seed-demo` / `EnsureLegacyDefaults` assigns **Legacy** to Local Demo Co / A1 cohort (not Enterprise all-ON).
### Remaining blockers / non-goals
1. Not every tenant mutation is `AssertFeature`'d (same as plan-permissions agent 10) — UI + critical writes first.
2. Live browser QA of admin polish + CSAT requires a running `npm run dev` session and staff test user.
3. OpenAPI public fragment may still omit some support/staff paths (session docs in plan-permissions / support contract JSON).
+76
View File
@@ -0,0 +1,76 @@
# Descrybe v2 — full AI smoke (local Green Chat)
**Date:** 2026-08-04
**LLM:** Green Chat `overloaded-local` (Gemma-class ~12B, `n_ctx=8192`) at `http://192.168.50.181:8767/v1`
**App:** API `:8080` + worker with `OPENAI_*` loaded from `root `.env` (or `/integrations/ai`)`
**Tenant (historical run):** was `demo@` on A1 — **outdated**. Canonical: `demo@`**Platform Demo**; A1 → `a1-primary@` ([safe-test-fixtures.md](safe-test-fixtures.md)). Matrix below is a dated snapshot.
No secrets are recorded here (key length only: 64).
## Matrix
| Feature | Result | Sample / notes |
|---------|--------|----------------|
| Login | **PASS** | `demo@descrybe.local` |
| A1 dump credits | **PASS** | plan=A1, `can_use_ai=true`, remaining≈2284 (dump `credit_balances`) |
| Brand kit + formula tips | **PASS** | tips include tone, preferred terms (`wireless`,`premium`), donts |
| Brand kit in AI prompts | **PASS** | SEO/campaign/enhance copy used brand terms |
| SEO `mode=ai` | **PASS** | ~1s; title `Sample Gadget \| Premium Wireless Performance`; credits_charged=2 |
| Campaign `mode=ai` (spring) | **PASS** | ~5s; subject `Upgrade your spring relaxation`; status=ready; unsub footer present |
| Email generate (AI) | **PASS** | same Completer path as campaign generate |
| Processing `enhance_only` | **PASS** | ~4s; `ai_enhance` done; desc rewritten with premium/wireless framing |
| EPREL path | **PASS** | job accepted (`eprel_only`); enricher disabled in this env — non-AI step |
| Formula/preview AI tips | **PASS** | rule-based `FormulaTips` from brand kit (no LLM) |
**Overall: PASS** against LAN Green Chat after prompt/JSON hardening.
## Sample outputs (truncated, no secrets)
### SEO apply
```json
{
"product_id": "d2c309ed-…",
"mode": "ai",
"meta_title": "Sample Gadget | Premium Wireless Performance",
"meta_description": "Experience premium wireless functionality with the Sample Gadget. Designed for reliable performance …",
"credits_charged": 2
}
```
### Campaign generate
- **subject:** Upgrade your spring relaxation
- **plain (snip):** Experience premium relaxation with our latest collection. Our wireless-ready designs…
### Enhance
- **name:** Sample Gadget (kept)
- **description (snip):** Experience premium wireless connectivity for seamless device integration…
## Commands (PowerShell sketch)
```powershell
# Load OPENAI_* from root `.env` (or `/integrations/ai`) — restart api.exe + worker.exe so worker logs:
# worker: OpenAI enabled base=http://…:8767/v1 model=overloaded-local
Test-NetConnection 192.168.50.181 -Port 8767
curl.exe -sS http://192.168.50.181:8767/v1/models -H "Authorization: Bearer $env:OPENAI_API_KEY"
# Session: GET any /api/* for descrybe_csrf cookie, then:
# POST /api/auth/login with X-CSRF-Token matching cookie
# PUT /api/brand
# POST /api/seo/apply {"product_id":"…","mode":"ai"}
# POST /api/campaigns → POST /api/campaigns/{id}/generate {"mode":"ai"}
# POST /api/processing/jobs {"raw_product_ids":["…"],"processing_type":"enhance_only"}
```
Cookie jar used locally: `artifacts/ai-full-smoke-cookies.txt` (gitignored / local only).
## Related
- [e2e-processing.md](e2e-processing.md) — Background Tasks create/cancel/progress + enhance UI
- [local-llm-tuning.md](local-llm-tuning.md) — temps, max tokens, truncation, JSON retry
- [green-chat-llm.md](green-chat-llm.md) — wiring Green Chat as OpenAI
- [demo-user.md](demo-user.md) — demo credentials / Enterprise
- [portable-mysql-pg-migration.md](portable-mysql-pg-migration.md) — migrated catalog
+92
View File
@@ -0,0 +1,92 @@
# Analytics & usage audit (Descrybe v2)
Date: 2026-08-04
Scope: company billing usage, admin analytics, meters vs live Postgres.
## Surfaces
| Surface | Path / API | Role |
|---------|------------|------|
| Company billing / usage | `/billing``GET /api/billing/credits`, `GET /api/billing/usage?range=` | Tenant |
| Dashboard meters | `/dashboard``/api/auth/me` credits + product count | Tenant |
| Admin overview | `/admin` → summary from `/api/admin/analytics` | Platform admin |
| Admin analytics | `/admin/analytics``GET /api/admin/analytics?days=` | Platform admin |
| Admin billing | `/admin/billing` | Plans / credits / cycles |
## Bugs found
1. **Stale cycle override on company usage**
`UsageSummary` preferred the latest `billing_cycles` row (`credits_used=22`, `products_processed=12`, JuneJuly window) over live wallet and catalog. Demo wallet was `used_credits=0` with **~4323+** processed products and an **AugSep** `company_plans` cycle.
2. **Misleading empty / range UI on `/billing`**
“Usage history” gated on `credits_used > 0`, so a rich catalog with zero wallet spend looked empty. The 7d/30d selector did not change any numbers.
3. **Admin provider cards at zero on running binary**
Disk already had `ai_provider_mode` rollups; the old `api.exe` still returned stub zeros. After rebuild, internal mode matches DB (`internal` on all products/jobs).
4. **Feeds missing from admin volume**
Input/export feed counts were not exposed on the analytics summary.
## Fixes
### `GET /api/billing/usage?range=7d|30d|cycle|all` (default `30d`)
- **Credits** always from live `credit_balances` (not historical cycle rows).
- **Products / tokens** filtered by range; `products_total` is all-time catalog.
- **Cycle dates** from active `company_plans` (`billing_cycle_start` / `next_billing_date`).
- **Feeds** (`feeds_input`, `feeds_export`) and `jobs_total` from live tables.
- **Series** (products + tokens by UTC day) for 7d / 30d / cycle.
- Notes clarify that credit debits are not daily-ledgered yet.
### Admin analytics
- Summary includes `feeds_input` / `feeds_export`.
- Provider breakdown + detail + per-day class series from `ai_provider_mode`.
- Notes updated; billing-cycle table labeled as historical rollups that may lag the active plan window.
### UI
- `/billing`: catalog card, range metric cards, honest empty states, range-driven chart via `AdminSeriesChart`.
- `/admin/analytics`: feed counts on Volume card; clearer cycle table copy.
## Demo verification (Local Demo Co)
Against `postgres://…@localhost:5433/descrybe` after rebuild, logged in as `demo@descrybe.local`:
| Meter | API | DB |
|-------|-----|-----|
| Wallet used / total | 0 / 1_000_000 | 0 / 1_000_000 |
| Products (all) | 4326 | 4326 |
| Input / export feeds | 12 / 5 | 12 / 5 |
| Plan cycle | 2026-08-04 → 2026-09-04 | `company_plans` |
| Platform products | 7225 | 7225 |
| Platform credits used | 26 | 26 |
| Platform feeds in/out | 35 / 12 | 35 / 12 |
| Provider internal tokens/products | 11_383_374 / 7225 | `ai_provider_mode=internal` |
Previously the usage API returned **22 credits / 12 products** from a stale `billing_cycles` row — fixed.
## Remaining limitations
- No per-day **credit** ledger; range filters apply to products/tokens only.
- `billing_cycles` historical rows can lag or disagree with the active plan window; they remain an admin history table, not the tenant source of truth.
- Token series for migrated catalogs often clumps on import day(s).
## Verification commands
```powershell
cd apps/api
go test ./internal/billing/ -run ParseUsageRange -count=1
go build -o bin/api.exe ./cmd/api
cd ../web
npm run check
```
Demo smoke (after API restart):
```powershell
# login as demo@descrybe.local then:
# GET /api/billing/usage?range=30d
# GET /api/admin/analytics?days=30
```
+54
View File
@@ -0,0 +1,54 @@
# API surface smoke - admin, health, docs, public v1
**When:** 2026-08-04 03:03:48 +02:00
**API:** http://127.0.0.1:8080
**Web:** http://localhost:5174
**Demo:** `demo@descrybe.local` / `DemoPass123!` (`is_platform_admin=true`)
**Demo API key:** `dk_demo_local_descrybe_test_key_v1` (**Platform Demo** after seed-demo; not A1)
> **Note (isolation):** Historical matrix rows below may still say Local Demo Co / A1 — treat as outdated. Canonical identity: [safe-test-fixtures.md](safe-test-fixtures.md).
## Summary
| Metric | Value |
|--------|-------|
| PASS | 20 |
| FAIL | 0 |
| Overall | PASS |
## Matrix
| Check | Result | Detail |
|-------|--------|--------|
| demo is_platform_admin | **PASS** | db=true |
| login demo platform admin | **PASS** | True |
| GET /api/auth/me platform admin | **PASS** | Local Demo Co |
| GET /api/admin/users | **PASS** | status=200 |
| GET /api/admin/companies | **PASS** | status=200 |
| GET /api/admin/analytics | **PASS** | status=200 |
| GET /api/admin/jobs | **PASS** | status=200 |
| GET /healthz | **PASS** | status=200 |
| GET /readyz | **PASS** | status=200 |
| GET /docs (web) | **PASS** | status=200 |
| GET /api/v1/openapi.yaml | **PASS** | status=200 |
| OpenAPI has auth + localhost server | **PASS** | clarity text present |
| v1 GET /products?limit=1 (demo key) | **PASS** | status=200 |
| v1 GET /categories?limit=1 (demo key) | **PASS** | status=200 |
| v1 GET /attributes?limit=1 (demo key) | **PASS** | status=200 |
| v1 GET /feeds (demo key) | **PASS** | status=200 |
| v1 GET /export-feeds (demo key) | **PASS** | status=200 |
| v1 GET /process (demo key) | **PASS** | status=200 |
| v1 GET /health (demo key) | **PASS** | status=200 |
| v1 products without key -> 401 | **PASS** | status=401 |
## Fixes applied this run
1. **OpenAPI clarity** (`apps/api/internal/httpapi/v1_openapi.go`): expanded `info.description` with auth headers, curl example, base path notes; added `http://localhost:8080/api/v1` server; ASCII-safe Bearer scheme description.
2. **Docs page clarity** (`apps/web/src/routes/docs/+page.svelte`): header link (OpenAPI YAML), loading/error/retry for RapiDoc, theme via `marketingTheme`.
## Notes
- `GET /healthz` / `GET /readyz` are on the API host; Vite proxies them from the web origin in local dev.
- Interactive docs: `http://localhost:5174/docs` (RapiDoc); raw spec: `/api/v1/openapi.yaml`.
- Public `/api/v1/*` is API-key only; dashboard `/api/*` is session + CSRF.
- Related: [demo-user.md](demo-user.md), [qa-local-demo.md](qa-local-demo.md), [local-smoke-results.md](local-smoke-results.md).
+96
View File
@@ -0,0 +1,96 @@
# Billing & credits audit (Descrybe v2)
Date: 2026-08-04
Scope: credit grant, debit (SEO / campaign / enhance), remaining display, Free vs Enterprise, Billing UI vs API.
## Verdict
Demo **Enterprise** wallet and Billing UI now agree on **1,000,000 remaining / 0 used**.
**Free** grants **0** AI credits (`can_use_ai=false`).
Stale `billing_cycles` rows no longer inflate “credits used” on `/api/billing/usage` or the Billing page.
## Trace (source of truth)
| Concern | Path | Behavior |
|--------|------|----------|
| Grant on signup | `billing.ProvisionFreePlan``AssignPlan` | Free plan → wallet `total=0`, `used=0` |
| Grant on assign / renew | `AssignPlan`, `RunDueBillingCycles` | Sets `credit_balances.total_credits` from `plans.monthly_credits` (or trial credits), resets `used=0`; opens a fresh open `billing_cycles` row |
| Enterprise pack | `billing.EnterpriseUnlimitedCredits` | `1_000_000` (marketing “Unlimited”; wallet is finite so debit works) |
| Demo grant | `cmd/seed-demo` | Assigns Enterprise to Local Demo Co |
| Overview / me | `CreditsOverview``/api/billing/credits`, `/api/auth/me` | `remaining` = `max(0, totalused)`; entitlements `can_use_ai` / Free vs paid |
| Usage | `UsageSummary(range)``/api/billing/usage?range=` | **Credits from live wallet**; product/token series filtered by range |
| Enhance / process debit | `processing.Pipeline``ConsumeCredits(..., "product_processing")` | Skipped entirely when **BYOK**; Free + 0 tokens = no debit |
| SEO AI debit | `seo.Service.Apply` mode=`ai` | Requires `CanUseAI` **and** `RemainingCredits≥1`, then `ConsumeCredits(..., "seo_meta_ai")` |
| Campaign AI debit | `campaigns.Generate` mode=`ai` | Same gates; **errors propagated** (no silent swallow); `campaign_copy` cost row seeded |
| Gates at job start | `AssertCanStartProcessing` | SKU cap always; credit wallet only when `RequiresAI` / `RequiresEPREL` |
### Debit formula
`debit = cost(feature) + ceil(tokens/1000) * cost(openai_token_k)` (minimum 1 when charging).
Default costs: `product_processing`, `openai_token_k`, `seo_meta_ai`, `campaign_copy` (all 1).
### Entitlements
`ComputeEntitlements`: Free ⇒ no AI/EPREL unless leftover wallet credits; paid ⇒ `can_use_ai` even at 0 remaining (processing/SEO/campaign still require remaining ≥ 1 before AI work).
## Bugs found & fixed
1. **Billing UI vs API mismatch (critical)**
`/api/billing/usage` previously preferred the latest `billing_cycles` row by `start_date`, including **ended** cycles. After `seed-demo` / `AssignPlan` reset the wallet to 1M/0, usage still showed e.g. **22 credits used** while remaining showed **1,000,000**.
**Fix:** `UsageSummary` always reports wallet `used`/`total`/`remaining`; range filters products/tokens only. `AssignPlan` closes open cycles and inserts a new open cycle. `ConsumeCredits` updates only `end_date > now()` cycles (creates one if missing).
2. **Campaign silent free AI**
`ConsumeCredits` errors were ignored (`_ = ...`), and AI could run when paid entitlements said `CanUseAI` but wallet was empty.
**Fix:** require `RemainingCredits ≥ 1`; always debit after AI; map `insufficient_credits` → HTTP 402.
3. **SEO AI empty wallet**
Only checked `CanUseAI` (true on paid with 0 remaining).
**Fix:** require `RemainingCredits ≥ 1` before AI.
4. **Missing `campaign_copy` cost seed**
Fell back to 1 via `lookupCost`, but row was absent.
**Fix:** seed in `EnsureDefaultCosts`.
5. **Billing page**
- Fake date-range labels (did not call API with `range`).
- “Out of credits” banner on Free (0 is expected).
- Only showed remaining, not used/total / plan grant.
**Fix:** wired `?range=`; Free info banner; show used/total + Enterprise “Unlimited” grant label; usage copy clarifies wallet vs product range.
6. **Negative remaining**
Clamp `remaining` / `credits_remaining` to ≥ 0 in overview and usage.
## Smoke results (`demo@descrybe.local` / `DemoPass123!`)
```text
CREDITS plan=Enterprise total=1000000 used=0 rem=1000000 monthly=1000000 free=False
USAGE used=0 total=1000000 rem=1000000 (matches /auth/me)
PLANS Free=0, Starter=300, Growth=2000, Business=10000, Enterprise=1000000
AFTER_FREE assign → total=0 rem=0 free=True ai=False
AFTER_ENT assign → total=1000000 rem=1000000
Open billing_cycles row created on AssignPlan (credits_used=0)
```
Commands:
```powershell
cd apps/api
go build ./...
go test ./internal/billing/... ./internal/campaigns/... ./internal/seo/...
# restart bin/api.exe, then:
# login + curl /api/billing/credits and /api/billing/usage?range=30d
```
## Known gaps (not changed)
- **No daily credit ledger** — range filters cannot reconstruct historical credit burn; only products/tokens. Notes on usage response document this.
- **Paid normalize still debits** base `product_processing` when `CanUseAI` and tokens=0 (Free normalize does not). Intentional metering unless product asks to make normalize free on paid too.
- **Campaign/SEO charge after LLM call** — empty wallet is gated first; mid-flight race can still burn tokens then fail debit (acceptable without a reservation ledger).
- **Migrated tenants** without a public plan may still hold legacy wallet balances; entitlements treat empty plan name as Free with leftover-credit AI unlock.
- **Plan `nou` / Merkur trial** remain admin/DB-only; hidden from `/api/billing/plans`.
## Related docs
- [free-tier.md](free-tier.md)
- [demo-user.md](demo-user.md)
- Sibling v1 `PRICING-AND-USER-GROWTH.md` (packaging; Free AI pack overridden here to **0**)
+377
View File
@@ -0,0 +1,377 @@
# Cutover runbook
Operational checklist for switching from legacy Descrybe (Next.js + Clerk + MySQL) to Descrybe v2 (Go + SvelteKit + PostgreSQL). See [schema-map.md](schema-map.md) for ID remapping and [features.md](features.md) for phase parity gates.
## Production readiness (read first)
**Production cutover: NO-GO** until all **10** ops blockers in [production-readiness.md](production-readiness.md#remaining-blockers-production-cutover--ops--true-open-items) are cleared (ETL status/counts: [migration-readiness.md](migration-readiness.md)). Staging live ETL succeeded (2026-08-03); that does **not** mean production DNS/cutover is ready.
Run the sequenced dry-runs in [NO-GO blockers gate](#no-go-blockers-gate-sequenced-dry-runs) **before** DNS flip. Do not copy that table into [production-checklist.md](production-checklist.md) — checklist links here.
Staging may be used for login testing after set-password (preferably after email patch).
**Staging auth rehearsal (no SMTP):** promote one company admin → re-issue set-password → copy invite URL → one login smoke — see [staging-auth-rehearsal.md](staging-auth-rehearsal.md) and `scripts/staging-auth-rehearsal.ps1` / `.sh`.
## NO-GO blockers gate (sequenced dry-runs)
Operator gate for the **10** cutover blockers ([production-readiness.md](production-readiness.md#remaining-blockers-production-cutover--ops--true-open-items)). Run **in order**; each step is **dry-run / report-only first**. Live writes need `-confirm` / `confirm=true` (or equivalent) after review. All `go run` commands below are from `apps/api` unless noted. Do **not** invent DSNs or mutate A1 live emails/roles without an intentional dry-run review.
**Summary strip (platform session):** `GET /api/admin/readiness``must_set_password`, `companies_without_admin`, `companies_without_plan`, `companies_without_api_keys` (reissue inventory; keys never ETL'd). Admin companies filter: `GET /api/admin/companies?without_api_keys=1`. **Presence flags (no secrets):** `GET /api/admin/diagnostics``config.email_dry_run`, `smtp_enabled`, `stripe_mock`, `stripe_secret_set`; cutover block includes `companies_without_api_keys`. **ETL gap inventory (read-only COUNTs):** `migration_inventory.files_metadata_only`, `processing_jobs_migrated`, `tasks_total` — not an import path.
| # | Blocker | Status | Dry-run / report-only (clear before GO) |
|---|---------|--------|----------------------------------------|
| **1** | `@legacy.local` email hygiene (Clerk removed) | **CODE DONE** / optional ops | Clerk is **removed from the product** — not a hard NO-GO. Residual `@legacy.local` rows: `go run ./cmd/migrator -list-legacy-emails` → optional `-patch-emails -dry-run` / `-confirm` (never overwrites real emails). Detail: [portable-mysql-pg-migration.md](portable-mysql-pg-migration.md#clerk--legacylocal-email-repair-cutover-data-hygiene) |
| **2** | Promote company admins (`role=member`) | **CODE/LOCAL** tooling + list/dry-run; **OPS OPEN** cutover `-confirm` | `go run ./cmd/migrator -list-member-memberships -postgres "$DATABASE_URL"` → promote **non-A1 only** with `-promote-company-admins -email …` or `-company-id …` then `-dry-run` / `-confirm` (**never** `a1=true`; A1 rows are always skipped). Local Demo: already `admin` via `seed-demo` — see [demo-user.md](demo-user.md). Alt: [staging-auth-rehearsal.md](staging-auth-rehearsal.md) |
| **3** | Skipped `company_plans` (`plan_id=6`) | **CODE/LOCAL** tooling + list/dry-run; **OPS OPEN** cutover `-confirm` | `go run ./cmd/migrator -list-companies-without-plans -postgres "$DATABASE_URL"``-assign-missing-plans -plan-name Free -dry-run` (live needs `-confirm`). Alt: `GET /api/admin/companies?without_active_plan=1` |
| **4** | Live SMTP + set-password smoke | **OPS OPEN** | After #1#3: `-issue-set-password-invites` (or rehearsal URLs) → `go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run` → admin `POST /api/admin/settings/mail/test` under dry-run expects `skipped` → then live send + one login. See [ops-runtime.md](ops-runtime.md) |
| **5** | Real Stripe + signed webhooks | **OPS OPEN** | `GET /api/admin/diagnostics``stripe_mock=false`, `stripe_secret_set=true`; confirm webhook secret in `/admin/settings` (presence only). Live Checkout/webhook smoke is ops (not boot-required) |
| **6** | goose **039042** + worker + Node host | **CODE/LOCAL** migrate scripts + deploy-check; **OPS OPEN** prod host | **Apply (one command, prod host):** `npm run migrate` with `DATABASE_URL` set — goose `up` through head (**039** `worker_heartbeats` + **040** indexes + **041** `password_reset_tokens` + **042** `user_session_version`) + sqlc. Then restart **api + worker** together (`cmd/api` + `cmd/worker`; local: `npm run dev:backend` / `npm run dev`). **Verify (read-only):** `HEALTH_BASE_URL=https://YOUR_API_HOST npm run cutover:deploy-check` — goose applied, `/readyz` `checks.worker=ok`, adapter-node host gates. Web host: `npm run build --workspace=web` then `npm run start:web`. Local PASS does **not** clear prod. See [production-checklist.md](production-checklist.md) §§01 / §4 |
| **7** | Orphan-processed cleanup | **CODE/LOCAL** report/dry-run; **OPS OPEN** cutover confirm | `GET /api/admin/jobs/orphan-processed` (report). `POST …/orphan-processed-cleanup` **without** `confirm=true` is report-only. Live delete only with `?confirm=true` / JSON `{"confirm":true}`. See [production-checklist.md](production-checklist.md) §1b |
| **8** | Multi-replica edge rate limits | **CODE DONE** env + snippets; **OPS OPEN** live edge | Confirm edge/API-gateway RPM caps; optional `RATE_LIMIT_REPLICAS=N` divides **HTTP middleware only** (not a shared store). Snippets: [deploy/examples/edge-rate-limit.md](../deploy/examples/edge-rate-limit.md). Dry-run = config review + [security-notes.md](security-notes.md) — no in-app “simulate N replicas” flag. See §8 below / [production-checklist.md](production-checklist.md) §1f |
| **9** | Metrics scrape + RED/sync alerts | **CODE DONE** examples; **OPS OPEN** live scrape | **CODE DONE:** gated `/metrics` + example scrape/alert files in [`deploy/prometheus/`](../deploy/prometheus/) (see [production-checklist.md](production-checklist.md) §1d). **OPS OPEN:** live scrape targets + Alertmanager only. Dry-run: `curl -sS http://127.0.0.1:$HTTP_ADDR/metrics \| head` (prod Gate: loopback unless `METRICS_PUBLIC=1`). On-call worker age: `/readyz` `worker_last_seen_age_s` + `checks.worker` (≤**60s**; not a `/metrics` series; stale → **503**) |
| **10** | Unmigrated `api_keys` / blobs / jobs | **OPS OPEN** (honesty tooling may be CODE) | Accept gap: tenants **reissue API keys** + **reconnect stores**; **blobs** are metadata-only (bytes not copied); **jobs/history** not backfilled unless ops ran domain `jobs` (cutover default = empty). Spot-check Settings → API keys, Files, Processing empties, dashboard **ETL gaps** panel, `/stores` reconnect honesty; no migrator dry-run invents keys or blobs |
**Still unchecked live clears:** **#4 live SMTP**, **#5 live Stripe** — do not treat CODE/LOCAL rows as production GO. Clerk is removed from the product (#1 is optional hygiene only).
**Pass rule:** every row above has a reviewed dry-run/report (or documented exception). Only then proceed to [DNS switch](#5-dns-switch). Detailed apply steps for #1#3 live under [§2 Run migrator](#2-run-migrator).
### 8. Multi-replica edge rate limits
**CODE DONE:** in-process HTTP middleware + optional `RATE_LIMIT_REPLICAS` division (`httpapi/ratelimit.go`). **No shared Redis store**`RATE_LIMIT_BACKEND=redis|postgres` is accepted as docs-only and forced to `memory` (boot warns).
**When N=1:** leave defaults (`RATE_LIMIT_REPLICAS` unset/`1`); edge zones optional.
**When N>1 (ops):** without edge, effective RPM ≈ **N×** documented budgets. Clear this blocker with:
1. **Env contract** (every API replica):
```text
RATE_LIMIT_REPLICAS=<N> # N = API OS processes behind the shared edge
RATE_LIMIT_MULTI_REPLICA=true # acknowledge multi-replica without shared store
TRUSTED_PROXIES=<edge CIDRs/IPs> # hop-1 proxy peers only (so RemoteAddr is client IP)
# RATE_LIMIT_BACKEND=memory # redis|postgres not implemented — forced to memory
```
2. **Edge:** apply **one** shared nginx/Caddy rate-limit config in front of all replicas — [deploy/examples/edge-rate-limit.md](../deploy/examples/edge-rate-limit.md). Zones align to auth **10**/register **5**/public **30**/API-key IP **60** RPM.
3. Confirm API boot log includes the in-process / edge warning (`ShouldWarnRateLimits`).
4. Checklist boxes: [production-checklist.md](production-checklist.md) §1f.
`RATE_LIMIT_REPLICAS` divides **HTTP middleware only** (ceil) under even load — not login email lockout, `StartLimiter`, `AIRateLimiter`, or email send limiters. Edge hard caps remain required for cluster-wide RPM.
## Preconditions
- [ ] Feature-parity gate passed for all phases in production use (AE as applicable)
- [ ] Staging migrator dry-run succeeded; row-count and orphan-FK reports clean
- [ ] **`MIGRATE_MYSQL_DSN` provided by operator** (live dry-run/load blocked until then — do not invent credentials)
- [ ] Postgres backups / PITR configured
- [x] **Clerk removed from product auth** — no live Clerk cutover dependency. Optional: patch residual `@legacy.local` via migrator `-list-legacy-emails` / `-patch-emails` before invites ([portable-mysql-pg-migration.md](portable-mysql-pg-migration.md#clerk--legacylocal-email-repair-cutover-data-hygiene)); never overwrite real A1/live emails
- [ ] **Company admin memberships promoted** (import defaults all to `role=member`)
- [ ] **Skipped `company_plans` resolved** (or accepted with documented exception)
- [ ] Set-password invites re-issued **after** email repair (staging load used `-skip-post-import`)
- [ ] Set-password email templates and SMTP verified ([ops-runtime.md](ops-runtime.md)); mailhooks + login smoke proven
- [ ] Production secrets live in **one** root/host `.env` (or secret store mapped into process env) — **not** a duplicate `apps/api/.env`. Bootstrap only: `DATABASE_URL`, `APP_ENV=production`, `WEB_ORIGIN` (https), `SESSION_SECURE=true`, `APP_ENCRYPTION_KEY`, `TOKEN_SIGNING_SECRET` (plus `HTTP_ADDR` / `PUBLIC_API_URL` as needed). See [production-checklist.md](production-checklist.md) §5 and root `.env.example`
- [ ] `APP_ENCRYPTION_KEY` set **before** storing Woo/email/AI/Stripe secrets in the UI (`CREDENTIALS_ENCRYPTION_KEY` alias accepted; derived key is local/dev only)
- [ ] Platform Stripe / EPREL / feed private-URL allowlist configured in `/admin/settings` (env optional fallback; not required at boot)
- [ ] Tenant OpenAI / marketing email / store connectors configured in the dashboard (`/integrations/ai`, `/integrations/email`, `/stores`) after first platform admin login — not via duplicated env files or tmp placeholders
- [ ] Rollback owners and on-call identified
- [ ] Maintenance window communicated to customers
**Status:** production cutover is **NO-GO** and **not executed** by automation; checklist items below remain operator-owned. Do not treat a green staging ETL as cutover-ready.
## Local stack quick reference
From repo root `f:/laragon/www/_MY/descrybe-v2` (or clone root):
| Step | Command |
|---|---|
| Env | Copy root `.env.example``.env`; fill bootstrap only (see [README.md](../README.md#environment-one-file)) |
| Postgres 16 (host **5433** → container 5432) | `make up` / `docker compose up -d` (Postgres only) |
| Stop Postgres | `make down` |
| Apply goose schema + sqlc | `npm run migrate` (or `make migrate` / `.\scripts\migrate.ps1`) |
| API + worker (+ web) | `npm run dev` — api :28471 + web :28472 + worker (`/readyz` needs worker) |
| API + worker only | `npm run dev:backend` / `make backend` |
| API or worker alone | `make api` / `make worker` (API-only → `/readyz` 503 is expected) |
| Unit tests / vet | `make test` / `make vet` |
| Local rehearsal (dry-run) | `make cutover-rehearsal` / `node scripts/cutover-local-rehearsal.mjs` |
Do not copy secrets into `apps/api/.env` or temporary placeholder files. One root `.env` (or exported process env) is enough. Stripe / EPREL / feed allowlist → `/admin/settings`; tenant OpenAI / marketing mail / stores → their dashboard routes.
Default local DSN (matches `docker-compose.yml`):
```text
DATABASE_URL=postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable
```
### Goose 039042 (prod apply + verify)
One-command schema apply on the target host (requires `DATABASE_URL`; no SMTP/Stripe):
```bash
npm run migrate
# → goose up through 042_user_session_version (039 worker_heartbeats + 040 indexes + 041 password_reset_tokens + 042 session_version) + sqlc
```
Then restart **api and worker together** so `/readyz` sees a fresh processing heartbeat. Local rehearsal: `npm run dev:backend` (or full `npm run dev`).
Read-only gate (does **not** run `goose up`):
```bash
HEALTH_BASE_URL=http://127.0.0.1:28471 npm run cutover:deploy-check
# prod: HEALTH_BASE_URL=https://YOUR_API_HOST npm run cutover:deploy-check
# web host: npm run build --workspace=web && npm run start:web
```
**Local rehearsal (dry-run bundle, no SMTP/Stripe):** `node scripts/cutover-local-rehearsal.mjs` or `make cutover-rehearsal` — deploy-check → `-list-legacy-emails` (no patch) → `-list-member-memberships``-list-companies-without-plans` → orphan-processed POST **without** `confirm=true`. Assign / promote / orphan deletes stay separate (`-confirm` / `confirm=true`). Needs `DATABASE_URL`; orphan step needs API up + `DESCRYBE_SMOKE_PASS` (or `CUTOVER_REHEARSAL_PASS`) for platform-admin login ([demo-user.md](demo-user.md)). Optional: `--skip-deploy-check` / `--skip-lists` / `--skip-orphan` / `--skip-goose` / `--skip-readyz`.
Health probes (API default `:8080`; local `npm run dev` uses `:28471`):
- `GET /healthz` — liveness (no DB); returns `maintenance` / `read_only`
- `GET /readyz` — readiness: Postgres ping **and** fresh `worker_id=processing` heartbeat (≤**60s**) plus queue probe; **503** if DB down or worker missing/stale
Both paths are exempt from the maintenance gate so cutover rehearsal probes stay green.
## 1. Freeze legacy writes
1. Announce maintenance.
2. Put legacy app in read-only / maintenance mode (disable uploads, sync cron, processing, and mutating APIs).
3. Stop or pause workers: feed sync cron, River/queue consumers, Clerk-driven webhooks that write MySQL.
4. Confirm no new writes: check MySQL `updated_at` / binlog idle for critical tables.
5. Take a final MySQL dump (or snapshot) labeled with freeze timestamp.
Do not start the migrator until writes are frozen (or accept a documented delta re-run).
## 2. Run migrator
```bash
# From repo root — apply PG schema first (requires DATABASE_URL)
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
make migrate
# Windows PowerShell: $env:DATABASE_URL="..."; .\scripts\migrate.ps1
# Offline fixture dry-run (no MySQL) — tooling smoke only
cd apps/api
go run ./cmd/migrator -dry-run -fixture ./cmd/migrator/testdata/fixture.json -maps-dir ../../artifacts
# Dry-run against real MySQL (report + id-map; no PG writes of migrated rows)
# Flags: -mysql | MIGRATE_MYSQL_DSN, -postgres | DATABASE_URL, -dry-run, -maps-dir, -id-map, -skip-post-import
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-dry-run \
-maps-dir ../../artifacts \
-id-map ../../artifacts/id-map.json
# Live load (reuse / write artifacts/id-map.json)
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-id-map ../../artifacts/id-map.json \
-maps-dir ../../artifacts
```
### Migrator flags
| Flag | Env default | Purpose |
|---|---|---|
| `-mysql` | `MIGRATE_MYSQL_DSN` | Legacy MySQL DSN (required unless `-fixture`) |
| `-postgres` | `DATABASE_URL` | Target Postgres URL (required for live load; required for MySQL dry-run) |
| `-dry-run` | — | Remap/count without writing migrated rows |
| `-fixture` | — | Offline JSON fixture (dry-run only; omit `-postgres`) |
| `-maps-dir` | `artifacts` | Output dir for id-map / validation / hooks (gitignored) |
| `-id-map` | `<maps-dir>/id-map.json` | Unified ID map path |
| `-skip-post-import` | — | Skip set-password invite hook generation |
| `-fallback-plan-name` | — | During ETL: when a legacy `company_plans.plan_id` is missing from `plans` (e.g. `plan_id=6`), assign this Postgres plan name instead of skipping (safe insert only) |
| `-list-companies-without-plans` | — | Postgres-only: list companies with no active `company_plans` row |
| `-assign-missing-plans` | — | Postgres-only: assign `-plan-name` to companies **without** an active plan (never overwrites an existing active plan; requires `-dry-run` **or** `-confirm`) |
| `-plan-name` | `Free` | Plan name for `-assign-missing-plans` (case-insensitive) |
| `-list-member-memberships` | — | Postgres-only: list active `memberships` with `role=member` (optional `-email` / `-user-id` / `-company-id`; prints `a1=true/false`) |
| `-promote-company-admins` | — | Postgres-only: promote matching active `member``admin` (requires `-email`, `-user-id`, **or** `-company-id`; **never** promotes A1 `a1=true`; requires `-dry-run` **or** `-confirm`) |
| `-email` / `-user-id` / `-company-id` | — | Filters/targets for membership role tooling (`-company-id` alone scopes promote; A1 still skipped) |
| `-list-legacy-emails` | — | Postgres-only: list users still on `@legacy.local` (read-only) |
| `-export-legacy-emails` | — | Postgres-only: write inventory + emails stub map (`-emails-out` or `<maps-dir>/legacy-emails.json`) |
| `-patch-emails` | — | Postgres-only: patch `users.email` from `-emails-file` for rows still `@legacy.local` (never overwrites real emails; requires `-dry-run` **or** `-confirm`) |
| `-emails-file` / `-emails-out` | — | Email map input for patch; export output path |
| `-confirm` | — | Required for live `-assign-missing-plans` / `-promote-company-admins` / `-patch-emails` writes (omit with `-dry-run` to preview only — **no blind live writes**) |
### Patch residual `@legacy.local` emails (optional hygiene)
Clerk is removed from the product. Synthetic addresses still break set-password invites (`IsSyntheticLegacyEmail`) if any remain. **Optional:** list → export → fill real emails → dry-run patch → confirm:
```bash
cd apps/api
# 1) Count/list still-synthetic users (read-only)
go run ./cmd/migrator -list-legacy-emails -postgres "$DATABASE_URL"
# 2) Export inventory + stub emails{} map (fill from operator email inventory: legacy_user_id → primary email)
go run ./cmd/migrator -export-legacy-emails -maps-dir ../../artifacts -postgres "$DATABASE_URL"
# 3) Preview patches (no writes) — never overwrites a real (non-@legacy.local) address
go run ./cmd/migrator -patch-emails -emails-file ../../artifacts/legacy-emails.json -dry-run -postgres "$DATABASE_URL"
# 4) Apply only after reviewing the dry-run (-confirm required)
go run ./cmd/migrator -patch-emails -emails-file ../../artifacts/legacy-emails.json -confirm -postgres "$DATABASE_URL"
```
### Resolve skipped `company_plans` (`plan_id=6`)
Staging skipped rows whose legacy `plan_id` was absent from `plans`. Safe options (no deletes; **never** run live assign without a dry-run preview first):
**A. During a future load** — map missing plan ids to a valid public plan:
```bash
cd apps/api
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-fallback-plan-name Free \
-id-map ../../artifacts/id-map.json \
-maps-dir ../../artifacts
```
**B. After load (Postgres only)** — list → dry-run → confirm:
```bash
cd apps/api
# 1) List companies missing an active plan (read-only)
go run ./cmd/migrator -list-companies-without-plans -postgres "$DATABASE_URL"
# 2) Preview assignments (no writes) — required before live
go run ./cmd/migrator -assign-missing-plans -plan-name Free -dry-run -postgres "$DATABASE_URL"
# 3) Apply only after reviewing the dry-run (-confirm required; refuses without it)
go run ./cmd/migrator -assign-missing-plans -plan-name Free -confirm -postgres "$DATABASE_URL"
```
Live assign without `-confirm` exits with: `refusing live write … (no blind live writes)`. Existing active plans are never overwritten.
### Promote company admins (`role=member` → `admin`)
Import defaults all memberships to `role=member` when legacy `profiles.role` is absent. Platform admins (`users.is_platform_admin`) come from `admin_users` via `applyPlatformAdmins` during load — separate from company-admin memberships.
**Hard rule:** never promote A1 cohort rows (`a1=true` / `billing.IsA1CohortCompany`). Dry-run and `-confirm` both print `skip … a1_cohort` and leave those memberships as `member`.
**Local Demo path:** `seed-demo` already binds `demo@descrybe.local` as **admin** on **Platform Demo** only (never A1). If `-list-member-memberships` shows only A1, there is nothing safe to promote — re-run `go run ./cmd/seed-demo -postgres "$DATABASE_URL"` rather than inventing users. See [demo-user.md](demo-user.md) / [safe-test-fixtures.md](safe-test-fixtures.md).
**After load (Postgres only)** — list → dry-run → confirm (non-A1 only):
```bash
cd apps/api
# 1) List active member memberships (read-only; optional filters)
go run ./cmd/migrator -list-member-memberships -postgres "$DATABASE_URL"
# optional: -email user@example.com | -user-id <uuid> | -company-id <uuid>
# 2) Preview promote (no writes) — scope to a non-A1 company or email; A1 rows are skipped
go run ./cmd/migrator -promote-company-admins -company-id <non-a1-uuid> -dry-run -postgres "$DATABASE_URL"
# or: -email user@example.com -dry-run
# 3) Apply only after reviewing the dry-run (-confirm required; refuses without it)
go run ./cmd/migrator -promote-company-admins -company-id <non-a1-uuid> -confirm -postgres "$DATABASE_URL"
```
Unscoped promote (no `-email` / `-user-id` / `-company-id`) is refused. Live promote without `-confirm` exits with: `refusing live write … (no blind live writes)`.
SQL / UI alternates remain in [staging-auth-rehearsal.md](staging-auth-rehearsal.md).
**C. Platform admin API**
- `GET /api/admin/companies?without_active_plan=1` — companies with no active plan (`has_active_plan` is always included on the list response)
- `GET /api/admin/plans` — pick a valid `plan_id`
- `POST /api/admin/plans/assign``{"company_id":"…","plan_id":N}` (full assign; prefer migrator `-assign-missing-plans` when you must not touch companies that already have a plan)
Validation reports include informational check `companies_without_active_plan` (count + sample). Fix until count is 0 (or document an accepted exception).
Artifacts (`artifacts/`, gitignored): `id-map.json`, `validation-report.json`, `set-password-hooks.json`. Never commit production maps or hook tokens.
Load order and remapping rules: [schema-map.md](schema-map.md).
Post-load:
```sql
-- Prefer CONCURRENTLY outside a transaction for large tables
ANALYZE;
```
## 3. Verify counts
Compare MySQL vs Postgres for each migrated table (users, companies, memberships, plans, products, feeds, etc.):
| Check | Pass criteria |
|---|---|
| Row counts | Match within expected skips (ephemeral jobs, Clerk-only rows) |
| Orphan FKs | Zero after remapping |
| Spot checks | Sample products / feeds / memberships per company |
| Admin flag | Every legacy `admin_users` row → `users.is_platform_admin` |
| User emails | Real addresses (not `@legacy.local`) before invites — `-list-legacy-emails` / `-patch-emails` (`-dry-run` then `-confirm`); see [migration-readiness.md](migration-readiness.md) |
| Membership roles | Company admins promoted via `-list-member-memberships` / `-promote-company-admins` (`-dry-run` then `-confirm`; import defaults `role=member`) |
| Company plans | Skipped plans resolved via `-fallback-plan-name` / `-list-companies-without-plans` / `-assign-missing-plans` (`-dry-run` then `-confirm`) or admin assign; staging had 2 skipped (`plan_id=6`) |
| ID map | Every remapped FK resolves via `artifacts/id-map.json` |
Archive the verification report with the freeze dump and ID map.
## 4. Set passwords after import
Migrated users have `must_set_password = true` and no imported password hash. Full operator notes: [migration-readiness.md](migration-readiness.md).
**Before this step:** ensure invite targets are real emails (optional `@legacy.local` hygiene if any remain); promote company-admin memberships; account for skipped `company_plans`. Staging load used `-skip-post-import`, so invites were **not** issued automatically — re-issue only after emails are real.
1. Re-issue invites (Postgres only; required after `-skip-post-import` or email repair):
```bash
cd apps/api
go run ./cmd/migrator -issue-set-password-invites -postgres "$DATABASE_URL" -maps-dir ../../artifacts
# writes artifacts/password_invites.json + artifacts/set-password-hooks.json and prints URLs
```
2. **Staging rehearsal without SMTP (preferred first):** copy printed URLs — do **not** require mailhooks yet. Checklist + scripts: [staging-auth-rehearsal.md](staging-auth-rehearsal.md).
3. Send emails (rate-limited) when proving SMTP — still **unproven** until this succeeds. Smoke under `EMAIL_DRY_RUN` first (`-dry-run`; see [ops-runtime.md](ops-runtime.md)):
```bash
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run
# Live: EMAIL_DRY_RUN=false + SMTP_ENABLED=true
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json
```
4. Local one-user bootstrap (dev only): `go run ./cmd/migrator -set-password "email:pass" -postgres "$DATABASE_URL"`.
5. Smoke-test: `/accept-invite` → set password → login → session cookie.
6. Platform admins: confirm `is_platform_admin` and `/admin` routes; company admins: promote `memberships.role` to `admin` (see rehearsal doc).
Do not flip DNS until SMTP/mailhooks/login smoke is proven and at least one admin and one normal user can log in on the new stack. See [ops-runtime.md](ops-runtime.md).
## 5. DNS switch
**NO-GO** until the [NO-GO blockers gate](#no-go-blockers-gate-sequenced-dry-runs) (#1#10) is cleared. Staging ETL success alone is not enough.
1. Deploy Go API + worker + SvelteKit (`adapter-node@5.5.7` — Node host for `build/` output; see [production-readiness.md](production-readiness.md#deploy-note-sveltekit-adapter-decided)) behind the reverse proxy.
2. Health checks green: `/healthz`, `/readyz` (`checks.worker=ok`), web origin. Prefer `node scripts/cutover-deploy-check.mjs` after goose through **042** and worker restart.
3. Update DNS / reverse proxy to point production traffic at v2.
4. Keep legacy app offline or redirected; do not dual-write.
5. Monitor errors, login success rate, job queues, export URLs.
## 6. Clerk status (removed from product)
Descrybe v2 does **not** use Clerk. Legacy Clerk decommission (webhooks/keys/app) is historical cleanup on the old stack only — **not** a v2 cutover NO-GO blocker.
## 7. Rollback and MySQL retention
**If cutover fails before or shortly after DNS switch:**
1. Point DNS / proxy back to the legacy Next.js app.
2. Re-enable legacy writes only if the freeze dump is still the source of truth (or restore MySQL from the freeze dump).
3. Leave Postgres as non-authoritative until a corrected migrator re-run.
**Retention:**
- Keep MySQL **read-only** (dump + live replica or stopped primary) for **at least 30 days** after successful cutover.
- Retain `artifacts/id-map.json`, freeze dump, and verification reports for the same period.
- After 30 days and sign-off, decommission MySQL per data-retention policy.
## Quick reference order
0. Clear **NO-GO** blockers (hard live: **SMTP #4** + **Stripe #5**; other rows CODE/LOCAL or optional) via [sequenced dry-runs](#no-go-blockers-gate-sequenced-dry-runs)
1. Freeze legacy writes
2. Run migrator
3. Verify counts (+ emails, membership roles, skipped plans)
4. Optional email hygiene → promote/plans → re-issue set-password → prove SMTP/login (+ Stripe / deploy / orphan / edge / metrics)
5. DNS switch (only after hard live clears)
6. Legacy Clerk cleanup is historical only (not a v2 blocker)
7. Keep MySQL 30 days (rollback safety)
+306
View File
@@ -0,0 +1,306 @@
# Demo user (staging / local)
Local bootstrap account for testing Descrybe v2 as a **standalone full-admin sandbox**.
Demo is **not** an A1 company member — staff use Admin → Users → Switch to user to act for customers.
**Isolation cheat sheet:** [safe-test-fixtures.md](safe-test-fixtures.md)
**QA walkthrough:** [qa-local-demo.md](qa-local-demo.md)
## Smoke / tests — Platform Demo only
**MUST** run smoke checks, E2E, Postman, and automated tests against **Platform Demo** (`demo@descrybe.local` / `dk_demo_local_descrybe_test_key_v1`). Keep **A1 Slovenija** clean: **read-only** (dump-faithful billing/catalog inspection only — no process, upload, mutate, or mock-llm writes under A1). Full rules: [safe-test-fixtures.md](safe-test-fixtures.md).
## Credentials
| Field | Value |
|-------|--------|
| Email | `demo@descrybe.local` |
| Password | `DemoPass123!` |
| `must_set_password` | `false` |
| `is_platform_admin` | `true` |
| `is_active` | `true` |
| Company | **Platform Demo** (isolated sandbox) |
| Plan | **Platform Demo** (`is_custom=true`, ~1M credits, unlimited SKUs, all feature gates ON) |
`seed-demo` also upserts alias `demo@descrybe.test` / `DemoPass123!` (`-also-email`). Prefer **`.local`**.
### Local logins (after demo isolation)
| Role | Email | Password | Notes |
|------|-------|----------|-------|
| Demo admin | `demo@descrybe.local` | `DemoPass123!` | Platform admin; **Platform Demo** only; UserSwitcher **Demo admin** under Platform Demo |
| Demo alias | `demo@descrybe.test` | `DemoPass123!` | Same sandbox |
| Primary A1 | `a1-primary@descrybe.local` | `DemoPass123!` | Clerk `user_30AqqJ8uepxvPUzDSqy81U5w6Ll`; **A1 Slovenija** only; limited legacy nav |
Do **not** merge demo into A1. Switchable list groups demo under **Platform Demo** and A1 under **A1 Slovenija**.
### System assistant guide personas
Empty Free-plan companies for System assistant QA (password `DemoPass123!`):
```bash
cd apps/api
go run ./cmd/seed-guide-personas -postgres "$env:DATABASE_URL"
```
| Email | Suggested scenario |
|-------|--------------------|
| `guide-feed-url@descrybe.local` | Add feed from URL |
| `guide-upload-csv@descrybe.local` | Upload CSV |
| `guide-shopify@descrybe.local` | Connect Shopify |
| `guide-woocommerce@descrybe.local` | Connect WooCommerce |
| `guide-mapping@descrybe.local` | Map fields |
| `guide-process@descrybe.local` | Start processing |
| `guide-api@descrybe.local` | API keys + curl examples |
| `guide-support@descrybe.local` | Support ticket |
| `guide-attributes@descrybe.local` | List / create attributes |
| Surface | URL |
|---------|-----|
| Web login | http://localhost:28472/login -> `/dashboard` |
| Marketing | http://localhost:28472/ |
| API docs (RapiDoc) | http://localhost:28472/docs |
| OpenAPI | http://localhost:28471/api/v1/openapi.yaml (or same-origin via Vite proxy) |
| API | http://localhost:28471 |
**Browser smoke:** prefer `demo@descrybe.local` on **Platform Demo** (keep A1 read-only). Supply the password via `DESCRYBE_SMOKE_PASS` (see root `.env.example`) or `codehelper connections set-secret --name local-descrybe`. Recipe: `spa_hydrate` / login on `/login`.
Do **not** use this account or API key in production.
## Demo API key
Seeded for **Platform Demo** (sandbox company). Stored only as a SHA-256 hash (`auth.HashAPIKey`); plaintext is local-only.
| Field | Value |
|-------|--------|
| Key | `dk_demo_local_descrybe_test_key_v1` |
| Prefix | `dk_demo_lo` |
| Name | Demo local API key |
```http
Authorization: Bearer dk_demo_local_descrybe_test_key_v1
```
or
```http
X-API-Key: dk_demo_local_descrybe_test_key_v1
```
```bash
curl -s -H "Authorization: Bearer dk_demo_local_descrybe_test_key_v1" \
http://localhost:8080/api/v1/products?limit=1
```
## Billing / plan (A1 Slovenija — dump-faithful)
Company Settings and `/api/billing/credits` must show **migrated A1 numbers**, not a fake demo 1M / “Legacy” / “Local Demo Co” rename.
| Field | MySQL source | Value |
|-------|--------------|-------|
| Company name | `companies.name` | `A1 Slovenija` |
| PG company id | — | `604f23a8-b66e-4b21-8b45-0d72b68f4790` |
| `legacy_company_id` | MySQL `companies.id` | `97e1a309-3d23-4aa2-b518-8e8d7afdfec7` |
| Plan | `plans` + `company_plans.plan_id` | **A1** (`is_custom=true`, `is_legacy=false`, `term=yearly`, `yearly_credits=20000`, `monthly_credits=0`) — **pay-as-you-go** (credits wallet). Open-ended contract (`contract_end_date` null) is intentional for this demo tenant; `company_plans.notes` documents PAYG. |
| Allocated | `company_plans.total_credits_allocated` | `2500` |
| Wallet total | `credit_balances.total_credits` | `2500` |
| Wallet used | `credit_balances.used_credits` | `163` |
| Remaining | computed | `2337` |
| Language / merge GTIN | `company_settings` | `sl` / `false` |
| Usage snapshot | `usage_metrics` | folded into `company_settings.settings._legacy_usage` (dump-faithful) |
| `billing_cycles` / `cost_metrics` in dump | — | **empty** for A1; PG cycle mirrors contract window with `credits_used=163` |
`seed-demo` **preserves** this wallet for the A1 cohort (does not `AssignPlan` over it — that would zero `used_credits` and inflate totals).
### Fresh processing state (separate from seed)
After `npm run seed:a1` / `go run ./cmd/seed-a1 -mode reimport …`, clear processing history without wiping catalog:
```powershell
cd apps/api
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
go run ./cmd/seed-a1-reset-processing
# optional: -dry-run | -name "A1 Slovenija"
```
Retains feeds, mappings, raw/mapped products, attributes, categories. Deletes processing jobs/job-products and processed_products; sets all raw to `unprocessed`.
### Local processing when credits run low
Remaining **2337** is enough for light local smoke. For heavy AI runs without rewriting dump usage history:
1. Prefer platform-admin **AddCredits** (increases `total_credits` only; keeps `used_credits` / `_legacy_usage` accurate), or
2. Process under a separate non-A1 sandbox company.
Do **not** re-seed a 1M “demo pack” onto A1 — that confuses Company Settings.
```sql
SELECT c.name, p.name AS plan, p.monthly_credits, p.yearly_credits, p.term, p.is_custom,
cp.total_credits_allocated, cb.total_credits, cb.used_credits,
(cb.total_credits - cb.used_credits) AS remaining
FROM companies c
JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active
JOIN plans p ON p.id = cp.plan_id
JOIN credit_balances cb ON cb.company_id = c.id
WHERE c.legacy_company_id = '97e1a309-3d23-4aa2-b518-8e8d7afdfec7';
```
```powershell
# A1 wallet check: login as a1-primary@ (or Admin switch), NOT demo@ — expect plan=A1 remaining≈2337
# demo@ lands on Platform Demo (custom plan / high credits), not A1
curl -s -b cookies.txt http://localhost:8080/api/billing/credits
curl -s -b cookies.txt http://localhost:8080/api/me
```
## How it was created
```powershell
cd apps/api
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
go run ./cmd/seed-demo -postgres $env:DATABASE_URL
# default email is demo@descrybe.local — override with -email if needed
```
`seed-demo` upserts the demo user(s) (argon2id), sets platform admin, ensures **Platform Demo** company (never renames A1), binds membership **only** to that company (removes stray A1 memberships), upserts the demo API key on Platform Demo, and assigns the custom **Platform Demo** plan (full features / high credits). A1 wallet and plan stay dump-faithful — see Billing section below. New self-serve signups still get **Free** — see [free-tier.md](free-tier.md).
### Company-admin promote (migrator)
Imported cutover memberships default to `role=member`. Local Demo does **not** need `-promote-company-admins``seed-demo` already grants **admin** on Platform Demo.
```powershell
cd apps/api
# Inventory (read-only). Expect a1-primary@ on A1 with a1=true; demo@ will not appear (already admin).
go run ./cmd/migrator -list-member-memberships -postgres $env:DATABASE_URL
# Promote non-A1 only (A1 always skipped). Scope with -email / -user-id / -company-id.
go run ./cmd/migrator -promote-company-admins -company-id <non-a1-uuid> -dry-run -postgres $env:DATABASE_URL
go run ./cmd/migrator -promote-company-admins -company-id <non-a1-uuid> -confirm -postgres $env:DATABASE_URL
```
**Never** promote `a1=true`. Full ops notes: [cutover.md](cutover.md#promote-company-admins-rolemember--admin).
Empty local register (no migrated catalog):
```powershell
pwsh -File .\scripts\seed-local.ps1
```
## Primary company (Platform Demo)
Login lands on **Platform Demo**. A1 Slovenija (`604f23a8-…` / legacy `97e1a309-…`) stays a separate customer tenant — switch via Admin impersonation, not demo membership.
### Completeness vs legacy MySQL (A1 Slovenija)
Verified after migrator live load + dump-faithful billing restore. MySQL source: `descrybe_new` company `97e1a309-3d23-4aa2-b518-8e8d7afdfec7`.
| Entity | MySQL (A1) | Postgres (A1 Slovenija) | Status |
|--------|----------:|-------------------------:|--------|
| Categories | 119 | 120 | OK (+1 local CSV smoke `Test Category`) |
| Attributes | 303 | 303 | OK |
| Category attributes | 307 | 307 | OK |
| Custom variables | 3 | 3 | OK |
| Input feeds | 12 | 12 | OK |
| Feed mappings | 11 | 11 | OK (ComTrade has no `field_mappings` in MySQL either) |
| Category title/description formulas | 119 / 119 | 119 / 119 | OK — list API now returns templates + `has_*_formula` flags |
| Standard fields (A1 dump keys) | 22 | 22 (+ ecommerce seed) | After ensure: ~44 enabled (dump keys kept + full ecommerce catalog for mapping) |
| Export feeds | 2 | 2 | OK |
| Processed products | 4321 | 4323 | OK (+2 local smoke samples) |
| Raw products | 31062 | 23744 | OK intentional — **7320** GTIN deduped under `(company_id, gtin)` unique; +2 smoke |
| Orphan FKs (company-scoped) | — | 0 | OK (mappings/company, processed→raw/feed, raw→feed, export→source) |
Global migrator validation: `artifacts/validation-report.json``mode: live`, orphans **16/16 pass**. Attribute collapse globally (95k → 57k) is distinct-key unique constraint, not an A1 gap.
| Company | ID | Input feeds | Products (processed) | Raw products | Export feeds | Categories | Mappings |
|---------|-----|------------:|---------------------:|-------------:|-------------:|-----------:|---------:|
| **A1 Slovenija** (default) | `604f23a8-b66e-4b21-8b45-0d72b68f4790` | 12 | ~4323 | ~23744 | 2 | 120 | 11 |
| Descrybe | `3802e3c1-b292-4d1b-9fcf-eca193f364c8` | 10 | 2742 | 85608 | 2 | 2334 | 8 |
| Merkur d.o.o. | `57921d6c-c65a-4867-b9df-e5cbe39d741b` | 3 | 151 | 9429 | 2 | 5442 | 2 |
Counts drift slightly after CSV smoke uploads; always key off company id `604f23a8-…` / `legacy_company_id` `97e1a309-…`.
## App paths (accurate)
| Task | Path |
|------|------|
| Products | `/products` (use **Unprocessed** if Processed looks empty — UI also auto-falls back) |
| Feeds / mapping | `/feeds` → feed → mappings (Elkotex: `/feeds/7495b404-68d3-4f94-9568-1d2c7a81ee8a/mapping`) |
| Category formulas | Categories → Edit → Title/Description formula (templates migrated; list shows `has_*_formula`) |
| Standard fields | `/standard-fields` — Enable recommended if mapping targets look sparse after a fresh import |
| CSV uploads | Dashboard Upload CSV; `/products`, `/categories`, `/attributes`; history at `/files` |
| Process history | `/processing` (migrated A1 jobs when domain `jobs` was imported; also live jobs) |
| Switch to Primary A1 | Login `a1-primary@descrybe.local` / `DemoPass123!`, or Admin → Users → **Switch to user** |
| Brand + logo | `/brand` (`POST /api/brand/logo`) |
| Campaigns | `/campaigns` |
| Content calendar | `/marketing/calendar` |
| SEO | `/seo` |
| Reviews | `/woocommerce?tab=reviews` (nav `/reviews` redirects here) |
| Email sending | `/integrations/email` |
| Billing / plans | `/billing`, `/plans` |
| Public pricing | `/pricing` |
API CSV: `POST /api/products/upload`, `/api/categories/upload`, `/api/attributes/upload` (and `/import` aliases). Product search: `q` or `search`; `feed_id` filters supported.
After backend route changes, **rebuild/restart the Go API** or smoke can show stale 404s.
## Switching company
`POST /api/auth/select-company` with CSRF + session cookie:
```json
{ "company_id": "3802e3c1-b292-4d1b-9fcf-eca193f364c8" }
```
## Smoke checks
```sql
SELECT email, must_set_password, is_platform_admin, is_active
FROM users WHERE email = 'demo@descrybe.local';
SELECT c.id, c.name,
(SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id) AS products,
(SELECT COUNT(*) FROM input_feeds f WHERE f.company_id = c.id) AS feeds
FROM companies c
WHERE c.name = 'Platform Demo'
ORDER BY c.created_at DESC
LIMIT 1;
-- expect Platform Demo sandbox (not A1 Slovenija / 604f23a8-…)
```
```powershell
cd apps/api
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
go run ./cmd/seed-demo -postgres $env:DATABASE_URL
# Session login (CSRF double-submit cookie from any GET)
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
try { Invoke-WebRequest http://127.0.0.1:8080/api/auth/me -WebSession $session -UseBasicParsing | Out-Null } catch {}
$csrf = ($session.Cookies.GetCookies("http://127.0.0.1:8080") | Where-Object Name -eq descrybe_csrf).Value
$body = '{"email":"demo@descrybe.local","password":"DemoPass123!"}'
Invoke-WebRequest http://127.0.0.1:8080/api/auth/login -Method POST -WebSession $session `
-Headers @{ "X-CSRF-Token" = $csrf; "Content-Type" = "application/json" } -Body $body -UseBasicParsing
Invoke-RestMethod http://127.0.0.1:8080/api/products?limit=3 -WebSession $session
Invoke-RestMethod http://127.0.0.1:8080/api/feeds -WebSession $session
Invoke-RestMethod http://127.0.0.1:8080/api/categories?limit=5 -WebSession $session
```
Expect: login **200**, default company **Platform Demo**, demo API key scoped to that company. A1 catalog counts (~4323 products) belong to **A1 Slovenija** only — switch as `a1-primary@descrybe.local`, do not process under demo.
```powershell
cd apps/api; go build ./...
cd ../web; npm run check
```
## Related
- [e2e-feeds-process-export.md](e2e-feeds-process-export.md)
- [qa-local-demo.md](qa-local-demo.md) — login steps, nav status, uploads, known limits
- [migration-run-log.md](migration-run-log.md) — full MySQL→PG migrator report
- [marketing-suite-user-guide.md](marketing-suite-user-guide.md)
- [free-tier.md](free-tier.md)
- [go-live-checklist.md](go-live-checklist.md)
- Repo root `README.md`
## Related
- [api-surface-smoke.md](api-surface-smoke.md) — admin / healthz / docs / public v1 smoke
- [qa-local-demo.md](qa-local-demo.md)
+351
View File
@@ -0,0 +1,351 @@
# Descrybe v2 — UI / UX design gaps vs legacy
**Audit date:** 2026-08-03
**Legacy:** `f:/laragon/www/_MY/descrybe`
**v2 web:** `f:/laragon/www/_MY/descrybe-v2/apps/web`
**Scope:** Visual and UX parity only (routes, nav, dialogs, stubs, layout). No fixes in this pass.
**Related:** [status-and-gaps.md](status-and-gaps.md) (backend/product gaps), [features.md](features.md)
Priority legend:
| Priority | Meaning |
|----------|---------|
| **P0** | Blocks daily-ops parity or presents a broken/misleading UI for a core path |
| **P1** | Important feature parity / polish; workaround exists or path is secondary |
| **P2** | Nice-to-have chrome, marketing, or low-traffic screens |
---
## 1. Legacy dashboard + auth routes
### Auth / entry (`app/(auth)` + related)
| Route | File |
|-------|------|
| `/login` | `app/(auth)/login/[[...login]]/page.tsx` |
| `/signup` | `app/(auth)/signup/[[...signup]]/page.tsx` |
| `/accept-invitation` | `app/accept-invitation/page.tsx` |
| `/onboarding` | `app/onboarding/page.tsx` |
Auth layout: `app/(auth)/layout.tsx` (Clerk SignIn/SignUp, branded header with **Book a Demo**, privacy/terms footer).
### Dashboard (`app/dashboard`)
| Route | File / notes |
|-------|----------------|
| `/dashboard` | `page.tsx` → news + stats home |
| `/dashboard/products` | `products/page.tsx` |
| `/dashboard/feeds` | `feeds/page.tsx` |
| `/dashboard/feeds/[feedId]/mapping` | `feeds/[feedId]/mapping/page.tsx` (older mapping) |
| `/dashboard/feeds/[feedId]/mapping-v2` | `feeds/[feedId]/mapping-v2/page.tsx` (**current** entry from feed actions) |
| `/dashboard/export-feeds` | `export-feeds/page.tsx` |
| `/dashboard/export-feeds/new` | `export-feeds/new/page.tsx` (`?format=xml\|csv` → full `ExportFeedBuilder`) |
| `/dashboard/export-feeds/[feedId]` | exists as edit path used by table “Edit” |
| `/dashboard/categories` | `categories/page.tsx` |
| `/dashboard/categories/[categoryId]/title-formula` | formula builder |
| `/dashboard/categories/[categoryId]/description-formula` | description formula builder |
| `/dashboard/attributes` | `attributes/page.tsx` |
| `/dashboard/standard-fields` | `standard-fields/page.tsx` |
| `/dashboard/tasks` | `tasks/page.tsx` (background tasks) |
| `/dashboard/billing` | `billing/page.tsx` |
| `/dashboard/settings` | `settings/page.tsx` |
| `/dashboard/plans` | `plans/page.tsx` |
| `/dashboard/process/new` | `process/new/page.tsx` (dedicated process wizard) |
| `/dashboard/structured-descriptions` | present; **commented out of sidebar** |
| `/dashboard/vector-categories` | present; not in sidebar |
### Admin (outside dashboard, gated)
| Route | File |
|-------|------|
| `/admin` | `app/admin/page.tsx` |
| `/admin/users` | `app/admin/users/page.tsx` |
| `/admin/analytics` | `app/admin/analytics/page.tsx` |
| `/admin/billing` | `app/admin/billing/page.tsx` |
| `/admin/logs` | `app/admin/logs/page.tsx` |
| `/admin/stuck-products` | `app/admin/stuck-products/page.tsx` |
| `/admin/settings` | `app/admin/settings/page.tsx` |
| `/admin/bootstrap` | `app/admin/bootstrap/page.tsx` |
| `/admin/migrate-organizations` | `app/admin/migrate-organizations/page.tsx` |
| `/admin/tasks-cleanup` | `app/admin/tasks-cleanup/page.tsx` |
WooCommerce in legacy is **not** a top-level dashboard nav item; it lives under export-feed actions (`components/export-feeds/woocommerce-*.tsx`).
### Legacy sidebar nav (`components/utilities/sidebar.tsx`)
Order: Dashboard → Products → Feeds → Export Feeds → Categories → Attributes → Standard Fields → Background Tasks → Usage & Billing → Settings.
(Structured Fields / WooCommerce / Admin are **not** in this customer sidebar.)
---
## 2. v2 routes (`apps/web/src/routes`)
### Auth
| Route | File |
|-------|------|
| `/login` | `login/+page.svelte` |
| `/register` | `register/+page.svelte` (legacy `/signup`) |
| `/accept-invite` | `accept-invite/+page.svelte` (legacy `/accept-invitation`) |
### App shell
| Route | File |
|-------|------|
| `/` | `+page.svelte` (dashboard home; legacy `/dashboard`) |
| `/products` | `products/+page.svelte` |
| `/feeds` | `feeds/+page.svelte` |
| `/feeds/[feedId]/mapping` | `feeds/[feedId]/mapping/+page.svelte` |
| `/export-feeds` | `export-feeds/+page.svelte` (list **and** create/edit dialog — no `/new` or `/[id]` routes) |
| `/categories` | `categories/+page.svelte` |
| `/categories/[categoryId]/title-formula` | `…/title-formula/+page.svelte` |
| `/categories/[categoryId]/description-formula` | `…/description-formula/+page.svelte` |
| `/attributes` | `attributes/+page.svelte` |
| `/standard-fields` | `standard-fields/+page.svelte` |
| `/processing` | `processing/+page.svelte` |
| `/tasks` | `tasks/+page.ts` only — **307 redirect**`/processing` |
| `/billing` | `billing/+page.svelte` |
| `/settings` | `settings/+page.svelte` |
| `/woocommerce` | `woocommerce/+page.svelte` (**v2-only** top-level page) |
| `/plans` | `plans/+page.svelte` (not in nav) |
| `/structured-descriptions` | `structured-descriptions/+page.svelte` (not in nav) |
| `/vector-categories` | `vector-categories/+page.svelte` (not in nav) |
### Admin
| Route | File |
|-------|------|
| `/admin` | `admin/+page.svelte` |
| `/admin/users` | `admin/users/+page.svelte` |
| `/admin/analytics` | `admin/analytics/+page.svelte` |
| `/admin/billing` | `admin/billing/+page.svelte` |
| `/admin/logs` | `admin/logs/+page.svelte` |
| `/admin/stuck-products` | `admin/stuck-products/+page.svelte` |
| `/admin/settings` | `admin/settings/+page.svelte` |
| `/admin/bootstrap` | `admin/bootstrap/+page.svelte` |
| `/admin/migrate-organizations` | `admin/migrate-organizations/+page.svelte` |
| `/admin/tasks-cleanup` | `admin/tasks-cleanup/+page.svelte` |
### v2 sidebar nav (`src/lib/components/Nav.svelte`)
Order: Dashboard → Products → Feeds → Export Feeds → Categories → Attributes → Standard Fields → Background Tasks → Usage & Billing → Settings → **WooCommerce****Admin**.
Admin link is **always rendered** (not gated by `is_platform_admin`); page gate is client-side via `requirePlatformAdmin()`.
---
## 3. Route / nav comparison matrix
| Capability | Legacy | v2 | Gap |
|------------|--------|----|-----|
| Dashboard home | `/dashboard` | `/` | Path rename only |
| Products | `/dashboard/products` | `/products` | Feature gaps (see P0) |
| Feeds list | `/dashboard/feeds` | `/feeds` | Mostly present |
| Feed mapping (rich) | `/feeds/.../mapping-v2` | `/feeds/.../mapping` | **No mapping-v2 / schema extract / live XML tree** |
| Export list | `/export-feeds` | `/export-feeds` | OK shell |
| Export create/edit | `/export-feeds/new`, `/[feedId]` + `ExportFeedBuilder` | Single-page dialog | **Missing full builder route** |
| Categories + formulas | yes | yes | Bulk Formula stub; double title chrome |
| Attributes | yes | yes | Missing per-attr Manage Categories |
| Standard Fields | yes (live) | shell + “API not available” banner | **Non-functional nav item** |
| Background Tasks | `/tasks` | `/processing` (+ `/tasks` redirect) | Floating task indicator missing |
| Billing / Settings | yes | yes | Close |
| Plans | `/plans` | `/plans` | Shell; API banner |
| Process wizard | `/process/new` | — | **Missing dedicated route** |
| Structured descriptions | route (nav commented out) | route, no nav | Shell; API banner |
| Vector categories | route | route, no nav | Shell; API banner |
| WooCommerce | export-feed dialogs | `/woocommerce` nav item | Different IA; maps UI thin |
| Admin | separate `/admin` layout, not in customer sidebar | always in customer Nav | **IA + visibility gap** |
| Signup | Clerk `/signup` | `/register` | Rename OK |
| Invite | `/accept-invitation` | `/accept-invite` | Rename OK |
| Onboarding tour | `TourProvider` in dashboard layout | — | Missing |
---
## 4. Dialog inventory
### Legacy dialogs (representative)
| Dialog | Path | v2 counterpart |
|--------|------|----------------|
| Export selection (pick export feed) | `components/products/export-selection-dialog.tsx` | **Missing** — CSV download only |
| CSV EAN upload | `components/dashboard/csv-ean-upload-dialog.tsx` | `UploadEansDialog.svelte` |
| Add category attribute (product edit) | `components/dashboard/add-category-attribute-dialog.tsx` | **Missing** |
| Manage categories (per attribute) | `components/attributes/manage-categories-dialog.tsx` + TreeSelect | **Missing** (bulk CSV only) |
| Manage list values | `components/attributes/manage-list-values-dialog.tsx` | Inline dialog in `attributes/+page.svelte` |
| Bulk category↔attribute CSV | `components/attributes/bulk-category-assignments-dialog.tsx` | Inline bulk dialog |
| Bulk formula builder | `components/categories/bulk-formula-builder-dialog.tsx` | **Stub button** (toast message) |
| Field mapping / sync history | `components/feeds/*` | Partial (history dialog exists; mapping preview stub) |
| Export WooCommerce config | `components/export-feeds/woocommerce-integration-dialog.tsx` | Moved to `/woocommerce`; not on export row |
| Standard field / group dialogs | `components/standard-fields/*` | Present in page; **API unavailable** |
| Admin assign/create plan | `components/admin/billing/*` | Admin billing page exists; verify depth |
| Add credits | `components/dashboard/add-credits-dialog.tsx` | Inline on billing page |
### v2 dialogs present under `$lib/components`
Category: `AddCategoryDialog`, `EditCategoryDialog`, `DeleteCategoryDialog`, `TreeSelectDialog`, formula dialogs (`CustomVariableDialog`, `ManageVariablesDialog`, `TextElementDialog`, `ConfirmationDialog`).
Products: `UploadEansDialog` only.
UI primitive: `Dialog.svelte`.
Many “dialogs” are inlined in route `+page.svelte` files rather than shared components.
---
## 5. Findings by priority
### P0 — fix before claiming UI cutover readiness
1. **Feed mapping: live XML/CSV schema preview****DONE (2026-08-08)**
- **v2 now:** `feeds/[feedId]/mapping/+page.svelte` + `FeedSourcePreview` + `MappingPreviewPanel` + `POST /api/feeds/{id}/extract-schema` (click-to-pick path, extract/refresh, sample preview).
- Residual polish (CSV column-click pick, legacy mapping-v2 chrome) is P1 — not a cutover blocker.
2. **Export feed create/edit is a thin dialog, not the full builder****WAIVED for cutover**
- **WAIVER ID:** `DESIGN-P0-EXPORT-BUILDER-2026-08-08`
- **v2:** `export-feeds/+page.svelte` in-dialog create/edit (`formRoot` / `formItem` / flat `fields[]`) covers create, edit, refresh, and download for daily ops.
- **Deferred:** dedicated `/export-feeds/new` + `/export-feeds/[id]` `ExportFeedBuilder` (structure editor, custom variables, Woo column actions) — large UI port, not required when dialog template editing works.
- Re-open only if a tenant needs legacy builder-only flows.
3. **Products “Export Selected” bypasses export feeds**
- **v2:** `products/+page.svelte` `exportSelected()` builds a client-side CSV blob and downloads it.
- **Legacy:** `ExportSelectionDialog` → choose configured export feed → server export job.
- **Wrong:** Wrong UX contract; no feed picker dialog; cannot export via template XML/CSV.
4. **“Reset to Unprocessed” is a stub**
- **v2:** `products/+page.svelte` sets `success = "Reset to unprocessed is not available in v2 yet."`
- **Legacy:** mutation in `products-client.tsx` calls reset action and updates selection.
- **Wrong:** Button appears in `ProductProcessingActions.svelte` but does nothing useful.
5. **Standard Fields API****DONE (2026-08-08)**
- **v2:** `GET/POST/PATCH/DELETE /api/standard-fields` + `/api/field-groups` (+ bulk-enable / enable-recommended) mounted in `httpapi/server.go`; `standard-fields/+page.svelte` CRUD wired via `apiAvailable`.
- Stale “API not available” copy in older checklists no longer applies when the API is up.
6. **Product edit Attributes tab****DONE (MVP, 2026-08-08)**
- **v2:** `ProductEditPanel` Attributes tab supports add/edit/remove key-value rows; save/accept PATCH includes `attributes` (`UpdateProcessedProduct`).
- **Deferred (P1):** legacy `AddCategoryAttributeDialog` (pick from category attribute catalog) — not required for free-form attribute fix-ups.
7. **Attributes: no per-attribute “Manage Categories” / tree assign dialog**
- **v2:** Row menu = Edit Details / Manage Values / Delete; bulk CSV assign only (`showBulkAssign`). Unit/example fields in Edit are **disabled**.
- **Legacy:** `ManageCategoriesDialog` + TreeSelect + editable unit/example.
- **Wrong:** Operators cannot visually assign one attribute to categories without CSV.
8. **Admin Settings / Admin home are UI demos (non-persisting)**
- **v2:** `admin/settings/+page.svelte``saveDemo(label)`*“saved (UI demo — not persisted by API yet)”* for General/Security/API/Pricing/Maintenance.
- **v2:** `admin/+page.svelte`*“Admin configuration saved! (UI demo — not persisted…)”*.
- **Wrong:** Controls look real; saves are fake.
9. **Admin Logs page has no data path**
- **v2:** `admin/logs/+page.svelte`*“System logs API is not available in v2 yet.”* Table chrome only.
- **Wrong:** Nav item under Admin presents empty operational tooling.
10. **Admin always visible in customer sidebar**
- **v2:** `Nav.svelte` hardcodes `{ href: "/admin", label: "Admin" }` for every signed-in user.
- **Legacy:** Admin is a separate app shell (`app/admin/layout.tsx` + `AdminSidebar`); **not** in `components/utilities/sidebar.tsx`.
- **Wrong:** Clutters IA; non-admins hit a gate/error after navigation (misleading).
11. **Categories Bulk Formula Builder is stubbed**
- **v2:** `categories/+page.svelte` button sets success message: *“Bulk Formula Builder … is not wired in v2 yet.”*
- **Legacy:** `bulk-formula-builder-dialog.tsx` full CSV → AI titles flow.
- **Wrong:** Primary page action is decorative.
12. **Floating TaskStatusIndicator****DONE (2026-08-08)**
- **v2:** `TaskStatusIndicator` mounted from `+layout.svelte` (signed-in, non-auth/marketing/admin); polls `/api/processing/jobs`, shows active job progress + link to `/processing`.
- Scope: processing jobs only (feed sync remains on feed row / history — same as existing feeds UX).
---
### P1 — important parity / incomplete dialogs
13. **Dedicated `/process/new` wizard missing**
- Legacy: `app/dashboard/process/new/` selects raw products + kicks processing.
- v2: processing starts from Products selection only. Dashboard “Start Processing” just `goto("/products")`.
14. **Export list missing WooCommerce column / row actions**
- Legacy table: optional WooCommerce status + Configure/Sync actions (`export-feeds-table.tsx`).
- v2 table columns: Name, Format, Feed URL, Last Updated, Status, Actions — no Woo column.
15. **Feed source types UX limited to URL CSV/XML**
- v2 Add Feed dialog: name + URL + type select (`feeds/+page.tsx`).
- Legacy mapping-v2 supports CSV/Excel paths and richer reset/extract; FTP/file upload remain product gaps (also backend).
16. **Categories page double title / spacing**
- `PageShell title="Categories"` **and** inner `<h1 class="text-3xl…">Categories</h1>` in `categories/+page.svelte` → duplicated heading and extra vertical space vs legacy single header pattern.
17. **Plans / Structured Descriptions / Vector Categories shells**
- Routes exist with legacy-looking UI but API banners (`plans/+page.svelte`, `structured-descriptions/+page.svelte`, `vector-categories/+page.svelte`).
- Not in nav (same as legacy for structured), but deep-links look “done” while disabled.
18. **Admin bootstrap is instructional only**
- `admin/bootstrap/+page.svelte`: *“Bootstrap is not available via API… set is_platform_admin in DB.”*
19. **WooCommerce rich mapping UI**
- `/woocommerce` has config + auto-map buttons, but no legacy-depth mapping table/editor for overrides (status-and-gaps also notes this).
20. **Auth marketing chrome incomplete**
- Legacy login: sticky header + **Book a Demo** CTA + privacy/terms links.
- v2 auth layout (`+layout.svelte`): logo header + simple footer copyright; no Demo CTA, no privacy/terms routes wired in footer.
21. **Onboarding tour / NavigationProgress missing**
- Legacy dashboard layout wraps `TourProvider` + `NavigationProgress`.
- v2 layout has neither.
22. **Job/export dialogs from products**
- Legacy `job-export-dialog.tsx` for exporting job results.
- v2: not found under products components.
23. **Attribute edit: unit & example disabled**
- `attributes/+page.svelte` Edit dialog: `editUnit` / `editExample` inputs `disabled` — looks editable in legacy.
24. **NewsFeed “More updates coming soon”**
- `NewsFeed.svelte` ends with static “coming soon” — fine as content, but weaker than legacy news richness if operators expect changelog parity.
---
### P2 — polish / low traffic
25. **Path / naming deltas** (`/register` vs `/signup`, `/accept-invite` vs `/accept-invitation`, `/processing` vs `/tasks`) — redirects/docs only.
26. **Admin migrate-orgs / tasks-cleanup** — pages exist; confirm copy and empty states match legacy density.
27. **Description/title formula:** drag reorder exists (`FormulaBuilder.svelte` HTML5 DnD); still may lack legacy `@hello-pangea/dnd` polish / edge cases — verify against `title-formula` client before calling pixel-complete.
28. **Auth card-only main** vs Clerk multi-step branding (intentional first-party auth; visual tone differs).
29. **No dark-theme admin shell** — legacy admin forces `defaultTheme="dark"`; v2 admin uses same light customer tokens.
30. **Sign-out placement** — v2 Nav has Sign out under items; legacy relied more on Clerk user button in header (v2 has avatar → settings only).
---
## 6. Known stubs / demo markers (code pointers)
| Location | Marker |
|----------|--------|
| `routes/categories/+page.svelte` | Bulk Formula Builder not wired |
| `routes/products/+page.svelte` | Reset to unprocessed not available |
| `routes/plans/+page.svelte` | Plans API not available |
| `routes/structured-descriptions/+page.svelte` | Structured descriptions API not available |
| `routes/vector-categories/+page.svelte` | Vector categories API not available |
| `routes/admin/settings/+page.svelte` | `saveDemo()` — UI demo |
| `routes/admin/+page.svelte` | UI demo save |
| `routes/admin/logs/+page.svelte` | Logs API not available |
| `routes/admin/bootstrap/+page.svelte` | Bootstrap not available via API |
| `lib/components/NewsFeed.svelte` | “More updates coming soon” |
~~Removed as closed:~~ mapping XML tree placeholder; standard-fields “API not available” (API mounted).
---
## 7. Suggested fix order (UI-only)
1. ~~Feed mapping preview + schema extract UX~~ **DONE**.
2. ~~ExportFeedBuilder dedicated routes~~ **WAIVED** (`DESIGN-P0-EXPORT-BUILDER-2026-08-08`) — dialog covers daily ops.
3. Export Selection dialog on products; implement Reset to Unprocessed.
4. ~~Product edit attribute editing~~ **DONE (MVP)**; Add Category Attribute remains P1.
5. Attributes Manage Categories (reuse `TreeSelectDialog`).
6. Gate Admin (and optionally Woo) in `Nav.svelte`.
7. Replace admin `saveDemo` with real endpoints or disable Save.
8. Re-enable Bulk Formula Builder dialog.
9. ~~Port TaskStatusIndicator into `+layout.svelte`~~ **DONE**.
10. Auth footer links + Demo CTA if marketing parity required.
11. ~~Standard Fields API~~ **DONE** (handlers mounted).
---
## 8. Out of scope for this audit
Backend/migrator/CI gaps already tracked in [status-and-gaps.md](status-and-gaps.md). This document only covers what users see and click in the web app versus the legacy Next.js UI.
+79
View File
@@ -0,0 +1,79 @@
# Docker Compose (Postgres only)
One file: [`docker-compose.yml`](../docker-compose.yml). Compose runs **Postgres 16** on host port **5433** only. API, web, and worker stay on the host via `npm run setup` + `npm run dev` (see root [README.md](../README.md)).
## What starts
| Command | Services |
|---------|----------|
| `docker compose up -d` | Postgres (`descrybe-v2-postgres`) with `pg_isready` healthcheck |
| `npm run setup` | Ensures `.env`, starts Compose Postgres, runs goose migrate |
| `npm install && npm run dev` | Host API (:28471) + web (:28472) + worker |
Host DSN (matches Compose publish):
```text
postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable
```
`GET /healthz` = API liveness. `GET /readyz` needs the **host worker** (included in `npm run dev`).
## Prerequisites
- [Docker Desktop](https://docs.docker.com/desktop/) (Windows / macOS) **or** Docker Engine + Compose v2 (Linux)
- Node **≥ 20** and Go **1.25** on the host for `npm run setup` / `npm run dev`
## Windows (Docker Desktop)
1. Install Docker Desktop; prefer the **WSL2** backend (Settings → General).
2. Start Docker (whale icon), then from the repo in PowerShell:
```powershell
npm run setup
npm install
npm run dev
```
3. If **5433** is busy: stop the other Postgres/Compose project or change only the host side of `ports:` in `docker-compose.yml` (`"5433:5432"``"NEWport:5432"`) and update `DATABASE_URL` in `.env`.
## macOS (Docker Desktop)
1. Install Docker Desktop (Apple Silicon or Intel build as needed).
2. From Terminal:
```bash
npm run setup
npm install && npm run dev
```
Same ports and DSN as Windows. Home-directory clones are shared with Docker by default.
## Linux (Docker Engine)
1. Install Docker Engine and the Compose **v2** plugin (`docker compose version`).
2. Add your user to the `docker` group (re-login) or use `sudo`.
3. From the repo:
```bash
npm run setup
npm install && npm run dev
```
Optional: bind Postgres to loopback only with `"127.0.0.1:5433:5432"` if you do not want LAN exposure.
## Makefile
```bash
make up # docker compose up -d
make down # docker compose down
make setup # npm run setup equivalent
```
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| `Bind for 0.0.0.0:5433 failed` | Another Compose/Postgres; `docker compose down` elsewhere or change host port |
| Postgres never healthy | `docker compose logs postgres`; wait for `pg_isready` |
| `/readyz` → 503 | Start host worker (`npm run dev` includes it; or `npm run dev:worker`). Read JSON `reason`. Compose does not run the worker. |
| Windows: Docker not running | Start Docker Desktop before `npm run setup` |
+141
View File
@@ -0,0 +1,141 @@
# E2E: Feeds → Map → Process → Export (Local Demo Co)
**When:** 2026-08-04
**Tenant:** Local Demo Co (`ee246275-dec0-4446-9e83-58d0c16c258a`)
**Web:** http://127.0.0.1:5174 (not 5173 — that port may be another Vite app)
**API:** http://127.0.0.1:8080
**DB:** `postgres://descrybe:***@localhost:5433/descrybe`
## Credentials
| | |
|---|---|
| Email | `demo@descrybe.local` |
| Password | `DemoPass123!` |
| API key | `dk_demo_local_descrybe_test_key_v1` |
Session login needs CSRF: GET any `/api/*` route to set `descrybe_csrf`, then POST `/api/auth/login` with header `X-CSRF-Token`.
```http
Authorization: Bearer dk_demo_local_descrybe_test_key_v1
```
## Pipeline (what “done” looks like)
```
input feeds → PUT mappings → (sync*) → POST process → export generate → public XML/CSV
```
\* Live FTP/FTPS supplier URLs return `400 ftp/ftps feed sync is not supported yet`. Local Demo Co already has migrated raw/processed rows, so process + export work without re-sync.
## Verified counts (after this E2E)
| Surface | Result |
|---------|--------|
| Input feeds | **12** (`active` / `mapped`) |
| Feed mappings | Present (e.g. Vama Trade **13** fields) |
| Processed products | **~4330** (`GET /api/v1/products`) |
| Raw / unprocessed | **23744** (`kind=raw` or `kind=unprocessed`) |
| Export generate XML | **4322** products, `status=completed` |
| Export generate CSV | **4322** products, `status=completed` |
| Process job | **5/5** completed (`normalize` → specs → fill → eprel skipped → ai) |
## API path (copy/paste)
### 1. List feeds + mappings
```powershell
$H = @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1"; "Content-Type" = "application/json" }
Invoke-RestMethod http://127.0.0.1:8080/api/v1/feeds -Headers $H
Invoke-RestMethod http://127.0.0.1:8080/api/v1/feeds/b5fc7c4c-830b-4d6e-a90c-9b72800a89a1/mappings -Headers $H
```
### 2. Save mappings (flips `unmapped` → `mapped`, sets `options.item_path`)
Body uses the dashboard shape `{ key, mapping: { fieldName, xpath } }`:
```powershell
$body = @{
mappings = @(
@{ key = "Export/Item/name"; mapping = @{ fieldName = "name"; xpath = "Export/Item/name" } }
@{ key = "Export/Item/EAN"; mapping = @{ fieldName = "gtin"; xpath = "Export/Item/EAN" } }
)
} | ConvertTo-Json -Depth 6
Invoke-RestMethod -Method PUT -Uri http://127.0.0.1:8080/api/v1/feeds/<feedId>/mappings -Headers $H -Body $body
```
### 3. Process sample raw products
```powershell
$raw = Invoke-RestMethod "http://127.0.0.1:8080/api/v1/products?kind=raw&limit=5" -Headers $H
$ids = @($raw.products | ForEach-Object { $_.id })
$jobBody = @{ raw_product_ids = $ids; processing_type = "normalize" } | ConvertTo-Json
$job = Invoke-RestMethod -Method POST -Uri http://127.0.0.1:8080/api/v1/process -Headers $H -Body $jobBody
# poll GET /api/v1/process/{id} until status=completed (worker must be running)
```
Dashboard alias: `POST /api/processing/jobs` (same body).
### 4. Export XML + CSV
```powershell
# Example feed IDs on Local Demo Co
$xmlId = "d52bc02c-77ee-4cd5-bcd9-fde652133f38" # XML - Example
$csvId = "d6ccb83e-7c76-4328-8b60-820503bd24d0" # CSV - Example
Invoke-RestMethod -Method POST -Uri "http://127.0.0.1:8080/api/v1/export-feeds/$xmlId/generate" -Headers $H -Body "{}"
Invoke-RestMethod -Method POST -Uri "http://127.0.0.1:8080/api/v1/export-feeds/$csvId/generate" -Headers $H -Body "{}"
# Public downloads (token from export feed)
# GET /api/public/export-feeds/{public_token}.xml
# GET /api/public/export-feeds/{public_token}.csv
```
Example tokens after seed/migrate: XML `f2321bf2d18609b7abf5ea0f0f9fbb86`, CSV `7fdea27d7bc9e21aec6403e24ccb3c8a`.
## UI path
| Step | URL |
|------|-----|
| Login | http://127.0.0.1:5174/login |
| Feeds | http://127.0.0.1:5174/feeds |
| Mapping | http://127.0.0.1:5174/feeds/`{feedId}`/mapping |
| Products | http://127.0.0.1:5174/products (Processed / Unprocessed tabs) |
| Processing | http://127.0.0.1:5174/processing |
| Export feeds | http://127.0.0.1:5174/export-feeds |
Session-cookie calls go through the Vite proxy (`/api/...``:8080`). Same CSRF rules as direct API.
SSR smoke (logged in): `/dashboard`, `/feeds`, `/products`, `/export-feeds`, `/processing`, `/feeds/.../mapping`**200**.
## Blockers fixed in this pass
| Issue | Fix |
|-------|-----|
| `parseMappings` ignored UI `{key,mapping}` rows → sync would apply **zero** fields | Unwrap nested `mapping` + `key` as xpath/source in `internal/feeds/mapping.go` |
| PUT mappings left feed `status=unmapped` | Derive `item_path` from xpath parents; always set `unmapped``mapped` when fields saved (`service.go` / `extract_schema.go`) |
| `kind=unprocessed` ignored (returned processed) | Alias to raw list in `catalog_handlers.go` |
| Empty-looking processed tab | Data was present (~4.3k); use Unprocessed/`kind=raw` for inventory; API totals verified |
## Known limits
- **FTP/FTPS sync** not supported: `POST .../sync` and `.../sync-process-sample` return 400 for supplier FTP URLs (Vama, ComTrade, …).
- **Worker required** for process jobs (`scripts/run-api.ps1 worker` or `go run ./cmd/worker`).
- Public export **HEAD** may 405; use **GET**.
- Web port is **5174** in local docs/smoke; confirm with the Descrybe title before testing.
## Verification commands
```powershell
cd apps/api
go test ./internal/feeds ./internal/httpapi -count=1
go build -o bin/api.exe ./cmd/api
cd ../web
npm run check
```
## Related
- [demo-user.md](demo-user.md) — credentials / API key
- [qa-local-demo.md](qa-local-demo.md) — broader Local Demo Co checklist
- [local-smoke-results.md](local-smoke-results.md) — prior smoke matrix
+151
View File
@@ -0,0 +1,151 @@
# E2E marketing suite (Descrybe v2)
**Date:** 2026-08-04
**App:** API `http://127.0.0.1:8080` + worker (OPENAI_* from `root `.env` / `/integrations/ai``) + web `:5174`
**LLM:** Green Chat `overloaded-local` at `http://192.168.50.181:8767/v1` (used when `OPENAI_*` is set)
**Tenant:** `demo@descrybe.local` / `DemoPass123!` → **Local Demo Co** (Enterprise, ~1M AI credits, `can_use_ai=true`)
No secrets are recorded here (OPENAI key length only: 64). Cookie jar used locally: `artifacts/e2e-marketing-cookies.txt` / session WebRequest (gitignored / local only). Results snapshot: `artifacts/e2e-marketing-matrix.json`.
## Scope
| Area | What is exercised |
|------|-------------------|
| **Brand kit** | `PUT/GET /api/brand` — tone, dos/donts, preferred terms, formula tips |
| **SEO AI** | `GET /api/seo/recommendations`, `POST /api/seo/apply` with `mode=ai` |
| **Campaigns AI** | `POST /api/campaigns` → `POST /api/campaigns/{id}/generate` `mode=ai` (+ template control) |
| **Email dry-run** | Configure provider if needed; `POST /api/email/send` with `force_dry_run=true`; campaign `POST .../send` with `dry_run=true` |
| **Content calendar** | `GET /api/marketing/calendar`, `POST /api/marketing/calendar/prepare` |
| **Green Chat** | TCP + `GET {OPENAI_BASE_URL}/models` before AI steps |
If `OPENAI_*` is unset or Green Chat is unreachable, AI steps are **SKIP**; template campaign generate and brand/email dry-run still run.
## Results (this run)
| Step | Result | Notes |
|------|--------|-------|
| Green Chat reachability | **PASS** | `OPENAI_BASE_URL=http://192.168.50.181:8767/v1`, model `overloaded-local`, `/v1/models` OK |
| Login | **PASS** | Local Demo Co, Enterprise, remaining ≈ 999996, `can_use_ai=true` |
| Brand kit PUT+GET | **PASS** | Tips include tone, preferred terms (`wireless`,`premium`), donts |
| Brand kit in AI prompts | **PASS** | Brand terms present in SEO + campaign copy |
| Sample product | **PASS** | `d2c309ed-…` Sample Gadget |
| SEO apply `mode=ai` | **PASS** | ~1s; title `Sample Gadget \| Premium Wireless Performance`; `credits_charged=2` |
| SEO recommendations | **PASS** | Report with recommendations / overall score |
| Campaign generate `mode=ai` | **PASS** | ~7s; `status=ready`, `generation_mode=ai`, unsubscribe footer present |
| Campaign generate `mode=template` | **PASS** | Black Friday subject filled without LLM |
| Email integration GET | **PASS** | Was unconfigured before stub SMTP |
| Email provider configure (SMTP stub) | **PASS** | Local stub so dry-run path can load transport |
| Email send `force_dry_run` | **PASS** | `dry_run=true`, reason `force_dry_run`, result status `dry_run` |
| Campaign send `dry_run` | **PASS** | Blast path with `dry_run=true` (no delivery) |
| Marketing calendar prepare | **PASS** | `preset_id=black_friday` → export feed “Black Friday 2026” |
**Overall: PASS** against LAN Green Chat.
### Failure fixed during this run
| Issue | Cause | Fix |
|-------|--------|-----|
| Calendar prepare **404** | Wrong path `/api/marketing/prepare` | Use **`POST /api/marketing/calendar/prepare`** with body `{ "preset_id": "black_friday", "format": "csv" }` |
### Notes (not failures)
- `POST /api/integrations/email/test` against the stub SMTP (`127.0.0.1:2525`) returns `failed` / send failed when **not** dry-running — expected. Prefer **`force_dry_run`** on `/api/email/send` or set `EMAIL_DRY_RUN=true` and restart API.
- Credits live under `GET /api/auth/me` → `credits` and the session billing routes `GET /api/billing/credits`, `GET /api/billing/usage`, `GET /api/billing/plans` (same tenant session + company context).
## Prerequisites
1. Postgres up (`make up` / docker compose on `:5433`).
2. API + worker running with env loaded from `root `.env` / `/integrations/ai`` (must include `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL` for AI).
3. Web optional for UI; this suite is API/session E2E.
4. Demo user seeded (`make seed` / `seed-demo`).
5. Green Chat listening on LAN (`Test-NetConnection 192.168.50.181 -Port 8767`).
## Commands (PowerShell)
```powershell
$base = "http://127.0.0.1:8080"
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
try { Invoke-WebRequest "$base/api/auth/me" -WebSession $session -UseBasicParsing | Out-Null } catch {}
$csrf = ($session.Cookies.GetCookies($base) | Where-Object Name -eq descrybe_csrf).Value
$login = '{"email":"demo@descrybe.local","password":"DemoPass123!"}'
Invoke-WebRequest "$base/api/auth/login" -Method POST -WebSession $session `
-Headers @{ "X-CSRF-Token" = $csrf; "Content-Type" = "application/json" } -Body $login -UseBasicParsing | Out-Null
$csrf = ($session.Cookies.GetCookies($base) | Where-Object Name -eq descrybe_csrf).Value
function ApiJson($method, $path, $obj) {
Invoke-RestMethod "$base$path" -Method $method -WebSession $session -TimeoutSec 120 `
-Headers @{ "X-CSRF-Token" = $csrf; "Content-Type" = "application/json" } `
-Body ($obj | ConvertTo-Json -Depth 8 -Compress)
}
# Brand kit
ApiJson PUT /api/brand @{
voice_tone = "confident concise"
dos = @("Lead with benefit")
donts = @("No hype")
preferred_terms = @("wireless","premium")
}
# SEO AI
ApiJson POST /api/seo/apply @{
product_id = "d2c309ed-fa99-4785-aa72-4f7d180f1f85"
mode = "ai"
}
# Campaign AI
$c = ApiJson POST /api/campaigns @{
name = "E2E Spring"
template_key = "spring"
use_default_prompt = $true
}
ApiJson POST "/api/campaigns/$($c.id)/generate" @{ mode = "ai" }
# Email dry-run (configure stub SMTP first if configured=false)
ApiJson PUT /api/integrations/email @{
provider = "smtp"
from_email = "noreply@descrybe.local"
from_name = "Descrybe E2E"
domain = "descrybe.local"
smtp_host = "127.0.0.1"
smtp_port = "2525"
smtp_user = "e2e"
smtp_password = "e2e-not-used"
is_enabled = $true
}
ApiJson POST /api/email/send @{
to = @("demo@descrybe.local")
subject = "E2E dry-run"
text = "Should not deliver"
mode = "test"
force_dry_run = $true
}
ApiJson POST "/api/campaigns/$($c.id)/send" @{
confirm = $true
recipients = @("demo@descrybe.local")
dry_run = $true
}
# Content calendar
Invoke-RestMethod "$base/api/marketing/calendar" -WebSession $session
ApiJson POST /api/marketing/calendar/prepare @{
preset_id = "black_friday"
format = "csv"
}
```
Green Chat probe (no app session):
```powershell
# Load OPENAI_* from root `.env` / `/integrations/ai` — do not print the key
Test-NetConnection 192.168.50.181 -Port 8767
curl.exe -sS -H "Authorization: Bearer $env:OPENAI_API_KEY" http://192.168.50.181:8767/v1/models
```
## Related
- [ai-full-smoke.md](ai-full-smoke.md) — broader AI smoke (enhance / EPREL)
- [green-chat-smoke.md](green-chat-smoke.md) — LAN Green Chat connectivity
- [green-chat-llm.md](green-chat-llm.md) — OPENAI_* wiring
- [email-sending.md](email-sending.md) — providers, `EMAIL_DRY_RUN`, blast confirm
- [marketing-suite-user-guide.md](marketing-suite-user-guide.md) — UI flows
- [demo-user.md](demo-user.md) — demo credentials
+66
View File
@@ -0,0 +1,66 @@
# Background Tasks (`/processing`) — E2E notes
**Date:** 2026-08-04
**App:** Descrybe v2 web `http://127.0.0.1:5174` + API `http://127.0.0.1:8080` + worker
**Tenant:** `demo@descrybe.local`**Platform Demo** (not A1; see [safe-test-fixtures.md](safe-test-fixtures.md))
**LLM:** Green Chat via `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL` in `root `.env` / `/integrations/ai`` — or local stub ([mock-llm.md](mock-llm.md)) for CI/dev without production keys
## What `/processing` does
- Nav label: **Background Tasks**
- Lists recent `processing_jobs` for the active company (poll every 5s while tab visible)
- Shows status, per-step badges (`normalize` → … → `ai_enhance`), product progress, start/complete times
- Actions: **Terminate** (pending/running), **Retry** (failed/cancelled/completed)
Legacy aliases (SSR 307 → `/processing`):
| Path | Redirect |
|------|----------|
| `/tasks` | `/processing` |
| `/dashboard/tasks` | `/processing` (`hooks.server.ts`) |
## Verified matrix (this run)
| Check | Result | Notes |
|-------|--------|-------|
| Login demo | **PASS** | Browser session on `:5174` |
| `GET /api/processing/jobs` | **PASS** | Shape `{ jobs: [...] }` |
| Create `normalize_only` | **PASS** | HTTP 202, `status=pending` |
| Cancel job | **PASS** | HTTP 200; `status=cancelled`; incomplete steps marked `cancelled` |
| Create `enhance_only` + Green Chat | **PASS** | ~1s; `normalize`+`ai_enhance` → `done`; product description rewritten |
| UI table progress / badges | **PASS** | Completed green; Cancelled grey; progress `1/1` / `0/1` |
| `/tasks` SSR redirect | **PASS** | 307 `Location: /processing` |
| Console / failed requests on page | **PASS** | 0 errors after login |
Sample enhance job: `045b3d15-…` → `completed`, steps both `done`.
Sample cancelled job: `56c0bcee-…` → `step_progress[0].status=cancelled`.
## Fixes applied this pass
1. **Cancel step progress** — `CancelJob` now marks pending/running steps as `cancelled` in `step_progress` (was left `pending`, confusing the UI).
2. **Terminate button alignment** — UI only offers Terminate for `pending`/`running` (matches API).
3. **Cancelled step display** — client coerces stale pending steps on cancelled jobs.
4. **SSR `/tasks` redirect** — replaced client `onMount` goto + universal `PageLoad` with `+page.server.ts` 307 (no client flash).
5. **Layout dark-class effect** — guard with `browser` before touching `document`.
## How to retest quickly
```powershell
# API must be up; worker must inherit OPENAI_* from root `.env` / `/integrations/ai`
Test-NetConnection 192.168.50.181 -Port 8767
# After CSRF + login cookie jar:
# POST /api/processing/jobs {"raw_product_ids":["…"],"processing_type":"enhance_only"}
# POST /api/processing/jobs/{id}/cancel
# GET /api/processing/jobs/{id} # poll until completed
# UI: http://127.0.0.1:5174/processing (not :3000 — that is Descrybe v1 Next)
```
Cookie jar used locally: `artifacts/processing-smoke-cookies.txt` (local only).
## Related
- [green-chat-smoke.md](green-chat-smoke.md) — LAN Green Chat + prior enhance smoke
- [ai-full-smoke.md](ai-full-smoke.md) — broader AI matrix
- [green-chat-llm.md](green-chat-llm.md) — wiring guide
+59
View File
@@ -0,0 +1,59 @@
# Email sending (marketing campaigns)
Tenant email delivery for Descrybe v2 marketing campaigns. **Platform invite SMTP** (`SMTP_*` + `internal/mail`) is separate and must not be used for blasts.
Schema lives in `011_email_campaigns.sql` (`email_providers`, `email_unsubscribes`, `email_sends`). Pending unsubscribe tokens: `014_email_unsub_pending.sql`.
## Providers
| Provider | Company settings | Env fallback |
|----------|------------------|--------------|
| **Resend** | API key (AES-GCM in `secrets_enc`), `from_email`, domain in `config` | `RESEND_API_KEY` if company key empty |
| **SMTP** | host/port/user/password in `secrets_enc`, from | `SMTP_HOST` / `SMTP_USER` / `SMTP_PASSWORD` |
Configure in UI: **`/integrations/email`** (Account → Email sending).
## Environment
Bootstrap encryption and rate limits live in the **root** `.env` (not `apps/api/.env`). Provider API keys belong in **`/integrations/email`**.
| Variable | Required | Purpose |
|----------|----------|---------|
| `APP_ENCRYPTION_KEY` | **Prod yes** | AES-256-GCM for email secrets at rest (`enc:v1:…`). 32-byte hex or base64. |
| `CREDENTIALS_ENCRYPTION_KEY` | fallback | Used if `APP_ENCRYPTION_KEY` unset. |
| `TOKEN_SIGNING_SECRET` | fallback | Last-resort key material (dev only). |
| `EMAIL_DRY_RUN` | no | `true` → never deliver; log dry-run / skipped sends. |
| `RESEND_API_KEY` | no | Optional platform Resend key fallback (prefer dashboard). |
| `EMAIL_SEND_RPM` | no | Per-company sends/minute (default `30`). |
| `EMAIL_SEND_RPH` | no | Per-company sends/hour (default `500`). |
| `PUBLIC_API_URL` | yes for unsub | Absolute API origin for `List-Unsubscribe` one-click URL. |
| `WEB_ORIGIN` | yes | Origin for `/unsubscribe` landing links. |
| `SMTP_*` | optional | Platform invite mailer (`internal/mail`); not a substitute for `/integrations/email`. |
```bash
openssl rand -hex 32
```
## API
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/integrations/email` | Public config (no secrets) |
| PUT/PATCH | `/api/integrations/email` | Upsert; blank secrets keep existing |
| POST | `/api/integrations/email/verify` | Resend domain API / SMTP domain match |
| POST | `/api/integrations/email/test` | Test send; success → `status=verified` |
| POST | `/api/email/send` | `mode=test\|blast`; blast needs `confirm_understood: "I understand"` |
| GET/POST | `/api/public/unsubscribe?token=…` | One-click + landing |
Blast without confirmation → `400`. Unverified blast → `412 email_not_verified`. Free plan / `EMAIL_DRY_RUN` → dry-run only.
## Setup
1. Set `APP_ENCRYPTION_KEY`; restart API.
2. `make migrate` (includes `011` + `014`).
3. Open `/integrations/email` → Resend or SMTP → save from + domain.
4. **Verify domain** (Resend: verify domain in Resend dashboard first).
5. **Send test** (marks provider verified).
6. Campaigns / blasts: `POST /api/email/send` with `confirm_understood: "I understand"`.
Package: `internal/email` (AES-GCM, Resend/SMTP transports, rate limits, unsubscribe). Shared table with `internal/campaigns`.
+75
View File
@@ -0,0 +1,75 @@
# EPREL integration (v2)
Descrybe enriches processed products with EU **EPREL** (European Product Registry for Energy Labelling) data when a registration ID is present on the feed item.
## How it works
1. **Feed sync / mapping** stores product fields in `raw_products.mapped_data` / `raw_data`. Vendor XML often includes an empty or populated `<EPRELID/>` (also `eprel_id`, `eprelId`, …).
2. **Processing worker** runs the canonical steps:
`normalize``parse_specs``fill_fields`**`eprel`** → `ai_enhance` (optional).
When `EPREL_ENABLED=true`, the `eprel` step:
- Discovers an ID via `eprel.ExtractID(normalized, mapped, raw)` (same key variants as legacy: `EPRELID`, `eprel_id`, …).
- Calls the public EPREL HTTP API (`internal/eprel.Client` / `Fetcher` interface) with a hard timeout and bounded response body.
- Merges results into `processed_products.attributes` and `processed_attributes` as:
- Flat keys: `eprel_id`, `eprel_label` / `eprel_label_url`, `eprel_pdf` / `eprel_pdf_url`, `eprel_energy_class`, `eprel_energy_scale`
- Nested object: `eprel: { id, label, pdf?, energy_class?, energy_scale? }`
- Also records a compact `field_sources.eprel` / step log entry.
3. **Export XML/CSV** resolves those keys (and aliases like `energy_class`, `eprel_label_url`) through the export template `source` field. Map them in the export feed builder like any other attribute.
EPREL failures are **soft**: the product still completes; a truncated note may appear in `gpt_response.steps` for the `eprel` step. No API keys or Authorization headers are written to logs.
## Configuration
Set on the **worker** process (enrichment runs during `ProcessJob`):
| Variable | Default | Purpose |
|---|---|---|
| `EPREL_ENABLED` | `false` | Turn on enrichment |
| `EPREL_TIMEOUT` | `10s` | HTTP client timeout (`10s`, `500ms`, or integer seconds `10`) |
| `EPREL_BASE_URL` | `https://eprel.ec.europa.eu/api` | Override for tests/proxies |
| `EPREL_FICHE_LANGUAGE` | `EN` | Language for product fiche PDF |
| `EPREL_API_KEY` | _(empty)_ | Optional `X-API-KEY` if your deployment requires a whitelisted key — **never log** |
Example:
```bash
EPREL_ENABLED=true
EPREL_TIMEOUT=10s
EPREL_FICHE_LANGUAGE=EN
```
## Public API calls (per product)
For registration id `{id}`:
- Label URL (constructed): `{base}/product/{id}/labels?format=png`
- Fiche: `GET {base}/product/{id}/fiches?noRedirect=true&language={lang}` → PDF path
- Info: `GET {base}/product/{id}``energyClass`, `energyClassRange` / `energyScale`
## Export mapping examples
Template field sources that work out of the box:
- `eprel_id`
- `eprel_label` / `eprel_label_url`
- `eprel_pdf` / `eprel_pdf_url`
- `eprel_energy_class` / `energy_class`
- `eprel_energy_scale` / `energy_scale`
- `attr.eprel_id` (generic attribute path)
## Package layout
- `apps/api/internal/eprel``Fetcher` interface, HTTP `Client`, ID normalize/extract, `MergeInto`
- `apps/api/internal/processing` — Engine step `eprel` (`StepEPREL`) after fill_fields
- `apps/api/internal/feeds` — export source aliases for EPREL fields
- `apps/api/cmd/worker` — sets `Engine.EPREL` from config when enabled
Processing type aliases: `eprel` / `eprel_only` run normalize + eprel only.
## Tests
```bash
cd apps/api
go test ./internal/eprel/ ./internal/processing/ ./internal/feeds/ ./internal/config/
go build ./...
```
+58
View File
@@ -0,0 +1,58 @@
# Descrybe v2 easy expansions
High-value, low-complexity catalog→sell expansions shipped without new DB tables.
## Navigation groupings
Dashboard sidebar (primary + More):
| Area | Links |
|------|--------|
| **Primary** | Dashboard, Products, **Stores** (`/stores`), Export, Billing |
| **More → Marketing** | Campaigns, Calendar, SEO, Brand, Reviews |
| **More → Catalog** | Categories, Attributes, Standard Fields |
| **More → Ops** | Processing, Settings, AI provider, Email sending, Admin |
Stores hub cards: WooCommerce, Shopify (sibling), Feed URL, CSV upload, Export/REST.
Connector contract: [store-connectors.md](store-connectors.md).
## Collision note
Email campaigns own `/campaigns` and `/api/campaigns`. Content calendar uses:
- UI: `/marketing/calendar`
- API: `GET/POST /api/marketing/calendar` (+ `/api/v1/marketing/calendar` for API keys)
Do **not** mount content calendar under `/api/v1/campaigns` or `/campaigns`.
## 1. Content calendar stub
- **UI:** `/marketing/calendar`
- **Presets:** Black Friday (week around BF), Christmas (Dec 126), computed per year
- **Persistence:** Campaign meta is stored on the export feed `template._campaign` JSON (no migration)
- **API:**
- `GET /api/marketing/calendar?year=2026` — presets + prepared campaigns for the company
- `POST /api/marketing/calendar/prepare` — body `{ "preset_id": "black_friday" | "christmas", "year"?, "format"?: "csv"|"xml", "force_new"?: boolean }`
## 2. Product quality score
- **UI:** Quality column on the products list (0100 badge + tooltip of missing SEO/completeness checks)
- **Weights:** title 20, description 20, meta title 15, meta description 15, category 10, attributes 10, image 10
- **Lib:** `apps/api/internal/marketing/quality.go`
- **API:**
- `GET /api/products/quality` (and `/api/v1/products/quality`) — paginated products with `quality_score` / `quality_grade` / `quality_checks`
- Optional `?min_score=60`
- Also exposed on `GET /api/products` (processed) via `quality_score` / `quality_grade` / `quality_checks`
## 3. One-click Prepare Black Friday
- **Dashboard:** “Prepare Black Friday” button creates (or reuses) a CSV export feed named `Black Friday {year}` with Google Shopping-style default mappings and calendar dates in `template._campaign`
- **Also:** Content Calendar cards call the same prepare endpoint
- **Idempotent:** Re-prepare returns the existing feed unless `force_new` is set
## Verification
```bash
cd apps/api && go test ./internal/marketing/ && go build ./...
cd apps/web && npm run check
```
+73
View File
@@ -0,0 +1,73 @@
# Feature checklist (phases AE)
Short parity checklist for Descrybe v2. Detailed gaps: **[status-and-gaps.md](status-and-gaps.md)**. Cutover: [cutover.md](cutover.md). Schema: [schema-map.md](schema-map.md).
Legend: `[x]` done · `[~]` partial/stub · `[ ]` missing
## Phase 0 — Scaffold
- [x] Monorepo: `apps/api`, `apps/web`, docs/scripts (migrator under `apps/api/cmd/migrator`)
- [x] Docker Compose PostgreSQL 16
- [x] goose migrations + sqlc generate
- [x] `cmd/api`, `cmd/worker`, `cmd/migrator`
- [x] SvelteKit shell + `PUBLIC_API_URL` + CSRF-aware client
- [x] ID map JSON format documented ([schema-map.md](schema-map.md))
- [ ] CI (`go test`, sqlc check, `svelte-check`)
- [ ] Automated tests
## Phase A — Platform
- [x] Email/password auth (argon2id), login/logout, session cookies (scs)
- [x] Invite accept + set-password (`must_set_password`)
- [x] Companies + memberships (replace Clerk orgs / profiles)
- [~] Team invite / revoke / remove member (invite + remove yes; revoke/list pending incomplete)
- [~] Settings: profile, company (language, merge-by-GTIN) — company yes; profile update thin
- [~] API keys CRUD (dashboard keys yes; **`/api/v1` product API missing**)
- [~] Billing/usage read + credit consume (overview + consume yes; plans/cycles/cron incomplete)
- [ ] `is_platform_admin` gate + migrator from `admin_users`
- [x] Health: `/healthz` + `/readyz` (Postgres `pool.Ping`)
- [x] Svelte: login, accept-invite, settings, billing overview, API keys
- [~] Migrator: companies, users, memberships, plans (+ dry-run) — needs real MySQL validation
- [ ] Invite / set-password emails (SMTP)
## Phase B — Catalog
- [~] Categories tree + CSV upload (CRUD + formulas API; no tree UI / CSV)
- [~] Attributes hierarchy + category links (CRUD; linking UI incomplete)
- [~] Custom variables + title/description formula editors (API yes; rich UI missing)
- [~] Products list/search/edit (API list/get/patch; UI basic)
- [ ] File uploads (products / EANs) + object storage
- [~] Migrator: categories, attributes, variables, products (best-effort; files incomplete)
## Phase C — Feeds / export
- [~] Input feeds (URL / file / FTP), mapping, schema extraction (CRUD + mapping JSON; sync/FTP/extract missing)
- [~] Sync jobs via River (DB job row stub; River not wired)
- [~] Export feed builder (XML/CSV) + public URLs (create + stub body)
- [ ] Preview APIs
- [~] Migrator: feeds, export feeds (mappings incomplete)
## Phase D — AI processing
- [~] Pipeline workers: categorize → attributes → enhance (**stub** marks processed)
- [~] Credits, rate limits, cancel/retry (credits + cancel yes; rate limits/retry incomplete)
- [ ] OpenAI / Pinecone behind interfaces
- [~] Job controls UI (list/start/cancel basic; no admin visibility)
## Phase E — WooCommerce + admin / cutover
- [~] WooCommerce sync (config only; test/sync stubs)
- [ ] Admin: users, billing, jobs, stuck cleanup
- [ ] Final ETL verification + count reports on real data
- [ ] Cutover runbook executed ([cutover.md](cutover.md))
- [ ] Clerk decommissioned; MySQL retained 30 days
## Suggested next milestones
1. Validate migrator `-dry-run` on production MySQL dump
2. Implement real feed sync (URL + CSV)
3. Implement real export XML/CSV
4. AI pipeline MVP behind interfaces
5. Port `/api/v1` for existing API clients
6. Catalog CSV + formula UI + product edit parity
7. Admin + Woo (if needed) → cutover
+32
View File
@@ -0,0 +1,32 @@
# Feed sync deltas (seller MVP)
High-value change signals from input feed syncs — without a full history table.
## Included
| Signal | Source mapped fields | Where surfaced |
|--------|----------------------|----------------|
| **New** | GTIN not previously in catalog for this company | Feed `options.last_sync_deltas.new`, sync job enrichment |
| **Price changed** | `price`, `sale_price`, `purchase_price` | counts + product filter `sync_change=price` |
| **Stock changed** | `stock`, `quantity`, `qty` | counts + `sync_change=stock` |
| **Availability changed** | `availability`, `stock_status`, `in_stock` | counts + `sync_change=availability` |
| **Title changed** | `title`, `name`, `product_name` | counts + `sync_change=title` |
| **Other changed** | any other mapped key change | counts + `sync_change=other` |
| **Unchanged / skipped** | identical mapped JSON / bad rows | existing job counters |
## Storage
- **Summary (last sync only):** `input_feeds.options.last_sync_deltas` — replaced each successful sync that processes rows (not when the whole feed file hash is unchanged).
- **Product flags (latest modifying sync):** `raw_products.mapped_data._sync_changes` — string array, e.g. `["price","stock"]`. Replaced on the next content update for that GTIN. Not a durable audit log.
## Filters
`GET /api/products?kind=unprocessed&sync_change=price` (also `stock`, `availability`, `title`, `other`, `new`, or `any`).
Processed list supports the same query param via join to the linked raw row.
## Out of scope (MVP)
- Multi-sync historical timelines / per-field before→after values
- Diffs when the entire feed file hash is unchanged (short-circuit)
- Shopify/Woo outbound push change reports
+83
View File
@@ -0,0 +1,83 @@
# Self-serve forgot-password — gap & recommended design
**Assessment date:** 2026-08-08
**Verdict:** **Out of scope for a minimal safe reuse.** Document only until a dedicated reset feature is approved.
**Mail infra:** Existing SMTP/`mail.Send` is enough; **do not** build a new mail stack. Confirm product copy/TTL/rate limits before implementing.
Related: [status-and-gaps.md](status-and-gaps.md), [ux-backlog.md](ux-backlog.md) (P0-10 set-password), [live-auth-security.md](live-auth-security.md), [migration-readiness.md](migration-readiness.md).
---
## What exists today (not forgot-password)
| Piece | Role | Why it cannot serve “I forgot my password” |
|-------|------|--------------------------------------------|
| `must_set_password` + `auth.SetPassword` | First password after migration / bootstrap | Update requires `must_set_password = true`; returns `ErrPasswordAlreadySet` otherwise |
| `ReissueSetPasswordInvite` | Admin re-issue durable invite | Requires `must_set_password` + active membership; ineligible for accounts that already set a password |
| `AcceptInvite` | Invite / first-password accept | Existing users with password must **verify** current password — does not reset |
| HMAC `IssueSetPasswordToken` + `handleCompleteSetPassword` | Token complete for first-set | Calls `SetPassword` → same `must_set_password` gate |
| Admin `POST …/admin/…` set-password email | Operator-only bulk/single send | AuthZ + eligibility same as above; login CTA points here (no public resend) — see P0-10 |
| `mail.SetPasswordMessage` / `MigratedSetPasswordMessage` | Email copy + `/accept-invite` links | Templates/links are first-set oriented; reusable **patterns** only |
| Login UI | `password_not_set` CTA → `/accept-invite` + admin Users | No “Forgot password?” link |
**Operator workarounds today:** platform admin re-issues set-password for `must_set_password` users; local/dev `ForceSetPassword` / migrator `-set-password` for bootstrap. Neither is self-serve recovery for users who already know they had a password.
---
## Why a “small patch” is unsafe
Reusing invite/HMAC set-password for established accounts would require relaxing `must_set_password` (or calling `ForceSetPassword` from a public token path). That collapses first-set and reset semantics, weakens single-use guarantees for HMAC tokens (no DB row until success), and risks account takeover if the public request endpoint is naively bolted on.
Intentional product gap (Wave 8): **no public self-serve resend** for set-password (P0-10).
---
## Recommended design (when approved)
Reuse **mail delivery and rate-limit patterns** from admin set-password; add a **separate** reset purpose. Do not overload `must_set_password` / `AcceptInvite`.
### API (sketch)
1. **`POST /api/auth/forgot-password`** `{ "email": "…" }`
- Always return the same opaque success (anti-enumeration).
- Rate-limit by IP + normalized email (mirror admin set-password limiters).
- If active user with deliverable email: issue **reset** token (prefer durable hashed row, invite-style; HMAC-only only if single-use store is added).
- Skip synthetic `@legacy.local` silently.
- Send `ForgotPasswordMessage` (new; link to `/reset-password?token=…`, not accept-invite first-set mode).
2. **`POST /api/auth/reset-password`** `{ "token", "password" }`
- Validate token; set new argon2id hash for that user **regardless of** `must_set_password` (dedicated `ResetPassword`, not `SetPassword` / not public `ForceSetPassword`).
- Invalidate token; optionally revoke other sessions.
- Clear `must_set_password` if still set.
### Web
- Login: “Forgot password?” → `/forgot-password`.
- `/forgot-password`: email form + generic confirmation copy.
- `/reset-password`: password + confirm; strip `?token=` from URL like accept-invite.
### Security checklist
- Constant-time / uniform responses and timing where practical.
- Token TTL short (e.g. 1h); one-time consume.
- CSRF on cookie-authenticated POSTs; public forgot/reset still need CSRF if under same cookie middleware.
- No token in list APIs or HTML; prefer `#token=` / exchange code later ([live-auth-security.md](live-auth-security.md) residual).
- Tests: happy path, expired/reuse, unknown email response shape, rate limit, inactive user, synthetic email.
### Explicitly out of this design
- New mail provider, queue, or template engine (extend `internal/mail`).
- Turning admin set-password into a public endpoint.
- Logged-in “change password” (optional follow-up; separate from forgot).
---
## Implementation status
| Item | Status |
|------|--------|
| Self-serve forgot / reset | **Implemented** (`041_password_reset_tokens`, `POST /api/auth/forgot-password`, `POST /api/auth/reset-password`, `/forgot-password` + `/reset-password`, i18n) |
| Migration first-set + admin re-issue | Implemented (P0-10) |
| Mail SMTP send path | Implemented; forgot-password reuses `mail.Send` + `EMAIL_DRY_RUN` / `ApplyDryRun` |
**ASSUMPTION:** Opaque `{status:"ok"}` responses; TTL **1h**; IP 10/min + email 3/hour limiters; no session revoke on reset (optional follow-up).
+109
View File
@@ -0,0 +1,109 @@
# Free tier verification (while demo is Enterprise)
Proof that `cmd/seed-demo` assigning **Enterprise** to Local Demo Co does **not** break forever-Free packaging or gates for new signups.
**Date:** 2026-08-04
**Related:** [free-tier.md](free-tier.md), [demo-user.md](demo-user.md)
## Expected matrix
| Check | Free signup | Demo (Enterprise) |
|-------|-------------|-------------------|
| Plan name | `Free` | `Enterprise` |
| `monthly_credits` | **0** | `1000000` |
| Wallet remaining | **0** | ≈1M |
| `can_use_ai` | `false` | `true` |
| `can_use_eprel` | `false` | `true` |
| AI-only job (`title` / `description` / `enhance`) | **402** `ai_requires_upgrade` | Allowed (credits) |
| EPREL-only job | **402** `eprel_requires_upgrade` | Allowed |
| Full / normalize process | **202** → completes; `ai_enhance` + `eprel` **skipped** | Full AI path |
## Live smoke (2026-08-04)
API: `http://127.0.0.1:8080` · Postgres: `localhost:5433`
### 1. Public plan rows after Enterprise seed
```sql
SELECT name, monthly_credits, max_products, is_custom
FROM plans WHERE lower(name) IN ('free','enterprise') ORDER BY name;
```
| name | monthly_credits | max_products | is_custom |
|------|----------------:|-------------:|:---------:|
| Free | **0** | 100 | f |
| Enterprise | 1000000 | null | t |
### 2. New Free signup path
`POST /api/auth/register``201``ProvisionFreePlan`.
Observed (`/api/auth/me``credits`):
- `plan.name=Free`, `monthly_credits=0`
- `remaining_credits=0`, `can_use_ai=false`, `can_use_eprel=false`, `is_free_plan=true`
### 3. 402 on AI (Free company with a raw product)
| Request | Status | Body code |
|---------|-------:|-----------|
| `POST /api/processing/jobs` `processing_type=title` | **402** | `ai_requires_upgrade` |
| `processing_type=description` | **402** | `ai_requires_upgrade` |
| `processing_type=enhance` | **402** | `ai_requires_upgrade` |
| `processing_type=eprel_only` | **402** | `eprel_requires_upgrade` |
Example body:
```json
{
"code": "ai_requires_upgrade",
"error": "ai features require a paid plan or AI credits — upgrade your plan or add AI credits",
"upgrade_url": "/pricing"
}
```
### 4. Non-AI process works
`POST /api/processing/jobs` `processing_type=full`**202**, job **completed**:
- `normalize` / `parse_specs` / `fill_fields``done`
- `eprel``skipped` (`entitlement_can_use_eprel`)
- `ai_enhance``skipped` (`entitlement_can_use_ai`)
### 5. Demo still Enterprise
`demo@descrybe.local``/api/billing/credits`: `plan.name=Enterprise`, `can_use_ai=true`, remaining ≈ 1M.
## Regression fixed
`ProvisionFreePlan` previously fell back to `SELECT id FROM plans ORDER BY id LIMIT 1` when Free was missing. After migration + `seed-demo`, **Enterprise can have a lower id than Free**, so that fallback could assign Enterprise to a new signup. Fallback removed — only the Free row is assigned (best-effort no-op if Free is absent after `EnsureDefaultPlans`).
`seed-demo` now **fails closed** if Free `monthly_credits ≠ 0` or `max_products ≠ 100` after `EnsureDefaultPlans`.
## Automated checks
```powershell
cd apps/api
go test ./internal/billing/ ./internal/processing/ -count=1
go build ./...
# Optional DB smokes (requires migrated + seed-demo DB):
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
go test ./cmd/seed-demo/ -count=1 -v
```
Unit coverage:
- `TestComputeEntitlements` — Free 0 credits → no AI/EPREL
- `TestDefaultPublicPlansFreeZeroCredits` — Free packaging locked at 0 / 100 SKUs
- `TestDefaultPublicPlansEnterpriseUnlimited` — Enterprise 1M / unlimited SKUs
- `TestFreePlanUnaffectedByEnterpriseSeed` — live Free row after demo seed
- `TestLocalDemoCoEnterpriseCredits` — demo company still Enterprise
## Re-run checklist
1. `go run ./cmd/seed-demo` (demo → Enterprise; Free row still 0 credits)
2. Register a fresh user → Free / 0 credits / `can_use_ai=false`
3. Start `title` job → 402 `ai_requires_upgrade`
4. Start `full` job → 202; AI/EPREL steps skipped
5. Login as demo → Enterprise credits unchanged
+78
View File
@@ -0,0 +1,78 @@
# Free tier enforcement (Descrybe v2)
How the forever-Free plan is packaged and gated versus paid plans.
## Packaging (EnsureDefaultPlans / ProvisionFreePlan)
| Plan | Price | Max products | Monthly AI credits | Notes |
|------|-------|-------------:|-------------------:|-------|
| **Free** | $0 | 50 | **0** | New signups via `ProvisionFreePlan` |
| Starter | $49/mo | 1,000 | 1,000 | ~50% cover; entry “lower fish” |
| Plus | $199/mo | 5,000 | 5,000 | ~50% cover; Woo+Shopify |
| Growth | $299/mo | 25,000 | 25,000 | Hero; near A1 absolute $; full stores + BYOK |
| Business | $499/mo | 50,000 | 50,000 | Plytix-class SKUs; ~50% cover |
| Scale | $999/mo | 150,000 | 120,000 | ~40% cover |
| Enterprise | Custom | Unlimited (`null`) | 1,000,000 | `is_custom`; large managed grant + BYOK |
**A1 legacy** stays dump-faithful (PAYG, ~100 products in practice, Stores/AI integrations off) — not overwritten by `EnsureDefaultPlans`. Public plans are intentionally richer so A1s deal is not “better” than what new customers get.
Extra AI beyond the monthly grant: buyable one-time **credit packs** (`GET /api/billing/credit-packs`, Checkout `{ "pack": "…" }`). See [stripe-setup.md](stripe-setup.md).
`EnsureDefaultPlans` upserts public plans by name (idempotent). Free intentionally grants **no** AI credits so signup never burns LLM cost. SKU/feed packaging follows the public ladder (50 SKUs on Free, etc.).
Existing Free wallets are not wiped on plan sync; only the `plans.monthly_credits` row is corrected. New cycles / `AssignPlan` allocate from the plan row.
## Entitlements
Exposed on `/api/auth/me` → `credits` and `/api/billing/credits`:
| Field | Meaning |
|-------|---------|
| `can_use_ai` | `remaining_credits > 0` **OR** paid plan (not Free) |
| `can_use_eprel` | **Always true** — EU EPREL is public free data on every plan (no credits). Platform may still disable the enricher via `eprel.enabled`. |
| `is_free_plan` / `is_paid_plan` | Derived from active plan name |
`billing.AIBrandApplyAllowed` already blocks brand-kit injection into AI prompts on Free.
## Free vs paid matrix
| Capability | Free | Paid (Starter+) |
|------------|------|-----------------|
| Signup / map feed / view products | Yes | Yes |
| Normalize / parse_specs / fill_fields | Yes (no credit debit) | Yes |
| `ai_enhance` (titles / descriptions / SEO copy) | **Skipped** (`can_use_ai=false`) | Yes (credits) |
| EPREL enrichment | **Yes (all plans)** | Yes |
| AI-only job (`title`, `description`, `enhance_*`) | **402** `ai_requires_upgrade` | Credits gate |
| EPREL-only job | **Yes** (no upgrade) | Yes |
| Email campaign AI generate | **402** via `can_use_ai` / `ErrAIRequiresUpgrade` | Credits / paid |
| SEO formula editors (config) | Editable | Editable |
| SEO AI apply (`/api/seo/apply`) | **402** via `can_use_ai` | Credits / paid |
| Email campaign AI generate | **402** on Free (`/api/campaigns/:id/generate` AI mode) | Credits / paid |
| Product SKU cap | 100 | Per plan |
| Upgrade banners | Dashboard + Products | Low-credit / limit banners |
## Processing behavior
1. **Start job** — `AssertCanStartProcessing` always checks SKU cap. Credit wallet is checked only when `RequiresAI` / `RequiresEPREL` (AI-only or EPREL-only types).
2. **Full / attributes jobs on Free** — allowed; pipeline `StepPolicy` sets `AllowAI=false`, `AllowEPREL=false` and appends clear notes (`ai_enhance: skipped (Free plan…)`, `eprel: skipped (paid plan…)`).
3. **ConsumeCredits** — no debit when `!CanUseAI && tokenCount==0` (free normalize path).
## Demo user
`cmd/seed-demo` ensures a standalone **Platform Demo** company (never renames A1), upserts `demo@descrybe.test` + `demo@descrybe.local`, binds them **only** to Platform Demo, and assigns the custom **Platform Demo** plan (~1,000,000 monthly AI credits, unlimited SKUs, all feature gates ON). Free plan definition for new signups remains `monthly_credits=0`. See [demo-user.md](demo-user.md) and [safe-test-fixtures.md](safe-test-fixtures.md).
## UI
- Products: Free info banner; AI options disabled with upgrade hint; process toast notes AI skip.
- Dashboard: Free-specific banner (not “out of credits”).
- Marketing pricing: Free lists 0 AI credits; EPREL included on all plans.
## Related
- [free-tier-verify.md](free-tier-verify.md) — live smoke while demo is Enterprise
- [eprel.md](eprel.md) — EPREL step details
- [demo-user.md](demo-user.md) — Enterprise credits for demo
- Repo sibling `PRICING-AND-USER-GROWTH.md` (v1 docs) — commercial ladder; Free AI pack in that doc is overridden here to **0** for cost control
- SEO apply: `apps/api/internal/seo` (`can_use_ai`)
- Campaign AI: `apps/api/internal/campaigns` (`ErrAIRequiresUpgrade` on AI generate)
+310
View File
@@ -0,0 +1,310 @@
# Full app QA report (Descrybe v2)
**Date:** 2026-08-04
**Repo:** `f:/laragon/www/_MY/descrybe-v2`
**Wave:** 20 parallel agents (credits, analytics, Stripe, tutorial, SEO copy, Woo, E2E, Free tier, settings, products, catalog, admin/API, security, dashboard, processing, exports, nav) + final reconcile
**Build gates (reconcile):** `go build ./...`**PASS** · `npm run check` (apps/web) → **0 errors / 0 warnings**
---
## Executive summary
The 20-agent QA wave is complete. Descrybe v2 is green to demo locally: credits and analytics now read the **live wallet** (no more stale cycle inflation), Stripe Checkout works in **mock** (`STRIPE_MOCK=true`) and is ready for real keys, the in-app tutorial is forced-on-actions but skippable, Woo has a seed path without a live shop, and both catalog and marketing E2E suites passed.
**Local Demo Co is back on Enterprise** after mock Stripe checkout temporarily assigned a paid self-serve plan — restored to ~1M AI credits / Unlimited packaging for continued demo use.
| You want… | Status |
|-----------|--------|
| Demo the product end-to-end | **Ready** — login below |
| Buy a plan in prod | Set Stripe env (see § Stripe) |
| Connect a real Woo store | UI `/woocommerce` or `WOO_*` env (see § Woo) |
| Try Woo audiences without WP | `make seed-woo` / `seed-woo-demo` |
**Still not production-cutover:** FTP feed sync, daily credit ledger, live Stripe/Woo untested in this wave, and cutover gates in [cutover.md](cutover.md).
---
## Demo login (use this)
| | |
|---|---|
| Email | `demo@descrybe.local` (alias `demo@descrybe.test` also works) |
| Password | `DemoPass123!` |
| Company | **Local Demo Co** (`ee246275-dec0-4446-9e83-58d0c16c258a`) |
| Plan | **Enterprise** · ~1,000,000 AI credits · `can_use_ai=true` (restored after mock Stripe) |
| Platform admin | yes |
| Web | http://127.0.0.1:5174/login |
| API | http://127.0.0.1:8080 |
| Docs | http://127.0.0.1:5174/docs |
| Demo API key | `dk_demo_local_descrybe_test_key_v1` |
Do **not** use this account or key in production. Details: [demo-user.md](demo-user.md).
---
## Executive matrix
| Area | Verdict | Evidence doc |
|------|---------|--------------|
| Credits / billing | **PASS** (bugs fixed) | [billing-credits-audit.md](billing-credits-audit.md) |
| Analytics / usage | **PASS** (bugs fixed) | [analytics-audit.md](analytics-audit.md) |
| Stripe Checkout / portal / webhooks | **PASS** (mock + real path; demo Enterprise restored after mock) | [stripe-setup.md](stripe-setup.md) |
| Free tier gates | **PASS** | [free-tier-verify.md](free-tier-verify.md) · [free-tier.md](free-tier.md) |
| Tutorial (forced + skippable) | **PASS** | [tutorial.md](tutorial.md) |
| SEO / marketing copy | **PASS** | sibling SEO pass + `/pricing` polish |
| WooCommerce | **PASS** (seed path; live optional) | [woocommerce-demo.md](woocommerce-demo.md) |
| E2E feeds → process → export | **PASS** | [e2e-feeds-process-export.md](e2e-feeds-process-export.md) |
| E2E marketing (brand/SEO/campaign/email) | **PASS** | [e2e-marketing.md](e2e-marketing.md) |
| Processing UI | **PASS** | [e2e-processing.md](e2e-processing.md) |
| Admin / health / public API | **PASS** 20/20 | [api-surface-smoke.md](api-surface-smoke.md) |
| Security (Stripe webhook / SSRF) | **PASS** (hardened) | [security-notes.md](security-notes.md) |
| Settings / API keys / invites | **PASS** | sibling settings QA |
| Products / categories / attributes | **PASS** (UX fixes) | siblings |
| Export public URLs | **PASS** | sibling export QA |
| AI (Green Chat LAN) | **PASS** when `:8767` open | [ai-full-smoke.md](ai-full-smoke.md) · [green-chat-smoke.md](green-chat-smoke.md) |
---
## 1. Credits & billing
**Verdict:** Demo Enterprise wallet and Billing UI agree: **1,000,000 remaining / 0 used**. Free grants **0** AI credits.
### What was wrong
1. `/api/billing/usage` preferred stale `billing_cycles` rows → showed e.g. **22 credits used** while wallet was 1M/0.
2. Campaign AI swallowed `ConsumeCredits` errors (silent free AI).
3. SEO AI only checked `CanUseAI` (true on paid with empty wallet).
4. Billing page fake date-range + false “Out of credits” on Free.
### Fixes
- `UsageSummary` always uses live `credit_balances` for credits; range filters products/tokens only.
- `AssignPlan` closes open cycles and opens a fresh cycle; `ConsumeCredits` updates only open cycles.
- Campaign/SEO require `RemainingCredits ≥ 1`; debit after AI; map insufficient → **402**.
- Billing UI: real `?range=`, Free info banner, used/total + Enterprise “Unlimited” label.
### Smoke
```text
CREDITS plan=Enterprise total=1000000 used=0 rem=1000000
USAGE matches /auth/me
AFTER_FREE → rem=0 can_use_ai=false
AFTER_ENT → rem=1000000
```
Full write-up: [billing-credits-audit.md](billing-credits-audit.md).
---
## 2. Analytics & usage
**Verdict:** Tenant + admin meters match live Postgres for Local Demo Co.
| Meter | API | DB |
|-------|-----|-----|
| Wallet used / total | 0 / 1_000_000 | match |
| Products (all) | ~4326 | match |
| Input / export feeds | 12 / 5 | match |
| Plan cycle | Aug → Sep 2026 | `company_plans` |
Admin analytics now includes feed counts and real `ai_provider_mode` rollups (after API rebuild). Details: [analytics-audit.md](analytics-audit.md).
---
## 3. Stripe (purchase path)
**Verdict:** Checkout + Customer Portal + signed webhooks implemented. Local works in **mock** without a Stripe account.
### Configure for real
1. Set in **root** `.env` (never commit secrets; placeholders in `.env.example`):
| Variable | Purpose |
|----------|---------|
| `STRIPE_SECRET_KEY` | `sk_test_…` / `sk_live_…` |
| `STRIPE_WEBHOOK_SECRET` | `whsec_…` |
| `STRIPE_MOCK` | `false` for live |
| `STRIPE_PRICE_STARTER_MONTHLY` / `_YEARLY` | Price IDs |
| `STRIPE_PRICE_GROWTH_MONTHLY` / `_YEARLY` | Price IDs |
| `STRIPE_PRICE_BUSINESS_MONTHLY` / `_YEARLY` | Price IDs |
| `WEB_ORIGIN` | e.g. `https://app.example.com` (Checkout return URLs) |
2. Stripe Dashboard: Products + Prices for Starter / Growth / Business; enable Customer Portal.
3. Webhook → `https://<api-host>/api/webhooks/stripe`
Events: `checkout.session.completed`, `customer.subscription.created|updated|deleted`.
4. Local: `stripe listen --forward-to localhost:8080/api/webhooks/stripe` → paste `whsec_…`.
5. Apply migration `016_stripe_billing.sql` (`pwsh -File scripts/migrate.ps1`), restart API.
### Mock local QA
`STRIPE_MOCK=true``POST /api/billing/checkout` assigns plan + credits and returns `mock:true`. Empty secret alone no longer free-upgrades (security harden).
**Note:** Mock checkout on Local Demo Co temporarily moved the tenant off Enterprise (e.g. to Starter). Enterprise was **restored** afterward so demo stays on ~1M credits / Unlimited. Re-run `go run ./cmd/seed-demo` (or admin AssignPlan Enterprise) if mock Checkout is used again on the demo company.
Enterprise stays sales-led (no self-serve price). UI: `/plans`, `/billing`. Full guide: [stripe-setup.md](stripe-setup.md).
---
## 4. WooCommerce
**Verdict:** Product push + orders/reviews pull + campaign audiences work. Live shop optional; **seed path** covers demo without WordPress.
### Configure for real
1. WooCommerce → Settings → Advanced → REST API → Read/Write key.
2. Descrybe `/woocommerce` → enable sync → Store URL + `ck_…` / `cs_…` → Save → Test Connection.
3. Queue product sync, then Orders / Reviews; keep **worker** running.
4. Optional env (or UI only): `WOO_STORE_URL`, `WOO_CONSUMER_KEY`, `WOO_CONSUMER_SECRET`
(aliases `WOOCOMMERCE_*` also work). Encrypt at rest needs `CREDENTIALS_ENCRYPTION_KEY`.
5. SSRF: `http` allowed only for localhost/loopback; remote shops need https (or tunnel).
### No live shop?
```powershell
cd apps/api
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
go run ./cmd/seed-woo-demo -postgres $env:DATABASE_URL -company "Local Demo Co"
# or: make seed-woo
```
Seeds Demo Electronics products, 4 orders, 3 reviews, draft “purchased” campaign. Details: [woocommerce-demo.md](woocommerce-demo.md).
---
## 5. Tutorial
**Verdict:** Forced action tour with Skip / Pause / Resume / Restart. No free Continue on action steps.
- Progress: `localStorage` key `descrybe.tutorial.v2`
- 15 steps: welcome → standard fields → feeds map/auto-map/save/sync-sample → products → export → campaigns → done
- Advance only on real clicks (`data-tour`) or successful `tutorial.reportAction(…)`
- Header + dashboard: Start / Resume / Restart
Manual checklist: [tutorial.md](tutorial.md).
---
## 6. E2E results
### Feeds → map → process → export — **PASS**
| Check | Result |
|-------|--------|
| 12 input feeds mapped | PASS |
| Process job 5/5 steps | PASS (worker required) |
| Export XML/CSV generate | PASS (~4322 products) |
| Public token downloads | PASS |
| Mapping PUT nested `{key,mapping}` | Fixed this wave |
FTP/FTPS supplier sync still **400** (not supported). Migrated data already present so process/export work without re-sync. Details: [e2e-feeds-process-export.md](e2e-feeds-process-export.md).
### Marketing (brand / SEO / campaign / email / calendar) — **PASS** (14/14)
Against Green Chat `overloaded-local` @ `192.168.50.181:8767`:
| Step | Result |
|------|--------|
| Brand kit PUT/GET + tips | PASS |
| SEO `mode=ai` | PASS (~1s, brand terms, credits charged) |
| Campaign `mode=ai` + template | PASS |
| Email / campaign `dry_run` | PASS |
| Calendar prepare Black Friday | PASS (path fix: `/api/marketing/calendar/prepare`) |
Details: [e2e-marketing.md](e2e-marketing.md).
### Processing UI — **PASS**
Create → cancel (steps marked cancelled) → enhance_only via Green Chat (~1s). `/tasks` → 307 `/processing`. Details: [e2e-processing.md](e2e-processing.md).
### Free tier (while demo is Enterprise) — **PASS**
New register → Free / 0 credits / `can_use_ai=false`; AI-only jobs → **402**; normalize/full still runs with AI/EPREL skipped. Demo remains Enterprise. Details: [free-tier-verify.md](free-tier-verify.md).
### Admin / health / public API — **PASS** 20/20
Platform admin routes, `/healthz` `/readyz`, RapiDoc docs, OpenAPI, v1 CRUD smoke + 401 without key. Details: [api-surface-smoke.md](api-surface-smoke.md).
---
## 7. Other sibling fixes (this wave)
| Area | Outcome |
|------|---------|
| **Security** | Stripe webhook always verified when `STRIPE_WEBHOOK_SECRET` set; mock purchases need explicit `STRIPE_MOCK`; generic webhook errors; SSRF notes in [security-notes.md](security-notes.md) |
| **Billing / Plans UI** | Enterprise shows Unlimited; Checkout CTAs; no false Free out-of-credits banner |
| **Dashboard** | Clear CTAs (tutorial / process / feeds / BF); clickable credit widgets; dead upload section removed |
| **SEO / copy** | Plain ecommerce voice; SeoHead on privacy/terms/features; clearer empty states |
| **Products** | Search includes processed/raw names; CSV ingest via Feeds only; server-side sort |
| **Categories / attributes** | Attributes default `roots=1`; formula 404 UX fixed |
| **Settings** | API keys CRUD, invites, email link; Copy Key no longer copies useless prefix |
| **Export feeds** | Public routes mounted before session auth (401→404/405); absolute public URLs in UI |
| **Nav / a11y / tutorial targets** | `data-tour` preserved for tour |
---
## 8. Known gaps
| Gap | Notes |
|-----|-------|
| No daily **credit** ledger | Usage range filters products/tokens only; credit burn is wallet-level |
| FTP/FTPS feed sync | Not supported; sync returns 400 for those supplier URLs |
| Stripe live untested in this wave | Mock + unit/integration coverage; real Checkout needs keys above |
| Live Woo optional | Seed covers audiences; live sync needs a real store + worker |
| Email delivery | Prefer `force_dry_run` / `EMAIL_DRY_RUN`; stub SMTP test fails without a real listener |
| Cutover / production | Staging data OK; production cutover still blocked — see [cutover.md](cutover.md) · [go-live-checklist.md](go-live-checklist.md) |
| Migrated tenants | May hold legacy wallets; entitlements treat empty plan as Free-with-leftover-credits |
| Campaign/SEO charge after LLM | Wallet gated first; mid-flight race can still burn tokens then fail debit |
| Structured-descriptions / vector-categories | UI shells exist; backend still partial ([status-and-gaps.md](status-and-gaps.md)) |
| CI | Makefile + unit tests; no GitHub Actions yet |
---
## 9. How to re-verify locally
```powershell
# Postgres
cd f:\laragon\www\_MY\descrybe-v2
docker compose up -d # :5433
# Build gates
cd apps\api; go build ./...
cd ..\web; npm run check
# Runtime (separate terminals)
# scripts\run-api.ps1 → API :8080
# make worker / go run ./cmd/worker
# make web → :5174
# Login
# demo@descrybe.local / DemoPass123!
```
Spot-check:
1. `/billing` → 1M remaining, usage matches wallet
2. `/plans` → Upgrade (mock Checkout if `STRIPE_MOCK=true`)
3. Start tutorial → action steps have no Continue
4. `/feeds` → map → `/processing``/export-feeds` generate
5. `/campaigns` + `/seo` AI (needs Green Chat + `OPENAI_*`)
6. `/woocommerce` or `make seed-woo` → orders/reviews + audience
---
## 10. Related docs index
| Doc | Topic |
|-----|-------|
| [billing-credits-audit.md](billing-credits-audit.md) | Credits correctness |
| [analytics-audit.md](analytics-audit.md) | Usage / admin analytics |
| [stripe-setup.md](stripe-setup.md) | Stripe real + mock |
| [woocommerce-demo.md](woocommerce-demo.md) | Woo live + seed |
| [tutorial.md](tutorial.md) | Guided tour |
| [e2e-feeds-process-export.md](e2e-feeds-process-export.md) | Catalog pipeline E2E |
| [e2e-marketing.md](e2e-marketing.md) | Marketing AI E2E |
| [e2e-processing.md](e2e-processing.md) | Background tasks |
| [free-tier-verify.md](free-tier-verify.md) | Free vs Enterprise proof |
| [api-surface-smoke.md](api-surface-smoke.md) | Admin / docs / v1 |
| [demo-user.md](demo-user.md) | Credentials |
| [security-notes.md](security-notes.md) | AuthZ / webhooks / SSRF |
| [status-and-gaps.md](status-and-gaps.md) | Broader product gaps |
| [ai-full-smoke.md](ai-full-smoke.md) | Green Chat AI matrix |
+157
View File
@@ -0,0 +1,157 @@
# Getting started — operator checklist
Concise path for a local or staging operator: **login → create/map feed → process → export**.
Aligned with current web routes and API mounts (`apps/api/internal/httpapi/server.go`, `v1.go`).
**First-time machine setup** (Docker / Node / Go / migrate / ports / `/readyz`): [README.md — Local setup (any OS)](../README.md#local-setup-any-os).
| | |
|---|---|
| Web | http://localhost:28472 |
| API | http://localhost:28471 |
| Postgres | `localhost:5433` (`docker compose``postgres:16-alpine`) |
| Health | `GET /healthz` (liveness), `GET /readyz` (DB + worker heartbeat ≤60s) |
| OpenAPI | http://localhost:28471/api/v1/openapi.yaml · UI: `/docs` |
Credentials and counts: [demo-user.md](demo-user.md). Do not use demo accounts in production. Never commit real `.env` secrets.
---
## 0. Bring the stack up
Canonical any-OS steps live in the README. Short path (Windows / macOS / Linux):
```bash
npm run setup # .env + Docker Postgres :5433 + goose migrate
npm install && npm run dev # API :28471 + web :28472 + worker
npm run seed # optional demo login
npm run health # /healthz + /readyz
```
Compose runs **Postgres only**. `npm run dev` / `npm run dev:app` include the worker (needed for process jobs and `/readyz` 200). `npm run dev:backend` is api+worker without web. `npm run dev:api` alone → `/readyz` 503.
**`/readyz` 503 without a worker is expected** (`checks.worker=missing` / `stale`, plus JSON `reason`). `/healthz` stays 200. Details: [README troubleshooting](../README.md#troubleshooting-readyz-returns-503).
**After login (company admin):** configure AI at `/integrations/ai`, marketing email at `/integrations/email`, stores at `/stores`. Do not rely on env copies or tmp placeholders for those secrets.
**Seed demo login** (after migrate; lands on **Platform Demo** — not A1):
```bash
npm run seed
# or: node scripts/seed-local.mjs
# or: pwsh -File .\scripts\seed-local.ps1
```
Optional password bootstrap for an existing user:
```powershell
cd apps/api
go run ./cmd/migrator -postgres $env:DATABASE_URL -set-password "you@example.com:YourPassword"
```
---
## 1. First login
- [ ] Open **`/login`** → sign in → land on **`/dashboard`**
- [ ] Demo (seeded): `demo@descrybe.local` / `DemoPass123!` (alias `demo@descrybe.test` also works)
- [ ] Or **`/register`** for a fresh company (empty catalog)
- [ ] Confirm company in the shell (demo default: **Platform Demo**; A1 via `a1-primary@descrybe.local`)
- [ ] Optional: start the in-app tutorial from the dashboard ([tutorial.md](tutorial.md))
Session API: `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/select-company`.
CSRF: browser session uses cookie + `X-CSRF-Token` (Vite proxies `/api` → API).
---
## 2. Create (or open) a feed
UI:
| Step | Where |
|------|--------|
| Stores hub | `/stores` (Feed URL / CSV cards) |
| Add URL feed | `/feeds?add=1&source=url` |
| Add CSV file | `/feeds?add=1&source=file` |
| List / open | `/feeds` → Map → `/feeds/{feedId}/mapping` |
| Standard fields (recommended first) | `/standard-fields` → enable recommended |
Checklist:
- [ ] Enable recommended standard fields on `/standard-fields`
- [ ] Create feed (name + public http(s) URL **or** CSV upload) — FTP/FTPS sync is **not** supported yet
- [ ] On mapping: Auto-map → Save mappings
- [ ] Sync (or **Sync + Process sample** on the mapping page)
Session API:
| Action | Method |
|--------|--------|
| Create | `POST /api/feeds` (JSON or multipart with `file`) |
| Sync | `POST /api/feeds/{id}/sync` |
| Mappings | `GET` / `PUT /api/feeds/{id}/mappings` |
| Extract schema | `POST /api/feeds/{id}/extract-schema` |
| Sync + process sample | `POST /api/feeds/{id}/sync-process-sample` |
Same shapes under **`/api/v1/...`** with `Authorization: Bearer <api_key>` (demo key in [demo-user.md](demo-user.md)).
---
## 3. Process products
- [ ] Keep the **worker** running (`npm run dev` includes it; or `npm run dev:worker` / `go run ./cmd/worker`). Without it, jobs stall and `/readyz` stays **503**.
- [ ] Open **`/products`** — use **Unprocessed** / raw if Processed looks empty
- [ ] Select raw products → process, **or** use mapping **Sync + Process sample**
- [ ] Watch jobs on **`/processing`**
| Surface | Endpoint |
|---------|----------|
| Dashboard | `POST /api/processing/jobs` · `GET /api/processing/jobs` · `GET /api/processing/jobs/{id}` |
| Public API (legacy clients) | `POST /api/v1/products/process` (`items[].ean` or `raw_product_ids`) → **200** `{ data: { process_id, … } }` · poll `GET /api/v1/products/process/{id}` until `COMPLETED` + `items` |
| Public API (flat jobs) | `POST /api/v1/process`**202** flat `ProcessingJob` · `GET /api/v1/process/{id}`**not** the same envelope as legacy |
On legacy `COMPLETED` poll `items[]`: `id` is **processed_products.id** (legacy). Also returned additively: `processed_product_id` (same as `id`) and `raw_product_id` (`raw_products.id`) so dual-mode clients can correlate with `raw_product_ids` without guessing.
Cancel / retry: `POST .../cancel` or `.../retry` on dashboard `/api/processing/jobs/{id}` or flat `/api/v1/process/{id}` paths (legacy products/process is start+status only).
---
## 4. Export
- [ ] Open **`/export-feeds`** → create or open a template (Google Shopping / Meta / custom CSV|XML)
- [ ] **Generate / Refresh** the feed
- [ ] Copy the public poll URL into Merchant Center / partner importer
- [ ] Optional: export selected rows from **`/products`** (export dialog)
| Surface | Endpoint |
|---------|----------|
| Create / list | `POST` / `GET /api/export-feeds` (also `/api/v1/export-feeds`) |
| Generate | `POST /api/export-feeds/{id}/generate` |
| Selected IDs | `POST /api/export-feeds/{id}/export-products` |
| Public download | `GET /api/public/export-feeds/{token}.xml` or `.csv` (no auth) |
---
## Minimal API smoke (after seed)
```powershell
$H = @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1" }
Invoke-RestMethod "http://localhost:28471/api/v1/feeds?limit=5" -Headers $H
Invoke-RestMethod "http://localhost:28471/api/v1/products?limit=1" -Headers $H
```
---
## Related
| Doc | Use when |
|-----|----------|
| [README Local setup](../README.md#local-setup-any-os) | First-time Docker/Node/Go/migrate; `/readyz` 503 without worker |
| [demo-user.md](demo-user.md) | Demo email, API key, company IDs |
| [tutorial.md](tutorial.md) | In-app guided tour steps |
| [store-connectors.md](store-connectors.md) | Woo / Shopify / feed URL / CSV / export |
| [e2e-feeds-process-export.md](e2e-feeds-process-export.md) | Historical API E2E notes (prefer Platform Demo + [safe-test-fixtures.md](safe-test-fixtures.md)) |
| [process-and-sell-summary.md](process-and-sell-summary.md) | Deeper process / EPREL / sell path |
| [safe-test-fixtures.md](safe-test-fixtures.md) | Demo vs A1 isolation (canonical) |
| [qa-local-demo.md](qa-local-demo.md) | Historical Local Demo Co QA (outdated naming) |
| [ops-runtime.md](ops-runtime.md) | SMTP, sessions, encryption keys |
+266
View File
@@ -0,0 +1,266 @@
# Descrybe v2 — go-live checklist
**Date:** 2026-08-04
**Stack:** Go API + SvelteKit + PostgreSQL (`f:/laragon/www/_MY/descrybe-v2`)
**Legacy (still live):** Next.js + Clerk + MySQL (`f:/laragon/www/_MY/descrybe`)
**Verdict: NO-GO for production cutover.** Staging PG has live-migrated data; production DNS/switch stays blocked until emails, membership roles, and SMTP/set-password login are proven.
**Staging rehearsal:** **GO for login testing** after Clerk email patch (or synthetic-email awareness) + set-password invites / `-set-password` bootstrap. See [migration-run-log.md](migration-run-log.md).
**Demo account:** `demo@descrybe.test` / `DemoPass123!` — platform admin of **Platform Demo** only (not A1). A1 catalog stays on `a1-primary@descrybe.local`. Re-seed with `go run ./cmd/seed-demo`. Canonical: [demo-user.md](demo-user.md), [safe-test-fixtures.md](safe-test-fixtures.md).
Synthesized from [status-and-gaps.md](status-and-gaps.md), [design-gaps.md](design-gaps.md), [migration-readiness.md](migration-readiness.md), [cutover.md](cutover.md), [ops-runtime.md](ops-runtime.md), [schema-map.md](schema-map.md), `apps/api/internal/httpapi/server.go`, and `apps/api/cmd/migrator`.
---
## 1. Design parity status
### Cloned / aligned
| Area | Status |
|------|--------|
| Design tokens (seasalt / russian-violet / majorelle-blue) | Matched in `layout.css` from legacy globals |
| Shared UI kit (`$lib/components/ui`) + PageShell | Present |
| Core nav destinations | Dashboard, Products, Feeds (+ mapping), Export Feeds, Categories (+ title/description formula), Attributes, Standard Fields, Background Tasks, Billing, Settings, WooCommerce, Admin (+ subpages) |
| Auth shells | `/login`, `/register`, `/accept-invite` |
| Extra shells | `/plans`, `/structured-descriptions`, `/vector-categories` (routes exist; not all in nav) |
| Formula builders | Drag-reorder HTML5 DnD present; closer to legacy than status docs sometimes imply |
Nav order (v2): Dashboard → Products → Feeds → Export Feeds → Categories → Attributes → Standard Fields → Background Tasks → Usage & Billing → Settings → **WooCommerce****Admin**.
### Still imperfect (do not claim pixel / UX cutover)
From [design-gaps.md](design-gaps.md) — **P0 UI blockers** for daily-ops parity:
1. ~~Feed mapping — no live XML/CSV tree preview / schema extract~~ **DONE** (extract-schema + `FeedSourcePreview` / `MappingPreviewPanel`)
2. Export create/edit — thin dialog, not full `ExportFeedBuilder` routes — **WAIVED** (`DESIGN-P0-EXPORT-BUILDER-2026-08-08`; dialog covers daily ops)
3. Products “Export Selected” — client CSV blob, not export-feed picker
4. “Reset to Unprocessed” — stub toast
5. ~~Standard Fields — “API not available” banner~~ **DONE** (`/api/standard-fields` + field-groups CRUD mounted)
6. ~~Product Attributes tab — read-only~~ **DONE (MVP)** (editable key/value → PATCH `attributes`; Add Category Attribute deferred P1)
7. Attributes — no per-attribute Manage Categories tree dialog
8. Admin Settings / Admin home — `saveDemo` (UI demo, not persisted)
9. Admin Logs — no API
10. Admin always visible in customer sidebar (legacy keeps Admin separate)
11. Categories Bulk Formula Builder — stub button
12. ~~No floating TaskStatusIndicator in layout~~ **DONE** (`TaskStatusIndicator` in `+layout.svelte`)
**P1 shells with API banners:** plans, structured-descriptions, vector-categories (UI cloned; backends missing).
**IA differences:** WooCommerce is top-level in v2 (legacy: export-feed dialogs); Admin in customer Nav; path renames (`/register`, `/accept-invite`, `/processing`).
**Honest summary:** Visual shell and most route shells are cloned. Mapping preview, standard-fields API, product attribute edit (MVP), and floating task indicator are closed or waived. Remaining primary blockers for some tenants: export-selected→feed picker, reset-to-unprocessed, admin demos/logs, bulk formula, Manage Categories.
---
## 2. Backend / feature stubs blocking real use
Prefer [status-and-gaps.md](status-and-gaps.md) over [features.md](features.md) — the phase checklist is **stale** in places (still lists feed sync / export / AI / Woo / admin as stubs while code has moved on).
### Hard blockers for many production tenants
| Gap | Detail |
|-----|--------|
| Real emails | Most users have synthetic `…@legacy.local` — Clerk emails not in MySQL; patch before invites |
| Set-password delivery | Hooks tooling exists; SMTP + mailhooks **unproven** on staging |
| Membership roles | All imported as `role=member` (`profiles.role` absent) — promote via migrator `-list-member-memberships` / `-promote-company-admins` (`-dry-run` then `-confirm`; see [cutover.md](cutover.md)) |
| `company_plans` | 2 rows skipped (`plan_id=6` missing in `plans`) |
| API keys migration | `api_keys` / `descrybe_api_key` **not** migrated — clients must mint new keys |
| Company settings | Language / merge-by-GTIN **not** fully migrated (`company_settings`) |
| File blobs | Metadata only; no blob copy; `raw_products.file_id` unset |
### Product / API leftovers (in-use phases)
| Area | Works today | Still blocking / incomplete |
|------|-------------|-----------------------------|
| Feed sync | URL + CSV/XML → `raw_products` | FTP/FTPS, Excel, uploaded-file paths rejected |
| Export | Streaming generate + public XML/CSV | Schedule/cron; object-storage persist |
| Processing | OpenAI + heuristic + optional Pinecone; DB claim queue | River optional; prompt/index tuning |
| `/api/v1` | Mounted (Bearer / `X-API-Key`); OpenAPI YAML | Live API-key DB round-trip harness not run |
| WooCommerce | REST test + queued sync + 15m enqueue | Rich maps UI; live store E2E; set `CREDENTIALS_ENCRYPTION_KEY` |
| Catalog | CRUD, CSV, formulas, ListFilter | S3 (`UPLOAD_DIR` only); trees >2000; merge-by-GTIN beyond CSV |
| Structured descriptions / vector categories | — | **No backend routes** (UI 404 banners) |
| Standard fields | `/api/standard-fields`, `/api/field-groups` | **Mounted** (CRUD + bulk enable) |
| Job queue | DB claim + NOTIFY | River client deferred (`jobs/river.go`) |
| Org export/import, WebSockets | — | Missing vs legacy |
### Routes mounted (session API — excerpt)
From `server.go`: auth (register/login/logout/accept-invite/complete-set-password/me/set-password), admin (users/companies/jobs/plans/credits/emails), company/team/api-keys/billing, categories/attributes/variables/products, feeds (+ sync/mappings), export-feeds (+ generate), processing jobs, WooCommerce, public export XML/CSV, `/healthz` + `/readyz`, `/api/v1/*`.
**Not mounted:** `/api/structured-descriptions`, `/api/vector-categories/*`.
**Mounted:** `/api/standard-fields`, `/api/field-groups` (CRUD + bulk enable).
---
## 3. DB migration status & how to run
### Goose schema (Postgres)
| Item | Status |
|------|--------|
| Migrations `001``007` | Applied on staging PG (see [migration-run-log.md](migration-run-log.md)) |
| Apply tooling | `make migrate` / `.\scripts\migrate.ps1` / `scripts/migrate.sh` (pin goose if Go &lt; 1.25.7) |
| Default local DB | Postgres 16 on host **5433** (`docker compose up -d`) |
| Default DSN | `postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable` |
```powershell
# Windows — apply schema + sqlc
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
.\scripts\migrate.ps1
```
```bash
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
./scripts/migrate.sh
# or: make migrate
```
### MySQL → Postgres ETL (`cmd/migrator`)
| Item | Status |
|------|--------|
| Tooling / fixture dry-run | Ready |
| Live MySQL dry-run | **Done** (2026-08-03) — exit 0 |
| Live load + validation | **Done** on staging PG — orphans **16/16 pass**; see [migration-run-log.md](migration-run-log.md) |
| Staging data (written) | 27 companies, 21 users, 15 memberships, 8278 categories, **57630** attributes (unique collapse), **118784** raw products (+7732 GTIN-deduped), 7219 processed, 31 feeds, 8 export feeds |
| Coverage gaps | api_keys, full company_settings, billing_cycles, feed tags, Woo configs (unless domain `woo`), jobs/history (unless domain `jobs`; cutover default empty), file blob bytes |
| Auth design | UUID + `legacy_*` + `must_set_password=true` + `admin_users``is_platform_admin`; **no password hashes imported** |
| Post-import | This live run used `-skip-post-import` — re-issue invites when emails are real |
```bash
cd apps/api
# Offline smoke ONLY (not cutover proof)
go run ./cmd/migrator -dry-run -fixture ./cmd/migrator/testdata/fixture.json -maps-dir ../../artifacts
# Live dry-run / load (already completed on staging — re-run only if reloading)
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-dry-run \
-maps-dir ../../artifacts \
-id-map ../../artifacts/id-map.json
go run ./cmd/migrator \
-mysql "$MIGRATE_MYSQL_DSN" \
-postgres "$DATABASE_URL" \
-maps-dir ../../artifacts \
-id-map ../../artifacts/id-map.json
```
Do **not** invent DSNs. Do **not** commit `artifacts/` (id-map, validation report, set-password tokens).
Full matrix: [migration-readiness.md](migration-readiness.md). Runbook: [cutover.md](cutover.md). Evidence: [migration-run-log.md](migration-run-log.md).
---
## 4. Post-migrate auth (set password)
Migrated users get `password_hash = NULL` and `must_set_password = true`. Clerk sessions do not carry over.
### Operator sequence
1. Prefer real emails first (Clerk export → patch `users.email`); then issue invites (live load used `-skip-post-import`).
2. Confirm artifact: `<maps-dir>/set-password-hooks.json` (also `password_invites.json`).
3. Configure SMTP (`SMTP_ENABLED=true`, host/port/user/password/from, `WEB_ORIGIN`). See [ops-runtime.md](ops-runtime.md).
4. Send mail:
```bash
cd apps/api
# Under EMAIL_DRY_RUN (default true): omit -dry-run → exit 1; with -dry-run → subject-only smoke (no SMTP)
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run # smoke
# Real send only after EMAIL_DRY_RUN=false + SMTP_ENABLED=true — see ops-runtime.md
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json # real send
```
5. User opens `/accept-invite?token=…` → sets password → login → session cookie.
6. Alternates: `POST /api/auth/complete-set-password`, admin `POST /api/admin/emails/set-password`, or migrator `-set-password` for local bootstrap.
7. **Gate:** do not flip DNS until at least one platform admin and one normal user can log in on v2.
8. Confirm `is_platform_admin` and `/admin` for migrated admins; promote tenant admins from `member` as needed (`-promote-company-admins -dry-run` then `-confirm`).
### Staging login rehearsal (exact commands)
Prefer the no-SMTP link-copy path first: [staging-auth-rehearsal.md](staging-auth-rehearsal.md) (`scripts/staging-auth-rehearsal.ps1` / `.sh`).
```bash
cd apps/api
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
# Re-issue invites for must_set_password users (Postgres only)
go run ./cmd/migrator -issue-set-password-invites \
-postgres "$DATABASE_URL" \
-maps-dir ../../artifacts
# Dev-only: set one user's password directly (not for production cutover)
go run ./cmd/migrator -set-password "email@example.com:YourPass123" \
-postgres "$DATABASE_URL"
```
### Risks
- SMTP off → users locked out after cutover
- Synthetic `@legacy.local` emails until Clerk export
- Tenant admins may land as `member`
- API customers need **new** API keys after cutover
---
## 5. Go / no-go for cutover TODAY
### Checklist
| Gate | Status |
|------|--------|
| Goose schema applyable locally | **Done** on staging (001007) |
| Staging stack runnable (API + worker + web) | Ready for **staging** use |
| UI visual shell cloned | Mostly yes |
| UI daily-ops parity (mapping, export builder, product attrs, …) | **Partial** — mapping/standard-fields/attr-edit/task indicator closed or waived; export-selected/reset/admin stubs remain |
| Feature parity for in-use phases | Partial — FTP/Excel, S3, scheduled export, several APIs missing |
| Live MySQL migrator dry-run | **Yes** (2026-08-03) |
| Staging live load + zero orphan FKs | **Yes** — 16/16 orphan checks pass |
| `migration-run-log.md` / artifacts present | **Yes** (log committed; artifacts gitignored) |
| Real emails (Clerk) | **No** — synthetic `@legacy.local` |
| Membership roles verified | **No** — all `member` |
| SMTP + set-password smoke | **No** (tooling only; post-import skipped this run) |
| `api_keys` / company_settings migration | **No** |
| Production secrets (`CREDENTIALS_ENCRYPTION_KEY`, SMTP, `TOKEN_SIGNING_SECRET`) | Operator-owned; not verified here |
| Cutover runbook executed | **No** |
| CI / GitHub Actions | Partial unit tests only |
### Recommendation
**NO-GO — do not cut over production traffic.**
Staging PG data load **succeeded**. Use staging for **login rehearsal** after set-password (and preferably after Clerk email patch). Production DNS switch stays blocked until at least:
1. Clerk (or other) email export → patch synthetic addresses; issue set-password invites; SMTP smoke for admin + member.
2. Explicit acceptance or fix of migration gaps: roles (`member` → admin), `plan_id=6` company_plans, api_keys re-issue plan, company_settings, file strategy.
3. P0 daily-ops UI/API gaps closed **or** signed waiver for tenants that do not need those paths.
- **Done (2026-08-08):** **P0-8** / **P0-9** remainder waived — `UX-P0-8-PROPOSAL-API-2026-08-08`, `UX-P0-9-SERVER-EMAIL-2026-08-08` in [ux-backlog.md](ux-backlog.md) (MVP Needs Review + local Alerts shipped).
4. Production secrets + `/readyz` deploy + freeze/rollback owners per [cutover.md](cutover.md).
Until then, keep legacy Descrybe as the live system.
### Suggested next actions (order)
1. Clerk email export → patch `users.email``-issue-set-password-invites` → SMTP/mailhooks → login smoke
2. Promote tenant admins; resolve skipped `plan_id=6` company_plans
3. Close or waive design P0s that match real tenant workflows — **P0-8 / P0-9 waived** (see [ux-backlog.md](ux-backlog.md))
4. Re-issue API key runbook for `/api/v1` customers
5. Execute [cutover.md](cutover.md) only after gates above are green
---
## Related docs
- [demo-user.md](demo-user.md) — staging demo login + company data counts
- [migration-run-log.md](migration-run-log.md) — live dry-run + load evidence
- [status-and-gaps.md](status-and-gaps.md) — product/backend gap analysis
- [design-gaps.md](design-gaps.md) — UI/UX parity audit
- [migration-readiness.md](migration-readiness.md) — ETL coverage & blockers
- [cutover.md](cutover.md) — freeze → migrate → DNS → Clerk decommission
- [ops-runtime.md](ops-runtime.md) — SMTP, sessions, Woo encryption
- [schema-map.md](schema-map.md) — ID remapping
- [features.md](features.md) — phase checkboxes (**may lag** status-and-gaps)
+3
View File
@@ -0,0 +1,3 @@
# Green Chat AI (moved)
Canonical guide: **[green-chat-llm.md](green-chat-llm.md)** — discover IP/port/key on the green host, then configure Descrybe via **`/integrations/ai`** (or optional root `.env` platform fallback).
+251
View File
@@ -0,0 +1,251 @@
# Green Chat → Descrybe v2 (OpenAI-compatible)
Use LAN **green-chat** (`systemd` unit `green-chat.service`, binary `/usr/local/bin/ge chat ser` / `ge chat serve`) as Descrybes LLM the same way you would use OpenAI.
Descrybe already speaks OpenAI Chat Completions via one shared client (`processing.OpenAIClient`). **Preferred setup:** company admin → **`/integrations/ai`** (custom OpenAI-compatible base URL + key, or a popular provider). Optional process-env `OPENAI_*` remains a platform fallback when the company uses mode **internal**.
| Feature | Process | Path |
|---------|---------|------|
| Product AI enhance | **worker** | Completer → `POST {base}/chat/completions` |
| Campaign email AI | **API** | same Completer |
| SEO meta AI | **API** | same Completer |
| Brand voice | API/worker | Brand kit `PromptBlock()` injected into those Completer prompts (no separate LLM call) |
Config resolve: `apps/api/internal/aiprovider` (company BYOK) → optional `OPENAI_*` from `apps/api/internal/config`
Client: `apps/api/internal/processing/openai.go`
Wiring: `cmd/worker/main.go`, `internal/httpapi/server.go`
**Do not commit real API keys, tokens, or production LAN endpoints.** Use a single **root** `.env` for bootstrap secrets — do not duplicate `apps/api/.env`.
For **CI / local processing without a real model**, use the OpenAI-compatible stub: **[mock-llm.md](mock-llm.md)** (`go run ./cmd/mock-llm`). Prefer tenant **`demo@descrybe.local`** (not A1).
---
## Setup (preferred: dashboard)
1. Ensure root `.env` has `APP_ENCRYPTION_KEY` (secrets at rest) and the API + worker are running.
2. Sign in as a **company admin****`/integrations/ai`**.
3. Choose **custom** (or a popular provider), set base URL to `http://GREEN_HOST:PORT/v1`, model id from `GET /v1/models`, paste API key, enable, **Test**.
4. Restart is **not** required for dashboard-saved keys (unlike process env).
## Optional env fallback (`OPENAI_*`)
When the company leaves AI mode **internal**, the platform Completer can use process env:
| Variable | Default | Role |
|----------|---------|------|
| `OPENAI_API_KEY` | _(empty)_ | Bearer token. **Must be non-empty** to enable the platform fallback Completer. Use the key from green-chat drop-ins, or any placeholder (e.g. `local`) if the server ignores auth. |
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Root **including** `/v1`. Client appends `/chat/completions`. |
| `OPENAI_MODEL` | `gpt-4o-mini` | Model id from green-chat `GET /v1/models` (often a GGUF path/name, not `gpt-4o-mini`). |
| `PROCESSING_RPM` | `60` | Client-side spacing between calls |
| `PROCESSING_MAX_RETRIES` | `3` | Retries on 429 / 5xx / transport errors |
There is no `OPENAI_API_BASE` — use **`OPENAI_BASE_URL`**.
Typical Green Chat path:
```text
POST http://GREEN_HOST:PORT/v1/chat/completions
└──────── OPENAI_BASE_URL ────────┘└── client appends ──┘
```
Also useful: `GET /v1/models`, and often `GET /health`.
### Example root `.env` (placeholders only — optional fallback)
Put these in the **repo root** `.env` only if you need platform-env fallback. Prefer `/integrations/ai`. Restart **both** API and worker after env changes.
```env
OPENAI_API_KEY=YOUR_GREEN_API_KEY_OR_local
OPENAI_BASE_URL=http://192.168.x.x:PORT/v1
OPENAI_MODEL=YOUR_MODEL_ID_FROM_v1_models
```
PowerShell (session only):
```powershell
$env:OPENAI_API_KEY = "YOUR_GREEN_API_KEY_OR_local"
$env:OPENAI_BASE_URL = "http://192.168.x.x:PORT/v1"
$env:OPENAI_MODEL = "YOUR_MODEL_ID_FROM_v1_models"
```
Free plan blocks AI credits (`can_use_ai=false`). Use a paid/demo company with credits for real smoke tests — see [free-tier.md](free-tier.md) and [demo-user.md](demo-user.md).
---
## Discover IP, port, path, and API key ON THE GREEN MACHINE
Run these on the Linux host that runs `green-chat` (SSH as a user that can `sudo` where needed).
### Unit + drop-ins
```bash
sudo systemctl cat green-chat
sudo systemctl cat green-chat.service.d/99-gemma4.conf
sudo systemctl cat green-chat.service.d/runtime.conf
```
Also useful:
```bash
sudo systemctl status green-chat --no-pager -l
ls -la /etc/systemd/system/green-chat.service.d/
sudo systemctl show green-chat -p Environment -p EnvironmentFiles -p ExecStart -p MainPID
```
Look for listen host/port, `--model` / `MODEL=`, and anything named `API_KEY` / `OPENAI_*` / `AUTH` / `TOKEN`. **Do not paste real secrets into git or chat logs.**
### Listen address and LAN IP
```bash
ss -tlnp | grep -E 'ge|green|chat'
hostname -I
sudo journalctl -u green-chat -n 50 --no-pager
```
| Bind you see | Meaning |
|--------------|---------|
| `0.0.0.0:PORT` or `*:PORT` | Reachable from Windows on the LAN (if firewall allows) |
| `127.0.0.1:PORT` only | **Not** reachable from other machines — rebind to `0.0.0.0` (or add a reverse proxy) |
Default Green Engine chat port is often **8767** (`GE_CHAT_PORT` / `--port`); your drop-ins may override it. Always trust `ss` / the unit over assumptions.
### API key
```bash
# Unit environment (may include keys — redact before sharing)
sudo systemctl show green-chat -p Environment --no-pager
sudo systemctl show green-chat -p EnvironmentFiles --no-pager
# Grep drop-ins / nearby config (redact output)
grep -RInE 'API_KEY|OPENAI|BEARER|TOKEN|AUTH' \
/etc/systemd/system/green-chat.service.d/ 2>/dev/null | head -40
```
If green-chat does not require auth, Descrybe still needs a **non-empty** `OPENAI_API_KEY` (e.g. `local`) so the Completer turns on.
### Model id
```bash
# From drop-ins
grep -RInE 'model|gemma|MODEL' /etc/systemd/system/green-chat.service.d/ 2>/dev/null
# Or from the running server (on the green host)
curl -sS -H "Authorization: Bearer YOUR_KEY_OR_local" \
"http://127.0.0.1:PORT/v1/models"
```
Use the `data[].id` value as `OPENAI_MODEL`.
### Loopback smoke test (on green host)
```bash
PORT=CHANGEME
KEY='YOUR_GREEN_API_KEY_OR_local'
MODEL='CHANGEME'
curl -sS -w "\nHTTP %{http_code}\n" \
-H "Authorization: Bearer $KEY" \
"http://127.0.0.1:${PORT}/v1/models"
curl -sS -w "\nHTTP %{http_code}\n" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"${MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hi in one word.\"}],\"temperature\":0.2}" \
"http://127.0.0.1:${PORT}/v1/chat/completions"
```
Expect HTTP 200 and `choices[0].message.content`.
### Firewall (green host)
```bash
sudo ufw status || true
# Example once PORT is known (restrict to your LAN):
# sudo ufw allow from 192.168.0.0/16 to any port PORT proto tcp
```
If loopback works but Windows does not: fix **bind** (`0.0.0.0`) and/or **firewall** first.
---
## Smoke test FROM Windows (Laragon)
Replace placeholders with values from the green host. This PC is typically on `192.168.50.x`.
```powershell
$GreenIP = "192.168.x.x" # from hostname -I on green machine
$Port = "PORT" # from ss / unit (often 8767)
$Key = "YOUR_GREEN_API_KEY_OR_local"
$Model = "YOUR_MODEL_ID"
Test-NetConnection -ComputerName $GreenIP -Port $Port
curl.exe -sS -w "`nHTTP %{http_code}`n" `
-H "Authorization: Bearer $Key" `
"http://${GreenIP}:${Port}/v1/models"
curl.exe -sS -w "`nHTTP %{http_code}`n" `
-H "Authorization: Bearer $Key" `
-H "Content-Type: application/json" `
-d "{\"model\":\"$Model\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hi in one word.\"}],\"temperature\":0.2}" `
"http://${GreenIP}:${Port}/v1/chat/completions"
```
Then configure **`/integrations/ai`** (preferred) or set root `.env` `OPENAI_*` and start both processes:
```powershell
cd f:\laragon\www\_MY\descrybe-v2
.\scripts\run-api.ps1 # campaigns + SEO AI
.\scripts\run-api.ps1 worker # product AI enhance (other terminal)
```
Root `.env` is loaded by `loadDotEnv` / `scripts/run-api.ps1` — do not recreate `apps/api/.env`. Prefer `/integrations/ai` for new setups ([UNCERTAIN] env platform OpenAI fallback may be removed later).
### App-level checks (after AI config + worker up)
| Surface | How |
|---------|-----|
| Campaign AI | `POST /api/campaigns/{id}/generate` with `{"mode":"ai"}` (paid/demo company) |
| SEO AI | `POST /api/seo/apply` with `{"product_id":"…","mode":"ai"}` |
| Processing | Worker running; start a job that runs enhance; notes must not say `openai_not_configured` |
| Brand | Fill brand kit; AI generate/enhance should reflect voice (same Completer) |
Email **send** (Resend/SMTP / `EMAIL_DRY_RUN`) is separate from Green Chat; AI only affects **copy generation**.
---
## Reachability checklist (this workstation)
Run when Green Chat IP/port is unknown or LAN calls fail:
1. On green: `sudo systemctl cat green-chat` (+ drop-ins above) → port, bind, model, key env names.
2. On green: `ss -tlnp | grep -E 'ge|green|chat'` → confirm `0.0.0.0` (not only `127.0.0.1`).
3. On green: `hostname -I` → LAN IP for Windows `.env`.
4. On green: loopback `curl` to `/v1/models` and `/v1/chat/completions`.
5. On green: firewall allows your Windows host (or LAN) to that TCP port.
6. On Windows: `Test-NetConnection GREEN_IP -Port PORT`, then the curl smoke tests.
7. Configure AI in **`/integrations/ai`** (or optional root `.env` `OPENAI_API_KEY` / `OPENAI_BASE_URL=http://GREEN_IP:PORT/v1` / `OPENAI_MODEL`).
8. Ensure API + worker are running; retest campaign/SEO/processing on a **non-Free** company.
### Probe notes (2026-08-04, Laragon PC `192.168.50.119`)
| Target | Result |
|--------|--------|
| Process env `OPENAI_*` | Unset |
| DNS `green` / `green-chat` / `gemma` | No resolution |
| LAN neighbors (`192.168.50.39`, `.100`, `.143`, `.181`) on default chat ports (`8767`, `8080`, …) | **No** OpenAI-compatible green-chat endpoint reachable |
| Open ports seen | `.143:8443`/`:9000` (not chat API); `.181:80`/`:443`; `.100:80` — not usable as `/v1/chat/completions` |
| Local `http://127.0.0.1:8767` (`ge chat serve` on this PC) | **OK**`GET /v1/models` 200; `POST /v1/chat/completions` 200 (`Hello.`) |
**Conclusion:** Descrybe wiring is ready. Remote gemma4 `green-chat` IP/port/key must come from the discovery commands on that host (likely loopback-bound or firewalled today).
---
## Related
- [ai-full-smoke.md](ai-full-smoke.md) — full AI feature pass/fail against LAN Green Chat
- [local-llm-tuning.md](local-llm-tuning.md) — weak-model prompt/JSON/token defaults
- [process-and-sell-summary.md](process-and-sell-summary.md) — `OPENAI_*` table
- [ops-runtime.md](ops-runtime.md) — worker / credits
- [free-tier.md](free-tier.md) / [demo-user.md](demo-user.md) — AI gates and demo credits
+108
View File
@@ -0,0 +1,108 @@
# Green Chat as OpenAI
Canonical wiring guide: **[green-chat-llm.md](green-chat-llm.md)** — OpenAI-compatible env (`OPENAI_*`), discovery, and smoke tests.
This page covers **LAN reachability** when green-chat is bound to loopback only.
---
## Current workstation probe (2026-08-04)
| Check | Result |
|-------|--------|
| Host | `192.168.50.181` (ping OK) |
| Port | `8767` |
| `Test-NetConnection 192.168.50.181 -Port 8767` | **TcpTestSucceeded = False** |
| `curl http://192.168.50.181:8767/v1/models` | Connection timed out |
| Local `http://127.0.0.1:8767` | Something else answers (Windows `ge chat serve` / local GGUF) — **not** the green host |
**Cause (expected):** green-chat on the green machine was started with `--host 127.0.0.1 --port 8767`, so it is not reachable over the LAN.
Descrybe **root** `.env` (or `/integrations/ai`) can still hold:
```env
OPENAI_BASE_URL=http://192.168.50.181:8767/v1
OPENAI_MODEL=overloaded-local
OPENAI_API_KEY=# set locally; do not commit
```
…but API/worker calls will fail until bind or tunnel is fixed. Restart **api** and **worker** after any process-env change (dashboard AI keys do not require restart).
---
## Fix A — Rebind green-chat to `0.0.0.0` (preferred)
On the **green machine** (SSH as a user with sudo). Inspect the current unit first:
```bash
sudo systemctl cat green-chat
sudo systemctl cat green-chat.service.d/*.conf 2>/dev/null
ss -tlnp | grep -E '8767|ge|green|chat'
```
Create a drop-in that forces LAN bind (adjust `ExecStart=` to match the real binary/flags from `systemctl cat`; only change `--host`):
```bash
sudo mkdir -p /etc/systemd/system/green-chat.service.d
sudo tee /etc/systemd/system/green-chat.service.d/10-listen.conf >/dev/null <<'EOF'
[Service]
# Clear inherited ExecStart, then set full command with --host 0.0.0.0
ExecStart=
ExecStart=/usr/local/bin/ge chat serve --host 0.0.0.0 --port 8767
# If your unit uses Environment instead of flags, prefer e.g.:
# Environment=GE_CHAT_HOST=0.0.0.0
# Environment=GE_CHAT_PORT=8767
EOF
sudo systemctl daemon-reload
sudo systemctl restart green-chat
ss -tlnp | grep 8767
```
Confirm you see `0.0.0.0:8767` (or `*:8767`), not only `127.0.0.1:8767`. Open the firewall if needed:
```bash
sudo ufw allow from 192.168.50.0/24 to any port 8767 proto tcp
```
Then from **Windows**:
```powershell
Test-NetConnection 192.168.50.181 -Port 8767
curl.exe -sS -H "Authorization: Bearer YOUR_KEY" http://192.168.50.181:8767/v1/models
```
Keep `OPENAI_BASE_URL=http://192.168.50.181:8767/v1` in root `.env` or `/integrations/ai`, restart API + worker if using process env.
---
## Fix B — SSH tunnel from Windows (no systemd change)
If you cannot rebind yet, forward loopback from the green host to this PC:
```powershell
# On Windows (leave this session open)
ssh -L 8767:127.0.0.1:8767 green@192.168.50.181
```
Then point Descrybe at the tunnel:
```env
OPENAI_BASE_URL=http://127.0.0.1:8767/v1
OPENAI_MODEL=overloaded-local
```
**Port conflict:** this Laragon PC already has a **local** green-chat (or similar) on `127.0.0.1:8767`. Stop that local process first, or use a different local port:
```powershell
ssh -L 18767:127.0.0.1:8767 green@192.168.50.181
# then OPENAI_BASE_URL=http://127.0.0.1:18767/v1
```
Restart API + worker after changing `.env`.
---
## Related
- [green-chat-llm.md](green-chat-llm.md) — full OpenAI wiring, discovery, app-level smoke checks
- [ops-runtime.md](ops-runtime.md) — worker / credits
+67
View File
@@ -0,0 +1,67 @@
# Green-chat LAN smoke test
**Date:** 2026-08-04
**Retest time (local):** 2026-08-04 ~02:22-02:25 UTC+2
**Client:** Windows PC `GreenEclipse` (`192.168.50.119`)
**Target:** `http://192.168.50.181:8767/v1` (listen `0.0.0.0:8767`)
**Model:** `overloaded-local`
**Key:** present in `root `.env`` as `OPENAI_API_KEY` (len=64, ends `db46`) — not echoed here
**OPENAI_BASE_URL:** `http://192.168.50.181:8767/v1`
## Full smoke after LAN listen confirmed
| Step | Result | Notes |
|------|--------|-------|
| 1. `Test-NetConnection 192.168.50.181 -Port 8767` | **PASS** | `TcpTestSucceeded: True` (ICMP ping still False; TCP OK) |
| 2a. `GET /v1/models` | **PASS** | HTTP 200; model `overloaded-local` listed (OpenAI + Ollama-shaped payload) |
| 2b. `POST /v1/chat/completions` | **PASS** | HTTP 200; model `overloaded-local`; assistant content `smoke-ok` (~4 completion tokens) |
| 3. `OPENAI_*` in `root `.env`` | **PASS** | BASE → LAN `:8767/v1`, MODEL=`overloaded-local`, key present |
| 3b. Restart `api.exe` + `worker.exe` | **PASS** | Stopped old PIDs; restarted with session env from `.env` → api `67624`, worker `87588`; `/healthz`+`/readyz` 200 |
| 4a. Login `demo@descrybe.test` | **PASS** | CSRF + `POST /api/auth/login` HTTP 200; active company **Local Demo Co** `ee246275-…`; Growth plan; `can_use_ai=true`; 2000 credits |
| 4b. Campaign create + `POST …/generate` `mode=ai` | **PASS** | Created draft `85f79f6d-…`; generate HTTP 200 in ~6s; `status=ready`, `generation_mode=ai`, subject set |
| 4c. SEO `POST /api/seo/apply` `mode=ai` | **PASS** | Product `d2c309ed-…` (Sample Gadget); HTTP 200; meta title/description filled; `credits_charged=2` |
| 4d. Processing `enhance_only` (optional) | **PASS** | Job `573a2511-…` HTTP 202 → `completed`; steps `normalize`+`ai_enhance` done |
**Overall: PASS** — TCP 8767 open from Windows; green-chat models/chat OK; Descrybe API+worker reloaded env; demo AI campaign, SEO apply, and enhance_only processing all succeeded against LAN green-chat.
## Prior attempts (same day)
### After `99-listen-lan.conf` claim but port still closed (~02:19)
| Step | Result | Notes |
|------|--------|-------|
| TCP 8767 | **FAIL** | `TcpTestSucceeded: False` |
| HTTP / AI smoke | **SKIPPED** | Port not accepting |
### Before listen-config
| Step | Result | Notes |
|------|--------|-------|
| TCP 8767 | **FAIL** | Ping OK; TCP False |
| HTTP / AI | **SKIPPED** | |
## Commands used (no secrets)
```powershell
Test-NetConnection 192.168.50.181 -Port 8767
# Load OPENAI_* from root `.env` (do not print full key)
curl.exe -sS http://192.168.50.181:8767/v1/models -H "Authorization: Bearer $env:OPENAI_API_KEY"
# POST /v1/chat/completions with JSON file body, model overloaded-local
# Restart binaries with .env loaded into process env, then:
# POST /api/auth/login (X-CSRF-Token + cookie)
# POST /api/campaigns → POST /api/campaigns/{id}/generate {"mode":"ai"}
# POST /api/seo/apply {"product_id":"…","mode":"ai"}
# POST /api/processing/jobs {"raw_product_ids":["…"],"processing_type":"enhance_only"}
```
## Worker / API path
- `apps/api/bin/api.exe` + `apps/api/bin/worker.exe` (must inherit `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL` from `root `.env``).
- `scripts/run-api.ps1` loads `.env` then `go run` (binaries used for this smoke instead).
## Notes
- Do not commit `.env` or full API keys.
- Cookie jar for session smoke: `artifacts/green-chat-smoke-cookies.txt` (local only).
+172
View File
@@ -0,0 +1,172 @@
# Descrybe web — GTM container import
Importable Google Tag Manager workspace for `apps/web` analytics (`$lib/analytics`, Consent Mode v2, SPA `page_view`).
| File | Purpose |
|------|---------|
| [`descrybe-web-container.json`](./descrybe-web-container.json) | GTM export format version 2 — tags, triggers, variables |
No real secrets. Placeholders only: `G-XXXXXXXX`, `GTM-XXXXXXX`.
## Import fix notes
### Invalid `Parameter.type` enum (`STRING`)
GTM import failed with:
> File format is invalid. Error deserializing enum type [Type]. Unrecognized value [STRING].
**Root cause:** a previous edit set `consentSettings.consentType` list items to `"type": "STRING"`, matching outdated API prose (“list item is of type STRING”). That value is **not** in the real Parameter type enum.
Official GTM API `Parameter.type` values (camelCase in the API; **SNAKE_CASE** in container import/export JSON):
| API | Import/export JSON |
|-----|--------------------|
| `template` | `TEMPLATE` |
| `integer` | `INTEGER` |
| `boolean` | `BOOLEAN` |
| `list` | `LIST` |
| `map` | `MAP` |
| `triggerReference` | `TRIGGER_REFERENCE` |
| `tagReference` | `TAG_REFERENCE` |
There is **no** `string` / `STRING`. Text values (including consent type names like `analytics_storage`) use **`TEMPLATE`**.
This file now uses:
```json
"consentSettings": {
"consentStatus": "NEEDED",
"consentType": {
"type": "LIST",
"list": [{ "type": "TEMPLATE", "value": "analytics_storage" }]
}
}
```
### Invalid `{` / `}` in entity names
GTM rejects `{` / `}` in **tag / trigger / variable names**. An earlier draft named a tag `GA4 Event - Custom Event ({{Event}})`, which failed import with *“The name contains invalid character: `{`”*.
That tag is now **`GA4 Event - Catch-all Custom Events`**. The GA4 **event name parameter** still uses `{{Event}}` (built-in Event variable) — macro syntax is valid in parameter **values**, not in entity names.
## Prerequisites
1. Create a **GA4** property and note its Measurement ID (`G-…`).
2. Create a **GTM Web** container (or use an existing empty workspace). Note the container public ID (`GTM-…`).
3. App loads GTM only when `PUBLIC_GTM_ID` is a valid `GTM-…` id (see root `.env.example` and `apps/web/src/lib/analytics.ts`).
## Import steps (re-import)
1. Open [Google Tag Manager](https://tagmanager.google.com/) → your **Web** container.
2. **Admin****Import Container**.
3. Choose `docs/gtm/descrybe-web-container.json`.
4. Choose a **workspace** (new or existing).
5. Import option:
- Prefer **Merge** into an empty/new workspace, or
- **Overwrite** only if you intend to replace the workspace contents.
- If you previously failed mid-import or have a partial copy, use a **fresh workspace** (or Overwrite) so you do not keep the old invalid-named tag.
6. After import, open **Variables****Constant - GA4 Measurement ID** and set the value to your real Measurement ID (replace `G-XXXXXXXX`).
7. **Consent (verify):** each GA4 tag imports with **Additional Consent Checks** requiring **`analytics_storage`** (`consentStatus` = `NEEDED`, list item type `TEMPLATE`). If Preview shows tags firing without analytics consent, open each GA4 tag → **Advanced settings****Consent Settings** → require `analytics_storage`. Add `ad_storage` / `ad_user_data` / `ad_personalization` only if you later add ads tags.
8. **Preview** with Tag Assistant against a local/staging site that has analytics consent granted.
9. **Submit****Publish**.
10. Set **`PUBLIC_GTM_ID`** in the web env to the containers public ID (`GTM-…`) so it matches the published container. The placeholder `GTM-XXXXXXX` in the JSON is **not** a real container id — GTM assigns the public ID when you create the container; import merges into *that* container.
## What the container includes
### Variables
| Name | Type | Notes |
|------|------|--------|
| `Constant - GA4 Measurement ID` | Constant | **Replace** `G-XXXXXXXX` |
| `DL - value` / `currency` / `items` / `transaction_id` | Data Layer | Ecommerce fields (version 2) |
| `DL - page_path` / `page_title` / `page_location` | Data Layer | SPA `page_view` |
| `DL - billing_term` / `plan` / `pack_id` | Data Layer | Flat extras on checkout/purchase |
Built-ins enabled: Event, Page URL, Page Path, Page Hostname, Referrer.
### Triggers (Custom Event only)
| Trigger | Event name |
|---------|------------|
| `CE - page_view` | `page_view` |
| `CE - begin_checkout` | `begin_checkout` |
| `CE - purchase` | `purchase` |
| `CE - checkout_canceled` | `checkout_canceled` |
| `CE - sign_up` | `sign_up` |
| `CE - login` | `login` |
| `CE - generate_lead` | `generate_lead` |
| `CE - processing_job_started` | `processing_job_started` |
| `CE - consent_update` | `consent_update` |
| `CE - Other Custom Events (catch-all)` | Regex excluding the above + `gtm.*` |
**Do not** add History Change or GA4 enhanced-measurement automatic `page_view` — the app already pushes `page_view` from `trackPageview` / `AnalyticsHost`.
### Tags
| Tag | Fires on | Behavior |
|-----|----------|----------|
| `GA4 Configuration` | Initialization (built-in `2147479573`) | `sendPageView=false`; consent: `analytics_storage` |
| `GA4 Event - page_view` | `CE - page_view` | Sends `page_path`, `page_title`, `page_location` |
| `GA4 Event - begin_checkout` | `CE - begin_checkout` | Maps `currency`, `value`, `items`, plus `billing_term` / `plan` / `pack_id` |
| `GA4 Event - purchase` | `CE - purchase` | Same ecommerce fields + `transaction_id` + flat extras |
| `GA4 Event - Catch-all Custom Events` | Named core events + catch-all | Event name parameter = `{{Event}}` (dataLayer event name) |
## Ecommerce mapping (`begin_checkout` / `purchase`)
Expected dataLayer shape (flat, same object as `event`):
```js
{
event: "begin_checkout", // or "purchase"
currency: "USD",
value: 99,
items: [
{
item_id: "pro",
item_name: "Pro",
item_category: "subscription", // or "credit_pack"
price: 99,
quantity: 1
}
],
// purchase only:
transaction_id: "cs_test_…",
// optional flat extras:
billing_term: "monthly",
plan: "pro",
pack_id: "pack_…"
}
```
Dedicated tags map those DL variables into GA4 event parameters. **List / marketing prices may be approximates** (e.g. plan card pricing before Stripe tax/discounts); use Stripe / server data for finance-grade reporting.
## Consent
The app sets Consent Mode v2 defaults to denied, then updates via the CMP (`updateConsentMode` + `consent_update` dataLayer event). GTM tags are gated with **Additional Consent Required → `analytics_storage`**. Client-side `trackEvent` / `trackPageview` also no-op until analytics is granted.
## After publish — env checklist
```text
PUBLIC_GTM_ID=GTM-XXXXXXXX # must match the published container public ID
```
Do not put the GA4 Measurement ID in the web app env for primary loading — measurement ID lives in the GTM variable; the app loads GTM only.
## Re-import after this fix
If a previous import failed on `STRING` (or left a partial workspace):
1. Use a **new workspace** (or **Overwrite** on an empty one) so you do not keep a half-imported state.
2. **Admin****Import Container** → choose `docs/gtm/descrybe-web-container.json`.
3. Confirm all five GA4 tags show Consent → require `analytics_storage`.
4. Set **Constant - GA4 Measurement ID**, Preview, then Publish.
## Limitations / manual fix-ups
- **Placeholder account/container IDs** (`0000000000` / `GTM-XXXXXXX`) are rewritten by GTM on import into *your* container. If import rejects metadata, create an empty Web container first, then **Merge** this file.
- **Fingerprints** are synthetic; GTM regenerates them after import.
- **Catch-all** does not forward arbitrary event parameters to GA4 (only the event name). Dedicated ecommerce tags carry `value` / `currency` / `items` / `transaction_id`. Add event-parameter tables for other events if needed.
- **No ads tags** in this export — no `ad_*` consent requirements yet.
- **`items` as a GA4 event parameter** relies on GTM/GA4 accepting the DL array via the event settings table. If Preview shows empty items, switch the ecommerce tags to “Send ecommerce data” and push an `ecommerce: { … }` object from the app, or verify in Tag Assistant that `{{DL - items}}` resolves.
- Re-export from GTM after your first successful import if you want a fingerprint-perfect baseline in git.
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
# Live auth security verification
Date: 2026-08-04
Environment: local Descrybe v2 (API http://127.0.0.1:8080, web http://127.0.0.1:5174)
Demo credentials: see [demo-user.md](demo-user.md) (local only).
## Scope
Live checks against a running API + web app:
1. Login / logout (session cookie)
2. CSRF rejection when `X-CSRF-Token` is missing or wrong
3. API key auth (`Authorization: Bearer` and `X-API-Key`)
4. Invite flow without incorrectly leaking tokens in the UI/HTML
Related static notes: [security-notes.md](security-notes.md).
## Results (2026-08-04)
**23 / 23 checks passed** after the accept-invite UI fix below.
| Area | Result | Notes |
|------|--------|-------|
| CSRF cookie issued on GET under CSRF middleware | PASS | `descrybe_csrf`, 32 hex chars, not HttpOnly (readable by JS for double-submit) |
| POST `/api/auth/login` without CSRF header | PASS | HTTP 403 `csrf token mismatch` |
| POST `/api/team/invites` without CSRF (authenticated) | PASS | HTTP 403 |
| POST `/api/auth/logout` without CSRF | PASS | HTTP 403 |
| Login bad password | PASS | HTTP 401 |
| Login demo user + CSRF | PASS | HTTP 200; sets `descrybe_session` HttpOnly |
| GET `/api/auth/me` after login | PASS | Session identity returned |
| Logout with CSRF | PASS | HTTP 200; subsequent `/api/auth/me` returns 401 |
| Create invite (SMTP off) | PASS | `mail_sent=false`; token returned once in create response |
| GET `/api/team/invites` | PASS | Pending list has no `token` field |
| GET `/api/api-keys` | PASS | Prefix only; no plaintext `dk_` secret |
| `Authorization: Bearer` demo key to `/api/v1/products` | PASS | HTTP 200 |
| `X-API-Key` demo key | PASS | HTTP 200 |
| Bad / missing API key | PASS | HTTP 401 |
| `/api/v1/*` skips cookie CSRF | PASS | Key auth only |
| Accept-invite page with `?token=` | PASS | Token not in HTML; no visible token field; shows Invite link recognized |
| Accept invite + reuse | PASS | Accept 200; second accept 400 |
### Harness note
PowerShell `WebRequestSession.Headers` persists `X-CSRF-Token` across calls. CSRF-negative tests must call `$session.Headers.Clear()` (or use a client that does not cache custom headers). Without clearing, a missing-CSRF probe can false-pass.
## Fix applied
**Problem:** `/accept-invite?token=...` bound the secret into a visible `type="text"` input (reflected into the DOM). That is incorrect for email-link acceptance.
**Change:** `apps/web/src/routes/accept-invite/+page.svelte`
- If `token` is present in the query string: keep it only in JS state, show a link-recognized message (no token value in HTML), and `history.replaceState` to strip `token` from the address bar.
- If no query token: show a masked `type="password"` paste field with `autocomplete="off"`.
- Settings: `autocomplete="off"` on one-time invite accept-link and new API key display fields.
Also confirmed already correct:
- Create-invite returns `token` only when mail was not delivered (`inviteMailResult`).
- List invites / list API keys never return full secrets.
- New API key plaintext is shown once in a dismissible dialog; table shows `key_prefix` only.
## How to re-run (manual)
```powershell
# From repo root with API on :8080 and web on :5174
$base = "http://127.0.0.1:8080"
$s = New-Object Microsoft.PowerShell.Commands.WebRequestSession
try { Invoke-WebRequest "$base/api/auth/me" -WebSession $s -UseBasicParsing | Out-Null } catch {}
$csrf = ($s.Cookies.GetCookies([uri]$base) | ? Name -eq descrybe_csrf).Value
# Expect 403
$s.Headers.Clear()
try { Invoke-WebRequest "$base/api/auth/login" -Method POST -WebSession $s -ContentType "application/json" -Body '{"email":"x","password":"y"}' -UseBasicParsing } catch { [int]$_.Exception.Response.StatusCode }
# Login
Invoke-WebRequest "$base/api/auth/login" -Method POST -WebSession $s -Headers @{"X-CSRF-Token"=$csrf} `
-ContentType "application/json" -Body '{"email":"demo@descrybe.local","password":"DemoPass123!"}' -UseBasicParsing
# API key
Invoke-WebRequest "$base/api/v1/products?limit=1" -Headers @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1" } -UseBasicParsing
# Accept-invite HTML must not contain the probe token
$tok = "LIVESEC_TOKEN_SHOULD_NOT_SSR_LEAK_ABCDEF"
$html = (Invoke-WebRequest "http://127.0.0.1:5174/accept-invite?token=$tok" -UseBasicParsing).Content
$html.Contains($tok) # expect False
$html -match "Invite token" # expect False when ?token= present
```
Unit coverage (no live server):
```bash
cd apps/api && go test ./internal/httpapi/ -run "CSRF|InviteMail" -count=1
```
Note: as of this run, `go test ./internal/httpapi` failed to compile due to an unrelated missing `strconv` import in `internal/shopify/orders_sync.go`. Live HTTP checks above are the source of truth for this doc.
## Residual risks
1. Invite/reset links still use `?token=` -- the first request URL (and possibly reverse-proxy access logs) can contain the secret until the client strips it. Prefer `#token=` or a short-lived exchange code for a future hardening pass.
2. Operator one-time accept link on Settings (when SMTP is off) intentionally shows the full URL once so it can be copied -- dismiss after sharing; do not leave the tab open on a shared screen.
3. Demo API key / password in [demo-user.md](demo-user.md) are local-only; rotate or disable before shared staging.
4. In-process CSRF/session controls are not a substitute for HTTPS + `SessionSecure=true` in production.
5. **No self-serve forgot-password.** Password recovery for established accounts is operator/admin (migration set-password re-issue only). Do not relax `must_set_password` / `SetPassword` for public reset — see [forgot-password.md](forgot-password.md).
## Contract preserved
- Dashboard `/api/*` (non-v1): session cookie + CSRF double-submit (`X-CSRF-Token` equals `descrybe_csrf`).
- Public `/api/v1/*`: API key only; CSRF skipped.
- Invite plaintext token: create response only when undelivered; never on list.
- API key plaintext: create response only; list returns `key_prefix`.
+118
View File
@@ -0,0 +1,118 @@
# Live credits test (Local Demo Co)
**Date:** 2026-08-04
**Tenant:** Local Demo Co (`ee246275-dec0-4446-9e83-58d0c16c258a`) via `demo@descrybe.local`
**Stack:** API `:8080`, web `:5174` (Vite proxy), worker + Green Chat LLM, Postgres `:5433`
**Proof rule:** Live wallet deltas only. **No `seed-demo` / AssignPlan reset used as proof.**
## Verdict
**PASS.** SEO AI, campaign AI, and `enhance_only` each debited **2** credits (formula-aligned).
`/api/billing/credits`, `/api/billing/usage?range=30d`, `/api/auth/me`, open `billing_cycles.credits_used`, and `credit_balances` all agreed after the run.
Billing page HTTP **200**; UI APIs (same session via `:5174` proxy) matched the DB.
Enterprise remaining **label** stays “Unlimited”; numeric **used / total** reflect the wallet.
Wallet was **not** restored to 1M — burn was small (28 used of 1,000,000).
## Recorded balances
| Checkpoint | total | used | remaining | open cycle used |
|------------|------:|-----:|----------:|----------------:|
| Before this run | 1,000,000 | 22 | 999,978 | 22 |
| After SEO + campaign + enhance | 1,000,000 | 28 | 999,972 | 28 |
| **Delta (this run)** | — | **+6** | **6** | **+6** |
Pre-existing `used=22` came from earlier live AI activity in the same open cycle (not from seed).
## Cost table (active)
| feature_name | cost_per_unit |
|--------------|--------------:|
| `seo_meta_ai` | 1 |
| `campaign_copy` | 1 |
| `product_processing` | 1 |
| `openai_token_k` | 1 |
**Debit formula** (`billing.ConsumeCredits` / `EstimateDebit`):
`debit = cost(feature) + ceil(tokens/1000) * cost(openai_token_k)` (minimum 1 when charging).
With base=1 and tokens in (1..1000], expected debit = **2**.
## Operations
| Step | Endpoint | Result | Wallet debit | Notes |
|------|----------|--------|-------------:|-------|
| SEO AI | `POST /api/seo/apply` `mode=ai` product `d2c309ed-…` (Sample Gadget) | 200 | **2** | `credits_charged=2` matched Δused; title filled |
| Campaign AI | `POST /api/campaigns``…/{id}/generate` `mode=ai` | 201/200 | **2** | Campaign `6c135dd1-…`; subject “Upgrade your home essentials” |
| Enhance | `POST /api/processing/jobs` `enhance_only` raw `fa4d70c7-…` | 202→completed | **2** | Job `6a4f09fc-…`; steps `normalize`+`ai_enhance` done |
| **Sum** | | | **6** | Equals SEO+campaign+enhance |
## Billing UI / API consistency
Same browser-origin session (`http://127.0.0.1:5174`, cookies `descrybe_session` + `descrybe_csrf`):
| Surface | used | remaining | plan |
|---------|-----:|----------:|------|
| DB `credit_balances` | 28 | 999972 | Enterprise |
| `GET /api/billing/credits` | 28 | 999972 | Enterprise |
| `GET /api/billing/usage?range=30d` | 28 | 999972 | (wallet fields) |
| `GET /api/auth/me` → credits | 28 | 999972 | Enterprise |
| Open `billing_cycles` | 28 | — | — |
| `GET /billing` | HTTP 200 | — | — |
UI note: `formatCreditsRemaining` shows **Unlimited** for Enterprise; the “used of total” line stays numeric and matched the API.
## Checks (all PASS)
- SEO debit == `credits_charged` (2)
- Campaign debit ≥ 1 (2)
- Enhance debit ≥ 1 (2)
- Total == sum of three (6)
- Credits API == DB
- Usage API == DB
- `/auth/me` == DB
- Plan Enterprise; active company Local Demo Co
- Open cycle `credits_used` == wallet `used_credits`
Artifact: `artifacts/live-credits-summary.json` (`seeded: false`).
## Restore policy
Restore Enterprise **1,000,000 / 0** only if the demo wallet is depleted enough to block QA (e.g. near empty or wrong plan).
After this run: **28 used / 999,972 remaining****no restore**.
If restore is ever required for ops (not as debit proof):
```sql
-- operational reset only — not a substitute for live debit proof
UPDATE credit_balances
SET total_credits = 1000000, used_credits = 0, updated_at = now()
WHERE company_id = 'ee246275-dec0-4446-9e83-58d0c16c258a';
```
Prefer `AssignPlan`/admin billing over raw SQL when resetting plan windows; still do **not** treat that reset as evidence that debits work.
## Reproduce (PowerShell sketch)
```powershell
# Session against web origin so Billing UI cookies apply
$web = "http://127.0.0.1:5174"
$jar = "artifacts/live-credits-run-cookies.txt"
# GET /api/auth/me → CSRF; POST /api/auth/login; POST /api/auth/select-company
# Record GET /api/billing/credits + DB credit_balances
# POST /api/seo/apply {"product_id":"d2c309ed-fa99-4785-aa72-4f7d180f1f85","mode":"ai"}
# POST /api/campaigns {"name":"…","template_key":"spring","use_default_prompt":true}
# POST /api/campaigns/{id}/generate {"mode":"ai"}
# POST /api/processing/jobs {"raw_product_ids":["fa4d70c7-938a-405e-ada7-f7341eb64b53"],"processing_type":"enhance_only"}
# Poll job; re-read credits / usage / me / DB; assert deltas
```
Use raw JSON bodies (avoid PowerShell `ConvertTo-Json` boolean/`True` quirks on create).
## Related
- [billing-credits-audit.md](billing-credits-audit.md) — prior grant/debit/UI fixes
- [ai-full-smoke.md](ai-full-smoke.md) — Green Chat feature smoke
- [demo-user.md](demo-user.md) — credentials / Enterprise pack
- [free-tier.md](free-tier.md) — Free = 0 AI credits
+139
View File
@@ -0,0 +1,139 @@
# Live E2E — local Descrybe v2 (real HTTP, not seed-only)
**Verdict: PASS (21/21)**
**Window (UTC):** `2026-08-04T01:15:00.966Z``2026-08-04T01:16:02.300Z` (~61s)
**Tenant:** `demo@descrybe.local`**Local Demo Co** (`ee246275-dec0-4446-9e83-58d0c16c258a`)
**API:** `http://127.0.0.1:8080` (process `api.exe`, healthz/readyz 200)
**Worker:** `worker.exe` running (required for process job completion)
**Web:** `http://127.0.0.1:5174` (primary) and `:5173` (also 200; prefer **5174**)
**Build checks after run:** `go build ./...` exit 0 · `go vet ./...` exit 0 · `npm run check` (web) 0 errors
Raw machine log: `artifacts/live-e2e-summary.json`, `artifacts/live-e2e-run.jsonl`.
This run **executed** login, list, mapping PUT, process queue+poll, export generate, public download, campaign create+generate, and SEO apply. Counts and bytes below come from live responses, not from assuming seed state.
## Services (fail-closed)
| Probe | HTTP | UTC | Body / note |
|-------|-----:|-----|-------------|
| `GET /healthz` | **200** | 01:15:01.013Z | `{"status":"ok","maintenance":false,...}` |
| `GET /readyz` | **200** | 01:15:01.056Z | `{"status":"ready",...}` |
| `GET http://127.0.0.1:5174/` | **200** | 01:15:01.562Z | SvelteKit HTML |
| `GET http://127.0.0.1:5173/` | **200** | 01:15:01.580Z | HTML (secondary Vite; do not confuse with v2 app) |
If healthz had failed, the suite exits before auth.
## Auth
| Step | HTTP | UTC | Evidence |
|------|-----:|-----|----------|
| Session CSRF + `POST /api/auth/login` | **200** | 01:15:01.722Z | `demo@descrybe.local`, company **Local Demo Co** |
| `GET /api/auth/me` (same session) | **200** | (with login) | `company_id=ee246275-dec0-4446-9e83-58d0c16c258a`, `remaining_credits=999996` |
| Vite proxy login `POST http://127.0.0.1:5174/api/auth/login` | **200** | post-suite check | `GET .../api/auth/me` → Local Demo Co |
API key used for v1 product/feed/process/export calls: `Authorization: Bearer dk_demo_local_descrybe_test_key_v1`.
## Products & feeds
| Step | HTTP | UTC | Evidence |
|------|-----:|-----|----------|
| `GET /api/v1/products?limit=5` | **200** | 01:15:01.764Z | **total=4330**; sample `d2c309ed-fa99-4785-aa72-4f7d180f1f85` |
| `GET /api/v1/products?kind=raw&limit=5` | **200** | 01:15:01.918Z | 5 IDs returned (e.g. `fa4d70c7-938a-405e-ada7-f7341eb64b53`, …) |
| `GET /api/v1/feeds` | **200** | 01:15:01.964Z | **13** feeds; target **Vama Trade** `b5fc7c4c-830b-4d6e-a90c-9b72800a89a1` status=`active` |
| `GET .../feeds/{id}/mappings` | **200** | 01:15:01.990Z | **13** mappings |
| `PUT .../feeds/{id}/mappings` | **200** | 01:15:02.038Z | Re-saved 13 mappings; mapping row id `26951cb8-704d-423f-8357-4c8ff5a43720` |
## Process job (queued + polled to completion)
| Step | HTTP | UTC | Evidence |
|------|-----:|-----|----------|
| `POST /api/v1/process` | **202** | 01:15:02.081Z | **job_id=`a84b26db-c39f-4491-9623-90e68e411144`**, `status=pending`, `total_products=3`, `processing_type=normalize` |
| Poll `GET /api/v1/process/{id}` | **200** | running → **completed** at 01:15:10.214Z | `processed_products=3/3`, `current_step=done` |
Step progress (live GET after complete):
| Step | Status |
|------|--------|
| normalize | done |
| parse_specs | done |
| fill_fields | done |
| eprel | skipped (`no_id`) |
| ai_enhance | done |
Worker must be running; a stuck `pending`/`running` past the 3-minute poll window would be **FAIL**.
## Export generate + public URLs
| Step | HTTP | UTC | Evidence |
|------|-----:|-----|----------|
| `GET /api/v1/export-feeds` | **200** | 01:15:10.239Z | **3** export feeds |
| `POST .../export-feeds/d52bc02c-…/generate` (XML - Example) | **200** | 01:15:10.598Z | `status=completed`, **products_exported=4322**, `last_generated_at=2026-08-04T01:15:10Z` |
| `GET /api/public/export-feeds/f2321bf2d18609b7abf5ea0f0f9fbb86.xml` | **200** | 01:15:41.570Z | **10,171,817** bytes; starts with `<?xml version="1.0"...><products feed="XML - Example">` |
| `POST .../export-feeds/df6073cf-…/generate` (Black Friday 2026 CSV) | **200** | 01:15:42.141Z | `status=completed`, **products_exported=4316**, `last_generated_at=2026-08-04T01:15:42Z` |
| `GET /api/public/export-feeds/1d00586848b0cded58b49dfb0f615d64.csv` | **200** | 01:15:55.072Z | **8,908,695** bytes; CSV header `availability,brand,condition,...` |
Public downloads prove generate wrote real artifacts (multiMB bodies), not empty placeholders.
## Campaign generate
| Step | HTTP | UTC | Evidence |
|------|-----:|-----|----------|
| `POST /api/campaigns` | **201** | 01:15:55.114Z | **id=`a5a1759e-c729-4409-9f50-9fbb81658ea9`**, name `Live E2E 20260804-031555`, `template_key=spring`, `status=draft` |
| `POST /api/campaigns/{id}/generate` `mode=ai` | **200** | 01:16:00.678Z | `status=ready` (~5.5s) |
| Follow-up `GET /api/campaigns/{id}` | **200** | post-suite | `status=ready`, subject **Upgrade your home essentials** |
## SEO apply
| Step | HTTP | UTC | Evidence |
|------|-----:|-----|----------|
| `POST /api/seo/apply` `{product_id, mode:ai}` | **200** | 01:16:01.913Z | product `d2c309ed-…`; `mode=ai`; **credits_charged=2**; `meta_title=Sample Gadget \| Premium Wireless Connectivity` |
| `GET /api/seo/recommendations` | **200** | 01:16:02.298Z | response **83,536** bytes |
## Step matrix
| # | Step | HTTP | Result | UTC |
|--:|------|-----:|--------|-----|
| 1 | healthz | 200 | PASS | 01:15:01.013Z |
| 2 | readyz | 200 | PASS | 01:15:01.056Z |
| 3 | web_5174 | 200 | PASS | 01:15:01.562Z |
| 4 | web_5173 | 200 | PASS | 01:15:01.580Z |
| 5 | login | 200 | PASS | 01:15:01.722Z |
| 6 | list_products | 200 | PASS | 01:15:01.764Z |
| 7 | list_raw_products | 200 | PASS | 01:15:01.918Z |
| 8 | list_feeds | 200 | PASS | 01:15:01.964Z |
| 9 | get_mappings | 200 | PASS | 01:15:01.990Z |
| 10 | put_mappings | 200 | PASS | 01:15:02.038Z |
| 11 | queue_process | 202 | PASS | 01:15:02.081Z |
| 12 | poll_process | 200 | PASS | 01:15:10.214Z |
| 13 | list_export_feeds | 200 | PASS | 01:15:10.239Z |
| 14 | export_generate_xml | 200 | PASS | 01:15:10.598Z |
| 15 | public_export_xml | 200 | PASS | 01:15:41.570Z |
| 16 | export_generate_csv | 200 | PASS | 01:15:42.141Z |
| 17 | public_export_csv | 200 | PASS | 01:15:55.072Z |
| 18 | campaign_create | 201 | PASS | 01:15:55.114Z |
| 19 | campaign_generate | 200 | PASS | 01:16:00.678Z |
| 20 | seo_apply | 200 | PASS | 01:16:01.913Z |
| 21 | seo_recommendations | 200 | PASS | 01:16:02.298Z |
## Failures / fixes this run
**None.** No application code changes required; all exercised paths returned success codes and completed job/export/campaign/SEO side effects.
## How to re-run
Prerequisites: Postgres (`docker compose` on `:5433`), API `:8080`, worker, web `:5174`, demo user (`seed-demo`).
```powershell
# Fail closed if API down
curl.exe -sf http://127.0.0.1:8080/healthz
curl.exe -sf http://127.0.0.1:8080/readyz
# Session login needs CSRF cookie then X-CSRF-Token on POST /api/auth/login
# v1 calls: Authorization: Bearer dk_demo_local_descrybe_test_key_v1
# Process: POST /api/v1/process → poll GET /api/v1/process/{id} until completed
# Export: POST /api/v1/export-feeds/{id}/generate → GET /api/public/export-feeds/{token}.{xml|csv}
# Campaign: POST /api/campaigns → POST /api/campaigns/{id}/generate {mode:ai}
# SEO: POST /api/seo/apply {product_id, mode:ai}
```
Related: [e2e-feeds-process-export.md](e2e-feeds-process-export.md), [e2e-marketing.md](e2e-marketing.md), [demo-user.md](demo-user.md), [qa-local-demo.md](qa-local-demo.md).
+95
View File
@@ -0,0 +1,95 @@
# Live export channels (Google Shopping / Meta / custom)
**When:** 2026-08-04T03:17+02:00
**Tenant:** Local Demo Co (`ee246275-dec0-4446-9e83-58d0c16c258a`)
**API:** http://127.0.0.1:8080
**Web:** http://127.0.0.1:5174/export-feeds
**Auth:** `Authorization: Bearer dk_demo_local_descrybe_test_key_v1`
## What shipped
Export Feeds create menu + dialog expose clear channel presets:
| Preset | Format | Purpose |
|--------|--------|---------|
| Google Shopping (CSV) | CSV | Merchant Center scheduled fetch |
| Google Shopping (XML) | XML (`g:*` elements) | Merchant Center XML feed |
| Meta catalog (CSV) | CSV | Meta Commerce Manager data feed |
| Custom CSV / XML | CSV or XML | Any partner / webhook |
UI copy explains where to paste the public URL (Merchant Center → Products → Feeds, or Meta Commerce Manager → Catalog → Data sources). Templates store `channel` (`google_shopping` | `meta` | `custom`) with field mappings.
**Aug-8:** Wrong extension on a valid public token → opaque **404** (was **400**). **BREAKING** for clients that branched on format-mismatch 400. Only the matching `.xml`/`.csv` URL is advertised.
## Live create + public curl (this run)
### Created feeds
| Name | ID | Token | Generate |
|------|-----|-------|----------|
| Google Shopping CSV (live) | `ae8bd969-4bff-4520-9572-bffda0ebc98a` | `c06f5f17afcf1f26a4295931947140ce` | **4328** products, `completed` |
| Google Shopping XML (live) | `799ba83a-6a7e-4e1d-b5de-c02182aacec5` | `9bf8905985e4c2bb2e5f6a0f89ddc1b6` | **4328** products, `completed` |
### Public URLs (no auth)
```text
http://127.0.0.1:8080/api/public/export-feeds/c06f5f17afcf1f26a4295931947140ce.csv
http://127.0.0.1:8080/api/public/export-feeds/9bf8905985e4c2bb2e5f6a0f89ddc1b6.xml
```
### curl results
| URL | HTTP | Content-Type | Size |
|-----|------|--------------|------|
| CSV | **200** | `text/csv; charset=utf-8` | ~9.1 MB |
| XML | **200** | `application/xml; charset=utf-8` | ~10.6 MB |
CSV header:
```csv
id,title,description,link,image_link,availability,price,brand,gtin,google_product_category,condition
```
XML sample (namespace elements preserved):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<products feed="Google Shopping XML (live)">
<product>
<g:title>Sample Gadget</g:title>
<g:description>...</g:description>
<g:gtin>8700000000002</g:gtin>
<g:google_product_category>Gadgets</g:google_product_category>
</product>
...
```
## Reproduce
```powershell
$H = @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1"; "Content-Type" = "application/json" }
$base = "http://127.0.0.1:8080"
# Create (template.channel + Google fields) → POST /api/v1/export-feeds
# Generate → POST /api/v1/export-feeds/{id}/generate
# Public → GET /api/public/export-feeds/{token}.csv|.xml
curl.exe -s -o NUL -w "CSV %{http_code}\n" "$base/api/public/export-feeds/c06f5f17afcf1f26a4295931947140ce.csv"
curl.exe -s -o NUL -w "XML %{http_code}\n" "$base/api/public/export-feeds/9bf8905985e4c2bb2e5f6a0f89ddc1b6.xml"
# Preview headers only (GET — HEAD returns 405):
curl.exe -s "$base/api/public/export-feeds/c06f5f17afcf1f26a4295931947140ce.csv" | Select-Object -First 2
curl.exe -s "$base/api/public/export-feeds/9bf8905985e4c2bb2e5f6a0f89ddc1b6.xml" | Select-Object -First 8
```
## UI path
1. Login as `demo@descrybe.local` / `DemoPass123!`
2. Open **Export Feeds**
3. **Create Export Feed** → pick Google Shopping / Meta / Custom
4. Save → **Refresh Feed****Copy** public URL
## Notes
- Missing store attributes (`link`, `price`, …) export as empty cells/elements until mapped on the product; Google/Meta still accept the feed shape.
- `gtin` / `ean` sources resolve from product attributes (not SKU/id).
- XML output keys may use a single namespace colon (`g:id`) for Merchant Center.
+101
View File
@@ -0,0 +1,101 @@
# Live: Add Feed - URL preview/sync + CSV upload (Local Demo Co)
**When:** 2026-08-04
**Tenant:** Local Demo Co (`ee246275-dec0-4446-9e83-58d0c16c258a`)
**API:** http://127.0.0.1:8080
**Web:** http://127.0.0.1:5174
**Auth:** `Authorization: Bearer dk_demo_local_descrybe_test_key_v1`
## Verdict
**PASS** (real HTTP against Local Demo Co; worker running). No fake passes.
| Flow | Result |
|------|--------|
| URL create -> extract-schema (preview) -> map -> sync -> sample process | PASS |
| CSV multipart create -> extract-schema -> map -> sync -> sample process | PASS |
| Sync before mappings | **400** `no field mappings defined for feed` (expected) |
## Correct order
```
Add Feed (URL or CSV file)
-> status = unmapped
-> POST .../extract-schema (preview fields)
-> PUT .../mappings (status -> mapped; sets options.item_path for XML)
-> POST .../sync (raw_products)
-> POST .../sync-process-sample (or POST /api/v1/process)
```
Do **not** sync before mappings. FTP/FTPS supplier URLs still return 400 (`ftp/ftps feed sync is not supported yet`).
## Fixes applied during this live run
1. **New feeds start as `unmapped`** (`Create` INSERT in `internal/feeds/service.go`) so Sync stays gated until mapping; PUT mappings flips `unmapped` -> `mapped`.
2. **Feeds UI** (`apps/web/src/routes/feeds/+page.svelte`): after create, navigate to `/feeds/{id}/mapping`; block Sync when status is `unmapped`; sync toast no longer says "Next: map fields" after a successful sync.
## Evidence - URL (HTTPS Enim sample)
| Step | HTTP | Notes |
|------|------|-------|
| `POST /api/v1/feeds` JSON | 201 | Feed `453f6efa-...` (Live URL Test); Enim XML URL |
| `POST .../extract-schema` | 200 | 17 fields, `item_path=Export/Item`, preview present |
| Sync without map | 400 | `no field mappings defined for feed` |
| `PUT .../mappings` | 200 | name/brand/gtin/... |
| `POST .../sync` | 202 | **34** products synced, job `completed` |
| `POST .../sync-process-sample` (`limit:3`, `skip_sync:true`, `normalize`) | 202 | job `ae90ffc4-...` -> **completed** |
## Evidence - CSV upload
| Step | HTTP | Notes |
|------|------|-------|
| `POST /api/v1/feeds` multipart (`name`, `feed_type=csv`, `file`) | 201 | Feed `8d1d9e53-...`; `options.source_path` + `source_kind=csv` |
| `POST .../extract-schema` | 200 | 7 fields (EAN, Title, ...); preview = CSV head |
| `PUT .../mappings` | 200 | gtin/name/description/brand/price/stock/main_image |
| `POST .../sync` | 202 | **3** products synced |
| Sample process | 202 -> completed | job `53fede44-...`; all 3 raw -> `processed`; 3 processed products (quality grade C) |
## Retest after fix (CSV)
| Step | Result |
|------|--------|
| Create | 201, **status=`unmapped`** (`474f5343-...`) |
| Sync early | 400 mappings required |
| Map | status -> **`mapped`** |
| Sync | 202, 1 product |
| Sample process | job completed |
## Copy/paste
```powershell
$H = @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1"; "Content-Type" = "application/json" }
$base = "http://127.0.0.1:8080/api/v1"
# URL create
Invoke-RestMethod -Method POST -Uri "$base/feeds" -Headers $H -Body (@{
name = "My URL feed"; url = "https://www.enim.si/xml/A11109019400_nov/A1nov.xml"
feed_type = "xml"; sync_interval_minutes = 1440
} | ConvertTo-Json)
# CSV create (curl multipart preferred on Windows)
# curl -H "Authorization: Bearer dk_demo_local_descrybe_test_key_v1" `
# -F "name=My CSV feed" -F "feed_type=csv" -F "file=@sample.csv;type=text/csv" `
# http://127.0.0.1:8080/api/v1/feeds
# Preview / map / sync / sample (replace FEED_ID)
Invoke-RestMethod -Method POST -Uri "$base/feeds/FEED_ID/extract-schema" -Headers $H -Body "{}"
# PUT mappings via --data-binary @file.json to avoid PowerShell quote mangling, then:
Invoke-RestMethod -Method POST -Uri "$base/feeds/FEED_ID/sync" -Headers $H -Body "{}"
Invoke-RestMethod -Method POST -Uri "$base/feeds/FEED_ID/sync-process-sample" -Headers $H -Body (@{
limit = 3; skip_sync = $true; processing_type = "normalize"
} | ConvertTo-Json)
```
Worker required: `scripts/run-api.ps1 worker` (or `bin/worker.exe`).
## Related
- [e2e-feeds-process-export.md](e2e-feeds-process-export.md) - map -> process -> export on existing demo feeds
- [qa-local-demo.md](qa-local-demo.md) - Local Demo Co checklist
- [demo-user.md](demo-user.md) - credentials
+125
View File
@@ -0,0 +1,125 @@
# Live full processing pipeline (Local Demo Co + Green Chat)
**Date:** 2026-08-04
**Tenant:** Local Demo Co (`ee246275-dec0-4446-9e83-58d0c16c258a`)
**LLM:** Green Chat `overloaded-local` at `http://192.168.50.181:8767/v1`
**API:** `http://127.0.0.1:8080`
**Worker:** `apps/api/bin/worker.exe` with AI configured (dashboard `/integrations/ai` or root `.env` `OPENAI_*`) + `EPREL_ENABLED=true`
## Pipeline (canonical `full`)
```
normalize -> parse_specs -> fill_fields -> eprel -> ai_enhance
```
| Step | Role |
|------|------|
| `normalize` | Flatten mapped/raw keys |
| `parse_specs` | Parse `specifications` into attributes |
| `fill_fields` | Fill missing scalars / standard fields |
| `eprel` | Fetch EU energy label when `eprel_id` present + enricher on |
| `ai_enhance` | Green Chat rewrite of name + description |
## Prerequisites
1. Postgres on `:5433` (`docker compose up -d`)
2. API + worker running; worker **must** inherit:
- `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL`
- `EPREL_ENABLED=true` (otherwise `eprel` soft-skips)
3. Green Chat LAN reachable: `Test-NetConnection 192.168.50.181 -Port 8767`
4. Demo API key: `dk_demo_local_descrybe_test_key_v1`
(or session login `demo@descrybe.local` / `DemoPass123!`)
Restart worker after `.env` changes:
```powershell
cd f:\laragon\www\_MY\descrybe-v2
# load root .env into process env (prefer over legacy apps/api/.env), then:
.\apps\api\bin\worker.exe
# or: .\scripts\run-api.ps1 worker
```
## Live run (this pass)
### Sample (3 raw products with real EPREL IDs)
| GTIN | Mapped name | eprel_id |
|------|-------------|----------|
| `8806088787671` | Kuhinjska napa SAMSUNG NK24M3050PS/U1 | `52351` |
| `8059019024967` | Sušilni stroj HOOVER NDEH11A2TCBEXS-S, 11KG | `2155436` |
| `8806088803005` | Vg. pomivalni stroj SAMSUNG DW60M6040BB/EO | `346772` |
### Job
```powershell
$H = @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1"; "Content-Type" = "application/json" }
$ids = @(
"457f844c-6ab3-49ea-96c8-43a7749f8c50",
"9455d6f0-1f34-435f-925e-b5ce8cdcb45f",
"e2a815a0-b172-4fcb-b64b-d19682371ef4"
)
$body = @{ raw_product_ids = $ids; processing_type = "full" } | ConvertTo-Json
$job = Invoke-RestMethod -Method POST -Uri http://127.0.0.1:8080/api/v1/process -Headers $H -Body $body
# poll:
Invoke-RestMethod "http://127.0.0.1:8080/api/v1/process/$($job.id)" -Headers $H
```
Dashboard alias: `POST /api/processing/jobs` (same body).
| Field | Result |
|-------|--------|
| Job id | `512ab7f6-1537-47ae-ac3a-6b78616c0a53` |
| Status | **completed** (~6s) |
| Progress | **3/3** |
| Steps | all five **`done`** (normalize -> parse_specs -> fill_fields -> eprel -> ai_enhance) |
| Worker log | `processed=3 failed=0 mode=internal` |
### Field verification (before → after)
| GTIN | Before name (snip) | After `processed_name` | After EPREL | Tokens |
|------|--------------------|------------------------|-------------|--------|
| `8806088787671` | Kuhinjska napa Samsung 60… | **Samsung NK24M3050PS/U Kitchen Hood** | class **D**, label URL set, `eprel_id=52351` | 472 |
| `8059019024967` | Sušilni stroj HOOVER NDE… | **Hoover H-DRY 500 Essential 11kg** | class **E**, label URL set, `eprel_id=2155436` | 586 |
| `8806088803005` | Pomivalni stroj Samsung… | **Samsung DW60M6040BB/EO Built-in Dishwasher** | class **E**, label URL set, `eprel_id=346772` | 382 |
Also confirmed:
- `updated_at` advanced (was ~2026-08-03 22:16Z → 2026-08-04 01:16Z)
- Descriptions rewritten in English via Green Chat (`gpt_response.steps[].raw.model = overloaded-local`)
- Specs → attributes: **4752** keys including brand / dimensions / `eprel_*`
- `field_sources.eprel = eprel_api`, `name = ai_enhance`
- `ai_provider_mode = internal`
**Verdict: PASS** — full live pipeline against Local Demo Co + Green Chat.
## Issues fixed this pass
| Issue | Fix |
|-------|-----|
| Worker had EPREL enricher **disabled** (`EPREL_ENABLED` unset → skip) | Restarted worker with `EPREL_ENABLED=true`; keep the flag in root `.env` so future worker starts keep EPREL on |
| Empty `eprel_id` keys are common (~17k rows have the key; ~1051 non-empty) | Sample selection used products with **non-empty** `mapped_data.eprel_id` so the eprel step actually hits the EU API |
No API route defects in this run: `POST /api/v1/process` returned 202-shaped pending job; poll to `completed` worked without code changes.
## How to re-run quickly
```powershell
Test-NetConnection 192.168.50.181 -Port 8767
curl.exe -sS http://127.0.0.1:8080/readyz
# Ensure worker log shows:
# worker: OpenAI enabled base=http://192.168.50.181:8767/v1 model=overloaded-local
# worker: EPREL enricher enabled
# Pick 3 raw ids with non-empty eprel_id from SQL, then POST processing_type=full as above.
# Poll until status=completed; compare processed_name / processed_description / eprel_* attrs.
```
## Related
- [e2e-feeds-process-export.md](e2e-feeds-process-export.md) — feeds → process → export
- [e2e-processing.md](e2e-processing.md) — Background Tasks UI / cancel / enhance_only
- [green-chat-smoke.md](green-chat-smoke.md) — LAN Green Chat wiring
- [eprel.md](eprel.md) — EPREL env + attribute keys
- [qa-local-demo.md](qa-local-demo.md) — Local Demo Co checklist
+108
View File
@@ -0,0 +1,108 @@
# Live public API E2E — legacy `items` + EAN process
**When:** 2026-08-04 20:31 +02:00
**Verdict:** **PASS**
**Tenant:** Local Demo Co (`ee246275-dec0-4446-9e83-58d0c16c258a`)
**API:** `http://127.0.0.1:8080`
**Web:** `http://localhost:5174` (IPv6 `::1`; keep using `localhost` not `127.0.0.1` for Vite)
**Postgres:** `localhost:5433` (`descrybe-v2-postgres`)
**Auth:** Demo API key prefix `dk_demo_lo…` (full value in [demo-user.md](demo-user.md); redacted here)
## Stack
| Service | Port | Status |
|---------|------|--------|
| Go API | 8080 | Restarted for this run (`scripts/run-api.ps1`) so auth + legacy process binary loaded |
| Web (Vite) | 5174 | Up (left running) |
| Postgres | 5433 | Healthy |
| Worker | — | `apps/api/bin/worker.exe` already running |
## Contract under test
Legacy Descrybe public surface (not the flat `POST /api/v1/process` + `raw_product_ids` job shape):
1. `POST /api/v1/products/process` with `{ "items": [{ "ean": "…" }], "processing_type": "full" }`
2. Expect **HTTP 200** + `{ "data": { "process_id": "…", … } }`
3. `GET /api/v1/products/process/{process_id}` until `data.status == "COMPLETED"` and `data.items` is populated
## Steps & outcomes
### 0. Auth gate (no key)
```bash
curl -sS -i "http://127.0.0.1:8080/api/v1/products?limit=1"
```
**Result:** `401`
```json
{ "error": { "code": "unauthorized", "message": "Unauthorized" } }
```
### 1. List products (legacy envelope)
```bash
curl -sS -H "Authorization: Bearer dk_demo_lo…y_v1" \
"http://127.0.0.1:8080/api/v1/products?limit=2"
```
**Result:** `200``{ "data": [ … ], "meta": { "page": 1, "limit": 2, "total": 4340, "totalPages": … } }`
Sample rows: Samsung dishwasher (`needs_review`), Sample Gadget (`needs_review`).
### 2. Start process by EAN
```bash
curl -sS -X POST "http://127.0.0.1:8080/api/v1/products/process" \
-H "Authorization: Bearer dk_demo_lo…y_v1" \
-H "Content-Type: application/json" \
-d "{\"processing_type\":\"full\",\"items\":[{\"ean\":\"8806088803005\"},{\"ean\":\"8700000000002\"}]}"
```
**Result:** `200`
```json
{
"data": {
"process_id": "d21f718a-fda6-45f3-8d5f-a0aef7cb3c57",
"message": "Processing started for 2 product(s)",
"total_items": 2,
"processed_items": 2
}
}
```
### 3. Poll until `items` populated
```bash
curl -sS -H "Authorization: Bearer dk_demo_lo…y_v1" \
"http://127.0.0.1:8080/api/v1/products/process/d21f718a-fda6-45f3-8d5f-a0aef7cb3c57"
```
**Result:** first poll (~2s) → `COMPLETED` with **2 items**.
| EAN | title | notes |
|-----|-------|-------|
| `8700000000002` | Sample Gadget | attrs present; `eprel: null` |
| `8806088803005` | Samsung Built-in Dishwasher DW60M6040BB/EO | attrs + `eprel.energy_class=E`; images set |
Envelope fields: `status`, `process_id`, `processing_type=full`, `items[]`, `total_items=2`, `processed_at`.
## Fixes applied this run
| Issue | Action |
|-------|--------|
| Stale API binary still returned flat `{"error":"unauthorized"}` and `400 invalid json` for `items` | Stopped old `:8080` listener; restarted via `scripts/run-api.ps1` after `go build ./cmd/api` |
| Compile break: duplicate / missing `ParsePageLimitOffset` while parallel legacy landings raced | Kept single alias in `pagination.go``ParsePageLimit`; removed duplicate definition |
No secrets committed. Demo key plaintext only exists in local seed docs / DB hash.
## Blockers
None for this contract.
## Related
- [demo-user.md](demo-user.md) — demo credentials + API key
- [api-surface-smoke.md](api-surface-smoke.md) — broader v1 smoke
- [live-pipeline.md](live-pipeline.md) — native `raw_product_ids` / flat job shape
+92
View File
@@ -0,0 +1,92 @@
# Live public API verify — legacy-compatible contract
**When:** 2026-08-04 21:10 +02:00
**Verdict:** **PASS** (process E2E + dual-mode OK; minor shape notes below)
**API:** `http://localhost:8080`
**Web:** `http://localhost:5174`
**Postgres:** `localhost:5433` (`descrybe-v2-postgres`)
**Auth:** Demo API key prefix `dk_demo_lo…` (redacted; full value in [demo-user.md](demo-user.md))
**Worker:** `worker.exe` running (PID observed during run)
No API restart required — responses matched the current OpenAPI / handlers.
## PASS / FAIL by area
| Area | Result | Notes |
|------|--------|-------|
| Auth (missing key) | **PASS** | `401` `{ error: { code: "unauthorized", message: "Unauthorized" } }` |
| Auth (Bearer) | **PASS** | `200` products list |
| Auth (X-API-Key) | **PASS** | `200` products list |
| GET `/products` | **PASS** | `{ data, meta }` — presentProduct fields (`id`, `product_id`, `name`, `category`, `status`, `feed_id`, `quality_score`, `quality_grade`, `created_at`, `updated_at`) |
| GET `/products/quality` | **PASS** | `{ data, meta }` with `quality_checks`; meta has `page/limit/total` (no `totalPages`) |
| POST `/products/process` (items/EAN) | **PASS** | `200` `{ data: { process_id, total_items, processed_items, message } }` |
| GET poll → COMPLETED + `items` | **PASS** | `full` completed in ~4s with 2 items |
| Process variants | **PASS** | `normalize_only`, `title`, `category` all `200``COMPLETED` + `items` |
| Dual-mode `raw_product_ids` | **PASS** | On same path; must be **raw** UUIDs (not processed product list `id`) |
| GET categories / attributes | **PASS** | `{ data, meta }` with page/limit |
| GET feeds (+ one by id) | **PASS** | List `{ data, meta }`; GET one `{ data: {…} }`; sync **not** run |
| GET export-feeds | **PASS** | `{ data, meta }` |
| GET campaigns | **PASS** | `{ data: { year, presets, prepared } }` (no `meta`; matches OpenAPI) |
| OpenAPI process docs | **PASS** | Documents `items` / `process_id`, dual-mode, legacy vs flat `/process` |
## Process outcome (critical)
### Start (`full`, real demo GTINs)
```http
POST /api/v1/products/process
{ "processing_type": "full", "items": [
{ "ean": "8806088803005" },
{ "ean": "8700000000002" }
]}
```
- **HTTP 200**
- `process_id`: `d8f6d996-c0b8-43e7-9372-032fc725a523`
- `total_items`: 2
### Poll
- Status **COMPLETED** on first poll (~4s)
- `data.items` length **2** (Samsung dishwasher + Sample Gadget), with title/description/meta/eprel where applicable
### Variants (quick)
| `processing_type` | Start | Final | `items` |
|-------------------|-------|-------|---------|
| `full` | 200 | COMPLETED | yes |
| `normalize_only` | 200 | COMPLETED | yes |
| `title` | 200 | COMPLETED | yes |
| `category` | 200 | COMPLETED | yes (title may be null) |
### Dual-mode
- Body `{ "processing_type": "normalize_only", "raw_product_ids": ["<raw UUID>"] }`**200** envelope + COMPLETED with `items`.
- Using a **processed** product list `id``400 validation_error` / `no matching products for company` (expected: IDs must be `raw_products.id`).
- Flat surface `POST /api/v1/process` with same `raw_product_ids`**202** flat `ProcessingJob` (documented separate contract).
## OpenAPI spot-check
- `/api/v1/openapi.yaml` **200**, documents:
- Legacy: `POST /products/process` + `items[].ean``200` `{ data: { process_id, … } }`
- Poll: `GET /products/process/{id}` with `items` when COMPLETED
- Alternate body `raw_product_ids` on the same handler
- Flat `/process` surface called out as separate (not legacy envelope)
## Remaining gaps / notes
1. **ID confusion:** ~~OpenAPI under-documented~~ **Closed (docs):** OpenAPI now documents dual IDs explicitly — `GET /products` `id` = processed, `raw_product_id` = raw; `raw_product_ids` must use raw UUIDs; COMPLETED poll items expose `processed_product_id` + `raw_product_id`. Plan gates (402) + gates-before-EnsureRaw on `items[].ean` documented.
2. **Envelope inconsistency (intentional dual contract):** List endpoints use `{ data, meta }`; `GET /products/{id}` and flat `/process*` return **flat** objects (OpenAPI agrees). Not a regression vs docs.
3. **Quality meta:** `totalPages` omitted (products list includes it).
4. **Feed sync:** Skipped (avoid heavy sync spam). Single GET by UUID verified.
5. **DELETE / create:** Skipped (no throwaway unique_ids created).
6. **Export generate:** Not run (avoid large exports).
## Stack check
| Service | Port | Status |
|---------|------|--------|
| Go API | 8080 | Up (`/healthz` ok) |
| Web (Vite) | 5174 | Listening |
| Postgres | 5433 | Healthy |
| Worker | — | Running |
+28
View File
@@ -0,0 +1,28 @@
# Live Shopify dry-run smoke
**Verdict: PASS (dry-run)**
**Date:** 2026-08-04
**Tenant:** Local Demo Co (**Enterprise**)
**Goose:** migration **017** applied (DB at version **18**)
**API / worker:** restarted binaries with Shopify claim loop
Native Admin API product push + orders pull exercised with access token `dry-run` (no Shopify.com network).
## Steps
| Step | HTTP | Evidence |
|------|-----:|----------|
| `PUT /api/shopify` | **200** | `shop_domain=local-demo-co.myshopify.com`, `dry_run=true`, `is_enabled=true` |
| `POST /api/shopify/test` | **200** | `status=ok`, `shop_name=Dry Run Shop`, `dry_run=true` |
| `POST /api/shopify/sync` | **202** | product sync queued |
| `POST /api/shopify/sync-orders` | **202** | orders sync queued |
| Worker claim | — | `last_sync_status=success`, `last_orders_sync_status=success` |
| `GET /api/shopify/orders?limit=5` | **200** | 1 dry-run order (`dry-run@example.com`, total `19.99`) |
## Notes
- Real Shopify Admin API (nondry-run) still needs a custom app token + `*.myshopify.com` shop; SSRF blocks private hosts.
- Reviews are not supported (`reviews_supported=false`).
- Catalog CSV/XML feed remains the recommended production ingest workaround when a live shop token is unavailable.
See [shopify-connector.md](shopify-connector.md), [store-connectors.md](store-connectors.md).
+58
View File
@@ -0,0 +1,58 @@
# Live Stripe mock UI test
Date: 2026-08-04
Environment: local Descrybe v2 (`WEB_ORIGIN=http://localhost:5174`, API `:8080`)
Demo user: `demo@descrybe.local` / `DemoPass123!` (Local Demo Co)
## Preconditions
- `STRIPE_MOCK=true` in root `.env` (API restarted after change)
- Migration `016_stripe_billing` applied (goose version 16+)
- Web + API running (`localhost:5174`, `localhost:8080/healthz`)
- Demo company starts on **Enterprise** (run seed-demo if unsure)
## Flow exercised
1. Browser login at `/login`
2. Open `/plans` — Enterprise marked **Current plan**; **Upgrade to Starter** visible
3. Click **Upgrade to Starter**
4. Mock Checkout assigns Starter + credits and redirects to
`/billing?checkout=success&mock=1&plan=starter`
5. Assert success copy includes **Plan updated** / **starter**; Plan details show **Starter**
6. Restore demo: `cd apps/api && go run ./cmd/seed-demo -postgres $env:DATABASE_URL`
7. Re-check `/billing` and `/plans`**Enterprise**, **Unlimited**, no accidental leave on Starter
## Results
| Step | Result |
|------|--------|
| Stripe status (`GET /api/billing/stripe`) | `mock:true`, `configured:false` |
| Mock checkout API | `applied:true`, success URL with `mock=1&plan=starter` |
| UI Upgrade to Starter | Pass — redirect + success banner |
| seed-demo restore Enterprise | Pass — 1,000,000 credits / unlimited SKUs |
| Billing after restore | Pass — Enterprise, Unlimited, Compare plans |
| Plans after restore | Pass — Current plan on Enterprise; Upgrade CTAs on Starter/Growth/Business |
Artifacts: `artifacts/live-stripe-checkout.webp`, `artifacts/live-stripe-verify.webp` (+ `.report.json`).
## UI fixes from this pass
1. **Plans** — Enterprise subtitle no longer says “with Unlimited AI credits remaining” (redundant/confusing). Copy is now capacity-focused.
2. **Billing** — “Compare plans” was an `<a>` wrapping `<button>` (invalid nesting). Switched to `buttonClasses` on the anchor.
## Notes
- On Enterprise, Billing hides **Quick upgrade** (by design). Use `/plans` for self-serve mock upgrades.
- Mock Checkout on Local Demo Co **will** move the tenant off Enterprise. Always re-run `seed-demo` afterward.
- Do **not** leave the demo company on Starter.
- **Production:** real `STRIPE_SECRET_KEY`, publishable key, price IDs, and `STRIPE_WEBHOOK_SECRET` are **required** (see [stripe-setup.md](stripe-setup.md)). `STRIPE_MOCK=true` is local-only and is **not** a code blocker for staging as long as prod cutover sets real keys.
## Restore command
```powershell
cd apps/api
# DATABASE_URL from repo `.env` (Postgres :5433)
go run ./cmd/seed-demo -postgres $env:DATABASE_URL -email demo@descrybe.local -password 'DemoPass123!' -also-email demo@descrybe.test
```
See also: [stripe-setup.md](stripe-setup.md), [demo-user.md](demo-user.md).
+73
View File
@@ -0,0 +1,73 @@
# Live WooCommerce test (mock-woo WC REST)
**Verdict: PASS**
**Date:** 2026-08-04
**Tenant:** `demo@descrybe.local`**Local Demo Co** (plan **Enterprise**, 1,000,000 credits)
**API:** `http://127.0.0.1:8080` (restarted with parallel list build)
**Worker:** `worker.exe` claiming Woo jobs
**Mock store:** `cmd/mock-woo` on `http://127.0.0.1:19090`
This proves the **real WooCommerce REST client** path (Basic auth → `/wp-json/wc/v3/*`), **not** `seed-woo-demo` DB inserts.
## Start mock-woo
```powershell
cd f:\laragon\www\_MY\descrybe-v2\apps\api
go run ./cmd/mock-woo -addr 127.0.0.1:19090
# defaults: consumer key ck_mock_local / secret cs_mock_local
```
Health: `GET http://127.0.0.1:19090/healthz``{"status":"ok","service":"mock-woo"}`.
## Point Local Demo Co at mock
Session login + CSRF, then:
| Step | HTTP | Evidence |
|------|-----:|----------|
| `PUT /api/woocommerce` | **200** | `store_url=http://127.0.0.1:19090`, `is_enabled=true`, `has_credentials=true` |
| Cap for proof (SQL) | — | `sync_options.sync_limit=5` (still uses live client/batches) |
| `POST /api/woocommerce/test` | **200** | `{"status":"ok","message":"connection successful"}` |
| `POST /api/woocommerce/sync` | **202** | `woocommerce sync queued` |
| `POST /api/woocommerce/sync-orders` | **202** | orders sync queued |
| `POST /api/woocommerce/sync-reviews` | **202** | reviews sync queued |
Worker drained pending flags within ~6s:
| Badge | Result |
|-------|--------|
| `last_test_status` | `ok` |
| `last_sync_status` | `success` |
| `last_orders_sync_status` | `success` |
| `last_reviews_sync_status` | `success` |
## Direct mock HTTP (fixture proof)
Auth: `Authorization: Basic` base64(`ck_mock_local:cs_mock_local`).
| Request | HTTP | Notes |
|---------|-----:|-------|
| `GET .../wp-json/wc/v3/products?per_page=1` | **200** | After product push, SKUs present (e.g. created id `1001`) |
| `GET .../wp-json/wc/v3/orders?page=1` | **200** | **3** fixture orders (`5001``5003`) |
| `GET .../wp-json/wc/v3/products/reviews?page=1` | **200** | **2** fixture reviews (`7001``7002`) |
## DB rows from sync (not seed)
```sql
-- Local Demo Co: mock external_ids only
SELECT count(*) FROM woo_orders WHERE external_id IN (5001,5002,5003); -- 3
SELECT count(*) FROM product_reviews WHERE external_id IN (7001,7002); -- 2
```
`GET /api/woocommerce/orders?limit=10`**200** with `cara.buyer@example.com` / external_id `5003`.
`GET /api/woocommerce/reviews?limit=10`**200** with reviewer `Ben Buyer` / external_id `7002`.
## What this is / is not
| Is | Is not |
|----|--------|
| Live Test Connection + product batch push + orders/reviews pull via `internal/woocommerce` client | WordPress/Woo docker |
| Loopback HTTP allowed by SSRF (`NormalizeStoreURL` + `SafeHTTPClient(allowLoopback=true)`) | Proof against a public merchant store |
| Suitable local staging gate for Woo connector | Substitute for production HTTPS Woo credentials |
Related: [woocommerce-demo.md](woocommerce-demo.md) (seed path), [store-connectors.md](store-connectors.md).
+52
View File
@@ -0,0 +1,52 @@
# Local LLM tuning (weak / 8k-class models)
Defaults for OpenAI-compatible local servers such as Green Chat Gemma 12B (`overloaded-local`, ~8k context).
## Env
| Variable | Typical local value |
|----------|---------------------|
| `OPENAI_BASE_URL` | `http://<host>:8767/v1` |
| `OPENAI_MODEL` | id from `GET /v1/models` (e.g. `overloaded-local`) |
| `OPENAI_API_KEY` | required non-empty for `Enabled()` |
| `PROCESSING_RPM` | `60` |
| `PROCESSING_MAX_RETRIES` | `3` |
Restart **both** `api` and `worker` after changing `OPENAI_*`. Worker logs should say `OpenAI enabled base=… model=…`, not `heuristic completer`.
## Hardening defaults (code)
| Constant | Value | Where |
|----------|------:|-------|
| `DefaultStructuredTemp` | `0.2` (capped ≤0.3) | `processing/llm_json.go`, `openai.go` |
| `MaxTokensEnhance` | `350` | product title/description |
| `MaxTokensSEO` | `180` | meta title/description |
| `MaxTokensCampaign` | `650` | email JSON |
| `MaxProductDescRunes` | `400` | user context |
| `MaxAttrKeys` / `MaxAttrValueRunes` | `10` / `60` | attrs in enhance prompt |
| `MaxBrandInjectRunes` | `500` | brand kit block |
| `MaxCampaignProducts` | `8` | campaign product list |
## Prompt style
- Short system prompts with **bullet rules** + explicit JSON schema
- **One** few-shot example max
- Brand kit as compact bullets: `Brand:\n- tone: …\n- do: …`
- Product context: category + name + truncated desc + priority attrs only
## JSON reliability
`processing.CompleteJSON`:
1. Call Completer with `max_tokens` + low temperature
2. `StripJSONFences` / isolate `{…}`
3. On parse fail → **one retry** with `INVALID. Reply with ONLY one JSON object…`
4. Call sites fall back to originals/templates instead of storing garbage prose as titles
## Ops tips
- Prefer LAN TCP reachability checks (`Test-NetConnection host -Port 8767`) over ICMP
- Smoke: [ai-full-smoke.md](ai-full-smoke.md), [green-chat-llm.md](green-chat-llm.md)
- CI / no real model: [mock-llm.md](mock-llm.md) (`OPENAI_BASE_URL=http://127.0.0.1:18767/v1`) — includes **translation + processing verification**
- Locale lists + commented `OPENAI_*` / `MOCK_LLM_*` keys: root [`.env.example`](../.env.example)
- Do not commit real keys or LAN secrets
+49
View File
@@ -0,0 +1,49 @@
# Local smoke results — Descrybe v2
**When:** 2026-08-04 01:55:25 +02:00
**API:** http://127.0.0.1:8080
**DB:** `postgres://descrybe:***@localhost:5433/descrybe`
**Tenant:** Local Demo Co (`ee246275-dec0-4446-9e83-58d0c16c258a`) — DB: name=`Local Demo Co`, products=`4321`, feeds=`12`
**Auth:** `demo@descrybe.test` / `DemoPass123!`
## Summary
| Metric | Value |
|--------|-------|
| PASS | 12 |
| FAIL | 0 |
| Overall | PASS |
## Pass / fail matrix
| Check | Result | Detail |
|-------|--------|--------|| Postgres docker (port 5433) | **PASS** | `descrybe-v2-postgres Up 3 hours (healthy)` |
| DB Local Demo Co identity | **PASS** | `name=Local Demo Co products=4321 feeds=12 id=ee246275-dec0-4446-9e83-58d0c16c258a` |
| GET /healthz | **PASS** | `{"maintenance":false,"read_only":false,"status":"ok"} ` |
| GET /readyz | **PASS** | `{"maintenance":false,"read_only":false,"status":"ready"} ` |
| POST /api/auth/login (demo@descrybe.test) | **PASS** | `status=200 company_id=ee246275-dec0-4446-9e83-58d0c16c258a` |
| Login defaults to Local Demo Co | **PASS** | `expected=ee246275-dec0-4446-9e83-58d0c16c258a got=ee246275-dec0-4446-9e83-58d0c16c258a me.active=ee246275-dec0-4446-9e83-58d0c16c258a` |
| POST /api/auth/select-company (fallback) | **PASS** | `not needed — already on Local Demo Co` |
| GET /api/products (must total>0) | **PASS** | `{"kind":"processed","limit":5,"offset":0,"products":[{"category":"125","created_at":"2026-08-04T00:16:53.180098+02:00","feed_id":"705ff990-34bb-4f5e-bed1-202501…` |
| GET /api/feeds | **PASS** | `{"feeds":[{"created_at":"2026-08-04T00:05:59.210388+02:00","feed_type":"xml","id":"b5fc7c4c-830b-4d6e-a90c-9b72800a89a1","last_synced_at":null,"name":"Vama Trad…` |
| GET /api/seo/recommendations | **PASS** | `{"company_id":"ee246275-dec0-4446-9e83-58d0c16c258a","product_count":2000,"category_count":119,"overall_score":92.1,"can_use_ai":true,"checklist":[{"type":"miss…` |
| GET /api/campaigns/templates | **PASS** | `{"templates":[{"key":"christmas","name":"Christmas","season":"christmas","default_subject":"Holiday picks from {{brand}}","default_prompt":"Write a warm Christm…` |
| GET /api/brand | **PASS** | `{"ai_apply_allowed":true,"brand":{"company_id":"ee246275-dec0-4446-9e83-58d0c16c258a","voice_tone":"","dos":[],"donts":[],"primary_color":"","secondary_color":"…` |
## Endpoint expectations
| Endpoint | Expectation |
|----------|-------------|
| `GET /healthz` | 200 |
| `GET /readyz` | 200 |
| `POST /api/auth/login` | 200; session company = Local Demo Co |
| `GET /api/products?limit=5` | 200; `total > 0` |
| `GET /api/feeds` | 200; feeds length > 0 |
| `GET /api/seo/recommendations` | 200 |
| `GET /api/campaigns/templates` | 200; templates length > 0 |
| `GET /api/brand` | 200 |
## Notes
- Products list smoke requires `total > 0` and a non-empty `products` array for Local Demo Co.
- No git commit performed.
+492
View File
@@ -0,0 +1,492 @@
# Marketing Suite — architecture design (Descrybe v2)
**Status:** Design only — no implementation in this doc.
**Date:** 2026-08-04
**Repos:** `f:/laragon/www/_MY/descrybe-v2`
**Inputs:** [PRICING-AND-USER-GROWTH.md](../../descrybe/PRICING-AND-USER-GROWTH.md) (sibling), [process-and-sell-summary.md](process-and-sell-summary.md), [features.md](features.md), existing `internal/billing`, `internal/woocommerce`, `internal/processing`, `internal/mail`, Settings + Nav.
---
## 0. North star
Descrybes moat remains **feeds → map → process → export/Woo**. Marketing add-ons sit **on top of a live catalog + Woo connection**, not as a second product that competes with “paste ChatGPT into a plugin.”
```
[ Catalog + Brand kit ]
├── Product AI copy (existing pipeline) ── credits / BYOK
└── Marketing Suite
├── Audience (orders + opt-in)
├── Campaigns (seasonal / category / product)
├── Reviews (sync + social proof in copy)
└── SEO recommendations (catalog-derived)
```
**Principle:** Defaults get a merchant from “Black Friday email for buyers of Category X” in minutes. Advanced (custom prompts, BYOK, segment builders) stays behind optional panels.
---
## 1. Context from todays codebase
| Area | Today (v2) | Implication for Marketing |
|------|------------|---------------------------|
| **Woo** | Company-level `woocommerce_configs`; **outbound product push** only (`internal/woocommerce`); worker claims sync jobs | Must **add inbound** order (+ later review) sync; keep credentials encrypted; reuse same store config |
| **Credits** | `credit_balances` + `AssertCanStartProcessing` / `ConsumeCredits(..., featureName)` | Extend with feature names: `campaign_copy`, `seo_recommend`, `brand_kit_ai`; same wallet |
| **Plans** | `plans` / `company_plans` + SKU caps; public Free/Starter/Growth/Business/Enterprise in `pricing-data.ts` | Need **feature flags per plan** (not only meters) for Marketing |
| **AI** | Worker `Completer` (OpenAI or heuristic); no tenant BYOK yet | Campaign/SEO/Brand AI share Completer + future BYOK seam |
| **Mail** | Platform SMTP for invites (`internal/mail`); noop if disabled | **Campaign send ≠ platform invite SMTP** — tenant-verified provider required |
| **Nav / Settings** | Nav: PIM core + WooCommerce extra; Settings tabs: profile / company / api-keys / team | Add **Marketing** nav group; Settings → **Integrations** tab (Woo, Email, Reviews, AI keys) |
| **Jobs** | DB claim + `NOTIFY` for processing + Woo | Same pattern for `order_sync`, `review_sync`, `campaign_send`, `campaign_generate` |
### Pricing doc conflict (resolve explicitly)
[PRICING-AND-USER-GROWTH.md](../../descrybe/PRICING-AND-USER-GROWTH.md) currently proposes **Free = 50 AI credits / mo**.
**This suite design requires Free = ZERO AI credits** and gates campaigns / SEO AI / brand AI.
**ASSUMPTION (recommended):** Align product policy as:
| Plan | Core product AI | Marketing AI (campaigns / SEO / brand kit AI) |
|------|-----------------|-----------------------------------------------|
| **Free** | **0 credits** (map/export/Woo test only; reverse-trial exception below) | **Blocked** |
| **Starter+** | Included pack per plan | Included; burns same credit wallet (or BYOK) |
| **Reverse trial (14d Growth)** | Full Growth credits | Full Marketing allowed |
Update `pricing-data.ts` + plan seeds when implementing — Free must not advertise “50 AI credits” if policy is zero.
---
## 2. Product modules
### 2.1 Email campaigns
**Jobs:** Draft → preview → schedule/send seasonal or catalog-driven emails using Descrybe catalog + Woo audiences.
#### Campaign types (MVP → later)
| Type | Trigger / audience seed | Copy source |
|------|-------------------------|-------------|
| **Seasonal template** | Christmas, Black Friday, New Year, Summer (system templates) | Default prompts + brand kit |
| **By category** | Products in selected category tree node(s) | Catalog titles/images + formula fields |
| **By product set** | Explicit SKUs / product IDs / collection | Same |
| **Purchase-based** | Bought X / not bought Y (needs order sync) | Same + segment filters |
#### Prompt model (mirror product formulas)
```
campaign.prompt_mode = "default" | "custom"
campaign.system_prompt_override? // only if custom + plan allows
campaign.user_prompt_vars = { season, category_names, product_snippets, brand_voice, cta_url }
```
- **Default:** curated seasonal/category prompts + Brand kit (voice, tone, banned words).
- **Custom:** editable system/user prompt (Growth+); stored per campaign, versioned.
- **Preview:** generate N sample emails (13) before send; each preview burns credits (or BYOK).
#### AI path
| Path | When | Billing |
|------|------|---------|
| **Managed** | Default | Debit `campaign_copy` (+ token packs via existing `openai_token_k`) |
| **BYOK** | Growth add-on / Business+ | Completer uses tenant key; **no credit burn for inference**; still need paid plan feature flag |
**Never send AI-generated body without human confirm** on first send of a campaign (checkbox “I reviewed the preview”). Resends of an approved version can skip.
#### Data model (sketch)
```
marketing_campaigns
id, company_id, name, type, status (draft|scheduled|sending|sent|cancelled)
season_key?, category_ids[], product_ids[]
audience_query jsonb -- segment DSL (see §3)
prompt_mode, prompts jsonb
subject, preview_text, body_html, body_text
schedule_at?, sent_at?
ai_provider (managed|byok), credit_cost_est
created_by, created_at, updated_at
marketing_campaign_sends
id, campaign_id, recipient_hash, email_encrypted_or_token
status (queued|sent|bounced|unsubscribed|failed)
provider_message_id?, error?, sent_at?
marketing_campaign_events -- opens/clicks later (P2)
```
Worker jobs: `campaign_generate`, `campaign_send_batch` (chunked, rate-limited).
---
### 2.2 Order sync (Woo → audience)
**Goal:** Build segments like “purchased product/category X in last 90d AND never purchased Y.”
#### Sync design
- Extend Woo **Integrations** config (same `woocommerce_configs` row or sibling `integration_woocommerce_orders`):
- `orders_enabled`, `orders_last_synced_at`, `orders_cursor` (modified_after / page)
- Worker job `woo_order_sync` (incremental, bounded pages per tick)
- REST: `GET /wp-json/wc/v3/orders` (+ line items); map to local tables
```
woo_customers
company_id, woo_customer_id, email_hash, email_enc, name, marketing_opt_in, unsubscribed_at, ...
woo_orders
company_id, woo_order_id, customer_id, status, ordered_at, currency, total, raw_meta jsonb
woo_order_items
company_id, order_id, sku, product_id_local?, woo_product_id, quantity, category_ids_snapshot[]
```
**Privacy:** store emails encrypted at rest; display masked; use `email_hash` for uniqueness. Prefer Woo `marketing_opt_in` / billing consent fields when present.
#### Audience DSL (JSON, server-validated)
```json
{
"op": "and",
"rules": [
{ "type": "purchased_sku", "skus": ["ABC-1"], "since_days": 365 },
{ "type": "not_purchased_sku", "skus": ["ABC-2"], "since_days": 365 },
{ "type": "purchased_category", "category_ids": ["uuid…"], "since_days": 90 },
{ "type": "marketing_opt_in", "equals": true }
]
}
```
Resolver compiles to SQL with **hard filters**: `unsubscribed_at IS NULL`, `marketing_opt_in = true`, bounce suppression list. Always AND these safety filters server-side (client cannot disable).
**ASSUMPTION:** Guests without account email are skipped unless order billing email present and opted-in.
---
### 2.3 Reviews sync
| Provider | Phase | Notes |
|----------|-------|-------|
| **WooCommerce product reviews** (`/wp-json/wc/v3/products/reviews`) | **P1** | Same store credentials; incremental sync |
| **Placeholder providers** (Judge.me, Yotpo, Trustpilot) | **P2** | Interface only: `ReviewProvider` with `ListSince(cursor)`; UI “Coming soon” + webhook stub |
```
product_reviews
company_id, provider, external_id, product_sku, rating, title, body, author_hash
reviewed_at, synced_at, usable_in_marketing bool
```
Uses: social proof blocks in campaign templates; optional “mention top reviews” in default prompts; SEO module “products with &lt; N reviews.”
---
### 2.4 SEO recommendations (from catalog)
**Not** a full site crawler in P0. Catalog-first checks that reuse processed products + categories + standard fields:
| Check | Signal | Output |
|-------|--------|--------|
| Thin title / description | Length, uniqueness | Rewrite suggestion (AI = paid) |
| Missing attributes | Required attrs empty | Fill via process job CTA |
| Duplicate titles | Collision count | Merge / differentiate |
| Weak category paths | Depth / orphan | Taxonomy tip |
| Image gaps | No primary image | Upload / feed map tip |
| Keyword coverage | Brand kit primary keywords vs title | Nudge |
```
seo_recommendation_runs
id, company_id, status, created_at, summary jsonb
seo_recommendations
id, run_id, product_id?, severity, code, message, suggested_action
ai_draft?, applied_at?
```
- **Free:** show **rule-based** list only (no AI drafts).
- **Paid:** “Generate rewrite” → `seo_recommend` credits / BYOK → optional apply to product fields via existing product PATCH.
---
### 2.5 Brand kit
Company-scoped creative constraints for **product copy + campaigns**.
```
brand_kits
company_id PK
voice_summary text -- short “we sound like…”
tone_tags text[] -- e.g. friendly, expert, playful
banned_phrases text[]
preferred_phrases text[]
primary_color, secondary_color
logo_url / logo_file_id
fonts jsonb? -- optional
locale_default
updated_at
```
- **Manual edit:** available Starter+ (or Free read-only empty kit?). **ASSUMPTION:** Free can **view** empty kit + upgrade CTA; edit + AI-assist = paid.
- **AI assist (“Infer brand from catalog”):** paid; feature `brand_kit_ai`.
- Injected into processing enhance prompts **and** campaign defaults (single source of truth).
UI: Settings → Company **or** Marketing → Brand kit (prefer **Marketing → Brand kit** with deep-link from Integrations).
---
## 3. Plan gating & Free tier
### 3.1 Feature matrix
| Capability | Free | Starter | Growth | Business+ |
|------------|------|---------|--------|-----------|
| SKU / feed / storage meters | Per pricing ladder | ✓ | ✓ | ✓ |
| **AI credits** | **0** | Pack | Pack | Pack / BYOK |
| Product processing AI | ✗ (unless reverse trial) | ✓ | ✓ | ✓ |
| Marketing nav (read-only teasers) | Teaser + upgrade | ✓ | ✓ | ✓ |
| Campaigns create/send | ✗ | ✓ (caps) | ✓ | ✓ |
| Campaign AI generate | ✗ | Credits | Credits/BYOK | ✓ |
| Order sync | ✗ (or read-only test 1 page?) | ✓ | ✓ | ✓ |
| Audience “bought X not Y” | ✗ | ✓ | ✓ | ✓ |
| Reviews sync (Woo) | ✗ | ✓ | ✓ | ✓ |
| SEO rule-based | ✓ (limited rows) | ✓ | ✓ | ✓ |
| SEO AI rewrite | ✗ | ✓ | ✓ | ✓ |
| Brand kit edit | ✗ | ✓ | ✓ | ✓ |
| Brand kit AI | ✗ | ✓ | ✓ | ✓ |
| BYOK | ✗ | ✗ | Add-on | Included |
**ASSUMPTION:** Starter is the smallest plan that unlocks Marketing Suite send + order sync. Free stays acquisition for **feed aha**, not email ESP.
### 3.2 Enforcement seams (reuse billing)
Extend `internal/billing`:
```go
// Pseudocode — design only
AssertFeature(ctx, companyID, "marketing.campaigns")
AssertFeature(ctx, companyID, "marketing.order_sync")
AssertCanSpendCredits(ctx, companyID, feature, est)
```
- Plan row gains `features jsonb` (or `plan_entitlements` table).
- API returns `403` + machine code `feature_gated` / `insufficient_credits` for UI `UpgradeBanner`.
- Reverse trial: temporary Growth entitlements; day 15 → Free (0 credits, Marketing hard-off). Persist draft campaigns read-only.
### 3.3 Caps (Starter starter-limits)
| Meter | Starter (example) | Growth |
|-------|-------------------|--------|
| Campaigns / mo | 5 | 50 |
| Recipients / send | 2_000 | 25_000 |
| Order sync lookback | 180d | 730d |
| SEO AI rewrites / mo | 50 | 1_000 |
Exact numbers tunable; enforce server-side.
---
## 4. Integration style (UX IA)
### 4.1 Settings → Integrations
Replace scattered `/woocommerce` as the *only* integration home. Keep `/woocommerce` as redirect → Settings Integrations for one release if needed.
**Settings tabs:** `profile` | `company` | `team` | `api-keys` | **`integrations`**
Integrations cards:
1. **WooCommerce** — store URL, keys, test connection, product sync toggle, **order sync** toggle, **reviews sync** toggle
2. **Email sending** — provider (Resend / SES / SMTP / Postmark), from-domain, **verification status**, rate limits
3. **AI keys (BYOK)** — Growth+; encrypted; never logged
4. **Reviews providers** — Woo (active) + placeholders
### 4.2 Marketing nav
New top-level group (after Export Feeds / before Billing):
```
Marketing
├─ Campaigns
├─ Audiences
├─ Reviews
├─ SEO
└─ Brand kit
```
Free users see routes with locked empty-states + upgrade CTAs (not 404).
---
## 5. Safety (non-negotiable)
| Rule | Implementation |
|------|----------------|
| **No send without verified provider** | `email_provider.status == verified` AND DNS/domain checks passed; else Send button disabled + API `412 email_not_verified` |
| **Opt-out / unsubscribe** | Every email: `List-Unsubscribe` + one-click POST; landing page; set `unsubscribed_at`; suppress forever until re-opt-in via Woo/consent |
| **Opt-in required** | Audience resolver always requires marketing consent |
| **Rate limits** | Per-company send RPM/RPH; provider caps; exponential backoff; circuit breaker on bounce rate &gt; threshold |
| **PII** | No emails in logs; hash + encrypt; admin support tools mask by default |
| **Double opt-in for imported lists** | If CSV import ever added (P2): require confirmation — Woo-sourced opted-in only in P0/P1 |
| **Abuse** | Cap Free teasers; paid caps; platform admin kill-switch per company |
| **Legal copy** | Footer template mandatory (company address, unsubscribe) before schedule |
Platform invite SMTP (`internal/mail`) **must not** be used for marketing blasts.
---
## 6. UX principles
1. **Happy path wizard:** Pick template (BF/Christmas) → pick category/products → auto audience suggestion → generate 1 preview → connect email if missing → schedule.
2. **Advanced accordion:** custom prompts, raw audience DSL, UTM params, send-time optimization (P2).
3. **Reuse catalog chrome:** product pickers, category tree, UpgradeBanner, processing job progress patterns.
4. **Empty states teach the core path:** “Connect Woo + sync orders to target buyers of X.”
5. **Credit honesty:** show estimated credit cost before Generate; BYOK badge when active.
---
## 7. Interactive tutorial (activation)
Extend the pricing docs ≤15 min aha into a **guided checklist** (dashboard + optional spotlight tour):
| Step | Route / action | Done when |
|------|----------------|-----------|
| 1. Add feed | `/feeds` | Feed created with URL/file |
| 2. Map fields | `/feeds/{id}/mapping` | Required fields mapped (auto-map OK) |
| 3. Process sample | `/processing` | ≥10 products `completed` (credits: trial/paid) |
| 4. Export or Woo push | `/export-feeds` or Integrations | Export generated **or** Woo test sync OK |
| 5. Brand kit (paid) | `/marketing/brand-kit` | Voice saved **or** skipped with “later” |
| 6. Campaigns (paid) | `/marketing/campaigns` | Draft campaign created (send optional) |
- Persist `onboarding_progress` on `company_settings`.
- Free users complete 14; 56 show locked steps with value props.
- Tutorial deep-links must survive refresh; dismissible; “Replay tour” in help.
Aligns with [process-and-sell-summary.md](process-and-sell-summary.md) happy path + Marketing as step after export.
---
## 8. Technical architecture (v2 packages)
```
apps/api/internal/
marketing/ # campaigns, audiences, brand kit, seo services
integrations/
email/ # provider interface: Send, VerifyDomain, Webhooks
reviews/ # ReviewProvider
billing/ # + AssertFeature, feature costs
woocommerce/ # + OrdersClient, ReviewsClient, sync workers
apps/api/sql/schema/
01x_marketing.sql # tables above
01y_integrations_email.sql
apps/web/src/routes/
marketing/campaigns/
marketing/audiences/
marketing/reviews/
marketing/seo/
marketing/brand-kit/
settings/ # + integrations tab
unsubscribe/[token]/ # public
```
**Completer sharing:** marketing generation calls the same `processing.Completer` interface; inject company BYOK client when entitlement + key present.
**Idempotency:** send batches keyed by `(campaign_id, recipient_hash)`; sync cursors monotonic.
---
## 9. API sketch (dashboard + future public)
| Method | Path | Notes |
|--------|------|-------|
| GET/PATCH | `/api/integrations/woocommerce` | Existing + order/review flags |
| POST | `/api/integrations/woocommerce/sync-orders` | Enqueue |
| GET/PATCH | `/api/integrations/email` | Provider config |
| POST | `/api/integrations/email/verify` | Start verification |
| GET/PATCH | `/api/marketing/brand-kit` | |
| GET/POST | `/api/marketing/campaigns` | |
| POST | `/api/marketing/campaigns/{id}/generate` | Credits gate |
| POST | `/api/marketing/campaigns/{id}/schedule` | Requires verified email |
| POST | `/api/marketing/audiences/preview` | Count + sample (masked) |
| POST | `/api/marketing/seo/runs` | |
| POST | `/api/public/unsubscribe` | Token auth |
Public API v1 exposure of Marketing = **Business+ / later**; dashboard session auth first.
---
## 10. Risks & open decisions
| Topic | Risk | Recommendation |
|-------|------|----------------|
| Free AI credits vs pricing doc | Conflicting promises | **Ship Free = 0 AI**; update pricing doc + `pricing-data.ts` |
| Becoming an ESP | Deliverability / spam liability | Verified domain only; start with low caps; consider “export to Klaviyo” P2 |
| Order PII | GDPR | Encrypt, retention policy, delete-with-company, DPA |
| Woo permission scopes | Keys may lack orders/reviews | TestConnection checks endpoints; clear UI errors |
| Custom prompts abuse | Prompt injection / brand damage | Length limits; strip tools; require preview ack |
**Open questions (non-blocking for P0 scaffolding):**
1. First ESP: Resend vs SES vs tenant SMTP only?
2. Starter recipient cap exact number?
3. Free SEO rule-based: show all issues or cap at 20?
---
## 11. Sibling-agent checklist
### P0 — foundation (do first; unblocks everything)
- [ ] **P0.1** Align Free plan: **0 AI credits**; Marketing feature flags on `plans`; update `pricing-data.ts` + seeds; reverse-trial keeps Growth marketing.
- [ ] **P0.2** `billing.AssertFeature` + credit feature names (`campaign_copy`, `seo_recommend`, `brand_kit_ai`); wire `UpgradeBanner` codes.
- [ ] **P0.3** Settings → **Integrations** tab; move Woo config UX there (redirect old `/woocommerce`).
- [ ] **P0.4** Marketing nav shell + locked empty states for Free.
- [ ] **P0.5** Brand kit schema + CRUD (manual fields only).
- [ ] **P0.6** Email provider integration model + **verified-only send gate** (even if provider is stubbed behind interface).
- [ ] **P0.7** Campaign draft CRUD + default seasonal templates + generate-preview job (no mass send yet).
- [ ] **P0.8** Unsubscribe token endpoint + suppression list.
- [ ] **P0.9** Woo **order sync** (incremental) + `woo_orders` / items / customers tables.
- [ ] **P0.10** Audience preview: purchased X / not purchased Y + opt-in hard filters.
- [ ] **P0.11** Onboarding tutorial steps 14 wired to real completion signals; 56 gated.
- [ ] **P0.12** Safety tests: cannot send if unverified; cannot include unsubscribed; Free cannot generate campaign AI.
### P1 — sellable Marketing MVP
- [ ] **P1.1** Campaign schedule/send batches via verified provider + rate limits + bounce basic handling.
- [ ] **P1.2** Custom vs default prompts UI; credit estimate + BYOK seam (if BYOK platform work ready).
- [ ] **P1.3** Woo product **reviews** sync + Reviews Marketing page.
- [ ] **P1.4** SEO recommendation runs (rule-based + paid AI rewrite).
- [ ] **P1.5** Brand kit injection into product enhance + campaign defaults.
- [ ] **P1.6** Cap enforcement (campaigns/mo, recipients/send).
- [ ] **P1.7** Tutorial steps 56 + “first Black Friday campaign” recipe.
- [ ] **P1.8** Ops: kill-switch, send metrics on billing overview.
### P2 — expand
- [ ] **P2.1** Placeholder review providers (Judge.me / Yotpo) + webhooks.
- [ ] **P2.2** Open/click tracking; A/B subjects.
- [ ] **P2.3** Export audience / campaign to Klaviyo/Mailchimp.
- [ ] **P2.4** Site crawl SEO (beyond catalog).
- [ ] **P2.5** Public API for campaigns.
- [ ] **P2.6** Multi-language campaign variants from catalog locales.
- [ ] **P2.7** AI “infer brand kit from best products.”
---
## 12. Verification expectations (when built)
- Unit: audience SQL compiler, feature gate matrix, unsubscribe token.
- Integration: Woo orders pagination mocked; send blocked without verify.
- UI: Free user sees locks; Starter can preview generate with credits.
- No claim-done without `AssertFeature` coverage on every mutating Marketing route.
---
## 13. Rollback
- Feature flags `MARKETING_ENABLED` (platform) + per-plan entitlements.
- Schema additive only; drop via goose Down if needed.
- Disabling flag hides nav and 404s APIs without deleting drafts.
---
## Related docs
- Sibling pricing: `f:/laragon/www/_MY/descrybe/PRICING-AND-USER-GROWTH.md`
- [process-and-sell-summary.md](process-and-sell-summary.md)
- [features.md](features.md) · [status-and-gaps.md](status-and-gaps.md) · [security-notes.md](security-notes.md)
+70
View File
@@ -0,0 +1,70 @@
# Marketing Suite - user guide
Seasonal email campaigns, brand voice, SEO tips, WooCommerce reviews/orders audiences, and the in-app tutorial for Descrybe v2.
## Start the tutorial (1 click)
1. Sign in and open **Dashboard**.
2. Click **Start tutorial** (welcome card or header).
3. Follow the highlighted controls — you must perform each action (no free Continue on action steps).
4. Use **Resume tutorial** after Pause, or **Restart tutorial** after Skip / Done.
Full step list: [tutorial.md](tutorial.md).
## Black Friday campaign in 5 clicks
Assumes you already have products in the catalog (map a feed or import first).
1. Open **`/campaigns`** → Create campaign
(or **`/marketing/calendar`** → Prepare Black Friday, then open Campaigns).
2. Pick the **Black Friday** season template.
3. Choose categories/products (or leave defaults for top catalog items). Optionally set audience to purchasers after syncing Woo orders.
4. Click **Generate** (template mode is free; AI generate needs Starter+ / credits -- Free returns 402).
5. **Send test** to yourself, then **Schedule** or **Send** once your email provider is verified under **`/integrations/email`**.
### Optional: Content Calendar one-shot
**`/marketing/calendar` → Prepare Black Friday** creates a seasonal export-feed bundle for the BF window. Then open **`/campaigns`** to draft the email.
## Free vs paid
| Action | Free | Paid (Starter+) |
|--------|------|-----------------|
| Create campaign drafts, pick seasons | Yes | Yes |
| Template generate (no AI) | Yes | Yes |
| AI campaign / SEO / brand AI | No (`can_use_ai=false`, 0 monthly credits) | Yes (credits / BYOK) |
| Real email blast | Dry-run until upgrade + verified provider | Verified Resend/SMTP |
| Brand kit form | Yes | Yes |
| SEO recommendations (template fill) | Yes | AI fill on paid |
| Woo orders/reviews sync | Yes (connection required) | Yes |
Details: [free-tier.md](free-tier.md). Upgrade from **Pricing** / **Plans** when you hit an AI or send gate.
## Brand kit
**`/brand`** (nav: Marketing → Brand kit) — tone, do/don't, colors, preferred terms, logo URL, and **logo file upload** (`POST /api/brand/logo`). Product AI and campaign AI inject this voice when AI is enabled.
## SEO
**`/seo`** (nav: Marketing → SEO) — missing meta, thin/duplicate titles, weak images. Template apply on Free; AI fill on paid.
## Orders, reviews and purchase audiences
1. Connect the store under **`/woocommerce`** (Connection tab).
2. Open the **Orders** tab → **Sync orders** (worker pulls Woo REST orders into `woo_orders` / `woo_order_items`).
3. Open **Reviews** (`/woocommerce?tab=reviews`; nav `/reviews` redirects here) → **Sync reviews**.
4. In a campaign (`/campaigns`), choose a purchase-based audience. On send, campaigns call `ResolveAudience` against synced orders.
API: `POST /api/woocommerce/sync-orders`, `sync-reviews`; `GET /api/woocommerce/orders`, `reviews`; `POST /api/woocommerce/audience`.
## Email provider
**`/integrations/email`** (nav: Email sending) — Resend or SMTP, verify from-address/domain, then send tests. Free plans force dry-run for real blasts. See [email-sending.md](email-sending.md).
## Related docs
- [QA local demo](qa-local-demo.md) — login, Local Demo Co, uploads, nav status
- [Demo user](demo-user.md) — credentials + company counts
- [Marketing suite design](marketing-suite-design.md)
- [Free tier gates](free-tier.md)
- [Email sending](email-sending.md)
+220
View File
@@ -0,0 +1,220 @@
# Migrate `descrybe_new.sql` → v2 Postgres (A1 only)
**Date:** 2026-08-08 (local reseed from `descrybe_new (1).sql`)
**Status:** **PASS**
**Scope:** Single tenant **A1 Slovenija** only (all other dump tenants skipped / cleaned)
Secrets are masked below. Do not commit `.env`, MySQL passwords, `artifacts/`, or invite tokens.
## What “a1” resolved to
| Field | Value |
|-------|--------|
| MySQL company name | `A1 Slovenija` |
| MySQL / legacy `company_id` | `97e1a309-3d23-4aa2-b518-8e8d7afdfec7` |
| Match used | `companies.name` LIKE `%A1%` → exact row above (only A1 hit) |
| Postgres after migrate + demo | **A1 Slovenija** (dump name kept) |
| Postgres `companies.id` | `604f23a8-b66e-4b21-8b45-0d72b68f4790` (stable via `artifacts/descrybe-new-a1/id-map.json`) |
| `legacy_company_id` | `97e1a309-3d23-4aa2-b518-8e8d7afdfec7` |
**Company naming:** Keep dump name **A1 Slovenija**. Do not rename to “Local Demo Co”. Wallet/plan come from MySQL `credit_balances` / plan **A1** (2500 total / 163 used / 2337 remaining on 2026-08-08 dump) — not a fake 1M demo pack.
## Inputs
| Item | Value |
|------|--------|
| Dump | `C:\Users\Green Eclipse\Downloads\descrybe_new (1).sql` (~549 MB Adminer MySQL 8 dump) |
| Temp MySQL DB | Laragon `descrybe_new` (left intact; did **not** overwrite `descrybe_v2`) |
| Postgres | Docker `descrybe-v2-postgres` host port **5433** |
| Postgres DSN (local default) | `postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable` |
| Migrator | `apps/api/cmd/migrator` |
| Maps / report | `artifacts/descrybe-new-a1/` (gitignored); copy `docs/migration-reports/migration-report-a1-latest.json` |
## Commands run
### 1. Wipe v2 Postgres only (keep Docker volume)
```powershell
docker exec descrybe-v2-postgres psql -U descrybe -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'descrybe' AND pid <> pg_backend_pid();"
docker exec descrybe-v2-postgres psql -U descrybe -d postgres -c "DROP DATABASE IF EXISTS descrybe;"
docker exec descrybe-v2-postgres psql -U descrybe -d postgres -c "CREATE DATABASE descrybe OWNER descrybe;"
```
### 2. Goose migrations (001024)
```powershell
cd f:\laragon\www\_MY\descrybe-v2\apps\api
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 -dir sql/schema postgres $env:DATABASE_URL up
```
Note: `024_ai_prompts.sql` had a UTF-8 BOM (`\ufeff`); stripped so goose could parse (same class of fix as earlier `006_feed_sync.sql`).
### 3. Import dump into temp MySQL (streamed; ~2 min)
```powershell
# create empty DB, then stream file into mysql client (do not load 862MB into PowerShell)
mysql -uroot -p**** -e "DROP DATABASE IF EXISTS descrybe_new; CREATE DATABASE descrybe_new CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# stdin stream of descrybe_new.sql → descrybe_new
```
Dump-wide MySQL counts (before filter): companies 31, categories 8465, attributes 198319, xml_feeds 33, raw_products 159070, processed_products 7953, export_feeds 11.
### 4. A1-only migrator
```powershell
cd f:\laragon\www\_MY\descrybe-v2\apps\api
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
$env:MIGRATE_MYSQL_DSN = "root:****@tcp(localhost:3306)/descrybe_new?parseTime=true"
$maps = "f:\laragon\www\_MY\descrybe-v2\artifacts\descrybe-new-a1"
$a1 = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
go build -o $env:TEMP\descrybe-migrator.exe ./cmd/migrator
# dry-run then live (filter at migrate time)
& $env:TEMP\descrybe-migrator.exe -mysql $env:MIGRATE_MYSQL_DSN -postgres $env:DATABASE_URL `
-company $a1 -dry-run -maps-dir $maps -report-dir $maps -resume=false
& $env:TEMP\descrybe-migrator.exe -mysql $env:MIGRATE_MYSQL_DSN -postgres $env:DATABASE_URL `
-company $a1 -maps-dir $maps -report-dir $maps -resume `
-ensure-demo -demo-email demo@descrybe.local -demo-password 'DemoPass123!' `
-skip-post-import
```
Live elapsed ≈ **32s**. Validation: `mode: live ok=true`, orphan checks **17/17 PASS**.
### 5. Seed demo (Enterprise + API key + `.test` alias)
```powershell
go run ./cmd/seed-demo -postgres $env:DATABASE_URL `
-email demo@descrybe.local -password 'DemoPass123!'
```
### 6. Post-cleanup (junk from global user import)
Migrator still upserts **all** MySQL profile→user rows even with `-company` (memberships are filtered). Removed users with **no** membership (kept demo emails):
```sql
DELETE FROM users u
WHERE NOT EXISTS (SELECT 1 FROM memberships m WHERE m.user_id = u.id)
AND lower(u.email) NOT IN ('demo@descrybe.local','demo@descrybe.test');
-- deleted 22 orphan users
```
### 6b. Import A1 job history (optional follow-up)
Job history was skipped in the initial cutover. Re-run with domain `jobs` (rows tagged `ai_provider_mode=migrated` so 30-day retention does not purge them):
```powershell
& $env:TEMP\descrybe-migrator.exe -mysql $env:MIGRATE_MYSQL_DSN -postgres $env:DATABASE_URL `
-company $a1 -domains jobs -maps-dir $maps -report-dir $maps -resume -skip-post-import
```
Local/dev (**outdated — do not follow**): demo no longer owns A1; use **Platform Demo** + `a1-primary@` per [safe-test-fixtures.md](safe-test-fixtures.md). Historical logins:
| Role | Email | Password |
|------|-------|----------|
| Demo admin | `demo@descrybe.local` (alias `.test`) | `DemoPass123!` |
| Primary A1 | `a1-primary@descrybe.local` | `DemoPass123!` |
Primary maps to Clerk `user_30AqqJ8uepxvPUzDSqy81U5w6Ll`. Other A1 MySQL profiles were removed after migrate (jobs reassigned to primary). To reset the primary password:
```powershell
go run ./cmd/migrator -postgres $env:DATABASE_URL `
-set-password 'a1-primary@descrybe.local:DemoPass123!'
```
### 6c. Local Demo Co user cleanup (2026-08-05)
MySQL A1 (`97e1a309-…`) had **5** `profiles.user_id` values. Preference: keep **demo + primary only**.
| Action | Detail |
|--------|--------|
| Kept | `demo@descrybe.local`, `demo@descrybe.test`, `a1-primary@descrybe.local` (`user_30AqqJ…`) |
| Deleted (4) | `user_2tJxu…`, `user_2yaUP…`, `user_2zzh…`, `user_3005r…` (`@legacy.local`) |
| Reassigned | 3 `processing_jobs.user_id` → primary (280 jobs on primary; 51 still null) |
| Catalog | Unchanged — **4378** processed products, 331 jobs |
**Do not restore** those four `@legacy.local` members. A later agent re-inserted them for switcher labeling; they were deleted again. Local Demo Co stays at **3 users** / **3 memberships**. UserSwitcher labels: **Demo admin** (`.local` + `.test`) and **A1 · …81U5w6Ll** (`is_primary_a1`).
## Row counts — kept vs skipped
### Migrator written (A1 filter)
| Entity | Written / kept | Notes |
|--------|---------------:|-------|
| companies | 1 | Only A1; renamed → Local Demo Co |
| memberships | 5 → **3** after cleanup | A1 primary + 2 demo admins |
| users (after cleanup) | **3** | demo `.local` + demo `.test` + `a1-primary@descrybe.local` |
| plans | seed + migrated catalog | Global plan rows OK for local billing |
| company_plans | 1 → Enterprise via seed-demo | Other tenants plans skipped |
| credit_balances | 1 | 1M Enterprise credits after seed |
| categories | 119 | |
| attributes | 303 | |
| category_attributes | 307 | |
| custom_variables | 3 | |
| input_feeds (xml) | 12 | |
| feed_mappings | 11 | ComTrade has 0 mappings in source too |
| raw_products | 23748 | MySQL A1 had 56132; GTIN-deduped under `(company_id, gtin)` |
| processed_products | 4330 | |
| export_feeds | 2 | |
| files | 0 | Dump had 1 file metadata row for another context / skipped |
| processing_jobs | 179 | via `-domains jobs` (`ai_provider_mode=migrated`) |
### Skipped (other tenants / noise)
| Item | Approx. skipped |
|------|----------------:|
| Other companies | 30 of 31 |
| Non-A1 categories / attributes / feeds / products / exports | Rest of dump (see MySQL totals above) |
| Orphan users after cleanup | 22 |
| `company_plans` for non-A1 | 4 skipped in migrator log |
| `credit_balances` for non-A1 | 7 skipped |
| API keys / Clerk passwords / job history | Never imported |
### Final Postgres snapshot (A1 Slovenija — 2026-08-08)
| Metric | Count |
|--------|------:|
| companies | 1 (+ `__platform_settings__`) |
| users | **3** (demo.local, demo.test, a1-primary) |
| memberships | **3** |
| categories | 119 (108 with AI prompts overlay) |
| attributes | 303 |
| category_attributes | 307 |
| input_feeds | 12 |
| feed_mappings | 11 |
| raw_products | 23748 |
| processed_products | 4330 |
| export_feeds | 2 |
| processing_jobs | 179 |
| custom_variables | 3 |
| wallet | 2500 / 163 used / 2337 remaining |
## Demo admin (local only)
| Field | Value |
|-------|--------|
| Email | `demo@descrybe.local` (alias `demo@descrybe.test`) |
| Password | `DemoPass123!` (already documented in `docs/demo-user.md`) |
| Role | Platform admin + company **admin** on Local Demo Co |
| Plan | Enterprise — 1_000_000 AI credits, unlimited SKUs |
| API key | `dk_demo_local_descrybe_test_key_v1` (hash only in DB) |
Web: http://localhost:5174/login · API: http://localhost:8080
## Verification (this run)
| Check | Result |
|-------|--------|
| `GET /healthz` | ok |
| `GET /readyz` | database ok |
| `POST /api/auth/login` (CSRF cookie + Origin) | **200** → company Local Demo Co |
| `GET /api/v1/products?limit=1` (demo API key) | **200**, `meta.total=4378` |
| `GET /api/v1/feeds` | **200**, 12 feeds |
## Related docs
- [portable-mysql-pg-migration.md](portable-mysql-pg-migration.md) — migrator flags / domains
- [demo-user.md](demo-user.md) — demo credentials & Enterprise wallet
- [migration-run-log.md](migration-run-log.md) — prior full-tenant rehearsal
- Report: [migration-reports/migration-report-a1-latest.json](migration-reports/migration-report-a1-latest.json)
+133
View File
@@ -0,0 +1,133 @@
# Migration readiness — MySQL → Postgres + post-import passwords
**Last updated:** 2026-08-04
**Live ETL status:** **DONE on staging** (dry-run + load, 2026-08-03) — see [migration-run-log.md](migration-run-log.md).
**Production cutover:** **NOT ready** — remaining blockers below.
Migrated users are imported with `password_hash = NULL` and `must_set_password = true`. Login rejects empty/null hashes, so a random guess cannot succeed.
Fresh installs that never ran the migrator can keep using `/register`.
---
## Live dry-run + load (staging)
| Gate | Status |
|------|--------|
| Goose `001``007` on staging PG (`localhost:5433`) at ETL time (2026-08-03) | Done — **repo schema is now `001``023`**; re-apply goose `up` before any new staging/prod load |
| Offline fixture dry-run | Done |
| Live MySQL dry-run | Done (exit 0) |
| Live load to staging Postgres | Done (~11 min) |
| Orphan FK validation | **16/16 pass** |
| Evidence | [migration-run-log.md](migration-run-log.md); `artifacts/` gitignored |
### Counts written (staging)
| Entity | Written |
|--------|--------:|
| companies | 27 |
| users | 21 |
| memberships | 15 |
| categories | 8278 |
| attributes | 57630 (unique `(company_id, attribute_key)` collapse) |
| raw_products | 118784 (+7732 GTIN-deduped from MySQL) |
| processed_products | 7219 |
| input feeds | 31 |
| export feeds | 8 |
### Remaining blockers (before production cutover)
| Severity | Item |
|----------|------|
| **Data** | Most users have synthetic `…@legacy.local` emails — export from Clerk and patch `users.email` before invites (**tooling:** migrator `-list-legacy-emails` / `-export-legacy-emails` / `-patch-emails`; see [portable-mysql-pg-migration.md](portable-mysql-pg-migration.md#clerk--legacylocal-email-repair-cutover-data-hygiene)) |
| **Data** | All memberships imported as `role=member` — promote company admins |
| **Data** | 2 `company_plans` skipped (`plan_id=6` missing) — resolve with migrator list → `-dry-run``-confirm` (see [cutover.md](cutover.md#resolve-skipped-company_plans-plan_id6); no blind live assigns) |
| **Ops** | This load used `-skip-post-import` — re-issue set-password invites when emails are real |
| **Ops** | SMTP + mailhooks + login smoke still unproven |
| **Gaps** | See [Cutover coverage gaps](#cutover-coverage-gaps) below |
Staging is **GO for login testing** after set-password (preferably after email patch). Production DNS/cutover remains **NO-GO**.
### Cutover coverage gaps
Honest status vs migrator code (`cmd/migrator`, domains `settings` / `woo` / `files`):
| Area | Status | Operator action |
|------|--------|-----------------|
| **API keys** | **Not migrated** (intentional — secrets) | Clients must mint new keys; Settings → API Keys empty state says so |
| **company_settings** | **Partial**`language` + `merge_products` only (`migrateCompanySettings`) | Re-enter other legacy settings in-app if needed |
| **File blobs** | **Metadata only** — bytes not copied (`migrateFiles`) | Resync object storage / re-upload; Files empty + dashboard ETL gaps panel say so; platform admins see cheap COUNTs on **Admin → Diagnostics → ETL gap inventory** (`files_metadata_only` / `files_total`) |
| **Woo configs** | **Migrated when domain `woo` enabled** — from `wc_*` custom_fields (`migrateWooConfigs`) | Re-verify store URL + credentials; ensure `APP_ENCRYPTION_KEY` before prod secrets |
| **Jobs / history** | **Cutover default: not backfilled** — optional domain `jobs` can import `processing_jobs` (+ best-effort products) + `tasks` tagged `ai_provider_mode=migrated`; accepted production gap is empty history unless ops explicitly ran `jobs` | Expect empty Processing history after cutover; tenant UI must not claim history moved; diagnostics `migration_inventory.jobs_domain_ran` / `processing_jobs_migrated` show whether optional backfill ran |
Migrator run reports surface these as notes + count-report rows (`api_keys`, `files` metadata-only, `processing_jobs` when domain enabled, partial `company_settings`).
**Admin inventory (read-only):** `GET /api/admin/diagnostics``migration_inventory` — COUNT of metadata-only files, migrated-tagged jobs, and tasks. Not an import path; never fabricates blob bytes or job history.
---
## After a live import
### 1. Issue set-password invites (preferred)
Post-import already runs this unless `-skip-post-import`. To re-issue later (Postgres only, no MySQL):
```bash
cd apps/api
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
# WEB_ORIGIN controls printed /accept-invite links (default http://localhost:5174)
go run ./cmd/migrator -issue-set-password-invites \
-postgres "$DATABASE_URL" \
-maps-dir ../../artifacts
```
Artifacts (gitignored, do not commit):
- `artifacts/password_invites.json` — email, token, invite URL, expiry
- `artifacts/set-password-hooks.json` — same payload for `cmd/mailhooks`
Stdout prints `email<TAB>url` for each invite. Open a URL → `/accept-invite` → set password → session (invite path) or sign in.
**No-SMTP staging rehearsal** (promote admin → re-issue → copy link → one login): [staging-auth-rehearsal.md](staging-auth-rehearsal.md).
Invite tokens hit `POST /api/auth/accept-invite` (DB invite row). Existing users get `password_hash` set and `must_set_password` cleared.
### 2. Email delivery (optional)
```bash
cd apps/api
# Smoke first (works with EMAIL_DRY_RUN default true; no SMTP dial)
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run
# Live send requires EMAIL_DRY_RUN=false + SMTP — see ops-runtime.md
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json
```
Operator smoke steps (fail-closed under dry-run, pass criteria): [ops-runtime.md](ops-runtime.md) § SMTP.
### 3. Local bootstrap one user (dev only)
```bash
cd apps/api
export DATABASE_URL="postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
go run ./cmd/migrator -set-password "user@example.com:YourPass123" \
-postgres "$DATABASE_URL"
```
Then sign in at `/login`. Do not use this for production cutover.
### 4. Admin HMAC emails (platform admin UI)
`POST /api/admin/emails/set-password` emails links with `?mode=set-password`. Those tokens are HMAC (`TOKEN_SIGNING_SECRET`), not DB invites. The accept-invite page calls `POST /api/auth/complete-set-password`, then redirects to `/login`.
### 5. Authenticated set-password
Logged-in users can call `POST /api/auth/set-password` (session required) — useful after a partial login path, not for first-time migrated users.
## Smoke checklist
1. Migrated user cannot log in before setting a password.
2. Invite URL from `password_invites.json` opens `/accept-invite`.
3. Submit password (≥8 chars) → logged in (invite) or redirected to login (HMAC mode).
4. Subsequent `/login` works with the new password.
5. `must_set_password` is false in Postgres.
See also [cutover.md](cutover.md) §4, [go-live-checklist.md](go-live-checklist.md), and [ops-runtime.md](ops-runtime.md).
View File
@@ -0,0 +1,250 @@
{
"generated_at": "2026-08-04T00:38:05Z",
"mode": "live",
"domains": "settings,formulas,tags,woo,usage",
"resume": true,
"counts": {
"companies": 27,
"company_settings_skipped": 5,
"demo_memberships": 30,
"demo_user": 1,
"feed_tags_skipped": 1,
"field_groups_skipped": 23,
"memberships": 15,
"standard_fields_skipped": 145,
"structured_description_fields_skipped": 28,
"usage_metrics": 5,
"users": 21,
"woocommerce_configs": 0
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 27,
"postgres": 30,
"delta": 3
},
{
"entity": "profiles",
"mysql": 21,
"postgres": 77,
"delta": 56,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 25,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 9,
"delta": 4
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 10,
"delta": 5
},
{
"entity": "credit_balances",
"mysql": 6,
"postgres": 8,
"delta": 2
},
{
"entity": "categories",
"mysql": 8278,
"postgres": 8280,
"delta": 2
},
{
"entity": "attributes",
"mysql": 95292,
"postgres": 57630,
"delta": -37662
},
{
"entity": "category_attributes",
"mysql": 74377,
"postgres": 74377,
"delta": 0
},
{
"entity": "custom_variables",
"mysql": 36,
"postgres": 37,
"delta": 1,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 31,
"postgres": 34,
"delta": 3,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 126516,
"postgres": 124426,
"delta": -2090
},
{
"entity": "processed_products",
"mysql": 7219,
"postgres": 7221,
"delta": 2
},
{
"entity": "export_feeds",
"mysql": 8,
"postgres": 9,
"delta": 1
},
{
"entity": "files",
"mysql": 1,
"postgres": 3,
"delta": 2,
"note": "metadata only; blobs not copied"
}
],
"postgres_counts": {
"attributes": 57630,
"categories": 8280,
"category_attributes": 74377,
"companies": 30,
"company_plans": 10,
"credit_balances": 8,
"custom_variables": 37,
"export_feeds": 9,
"feed_mappings": 25,
"files": 3,
"input_feeds": 34,
"memberships": 77,
"plans": 9,
"processed_products": 7221,
"raw_products": 124426,
"users": 25
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 3,
"pass": true
}
],
"orphan_summary": {
"passed": 16,
"failed": 0,
"total": 16
},
"ok": true
},
"demo": {
"email": "demo@descrybe.local",
"password_set": true,
"user_id": "276d5d0e-665a-40e4-ac78-1cf27e807b10",
"primary_company": "ee246275-dec0-4446-9e83-58d0c16c258a",
"primary_company_name": "Local Demo Co",
"memberships_admin": 30,
"platform_admin": true,
"note": "Password documented in docs/portable-mysql-pg-migration.md (not written to report JSON)."
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; use seed-demo / ensure-demo for local keys.",
"File blobs are metadata-only; resync object storage separately."
],
"elapsed_ms": 2235
}
@@ -0,0 +1,251 @@
{
"generated_at": "2026-08-04T00:38:48Z",
"mode": "live",
"domains": "settings,formulas,tags,woo,usage",
"resume": true,
"counts": {
"companies": 27,
"company_settings": 5,
"demo_memberships": 30,
"demo_user": 1,
"feed_tags": 1,
"field_groups": 23,
"memberships": 15,
"standard_fields": 145,
"structured_description_fields": 28,
"usage_limits": 2,
"usage_metrics": 5,
"users": 21,
"woocommerce_configs": 0
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 27,
"postgres": 30,
"delta": 3
},
{
"entity": "profiles",
"mysql": 21,
"postgres": 77,
"delta": 56,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 25,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 9,
"delta": 4
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 10,
"delta": 5
},
{
"entity": "credit_balances",
"mysql": 6,
"postgres": 8,
"delta": 2
},
{
"entity": "categories",
"mysql": 8278,
"postgres": 8280,
"delta": 2
},
{
"entity": "attributes",
"mysql": 95292,
"postgres": 57630,
"delta": -37662
},
{
"entity": "category_attributes",
"mysql": 74377,
"postgres": 74377,
"delta": 0
},
{
"entity": "custom_variables",
"mysql": 36,
"postgres": 37,
"delta": 1,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 31,
"postgres": 34,
"delta": 3,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 126516,
"postgres": 124426,
"delta": -2090
},
{
"entity": "processed_products",
"mysql": 7219,
"postgres": 7221,
"delta": 2
},
{
"entity": "export_feeds",
"mysql": 8,
"postgres": 9,
"delta": 1
},
{
"entity": "files",
"mysql": 1,
"postgres": 3,
"delta": 2,
"note": "metadata only; blobs not copied"
}
],
"postgres_counts": {
"attributes": 57630,
"categories": 8280,
"category_attributes": 74377,
"companies": 30,
"company_plans": 10,
"credit_balances": 8,
"custom_variables": 37,
"export_feeds": 9,
"feed_mappings": 25,
"files": 3,
"input_feeds": 34,
"memberships": 77,
"plans": 9,
"processed_products": 7221,
"raw_products": 124426,
"users": 25
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 3,
"pass": true
}
],
"orphan_summary": {
"passed": 16,
"failed": 0,
"total": 16
},
"ok": true
},
"demo": {
"email": "demo@descrybe.local",
"password_set": true,
"user_id": "276d5d0e-665a-40e4-ac78-1cf27e807b10",
"primary_company": "ee246275-dec0-4446-9e83-58d0c16c258a",
"primary_company_name": "Local Demo Co",
"memberships_admin": 30,
"platform_admin": true,
"note": "Password documented in docs/portable-mysql-pg-migration.md (not written to report JSON)."
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; use seed-demo / ensure-demo for local keys.",
"File blobs are metadata-only; resync object storage separately."
],
"elapsed_ms": 2591
}
@@ -0,0 +1,188 @@
{
"generated_at": "2026-08-04T21:28:02Z",
"mode": "dry-run",
"domains": "all",
"resume": false,
"counts": {
"admin_users": 1,
"attributes": 198319,
"categories": 8465,
"category_attributes": 177404,
"companies": 31,
"company_plans": 3,
"company_plans_skipped": 2,
"company_settings": 7,
"credit_balances": 7,
"credit_balances_skipped": 1,
"custom_variables": 54,
"export_feeds": 11,
"feed_mappings": 26,
"feed_tags": 1,
"field_groups": 26,
"files": 1,
"input_feeds": 33,
"memberships": 20,
"plans": 5,
"processed_products": 7953,
"raw_products": 131641,
"raw_products_gtin_deduped": 27429,
"set_password_hooks_skipped_dry_run": 1,
"standard_fields": 176,
"structured_description_fields": 40,
"usage_metrics": 6,
"users": 27,
"woocommerce_configs": 0
},
"validation": {
"mode": "dry-run",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": -1,
"delta": 0
},
{
"entity": "profiles",
"mysql": 27,
"postgres": -1,
"delta": 0,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": -1,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": -1,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": -1,
"delta": 0
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": -1,
"delta": 0
},
{
"entity": "categories",
"mysql": 8465,
"postgres": -1,
"delta": 0
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": -1,
"delta": 0
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": -1,
"delta": 0
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": -1,
"delta": 0,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": -1,
"delta": 0,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 159070,
"postgres": -1,
"delta": 0
},
{
"entity": "processed_products",
"mysql": 7953,
"postgres": -1,
"delta": 0
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": -1,
"delta": 0
},
{
"entity": "files",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": -1,
"delta": 0,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 1023,
"postgres": -1,
"delta": 0,
"note": "ephemeral history not migrated"
}
],
"orphans": [
{
"check": "skipped_dry_run",
"count": 0,
"sample": "orphan FK checks require a live Postgres load",
"pass": true
}
],
"orphan_summary": {
"passed": 1,
"failed": 0,
"total": 1
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history (processing_jobs / queues) is ephemeral and not migrated.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 15654
}
@@ -0,0 +1,188 @@
{
"generated_at": "2026-08-04T21:30:58Z",
"mode": "dry-run",
"domains": "all",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": false,
"counts": {
"admin_users": 1,
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"company_plans_skipped": 4,
"company_settings": 1,
"credit_balances": 1,
"credit_balances_skipped": 7,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"field_groups": 1,
"input_feeds": 12,
"memberships": 5,
"plans": 5,
"processed_products": 4378,
"raw_products": 29115,
"raw_products_gtin_deduped": 27017,
"set_password_hooks_skipped_dry_run": 1,
"standard_fields": 22,
"usage_metrics": 1,
"users": 27,
"woocommerce_configs": 0
},
"validation": {
"mode": "dry-run",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": -1,
"delta": 0
},
{
"entity": "profiles",
"mysql": 27,
"postgres": -1,
"delta": 0,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": -1,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": -1,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": -1,
"delta": 0
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": -1,
"delta": 0
},
{
"entity": "categories",
"mysql": 8465,
"postgres": -1,
"delta": 0
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": -1,
"delta": 0
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": -1,
"delta": 0
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": -1,
"delta": 0,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": -1,
"delta": 0,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 159070,
"postgres": -1,
"delta": 0
},
{
"entity": "processed_products",
"mysql": 7953,
"postgres": -1,
"delta": 0
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": -1,
"delta": 0
},
{
"entity": "files",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": -1,
"delta": 0,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 1023,
"postgres": -1,
"delta": 0,
"note": "ephemeral history not migrated"
}
],
"orphans": [
{
"check": "skipped_dry_run",
"count": 0,
"sample": "orphan FK checks require a live Postgres load",
"pass": true
}
],
"orphan_summary": {
"passed": 1,
"failed": 0,
"total": 1
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history (processing_jobs / queues) is ephemeral and not migrated.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 13288
}
@@ -0,0 +1,296 @@
{
"generated_at": "2026-08-04T21:31:12Z",
"mode": "live",
"domains": "all",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"admin_users": 1,
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"company_plans_skipped": 4,
"company_settings": 1,
"credit_balances": 1,
"credit_balances_skipped": 7,
"custom_variables": 3,
"demo_memberships": 1,
"demo_user": 1,
"export_feeds": 2,
"feed_mappings": 11,
"field_groups": 1,
"input_feeds": 12,
"memberships": 5,
"plans": 5,
"processed_products": 4378,
"raw_products": 29115,
"raw_products_gtin_deduped": 27017,
"standard_fields": 22,
"usage_metrics": 1,
"users": 27,
"woocommerce_configs": 0
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 5,
"delta": -22,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 27,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 5,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 1,
"delta": -4
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 159070,
"postgres": 29115,
"delta": -129955
},
{
"entity": "processed_products",
"mysql": 7953,
"postgres": 4378,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 1023,
"postgres": -1,
"delta": 0,
"note": "ephemeral history not migrated"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"files": 0,
"input_feeds": 12,
"memberships": 5,
"plans": 5,
"processed_products": 4378,
"raw_products": 29115,
"users": 27
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 1,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"demo": {
"email": "demo@descrybe.local",
"password_set": true,
"user_id": "72b9ebb4-786a-4091-b3cc-671cf58cff4f",
"primary_company": "604f23a8-b66e-4b21-8b45-0d72b68f4790",
"primary_company_name": "Local Demo Co",
"memberships_admin": 1,
"platform_admin": true,
"note": "Password documented in docs/portable-mysql-pg-migration.md (not written to report JSON)."
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history (processing_jobs / queues) is ephemeral and not migrated.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 31389
}
@@ -0,0 +1,268 @@
{
"generated_at": "2026-08-04T22:48:50Z",
"mode": "live",
"domains": "jobs",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"companies": 1,
"memberships": 5,
"processing_job_products": 156,
"processing_job_products_skipped": 206,
"processing_jobs": 280,
"processing_jobs_skipped": 51,
"tasks_skipped": 2,
"users": 27
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 7,
"delta": -20,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 7,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 9,
"delta": 4
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 2,
"delta": -3
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 159070,
"postgres": 29115,
"delta": -129955
},
{
"entity": "processed_products",
"mysql": 7953,
"postgres": 4378,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 1023,
"postgres": -1,
"delta": 0,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated) pg_error"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 2,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 16,
"files": 0,
"input_feeds": 12,
"memberships": 7,
"plans": 9,
"processed_products": 4378,
"raw_products": 29115,
"users": 7
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 2,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 4588
}
@@ -0,0 +1,268 @@
{
"generated_at": "2026-08-04T22:49:07Z",
"mode": "live",
"domains": "jobs",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"companies": 1,
"memberships": 5,
"processing_job_products": 156,
"processing_job_products_skipped": 206,
"processing_jobs": 331,
"processing_jobs_user_missing": 51,
"tasks": 2,
"users": 27
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 7,
"delta": -20,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 7,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 9,
"delta": 4
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 2,
"delta": -3
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 159070,
"postgres": 29115,
"delta": -129955
},
{
"entity": "processed_products",
"mysql": 7953,
"postgres": 4378,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 1023,
"postgres": -1,
"delta": 0,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated) pg_error"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 2,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 16,
"files": 0,
"input_feeds": 12,
"memberships": 7,
"plans": 9,
"processed_products": 4378,
"raw_products": 29115,
"users": 7
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 2,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 4801
}
@@ -0,0 +1,268 @@
{
"generated_at": "2026-08-04T22:49:33Z",
"mode": "live",
"domains": "jobs",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"companies": 1,
"memberships": 5,
"processing_job_products": 332,
"processing_job_products_skipped": 30,
"processing_jobs": 331,
"processing_jobs_user_missing": 51,
"tasks": 2,
"users": 27
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 7,
"delta": -20,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 7,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 9,
"delta": 4
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 2,
"delta": -3
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 159070,
"postgres": 29115,
"delta": -129955
},
{
"entity": "processed_products",
"mysql": 7953,
"postgres": 4378,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 1023,
"postgres": -1,
"delta": 0,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated) pg_error"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 2,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 16,
"files": 0,
"input_feeds": 12,
"memberships": 7,
"plans": 9,
"processed_products": 4378,
"raw_products": 29115,
"users": 7
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 2,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 5436
}
@@ -0,0 +1,300 @@
{
"generated_at": "2026-08-08T07:51:11Z",
"mode": "live",
"domains": "all",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": false,
"counts": {
"admin_users": 1,
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"company_plans_skipped": 4,
"company_settings": 1,
"credit_balances": 1,
"credit_balances_skipped": 7,
"custom_variables": 3,
"demo_memberships": 1,
"demo_user": 1,
"export_feeds": 2,
"feed_mappings": 11,
"field_groups": 1,
"input_feeds": 12,
"memberships": 3,
"plans": 5,
"processed_products": 4330,
"processing_job_products": 177,
"processing_job_products_skipped": 20,
"processing_jobs": 179,
"raw_products": 23748,
"raw_products_gtin_deduped": 7320,
"standard_fields": 22,
"tasks": 2,
"usage_metrics": 1,
"users": 27,
"woocommerce_configs": 0
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 3,
"delta": -24,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 27,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 5,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 1,
"delta": -4
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 134006,
"postgres": 23748,
"delta": -110258
},
{
"entity": "processed_products",
"mysql": 7905,
"postgres": 4330,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 863,
"postgres": 179,
"delta": -684,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated)"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"files": 0,
"input_feeds": 12,
"memberships": 3,
"plans": 5,
"processed_products": 4330,
"raw_products": 23748,
"users": 27
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 1,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"demo": {
"email": "demo@descrybe.local",
"password_set": true,
"user_id": "0bfa93b5-4b08-4ae8-a231-9260d0a7ad96",
"primary_company": "5f6fd76d-fc48-43a1-ba81-bee5544a2ade",
"primary_company_name": "A1 Slovenija",
"memberships_admin": 1,
"platform_admin": true,
"note": "Password documented in docs/portable-mysql-pg-migration.md (not written to report JSON)."
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 22245
}
@@ -0,0 +1,267 @@
{
"generated_at": "2026-08-08T07:51:34Z",
"mode": "live",
"domains": "jobs",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"companies": 1,
"memberships": 3,
"processing_job_products": 177,
"processing_job_products_skipped": 20,
"processing_jobs": 179,
"tasks": 2,
"users": 27
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 4,
"delta": -23,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 28,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 5,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 1,
"delta": -4
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 134006,
"postgres": 23748,
"delta": -110258
},
{
"entity": "processed_products",
"mysql": 7905,
"postgres": 4330,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 863,
"postgres": 179,
"delta": -684,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated)"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"files": 0,
"input_feeds": 12,
"memberships": 4,
"plans": 5,
"processed_products": 4330,
"raw_products": 23748,
"users": 28
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 2,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 3043
}
@@ -0,0 +1,300 @@
{
"generated_at": "2026-08-08T07:52:07Z",
"mode": "live",
"domains": "all",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"admin_users": 1,
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"company_plans_skipped": 4,
"company_settings": 1,
"credit_balances": 1,
"credit_balances_skipped": 7,
"custom_variables": 3,
"demo_memberships": 1,
"demo_user": 1,
"export_feeds": 2,
"feed_mappings": 11,
"field_groups": 1,
"input_feeds": 12,
"memberships": 3,
"plans": 5,
"processed_products": 4330,
"processing_job_products": 177,
"processing_job_products_skipped": 20,
"processing_jobs": 179,
"raw_products": 23748,
"raw_products_gtin_deduped": 7320,
"standard_fields": 22,
"tasks": 2,
"usage_metrics": 1,
"users": 27,
"woocommerce_configs": 0
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 3,
"delta": -24,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 27,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 5,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 1,
"delta": -4
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 134006,
"postgres": 23748,
"delta": -110258
},
{
"entity": "processed_products",
"mysql": 7905,
"postgres": 4330,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 863,
"postgres": 179,
"delta": -684,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated)"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"files": 0,
"input_feeds": 12,
"memberships": 3,
"plans": 5,
"processed_products": 4330,
"raw_products": 23748,
"users": 27
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 1,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"demo": {
"email": "demo@descrybe.local",
"password_set": true,
"user_id": "117936ea-c76e-4604-b7e5-8f23907ad057",
"primary_company": "604f23a8-b66e-4b21-8b45-0d72b68f4790",
"primary_company_name": "A1 Slovenija",
"memberships_admin": 1,
"platform_admin": true,
"note": "Password documented in docs/portable-mysql-pg-migration.md (not written to report JSON)."
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 20738
}
@@ -0,0 +1,267 @@
{
"generated_at": "2026-08-08T07:52:28Z",
"mode": "live",
"domains": "jobs",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"companies": 1,
"memberships": 3,
"processing_job_products": 177,
"processing_job_products_skipped": 20,
"processing_jobs": 179,
"tasks": 2,
"users": 27
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 4,
"delta": -23,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 28,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 5,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 1,
"delta": -4
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 134006,
"postgres": 23748,
"delta": -110258
},
{
"entity": "processed_products",
"mysql": 7905,
"postgres": 4330,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 863,
"postgres": 179,
"delta": -684,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated)"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"files": 0,
"input_feeds": 12,
"memberships": 4,
"plans": 5,
"processed_products": 4330,
"raw_products": 23748,
"users": 28
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 2,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 3036
}
@@ -0,0 +1,296 @@
{
"generated_at": "2026-08-04T21:31:12Z",
"mode": "live",
"domains": "all",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"admin_users": 1,
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"company_plans_skipped": 4,
"company_settings": 1,
"credit_balances": 1,
"credit_balances_skipped": 7,
"custom_variables": 3,
"demo_memberships": 1,
"demo_user": 1,
"export_feeds": 2,
"feed_mappings": 11,
"field_groups": 1,
"input_feeds": 12,
"memberships": 5,
"plans": 5,
"processed_products": 4378,
"raw_products": 29115,
"raw_products_gtin_deduped": 27017,
"standard_fields": 22,
"usage_metrics": 1,
"users": 27,
"woocommerce_configs": 0
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 5,
"delta": -22,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 27,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 5,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 1,
"delta": -4
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 159070,
"postgres": 29115,
"delta": -129955
},
{
"entity": "processed_products",
"mysql": 7953,
"postgres": 4378,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 1023,
"postgres": -1,
"delta": 0,
"note": "ephemeral history not migrated"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"files": 0,
"input_feeds": 12,
"memberships": 5,
"plans": 5,
"processed_products": 4378,
"raw_products": 29115,
"users": 27
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 1,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"demo": {
"email": "demo@descrybe.local",
"password_set": true,
"user_id": "72b9ebb4-786a-4091-b3cc-671cf58cff4f",
"primary_company": "604f23a8-b66e-4b21-8b45-0d72b68f4790",
"primary_company_name": "Local Demo Co",
"memberships_admin": 1,
"platform_admin": true,
"note": "Password documented in docs/portable-mysql-pg-migration.md (not written to report JSON)."
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history (processing_jobs / queues) is ephemeral and not migrated.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 31389
}
@@ -0,0 +1,267 @@
{
"generated_at": "2026-08-08T07:52:28Z",
"mode": "live",
"domains": "jobs",
"company_filter": [
"97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
],
"resume": true,
"counts": {
"companies": 1,
"memberships": 3,
"processing_job_products": 177,
"processing_job_products_skipped": 20,
"processing_jobs": 179,
"tasks": 2,
"users": 27
},
"validation": {
"mode": "live",
"counts": [
{
"entity": "companies",
"mysql": 31,
"postgres": 1,
"delta": -30
},
{
"entity": "profiles",
"mysql": 27,
"postgres": 4,
"delta": -23,
"note": "profiles → memberships"
},
{
"entity": "users",
"mysql": -1,
"postgres": 28,
"delta": 0,
"note": "no password_hash imported mysql_missing"
},
{
"entity": "admin_users",
"mysql": 1,
"postgres": -1,
"delta": 0,
"note": "folded into users.is_platform_admin"
},
{
"entity": "plans",
"mysql": 5,
"postgres": 5,
"delta": 0
},
{
"entity": "company_plans",
"mysql": 5,
"postgres": 1,
"delta": -4
},
{
"entity": "credit_balances",
"mysql": 8,
"postgres": 1,
"delta": -7
},
{
"entity": "categories",
"mysql": 8465,
"postgres": 119,
"delta": -8346
},
{
"entity": "attributes",
"mysql": 198319,
"postgres": 303,
"delta": -198016
},
{
"entity": "category_attributes",
"mysql": 177404,
"postgres": 307,
"delta": -177097
},
{
"entity": "custom_variables",
"mysql": 54,
"postgres": 3,
"delta": -51,
"note": "label/example → value"
},
{
"entity": "xml_feeds",
"mysql": 33,
"postgres": 12,
"delta": -21,
"note": "xml_feeds → input_feeds"
},
{
"entity": "raw_products",
"mysql": 134006,
"postgres": 23748,
"delta": -110258
},
{
"entity": "processed_products",
"mysql": 7905,
"postgres": 4330,
"delta": -3575
},
{
"entity": "export_feeds",
"mysql": 11,
"postgres": 2,
"delta": -9
},
{
"entity": "files",
"mysql": 1,
"postgres": 0,
"delta": -1,
"note": "metadata only; blobs not copied"
},
{
"entity": "company_settings",
"mysql": 7,
"postgres": 1,
"delta": -6,
"note": "partial: language + merge_products only"
},
{
"entity": "api_keys",
"mysql": 4,
"postgres": -1,
"delta": 0,
"note": "not migrated; clients must mint new keys"
},
{
"entity": "processing_jobs",
"mysql": 863,
"postgres": 179,
"delta": -684,
"note": "migrated when domain jobs enabled (ai_provider_mode=migrated)"
}
],
"postgres_counts": {
"attributes": 303,
"categories": 119,
"category_attributes": 307,
"companies": 1,
"company_plans": 1,
"credit_balances": 1,
"custom_variables": 3,
"export_feeds": 2,
"feed_mappings": 11,
"files": 0,
"input_feeds": 12,
"memberships": 4,
"plans": 5,
"processed_products": 4330,
"raw_products": 23748,
"users": 28
},
"orphans": [
{
"check": "memberships_missing_user",
"count": 0,
"pass": true
},
{
"check": "memberships_missing_company",
"count": 0,
"pass": true
},
{
"check": "categories_missing_company",
"count": 0,
"pass": true
},
{
"check": "attributes_missing_company",
"count": 0,
"pass": true
},
{
"check": "custom_variables_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_company",
"count": 0,
"pass": true
},
{
"check": "raw_products_missing_feed",
"count": 0,
"pass": true
},
{
"check": "processed_missing_company",
"count": 0,
"pass": true
},
{
"check": "processed_missing_raw",
"count": 0,
"pass": true
},
{
"check": "processed_missing_feed",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_company",
"count": 0,
"pass": true
},
{
"check": "export_feeds_missing_source",
"count": 0,
"pass": true
},
{
"check": "feed_mappings_missing_feed",
"count": 0,
"pass": true
},
{
"check": "files_missing_company",
"count": 0,
"pass": true
},
{
"check": "company_plans_missing_plan",
"count": 0,
"pass": true
},
{
"check": "companies_without_active_plan",
"count": 0,
"pass": true
},
{
"check": "platform_admins",
"count": 2,
"pass": true
}
],
"orphan_summary": {
"passed": 17,
"failed": 0,
"total": 17
},
"ok": true
},
"notes": [
"Clerk is excluded: users mapped by email only; no Clerk API.",
"Legacy password hashes are never imported.",
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
"File blobs are metadata-only; resync object storage separately.",
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled."
],
"elapsed_ms": 3036
}
+129
View File
@@ -0,0 +1,129 @@
# Migration run log — legacy MySQL → v2 Postgres
**Date:** 2026-08-03 (local)
**Status:** **SUCCESS** (dry-run + live load, exit 0)
**Elapsed (live):** ~11 minutes
Secrets are masked below. Do not commit `artifacts/` maps or hook tokens.
## Environment
| Item | Value |
|---|---|
| Legacy MySQL source | `f:/laragon/www/_MY/descrybe/.env.local``DATABASE_URL` |
| MySQL (masked) | `root:****@tcp(localhost:3306)/descrybe_v2` |
| MySQL also present | `descrybe_v1`, `descrybe_21_05_2025` (not used this run) |
| Postgres | Docker Compose service `postgres` (`descrybe-v2-postgres`) |
| Postgres URL (masked) | `postgres://descrybe:****@localhost:5433/descrybe?sslmode=disable` |
| Goose | migrations **001007** applied (see below) |
| Migrator | `apps/api/cmd/migrator``go build ./cmd/migrator` **PASS** |
### Goose notes
- `docker compose up -d` — container healthy on host port **5433**.
- `scripts/migrate.ps1` failed on this machine: `goose@latest` needs Go ≥ 1.25.7 (runner had 1.25.5); `sqlc` not on PATH.
- Worked: `go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 -dir sql/schema postgres "$DATABASE_URL" up`
- Fixed **UTF-8 BOM** on `apps/api/sql/schema/006_feed_sync.sql` (goose parse error `\ufeff-- +goose Up`).
## Migrator fixes applied this session
1. **`profiles.role` missing** — memberships default to `member` (Clerk held org roles).
2. **`profiles.email` / no `users` table** — synthetic `…@legacy.local` emails; enrich from `admin_users` when present.
3. **`companies.language` missing** — load from `company_settings` / default `en`.
4. **MySQL DSN** — accept `mysql://…` URLs; normalize to `user:pass@tcp(host:port)/db?parseTime=true`; clearer ping/open errors with Laragon hints (passwords masked in logs).
5. **`raw_products.feed_ids`** — optional on MySQL (column absent); PG still gets `NULL` / jsonb.
6. **GTIN unique index** — dedupe `(company_id, gtin)` before insert; report `raw_products_gtin_deduped`.
7. **Dry-run `planMap`** — populate with legacy plan IDs so `company_plans` linkage is counted.
## Dry-run
```text
go run ./cmd/migrator -mysql "root:****@tcp(localhost:3306)/descrybe_v2?parseTime=true" \
-postgres "postgres://descrybe:****@localhost:5433/descrybe?sslmode=disable" \
-dry-run -maps-dir ../../artifacts -id-map ../../artifacts/id-map.json
```
| Entity | Count |
|---|---:|
| companies | 27 |
| users | 21 |
| memberships | 15 |
| admin_users (flagged) | 1 |
| plans | 5 |
| company_plans | 3 (+2 skipped: orphan `plan_id=6`) |
| credit_balances | 5 (+1 skipped: orphan company `…-0`) |
| categories | 8278 |
| attributes | 95292 |
| category_attributes | 74377 |
| custom_variables | 36 |
| input_feeds (xml) | 31 |
| feed_mappings | 24 |
| raw_products | 126516 |
| processed_products | 7219 |
| export_feeds | 8 |
| files | 1 |
| Validation | `ok=true` (orphan checks skipped in dry-run) |
## Live load
Same command without `-dry-run` (used `-skip-post-import` this run). Artifacts under `artifacts/` (gitignored).
### Migration report (attempts / written)
| Entity | Report | Notes |
|---|---:|---|
| companies | 27 | PG total 29 (includes 2 pre-existing seed rows) |
| users | 21 | PG total 23 (seed + migrate); **no password hashes imported** |
| memberships | 15 | 6 legacy profiles have `company_id` NULL → skipped |
| admin_users | 1 | → `users.is_platform_admin` |
| plans | 5 | |
| company_plans | 3 | 2 skipped (`plan_id=6` missing in `plans`) |
| credit_balances | 5 | 1 skipped (orphan company id) |
| categories | 8278 | PG 8279 (+1 seed) |
| attributes | 95292 | PG **57630** = MySQL `COUNT(DISTINCT company_id, attribute_key)` (unique constraint collapse) |
| category_attributes | 74377 | |
| custom_variables | 36 | PG 37 (+1 seed) |
| input_feeds | 31 | PG 33 (+2 seed) |
| feed_mappings | 24 | |
| raw_products | 118784 | + **7732** GTIN-deduped (= 126516 MySQL rows) |
| processed_products | 7219 | |
| export_feeds | 8 | PG 9 (+1 seed) |
| files | 1 | metadata only; blobs not copied |
### Validation (live)
- `mode: live ok=true`
- Orphan FK checks: **16 passed / 0 failed** (companies, memberships, feeds, products, mappings, platform admin, etc.)
## Blockers / follow-ups
| Severity | Item | Action needed |
|---|---|---|
| **Data** | Most users have synthetic emails (`user_…@legacy.local`) — Clerk emails are not in MySQL | Export emails from Clerk (or another source) and patch `users.email` before set-password invites |
| **Data** | All memberships imported as `role=member` (`profiles.role` absent) | Promote company admins post-cutover |
| **Data** | `company_plans` rows with `plan_id=6` skipped | Create missing plan or remap those companies |
| **Data** | 1 orphan credit balance (`company_id` suffix `-0`) | Ignore or repair source row |
| **Ops** | Attribute / GTIN collapses are intentional under v2 unique constraints | Accept PG counts; ID map still covers remapped FKs |
| **Ops** | Goose pin / BOM / sqlc PATH | Prefer pinned goose ≤ Go toolchain; keep schema files BOM-free; install sqlc if regenerating queries |
| **Ops** | Set-password hooks | Re-run without `-skip-post-import` (or `-issue-set-password-invites`) when ready to email invites |
## CLI verification
```text
cd apps/api
go build ./cmd/migrator # PASS
go test ./cmd/migrator -count=1 # PASS
```
Offline smoke (no MySQL):
```text
go run ./cmd/migrator -dry-run -fixture ./cmd/migrator/testdata/fixture.json -maps-dir ../../artifacts
```
## How to re-run
1. `docker compose up -d` in `descrybe-v2`
2. Apply goose (`goose@v3.24.3` or newer matching local Go)
3. Set DSN from legacy `.env.local` (convert `mysql://` → go-sql-driver form, or pass URL — migrator converts)
4. Dry-run, then live; reuse `artifacts/id-map.json` for idempotent remaps
+146
View File
@@ -0,0 +1,146 @@
# Mobile / tablet audit — descrybe-v2 web
**Date:** 2026-08-04
**App:** `apps/web` (SvelteKit) at `http://127.0.0.1:5174`
**Auth:** demo user `demo@descrybe.local` (see [demo-user.md](demo-user.md))
**Tooling:** codehelper `browser` with `device=mobile` (390×844) and `device=tablet` (768×1024), `format=png`
**Gate:** `npm run check`**0 errors, 0 warnings**
---
## Summary
Document-level horizontal overflow on dashboard table pages is fixed (products/feeds document width now matches the viewport ≈390px on mobile). Tables scroll horizontally inside shells. Modals use a bottom-sheet layout on small screens. Tutorial popover clamps and scrolls on narrow viewports. Remaining polish: denser header cluster, mapping/table column density, and tutorial popover vs open drawer.
| Area | Before (mobile) | After |
|------|-----------------|--------|
| Products page width | ~823px (page scroll) | **390px** (table scrolls inside) |
| Feeds page width | ~989px | **390px** |
| Campaigns / Woo | 627708px | Contained via layout `overflow-x-clip` + table shells |
| Billing / Dashboard / Pricing / Login | Already ~390 | OK |
| `npm run check` | — | **Pass** |
---
## Pages audited
### Login (`/login`) — mobile + tablet
- **Screenshot notes:** Centered sign-in card, brand header, footer legal links. No horizontal overflow. Form fields and Sign in CTA are comfortable tap sizes.
- **Findings:** Secondary text links (Create company / Accept invite / View pricing) are smaller than 44px but usable. OK for auth.
- **Fixes:** None required.
### Pricing (`/pricing`) — mobile
- **Screenshot notes:** Marketing shell with hamburger + theme toggle; plan cards stack vertically; Monthly/Yearly toggle fits.
- **Findings:** Header icon pair is close; FAQ rows are full-width (good). Hero CTA pair sits side-by-side — acceptable.
- **Fixes:** None required this pass.
### Dashboard (`/dashboard`) — mobile
- **Screenshot notes:** Hamburger + company switcher + short “Resume” tour control + avatar. Action grid (tutorial / processing / store / campaign) wraps. Stat cards stack full width.
- **Findings:** Header cluster is dense on 390px. Tutorial opens drawer for nav steps and shows popover over sidebar (step “Standard fields” observed).
- **Fixes:** Shorter header tour labels on xs; larger tap height (~3640px); layout padding `p-4 sm:p-6`.
### Products (`/products`) — mobile + tablet
- **Screenshot notes (mobile):** Status tabs + search/filters; product flex-table with Quality / Category / Status columns. Viewport no longer widens the document.
- **Screenshot notes (tablet):** Sidebar drawer pattern still uses hamburger at 768; table columns readable; “Resume tutorial” full label visible.
- **Findings:** Table still needs **in-card horizontal swipe** to see Actions; that is intended. Filter row can feel cramped on mobile.
- **Fixes:** `ProductTable` `min-w-[40rem]` + `overflow-x-auto`; page shell `min-w-0`.
### Feeds (`/feeds`) — mobile
- **Screenshot notes:** Stats cards stack; search card; wide feeds table with Name / Source / Type / Interval / Status / Last Synced / Actions. Map + ⋮ actions visible after horizontal scroll.
- **Findings:** Source header truncates in the first viewport until the user scrolls the table — expected with sticky Actions. Map/⋮ remain tight tap targets.
- **Fixes:** Card `min-w-0 overflow-hidden`; shared `Table` `min-w-[36rem]` + overflow shell.
### Mapping (`/feeds/[id]/mapping`) — mobile
- **Screenshot notes:** Title + Auto-map / Extract / Back actions wrap; step tabs and field mapping table; footer Save / Sync sample.
- **Findings:** Step tabs previously clipped horizontally (“Select Product Element” / “Map Fields” side-by-side).
- **Fixes:** Step tabs stack on xs (`flex-col``sm:flex-row`).
### Billing (`/billing`) — mobile
- **Screenshot notes:** Plan/credits cards stack; usage chart; Compare / Manage / Add credits actions.
- **Findings:** Header density same as other app pages. No page overflow.
- **Fixes:** Inherited layout/header fixes.
### Campaigns (`/campaigns`) — mobile
- **Screenshot notes:** Search + campaign table (Name / Season / Status / Updated / Actions).
- **Findings:** Table uses horizontal scroll inside card after shell fix.
- **Fixes:** Campaigns table Card `min-w-0 overflow-hidden`.
### Woo / Stores (`/woocommerce`) — mobile
- **Screenshot notes:** Setup steps card; Connection / Categories / … tabs (2-col grid on mobile); credential form; Save / Queue buttons.
- **Findings:** Setup step copy previously appeared clipped when the page itself overflowed; `break-words` added on the steps list.
- **Fixes:** `break-words` on setup `<ol>`; layout containment.
### Sidebar / nav
- **Screenshot notes:** Off-canvas drawer (`#app-sidebar`), overlay, primary links + More. Opens for tutorial nav targets.
- **Findings:** Drawer + tutorial popover compete for space on 390px (popover can cover the highlighted link). Skip/Pause/Back remain reachable.
- **Fixes:** Tutorial popover max-height + edge clamp + full-bleed width under 480px; header hamburger ≥40×40.
### Tutorial overlay
- **Screenshot notes:** Shade panels + highlight ring + popover (“STEP N OF 16”, Skip tour, Pause). Observed on dashboard with More → Standard Fields highlighted.
- **Findings:** Works on mobile; popover should stay smaller / lower when the drawer is open (follow-up).
- **Fixes:** `max-height: min(70dvh, 28rem)`, overflow scroll, safe-area padding, tighter edge inset on small screens.
### Modals (`Dialog`)
- **Intent:** Add Feed and similar dialogs.
- **Fixes:** Mobile bottom-sheet alignment (`items-end`), `max-h-[min(92dvh,900px)]`, scrollable body, removed `sm:min-w-[500px]` that fought small widths.
---
## Code changes (this pass)
| File | Intent |
|------|--------|
| `src/routes/+layout.svelte` | `max-w-[100vw] overflow-x-clip`, `min-w-0` main, compact tour header labels |
| `src/lib/components/DashboardHeader.svelte` | Larger hamburger/logout targets, tighter header gaps |
| `src/lib/components/CompanySwitcher.svelte` | Narrower max-width on xs |
| `src/lib/components/PageShell.svelte` | `min-w-0 max-w-full` |
| `src/lib/components/ui/Table.svelte` | `min-w-[36rem]` + overscroll containment |
| `src/lib/components/ui/TableShell.svelte` | Outer overflow + min-w-0 |
| `src/lib/components/ui/Dialog.svelte` | Mobile bottom sheet + max-height |
| `src/lib/components/ui/TabsList.svelte` | Horizontal scroll when needed |
| `src/lib/components/products/ProductTable.svelte` | Explicit min-width rows for in-shell scroll |
| `src/lib/components/tutorial/TutorialOverlay.svelte` | Small-screen popover sizing/clamp |
| `src/routes/feeds/+page.svelte` | Table card overflow containment |
| `src/routes/campaigns/+page.svelte` | Table card overflow containment |
| `src/routes/woocommerce/+page.svelte` | Setup steps `break-words` |
| `src/routes/feeds/[feedId]/mapping/+page.svelte` | Step tabs stack on mobile |
---
## Follow-ups (not blocking)
1. **Tutorial + drawer:** Prefer bottom-anchored popover when `navUi.mobileOpen`, or auto-scroll the highlighted nav item above the popover.
2. **Feeds/products actions:** Increase Map / ⋮ hit areas to ≥44px on touch.
3. **Products filters:** Stack category/feed/sort full-width on xs.
4. **Tablet (≥768):** Consider showing persistent sidebar earlier (`md:` vs `lg:`) so tablet does not rely only on the drawer.
5. **Add Feed modal e2e:** Browser click was flaky with overlapping outline targets; re-verify after drawer closed.
---
## How to re-run
```bash
# from apps/web
npm run check
```
Browser (codehelper MCP / CLI), with demo session:
- Base URL: `http://127.0.0.1:5174`
- `device=mobile` and `device=tablet`
- Prefer `format=png` if WebP encoding is unavailable in the agent host
- Authenticated pages: login then `session=<name>` reuse
Key routes: `/login`, `/pricing`, `/dashboard`, `/products`, `/feeds`, `/feeds/<id>/mapping`, `/billing`, `/campaigns`, `/woocommerce`.
+190
View File
@@ -0,0 +1,190 @@
# Mock LLM (OpenAI-compatible stub for CI / local processing)
Tiny local Chat Completions server that exercises Descrybes real `processing.OpenAIClient` path **without** production API keys or Green Chat.
| Item | Value |
|------|--------|
| Command | `apps/api/cmd/mock-llm` |
| Default listen | `127.0.0.1:18767` |
| Base URL | `http://127.0.0.1:18767/v1` |
| Model id | `mock-llm` |
| API key | `local-test` (placeholder; Completer requires non-empty) |
| Responses | `processing.HeuristicCompleter` (deterministic JSON for enhance) |
**Do not use the A1 tenant.** Prefer **`demo@descrybe.local`** → **Platform Demo** ([demo-user.md](demo-user.md), [safe-test-fixtures.md](safe-test-fixtures.md)).
Related: [green-chat-llm.md](green-chat-llm.md) (real local/LAN model), [local-llm-tuning.md](local-llm-tuning.md), [e2e-processing.md](e2e-processing.md).
---
## Start
```powershell
cd apps/api
go run ./cmd/mock-llm -addr 127.0.0.1:18767
# defaults: key=local-test model=mock-llm
# env overrides: MOCK_LLM_API_KEY, MOCK_LLM_MODEL
```
Health: `GET http://127.0.0.1:18767/healthz``{"status":"ok","service":"mock-llm",...}`.
Smoke:
```powershell
curl.exe -sS http://127.0.0.1:18767/v1/models -H "Authorization: Bearer local-test"
# POST /v1/chat/completions with JSON messages (same shape as OpenAI)
```
Endpoints:
| Method | Path | Notes |
|--------|------|--------|
| `GET` | `/healthz` | No auth |
| `GET` | `/v1/models` | Bearer required |
| `POST` | `/v1/chat/completions` | Bearer; assistant content from HeuristicCompleter |
| `POST` | `/v1/embeddings` | Bearer; tiny fixed vector for role probes |
Loopback is allowed by `OpenAIClient` dial policy (`localhost` / `127.0.0.1`).
---
## Config keys
Process-env platform fallback (root `.env` — restart **api** + **worker** after change):
```env
OPENAI_API_KEY=local-test
OPENAI_BASE_URL=http://127.0.0.1:18767/v1
OPENAI_MODEL=mock-llm
# optional tuning
# PROCESSING_RPM=60
# PROCESSING_MAX_RETRIES=3
```
Preferred for a single tenant: company admin → **`/integrations/ai`** → custom OpenAI-compatible:
- Base URL: `http://127.0.0.1:18767/v1`
- Model: `mock-llm`
- API key: `local-test`
`OPENAI_API_KEY` must be non-empty or the Completer stays disabled (`Enabled()`).
Unset `OPENAI_*` → worker skips AI enhance (or uses heuristic only if explicitly wired). Mock-llm is for proving the **HTTP** Completer path.
---
## How processing tests should call it
### 1. Unit / package tests (no process)
Prefer in-process stubs — already used:
- `stubCompleter` in `processing/steps_test.go`
- `HeuristicCompleter` for offline enhance shapes
- `httptest.NewServer` + `NewOpenAIClient(..., srv.URL+"/v1", ...)` as in `aiprovider/platform_role_test.go`
`go test ./cmd/mock-llm` covers health, auth, enhance JSON, and an `OpenAIClient` round-trip against the stub handlers.
### 2. Integration / E2E (worker + API)
1. Start mock-llm (above).
2. Set `OPENAI_*` **or** configure `/integrations/ai` on **Platform Demo** (not A1).
3. Ensure demo plan has `can_use_ai=true` and credits ([free-tier.md](free-tier.md)).
4. Run API + worker so the worker inherits env (or uses dashboard BYOK).
5. Enqueue `enhance_only` (or `full`) for a demo product:
```powershell
# After CSRF + login as demo@descrybe.local (cookie jar):
# POST /api/processing/jobs {"raw_product_ids":["…"],"processing_type":"enhance_only"}
# Poll GET /api/processing/jobs/{id} until completed
# Expect ai_enhance step done; gpt/step log shows OpenAI-compatible usage (not openai_not_configured)
```
Worker log should show OpenAI configured with `base=http://127.0.0.1:18767/v1 model=mock-llm`, not “platform OpenAI unset”.
### 3. CI
- Default CI: keep unit tests with `stubCompleter` / `httptest` (no long-lived process).
- Optional job: `go run ./cmd/mock-llm` in background → set `OPENAI_*` → run a narrow processing smoke. Do **not** commit real keys; use `local-test`.
---
## Translation + processing verification
Two independent axes — do not confuse them:
| Axis | What it controls | Where |
|------|------------------|--------|
| **UI locale** | Dashboard chrome strings | localStorage key descrybe-ui-locale (en es fr de it pt nl pl ja) — pps/web/src/lib/i18n/locales.ts |
| **Content language** | AI/product titles & descriptions | Company /settings → Content Language (companies.language) — allowlist in content-languages.ts / company/language.go |
### A. Translation (UI + content language)
Use **demo@descrybe.local** (not A1).
1. Web up (PUBLIC_API_URL / WEB_ORIGIN from root .env.example).
2. Login → open language switcher (or set localStorage.setItem("descrybe-ui-locale","fr") then reload) — expect French chrome, not raw
amespace.key paths; missing keys fall back to English.
3. /settings → Content Language → pick e.g. de or sl → save — company payload / GET /api/me shows the new language.
4. Optional checks: web
pm run check; Go company.ParseLanguage / IsAllowedLanguage.
Root .env.example documents both locale lists (comments only — not process-env).
### B. Processing against mock-llm
1. cd apps/api && go run ./cmd/mock-llm -addr 127.0.0.1:18767
2. Uncomment mock OPENAI_* in a **private** root .env (placeholders only — see .env.example) **or** set /integrations/ai on Platform Demo.
3. Restart **api** + **worker** if using process env.
4. Smoke stub: curl.exe -sS http://127.0.0.1:18767/v1/models -H "Authorization: Bearer local-test"
5. Package tests: go test ./cmd/mock-llm -count=1
6. Enqueue enhance (Integration / E2E section above) on a **demo** product — poll until i_enhance is done.
7. Optional: change content language, re-run enhance, confirm job still completes (heuristic mock text is language-agnostic; real Green Chat honors content language in prompts).
Checklist copy lives in root .env.example under **Locales** and **LLM test provider**.
---
## Failure modes (processing + mock-llm)
Prefer a **temporary** probe (closed port / wrong key / httptest timeout) over changing demo BYOK. If you stop mock-llm, restore it before leaving the session.
| Mode | How to induce (safe) | Completer / job behavior | User-facing note (after TruncateError) |
|------|----------------------|--------------------------|----------------------------------------|
| Provider down | Point client at closed port `127.0.0.1:18768`, **or** briefly stop mock-llm | `ai_enhance` **failed**; job item still completes with passthrough title/description | `AI provider unreachable — check base URL and that the service is running` |
| Bad API key | `OPENAI_API_KEY=wrong-key` against live mock-llm (or `/integrations/ai` test with wrong key) | Non-retryable 401; enhance fails; passthrough copy | `AI provider rejected the API key` |
| Timeout | Short `HTTPClient.Timeout` / `context.DeadlineExceeded` (unit: `TestRunSteps_enhanceViaHTTPLLMTimeout`) | Retries then fail; passthrough copy; step `failed` | `AI provider timed out — try again or check provider load` |
| Upstream 5xx | Mock returns 502 + message | Retries then fail; note keeps provider message when present | e.g. `green-chat unavailable` (retry prefix stripped) |
| Platform unset | Empty `OPENAI_API_KEY` and no company BYOK | Step **skipped** (not failed) | `ai_enhance: skipped (platform OpenAI unset; …)` |
| Free plan | `AllowAI=false` | Step **skipped** | Free-plan upgrade note |
Package coverage: `go test ./internal/processing -run 'HTTPLLM|mockLLM|TruncateError_classifies' -count=1`.
**Do not** permanently change Platform Demo `/integrations/ai` or root `.env` `OPENAI_*` for failure probes — use closed-port clients, stubs, or a short mock-llm stop.
### Restore mock-llm (after a stop)
```powershell
# Confirm down
curl.exe -sS http://127.0.0.1:18767/healthz
# Start again
cd apps/api
go run ./cmd/mock-llm -addr 127.0.0.1:18767
# Expect healthz status=ok; key/model defaults: local-test / mock-llm
curl.exe -sS http://127.0.0.1:18767/healthz
curl.exe -sS http://127.0.0.1:18767/v1/models -H "Authorization: Bearer local-test"
```
If api/worker were started with `OPENAI_BASE_URL=http://127.0.0.1:18767/v1` and `OPENAI_API_KEY=local-test`, no `.env` change is needed after restore. If you temporarily pointed a **non-demo** test company at a bad base URL, set it back to `http://127.0.0.1:18767/v1` + `local-test` (or clear BYOK to use platform env).
---
## What this is / is not
| Is | Is not |
|----|--------|
| OpenAI Chat Completions wire format for local/CI | A real GGUF / Ollama / Green Chat model |
| Deterministic enhance JSON via HeuristicCompleter | Substitute for production model quality |
| Safe placeholder key `local-test` | Something to commit as a secret |
For a real tiny local model, point the same `OPENAI_*` keys at Ollama/LM Studio/Green Chat (`OPENAI_BASE_URL=http://127.0.0.1:PORT/v1`) — see [green-chat-llm.md](green-chat-llm.md).
+184
View File
@@ -0,0 +1,184 @@
# Runtime ops notes (WS7)
Local/dev and production operator notes for platform, mail, billing, and WooCommerce schedules. **Do not commit secrets.**
**Env file policy:** one **root** `.env` (or secrets mapped into process env). Do not duplicate into `apps/api/.env` or tmp placeholders. Platform Stripe / EPREL / feed allowlist → `/admin/settings`; tenant OpenAI / marketing email / stores → `/integrations/ai`, `/integrations/email`, `/stores` — see [README.md](../README.md#environment-one-file).
## Health
- `GET /healthz` — liveness (no DB)
- `GET /readyz` — readiness: Postgres ping **and** a fresh worker heartbeat (`worker_id=processing`, stale after **60s**) plus queue probe; returns `maintenance` / `read_only` / `hypercare` flags and `checks.{database,worker,queue}`
- `GET /metrics` — Prometheus text (HTTP RED on API; worker sync series when `METRICS_ADDR` is set). Production Gate: loopback or `METRICS_PUBLIC=1`. Example scrape + alerts: [`deploy/prometheus/`](../deploy/prometheus/). Worker age for on-call is `/readyz` `worker_last_seen_age_s` (not a Prom series).
When the worker is down or stale, `/readyz` is **503** with short `error` plus operator `reason` (no secrets). Example (API-only / stale heartbeat):
```json
{
"status": "not_ready",
"service": "api",
"checks": { "database": "ok", "worker": "stale", "queue": "ok" },
"error": "worker heartbeat stale",
"reason": "Processing worker heartbeat older than 1m0s. API-only readiness 503 is expected — start or restart the worker (npm run dev includes it, or npm run dev:worker).",
"queue_pending": 5,
"worker_last_seen_age_s": 120
}
```
**Prefer one stack:** `npm run dev` runs **api + web + worker** (ports **28471** / **28472**). Compose starts **Postgres only** — the worker is a host process. Browsers need the web process — API-only leaves `:28472` **CONNECTION_REFUSED** even when `/readyz` is green.
If the API is already up without a worker (`npm run dev:api`), start **one** worker only (or use `npm run dev:backend` for api+worker):
```bash
npm run dev:worker
# equivalent: cd apps/api && go run ./cmd/worker
# api+worker (no web): npm run dev:backend
```
Do **not** start a second worker when `checks.worker=ok` / `worker_last_seen_age_s` is fresh — duplicate claim loops contend on A1 (or any tenant) job rows. Before restarting the full stack: `node scripts/free-dev-ports.mjs` (or rely on `predev`) then `npm run dev`, and stop any leftover standalone `cmd/worker`. Readiness itself is read-only (no A1 catalog writes). API-only → `/readyz` **503** is expected; see [README troubleshooting](../README.md#troubleshooting-readyz-returns-503).
Cutover rehearsal: probes must stay green while `MAINTENANCE_MODE` / `READ_ONLY_MODE` may block app traffic.
## Postgres pgx pool (API + worker)
Shared by `cmd/api` and `cmd/worker` via `internal/db.NewPool`. Size pools for **your** Postgres `max_connections` and replica count — do not copy high defaults blindly.
| Variable | Default | Purpose |
|---|---|---|
| `DB_MAX_CONNS` | `20` | Hard ceiling per process (`MaxConns`) |
| `DB_MIN_CONNS` | `2` | Warm floor (`MinConns`, ~1030% of max) |
| `DB_MAX_CONN_LIFETIME` | `1h` | Recycle connections before server-side idle kills / DNS drift |
| `DB_MAX_CONN_LIFETIME_JITTER` | `6m` | Random extra lifetime (~10% of lifetime) — avoids thundering-herd reconnects; `0` disables |
| `DB_MAX_CONN_IDLE_TIME` | `5m` | Close idle conns during health checks |
| `DB_HEALTH_CHECK_PERIOD` | `1m` | Background idle health check interval |
| `DB_STATEMENT_TIMEOUT` | `30s` | Postgres `statement_timeout` GUC per connection; `0` disables |
Formula sketch: `MaxConns ≈ (max_connections reserved) / instance_count`. Typical per-process range is 2050. Always keep jitter set in multi-instance deploys.
## SMTP (invites + set-password)
Platform invite / set-password mail uses process env (`internal/mail` + `cmd/mailhooks`). This is separate from **tenant** marketing email (`/integrations/email`).
Env (no defaults that embed secrets):
| Variable | Purpose |
|---|---|
| `SMTP_ENABLED` | `true` to send; otherwise no-op log (subject only, no PII) |
| `SMTP_HOST` / `SMTP_PORT` | SMTP server |
| `SMTP_USER` / `SMTP_PASSWORD` | Auth (optional for open relays) |
| `SMTP_FROM` | From address |
| `WEB_ORIGIN` | Base URL for accept-invite links |
| `TOKEN_SIGNING_SECRET` | HMAC for admin-issued set-password tokens (not migrator hooks) |
| `EMAIL_DRY_RUN` | Default **true** when unset (safe). Live `mailhooks` send fails closed until you pass `-dry-run` or set `EMAIL_DRY_RUN=false` (and/or disable dry-run in admin platform mail settings). |
### Migrator hooks → mail
After a live migrator run (or `go run ./cmd/migrator -issue-set-password-invites`), maps-dir contains `password_invites.json` and `set-password-hooks.json` (invite tokens + URLs for `must_set_password` users). See [migration-readiness.md](migration-readiness.md).
#### Operator smoke (no live SMTP)
Rehearse under dry-run **before** flipping SMTP. Prefer process env `EMAIL_DRY_RUN=true` (or leave unset — default is dry-run):
```bash
cd apps/api
# Expect exit 1: fail-closed when dry-run is on and -dry-run is omitted
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json
# Expect exit 0: counts subjects only; no SMTP dial
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run
# log: mailhooks: dry-run subject="Set your Descrybe password"
# stdout: mailhooks: sent=N skipped=… failed=0 smtp_enabled=… total=…
```
Pass criteria: second command exits `0`, `failed=0`, and subjects are logged without contacting an SMTP host. Unit gate: `go test ./cmd/mailhooks/`.
#### Live send (after SMTP proven)
```bash
cd apps/api
# Requires EMAIL_DRY_RUN=false (or admin platform mail dry-run off) + SMTP_ENABLED=true + host/from
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json
```
Rate limit via `-delay-ms` (default 100). Team invites use the same SMTP path from `POST /api/team/invites`.
## Session idle policy
- Absolute session lifetime: 7 days
- Idle timeout: `SESSION_IDLE_HOURS` (default 24)
- Set `SESSION_SECURE=true` behind HTTPS in production
## Credentials encryption (WooCommerce)
| Variable | Purpose |
|---|---|
| `CREDENTIALS_ENCRYPTION_KEY` | **Required in production** for AES-GCM at-rest encryption of Woo consumer secrets |
If unset, the API/worker derive a key from `TOKEN_SIGNING_SECRET` + `DATABASE_URL`. That derived key is **local/dev only** — rotate by setting an explicit `CREDENTIALS_ENCRYPTION_KEY` before storing production Woo credentials. Changing the key without re-saving configs makes existing ciphertext unreadable.
## AI (OpenAI-compatible / Green Chat)
**Preferred:** company admin configures the provider in **`/integrations/ai`** (popular BYOK or custom OpenAI-compatible base URL + key). Encrypted at rest with `APP_ENCRYPTION_KEY`.
Optional process-env platform fallback (when the company leaves mode **internal** and a platform key is present in config):
| Variable | Purpose |
|---|---|
| `OPENAI_API_KEY` | Bearer token; non-empty enables the platform Completer fallback |
| `OPENAI_BASE_URL` | Default `https://api.openai.com/v1`; LAN Green Chat e.g. `http://HOST:PORT/v1` |
| `OPENAI_MODEL` | Chat model id (`GET /v1/models`) |
If set, put these in the **root** `.env` only — not `apps/api/.env`. Shared by worker (processing enhance) and API (campaign + SEO AI). Brand kit injects into those prompts. See [green-chat-llm.md](green-chat-llm.md).
[UNCERTAIN] Other agents may remove env-based platform OpenAI in favor of dashboard-only configuration; prefer `/integrations/ai` for new setups.
## Billing / processing costs
- Debit per processed product: `processing_costs.product_processing` + `ceil(tokens/1000) * openai_token_k`
- Defaults seeded by worker/`EnsureDefaultCosts`
- Plans admin / assign / add-credits: `/api/admin/*` (platform admin only)
- Billing cycles: worker every 15m via `RunDueBillingCycles`
## EPREL energy labels
Optional enrichment during product processing. See [eprel.md](eprel.md).
**Preferred:** platform admin → **`/admin/settings`** (`values.eprel.enabled`, `eprel.base_url`, `eprel.timeout`, `eprel.fiche_language`, `eprel.api_key`). Process `EPREL_*` env is an optional fallback.
| Settings key / env | Purpose |
|---|---|
| `eprel.enabled` / `EPREL_ENABLED` | `true` to fetch label/fiche/class after AI steps |
| `eprel.timeout` / `EPREL_TIMEOUT` | HTTP timeout (default `10s`) |
| `eprel.fiche_language` / `EPREL_FICHE_LANGUAGE` | Fiche PDF language (default `EN`) |
| `eprel.api_key` / `EPREL_API_KEY` | Optional; never log |
## Feed private-URL allowlist
SSRF allowlist for private/LAN feed URLs: **`/admin/settings`** → `feeds.private_url_allowlist` (CSV). Optional env `FEED_URL_PRIVATE_ALLOWLIST` remains a fallback (settings merge/override — see `feeds.ApplyPrivateAllowlistCSV`).
## WooCommerce schedule
Worker already:
1. Claims `pending_sync` every 2s (`ClaimNextPending``SyncCompany`)
2. Every 15m enqueues due enabled configs (`EnqueueDueScheduled`, default 6h or `sync_options.schedule_interval_hours`)
Manual enqueue: `POST /api/woocommerce/sync` (dashboard) or operator cron hitting that endpoint / re-running enqueue SQL is unnecessary if the worker is up.
## Schema migrations
```bash
# Git Bash / WSL / macOS / Linux
make migrate
# PowerShell
.\scripts\migrate.ps1
```
Current head: **042_user_session_version.sql** (goose, not Drizzle; includes **039_worker_heartbeats** for `/readyz`, **040_job_hotpath_indexes** for claim/list, **041** for forgot-password tokens, **042** for `users.session_version`). Confirm goose status includes versions through **042** before relying on worker readiness / job claim indexes / self-serve reset / session revoke. MySQL→PG **data** cutover uses cmd/migrator separately — see [production-checklist.md](production-checklist.md) and [cutover.md](cutover.md).
Read-only gate: `npm run cutover:deploy-check` / `node scripts/cutover-deploy-check.mjs` (goose 039042 + `/readyz` worker + adapter-node host gates).
## Cutover blockers (honest)
- **No production cutover executed** from this repo automation.
- Live migrator dry-run needs operator-supplied `MIGRATE_MYSQL_DSN` — do not invent credentials. Until a real DSN is available, cutover stays blocked.
- SMTP must be verified against `set-password-hooks.json` before DNS switch.
+151
View File
@@ -0,0 +1,151 @@
# Performance notes (descrybe-v2 Go API)
Date: 2026-08-04
Stack: Go API (`apps/api`) + Postgres (goose migrations under `apps/api/sql/schema`).
Not the legacy Next.js / Drizzle repo (`descrybe`).
## Goals
Bound round-trips and memory on hot paths for large catalogs (Local Demo Co scale): product lists, feed sync, export feeds.
## Review vs legacy TS wins
| Concern | Legacy TS (`descrybe`) | Go v2 (`descrybe-v2`) |
|---------|------------------------|------------------------|
| Product list count + page | Parallel `Promise.all` | **Added** `parallelCountAndList` in `internal/catalog` |
| Slim list columns | Drizzle relation trim | Already selects list columns (not `SELECT *`) on HTTP list path |
| Feed sync re-parse every chunk | Bug in `syncFeedChunk` loop | **Already OK** — one download/parse per `Sync`, row callback + `upsertChunk` |
| XML/CSV export streaming | Added stream + disk cache | **Already OK**`streamExport` / `streamXML` / `streamCSV` over `pgx.Rows` + flush |
| List indexes | Drizzle `0028_…` MySQL | **Added** goose `018_list_hotpath_indexes.sql` (Postgres) |
## Changes landed on v2
### 0. Scale small wins (2026-08-09)
- **Keyset enforcement** — `normalizeProductListFilter` caps product `limit` at 200 and rejects deep OFFSET (`>5000`) unless `cursor`/`after_id` is set.
- **pgx pool jitter** — `DB_MAX_CONN_LIFETIME_JITTER` (default 6m) + sizing docs in `docs/ops-runtime.md`.
- **Sync concurrency cap** — `jobs.SyncSlots` / `ClampSyncWorkers` (default 1, max 2) in `cmd/worker` for feed/Woo/Shopify claims.
### 1. Parallel product list (`apps/api/internal/catalog/service.go`)
- `ListRawProducts`, `ListProcessedProducts`, `ListProcessedProductsDetailed` run **count** and **page** queries concurrently via `parallelCountAndList`.
- Arg slices are copied so limit/offset appends do not race the count query.
### 2. Feed sync (`apps/api/internal/feeds/sync.go` + `parse.go`)
No code change required for the legacy re-parse bug:
- `runSync` loads the feed once, parses once (`parseXMLItems` / `parseCSV` with per-row callback).
- Upserts in `upsertChunkSize` (100) with job progress updates.
- Content-hash short-circuit skips parse when the feed body is unchanged.
### 3. Export streaming (`apps/api/internal/feeds/export.go`)
No code change required:
- Public and generate paths use `streamExport``streamXML` / `streamCSV`.
- Rows come from a single scoped SQL query (`LIMIT` capped by `exportMaxProducts`); writers flush every `exportChunkHint` (500) rows.
- Content is not buffered as a full XML string for the public stream path.
### 4. Indexes (goose `018_list_hotpath_indexes.sql`)
- `processed_products_company_updated_idx``(company_id, updated_at DESC)`
- `processed_products_company_status_updated_idx``(company_id, status, updated_at DESC)`
- `raw_products_company_updated_idx``(company_id, updated_at DESC)`
- `raw_products_company_processed_updated_idx``(company_id, is_processed, updated_at DESC)`
- `categories_company_parent_idx``(company_id, parent_unique_id)`
Apply:
```powershell
cd f:\laragon\www\_MY\descrybe-v2
$env:DATABASE_URL = "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"
.\scripts\migrate.ps1
```
## Smoke (Local Demo Co)
Repeatable low-concurrency harness (healthz + v1 feeds/products list), RPS assumptions, and how to scale up: [`scripts/api-load-smoke/README.md`](../scripts/api-load-smoke/README.md). Default API base is `http://127.0.0.1:28471` (`HTTP_ADDR`).
```powershell
cd f:\laragon\www\_MY\descrybe-v2\scripts\api-load-smoke
go run . # -c 2 -d 5s -rate 6; ~6 aggregate RPS
```
One-off Measure-Command (API key from `docs/demo-user.md`):
```powershell
$h = @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1" }
Measure-Command { Invoke-RestMethod -Headers $h "http://127.0.0.1:28471/api/v1/products?limit=25&offset=0" }
Measure-Command { Invoke-RestMethod -Headers $h "http://127.0.0.1:28471/api/v1/products?kind=raw&limit=25&offset=0" }
```
Restart the API process after deploying Go changes so handlers pick up `parallelCountAndList`.
## API docs (/docs) performance (2026-08-04)
Viewer: **RapiDoc** (OSS web component; no Scalar cloud). Disk sizes:
| Asset | Raw | Gzip |
|-------|-----|------|
| RapiDoc `rapidoc-min.js` | **~843 KB** | **~213 KB** |
| Scalar ESM (previous) | 691 KB | ~154 KB (+ lazy chunks; ~3.8 MB total vendor tree) |
| Scalar IIFE (older) | 3661 KB | ~1052 KB |
| OpenAPI YAML (`v1_openapi.go`) | ~74 KB | ~12 KB |
Changes:
1. **RapiDoc vendor**`scripts/copy-rapidoc-ui.mjs` copies `dist/rapidoc-min.js` and writes `.gz`. Single file, no cloud registry/agent chunks.
2. **hooks.server.ts** — long-lived `Cache-Control` for `/vendor/rapidoc/*`; serves precompressed `.gz` when `Accept-Encoding: gzip`.
3. **/docs page** — `type=module` load, `modulepreload`, early `prefetchOpenApiSpec` + `loadSpec(blobUrl)` (no double fetch), theme via attribute (no remount).
4. **OpenAPI handler** — gzip body when requested (`Vary: Accept-Encoding`); coordinates with sibling Cache-Control / ETag / 304 work.
Verify: `node apps/web/scripts/copy-rapidoc-ui.mjs`, then load `/docs` and confirm Network shows ~843 KB (or ~213 KB gzip) for `rapidoc-min.js` and `Content-Encoding: gzip` on `/api/v1/openapi.yaml`.
## Benchmark / smoke (this run)
API was up at `http://127.0.0.1:8080`. Goose `018` applied (indexes live immediately). Go parallel list is in source — restart `cmd/api` to load it.
Local Demo Co via demo API key (3 probes each; cold then warm):
| Probe | total | page | ms (3 runs) | avg |
|-------|------:|-----:|-------------|----:|
| GET /api/v1/products?limit=25&offset=0 (processed) | 4337 | 25 | 90,19,10 | ~40 |
| GET /api/v1/products?limit=25&offset=1000 | 4337 | 25 | 14,10,9 | ~11 |
| GET /api/v1/products?kind=raw&limit=25&offset=0 | 23775 | 25 | 12,8,8 | ~9 |
| GET /api/v1/products?kind=raw&limit=25&offset=1000 | 23775 | 25 | 35,29,26 | ~30 |
`go build ./...` OK. Tests: `./internal/catalog` `./internal/feeds` `./internal/httpapi` OK.
## Million-SKU backlog (prioritized)
Operator/dev map from web research → concrete paths. Status notes reflect current tree (2026-08-04); do not treat “partial” as done at 1M SKU.
### P0 — do first (blocks million-SKU)
1. **Keyset pagination** — replace deep `OFFSET` with `(updated_at, id)` / `(created_at, id)` cursors.
- **Enforced (2026-08-09):** product lists reject `offset > 5000` without `cursor`/`after_id` (`MaxOffsetWithoutCursor`); product page limit capped at 200 (`MaxProductPageLimit`) in `normalizeProductListFilter`.
- API: `apps/api/internal/catalog/service.go` + `cursor.go`, handlers expose `next_cursor` / `next_after_id`.
- Web: `apps/web/src/lib/list.ts` (`pageOffset`), `apps/web/src/lib/components/products/ProductPagination.svelte` — still prefer cursor over deep offset.
2. **Set-based feed upserts** — today `upsertChunk` uses `pgx.Batch` of per-row INSERT/UPDATE (`apps/api/internal/feeds/sync.go`). Move to set-based `INSERT … SELECT` / `UNNEST` (or COPY + merge) for chunk apply.
3. **Kill JSONB ILIKE (full cast)**`CAST(mapped_data/raw_data AS text) ILIKE` is already avoided; search uses keyed `->>'…'` + `gtin`/`feed` ILIKE in `appendRawProductFilters` (`apps/api/internal/catalog/service.go`). Remaining P0: measure leading-wildcard ILIKE; add `pg_trgm`/expression indexes or constrain search to prefix/FTS before 1M.
4. **Stream feed download + parse**`downloadFeed` still `io.ReadAll` into a size-capped buffer (`apps/api/internal/feeds/download.go`); wired via `loadFeedSource` (`source.go`) into `Sync` (`sync.go`). Goal: stream HTTP → parser without holding the full body when possible.
5. **AI Batch API + content-hash skip****Hash-skip landed** (`HashEnhanceInput` + `field_sources.enhance_input_hash` in `apps/api/internal/processing`; loaded in `processOne` before `RunSteps` / `ConsumeCredits`). Sync `Completer` / `CompleteWithOptions` is still per-SKU. **OpenAI Batch deferred:** needs a job-level coordinator (JSONL submit -> `batch_id` -> reconcile -> debit) that does not fit the current sync completer interface; ~50% OpenAI discount remains a follow-up.
6. **Tenant-leading indexes** — composites landed in `018_list_hotpath_indexes.sql` / `020_raw_list_created_indexes.sql`, but single-column leftovers from `apps/api/sql/schema/002_catalog.sql` remain (e.g. `raw_products_company_id_idx`, `raw_products_gtin_idx`, `raw_products_feed_id_idx`, `raw_products_processing_status_idx`, `processed_products_company_id_idx`, `processed_products_product_id_idx`). Drop or replace with `(company_id, …)` leading keys where planners still pick the narrow indexes.
### P1 — next (fairness, scale edges, ops)
- **Per-tenant job fairness** — `apps/api/internal/jobs/river.go` (+ processing claim path in `pipeline.go`): avoid one company starving the queue.
- **Export beyond 50k** — `exportMaxProducts = 50000` in `apps/api/internal/feeds/export.go`; raise via keyset/chunked export or a read model (see P2).
- **CSV import batching** — `apps/api/internal/catalog/import_csv.go` + `httpapi/catalog_import_handlers.go`: per-row `Exec` today; batch/COPY.
- **Covering indexes** — extend list/export SELECT lists to match index INCLUDE / composite keys (build on `018`/`020`).
- **Observability** — request/query timing on list, sync, process, export; slow-query + job duration dashboards.
### P2 — later (architecture)
- **Partition plan** — `raw_products` / `processed_products` by `company_id` (or time) once single-tenant size dominates vacuum/autovacuum.
- **Export read model** — denormalized export rows so public/generate streams avoid heavy joins at read time.
- Stream XML via `encoding/xml` Token writer if payload construction grows heavy.
- Optional NDJSON disk cache for multi-worker resume of a single sync job (in-process `Sync` already avoids re-parse).
@@ -0,0 +1,359 @@
# User dashboard feature catalog (agent 1/10)
Inventory of signed-in **user dashboard** surfaces: everything rendered in the dashboard shell (`+layout.svelte` branch that is not marketing, auth, or `/admin/*`). Sources: `Nav.svelte`, route `+page.svelte` files, `plan-gates.ts`, `billing-display.ts`, `pricing-data.ts`, `entitlements.go`, `AssertCanStartProcessing`, and UI upgrade banners.
**Plan ladder (API defaults):** Free → Starter → Plus → Growth → Business → Scale → Enterprise (`apps/api/internal/billing/service.go` `defaultPublicPlans`, `apps/web/src/lib/components/pricing/pricing-data.ts`).
| Plan | SKU cap | Monthly AI credits | Paid (`is_paid_plan`) |
|------|---------|-------------------|------------------------|
| Free | 50 | 0 | No |
| Starter | 100 | 100 | Yes |
| Plus | 400 | 400 | Yes |
| Growth | 1,200 | 1,200 | Yes |
| Business | 4,000 | 4,000 | Yes |
| Scale | 12,000 | 12,000 | Yes |
| Enterprise | Unlimited (`max_products` null) | 1,000,000 wallet grant | Yes (custom) |
**Enforced today (API):** product SKU cap (`ErrProductLimitExceeded`), AI credit wallet + `can_use_ai` for AI-only processing (`ErrAIRequiresUpgrade` / `ErrInsufficientCredits`), email live-send blocked on Free (`email/service.go` dry-run), campaign AI generate (`campaigns/generate_send.go`), brand voice injection in AI prompts (`AIBrandApplyAllowed` → paid or trial). EPREL is **not** plan-gated (`CanUseEPREL` always true).
**Marketing copy only (not found enforced in API):** per-plan feed-source counts, export-feed counts, storage GB, WooCommerce “test vs full sync”, API read vs full access tiers, BYOK add-on packaging. Flag these when designing admin toggles — they need new enforcement or stay documentation-only.
---
## Admin toggle groups
Use these sections when grouping `feature_key` toggles in an admin plan editor.
| Section key | Scope |
|-------------|--------|
| `shell` | Global chrome: nav, header, command palette, tutorial, company switcher |
| `dashboard` | Home / overview |
| `catalog` | Products, categories, attributes, standard fields, structured descriptions |
| `feeds` | Import feeds, mapping, export feeds, file uploads |
| `stores` | Store hub, WooCommerce, Shopify connectors |
| `processing` | Background job monitor |
| `marketing` | Campaigns, calendar, brand, SEO, reviews |
| `integrations` | AI BYOK, email sending |
| `billing` | Billing, plans, checkout |
| `settings` | Account, company, alerts, API keys, team |
| `support` | Support center |
| `capabilities` | Cross-cutting gates (AI, SKU cap, email send, etc.) |
---
## Shell (`shell`)
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `shell.navigation` | Sidebar `Nav.svelte` — primary + More (Setup / Operate) | Navigate all dashboard routes; mobile drawer | All plans |
| `shell.command_palette` | Header area + `CommandPalette.svelte` (⌘K) | Quick-jump to Products, Feeds, Processing, Export, Settings, Billing, Support | All plans |
| `shell.company_switcher` | `DashboardHeader``CompanySwitcher.svelte` | Switch active company/workspace | All plans (multi-company users) |
| `shell.support_notifications` | `SupportNotificationBell.svelte` | Poll staff replies; open support tickets | All plans |
| `shell.tutorial` | Header tutorial buttons + `TutorialOverlay.svelte` | Start / resume / browse guided tour | All plans |
| `shell.account_menu` | Header avatar → `/settings` | Open account settings | All plans |
| `shell.billing_recovery_banner` | `BillingRecoveryBanner.svelte` (layout) | Past-due or missing-plan recovery CTAs | All plans (state-driven) |
---
## Dashboard (`dashboard`)
| feature_key | Route / component | User actions | Default by plan |
|-------------|-------------------|--------------|-------------------|
| `dashboard.overview` | `/dashboard``dashboard/+page.svelte` | Welcome, plan/credit summary | All |
| `dashboard.stats` | `DashboardStats.svelte` | View SKU count, active jobs, credits used/remaining | All |
| `dashboard.quick_links` | Dashboard shortcuts section | Jump to Products, Feeds, Processing | All |
| `dashboard.recent_jobs` | Dashboard jobs list | View recent processing jobs, open Processing | All |
| `dashboard.news_feed` | `NewsFeed.svelte` | Product news / announcements | All |
| `dashboard.activation_checklist` | `ActivationChecklist.svelte` | First-run onboarding steps | All |
| `dashboard.migrated_checklist` | `MigratedCohortChecklist.svelte` | Post-migration cutover checklist (cohort flag) | All (cohort) |
| `dashboard.etl_gaps` | `MigratedEtlGapsPanel.svelte` | Migration gap warnings | All (cohort) |
| `dashboard.store_reconnect` | `StoreReconnectBanner.svelte` | Reconnect broken store credentials | All |
| `dashboard.upgrade_banners` | `UpgradeBanner.svelte` on dashboard | Free plan, out-of-credits, SKU limit, trial, low credits | **Gated messaging** — see capabilities |
---
## Catalog (`catalog`)
### Products — `/products`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `catalog.products` | Nav **Products**`products/+page.svelte` | Browse, filter, sort, select products | All |
| `catalog.products.tab_processed` | `ProductTabs.svelte` | View completed processed products | All |
| `catalog.products.tab_needs_review` | Product tabs | Review AI enrichment before accept | All (needs AI run) |
| `catalog.products.tab_error` | Product tabs | View failed processing rows | All |
| `catalog.products.tab_processing` | Product tabs | View in-flight rows | All |
| `catalog.products.tab_unprocessed` | Product tabs | View raw / unprocessed inventory | All |
| `catalog.products.process_categories` | `ProductProcessingActions` | Run category assignment (no AI credits) | All |
| `catalog.products.process_attributes` | Processing actions | Normalize specs / fill attributes (no AI) | All |
| `catalog.products.process_ai_titles` | Processing actions | AI title generation | **Starter+** or Free with credits |
| `catalog.products.process_ai_descriptions` | Processing actions | AI description generation | **Starter+** or Free with credits |
| `catalog.products.enrichment_review` | `ProductEditPanel.svelte` | Accept / reject / edit AI fields | All (after AI run) |
| `catalog.products.inline_rename` | Product table | Quick rename processed titles | All |
| `catalog.products.export_selection` | Processing actions export | Export selected products | All |
| `catalog.products.upgrade_prompt` | Upgrade banners on products page | Free-plan and credit-limit CTAs | **Already gated** (UI) |
### Categories — `/categories`, `/categories/[id]/title-formula`, `/categories/[id]/description-formula`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `catalog.categories` | Nav More → Categories | List/edit category tree | All |
| `catalog.categories.title_formula` | Title formula page | Build title formulas with variables | Free: basic (1 cat marketing); Starter+: full |
| `catalog.categories.description_formula` | Description formula page | Build description formulas | Same as title formulas |
### Attributes — `/attributes`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `catalog.attributes` | Nav More → Attributes | Define attribute definitions | All |
| `catalog.attributes.bulk_import` | Attributes **Bulk Import** tab | CSV bulk import attributes | All |
### Standard fields — `/standard-fields`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `catalog.standard_fields` | Nav More → Standard Fields | Manage field dictionary | All |
| `catalog.standard_fields.groups` | Standard Fields **Groups** tab | Organize fields into groups | All |
### Other catalog pages (no nav link)
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `catalog.structured_descriptions` | `/structured-descriptions` | Structured description field config | All |
| `catalog.vector_categories` | `/vector-categories` | Dev/test vector index (Pinecone) | All if Pinecone configured |
---
## Feeds (`feeds`)
### Import feeds — `/feeds`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `feeds.list` | Nav **Feeds** | List supplier feeds, search, stats | All (Free marketing: 1 source) |
| `feeds.add_url` | Add feed dialog **Feed URL** tab | Add HTTP(S) feed URL | All |
| `feeds.add_csv` | Add feed **CSV file** tab | Upload CSV as feed source | All |
| `feeds.sync` | Per-feed sync actions | Trigger feed sync (FTP unsupported in UI) | All |
| `feeds.delete` | Feed row actions | Delete feed | All |
### Feed mapping — `/feeds/[feedId]/mapping`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `feeds.mapping` | From feeds list → Map | Map supplier fields to standard fields | All |
| `feeds.mapping.select_item` | Mapping tab 1 | Pick XML/CSV item element | All |
| `feeds.mapping.map_fields` | Mapping tab 2 | Column / path mapping, transforms | All |
| `feeds.mapping.queue_processing` | Post-map CTA | Queue products for processing after sync | All (AI steps follow capability gates) |
### Export feeds — `/export-feeds`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `feeds.export_feeds` | Nav **Export feeds** | List channel export definitions | All (Free marketing: 1 export) |
| `feeds.export_feeds.create` | Create / edit dialog | Configure export format & filters | All |
| `feeds.export_feeds.generate` | Generate action | Run export generation (rate-limited API) | All |
| `feeds.export_feeds.public_url` | Public URL display | Copy signed export URL | All |
### Uploads — `/files`
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `feeds.uploads` | `/files` | List/delete CSV uploads; link to product import | All |
---
## Stores (`stores`)
### Hub — `/stores`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `stores.hub` | Nav **Stores** | Overview of WooCommerce / Shopify cards | All |
| `stores.hub.api_keys_link` | Stores page | Deep-link to Settings → API keys | All |
### WooCommerce — `/woocommerce`
| feature_key | Tab / section | User actions | Default by plan |
|-------------|---------------|--------------|-------------------|
| `stores.woocommerce` | Nav More → Reviews uses `?tab=reviews` | Full WooCommerce integration page | All |
| `stores.woocommerce.connection` | Connection tab | Store URL, credentials, test connection | Free: test/limited (marketing); Starter+: full |
| `stores.woocommerce.categories` | Categories tab | Sync categories to/from Woo | Plan-dependent (marketing) |
| `stores.woocommerce.attributes` | Attributes tab | Sync attributes | Plan-dependent (marketing) |
| `stores.woocommerce.orders` | Orders tab | View recent orders | All |
| `stores.woocommerce.reviews` | Reviews tab | Manage product reviews | All |
| `stores.woocommerce.settings` | Settings tab | Sync options, overwrite guards | All |
| `stores.woocommerce.queue_sync` | Push products to Woo | Queue catalog sync jobs | Gated by processing/SKU caps |
### Shopify — `/stores/shopify` (also `/shopify` legacy)
| feature_key | Tab | User actions | Default by plan |
|-------------|-----|--------------|-------------------|
| `stores.shopify` | Stores hub → Shopify | Shopify connector | All |
| `stores.shopify.connection` | Connection tab | OAuth / API connection | All |
| `stores.shopify.orders` | Orders tab | View orders | All |
| `stores.shopify.settings` | Settings tab | Sync settings | All |
| `stores.shopify.queue_sync` | Sync actions | Push products to Shopify | Gated by processing/SKU caps |
---
## Processing (`processing`)
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `processing.monitor` | Nav More → **Processing** (`/processing`, alias `/tasks`) | Live job list, refresh, auto-poll | All |
| `processing.job_cancel` | Job row actions (if present) | Cancel / retry failed jobs | All |
| `processing.pipeline_steps` | Job detail / description | normalize → specs → fill → EPREL → AI | EPREL all; AI gated |
---
## Marketing (`marketing`)
### Campaigns — `/campaigns`, `/campaigns/new`, `/campaigns/[id]`
| feature_key | UI location | User actions | Default by plan |
|-------------|-------------|--------------|-------------------|
| `marketing.campaigns` | Nav More → Campaigns | List seasonal email campaigns | All |
| `marketing.campaigns.create` | `/campaigns/new``CampaignWizard.svelte` | Pick season, audience, products | All |
| `marketing.campaigns.generate_ai` | Wizard generate step | AI email draft generation | **Starter+** (`showUpgrade` on Free) |
| `marketing.campaigns.send` | Wizard / campaign detail | Send or schedule blast | Free: dry-run only (email service) |
| `marketing.campaigns.edit_content` | Campaign editor | Edit subject/body HTML | All |
### Content calendar — `/marketing/calendar`
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `marketing.content_calendar` | Nav More → Content calendar | Seasonal presets, link campaigns to dates, export feed prep | All |
### Brand — `/brand`
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `marketing.brand_kit` | Nav More → Brand | Edit voice, colors, logo, preferred terms | All (view); admin edit |
| `marketing.brand_ai_apply` | Brand + formula previews | Inject brand voice into AI enhance prompts | **Starter+** (`ai_apply_allowed` API) |
### SEO — `/seo`
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `marketing.seo` | Nav More → SEO | SEO checklist scores, recommendations | All |
| `marketing.seo.template_fill` | SEO actions | Rule-based meta template fill | All |
| `marketing.seo.ai_rewrite` | SEO actions | AI meta rewrite | **Starter+** or credits (`can_use_ai`) |
### Reviews — `/woocommerce?tab=reviews`
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `marketing.reviews` | Nav More → Reviews | WooCommerce reviews tab | All |
---
## Integrations (`integrations`)
### AI — `/integrations/ai`
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `integrations.ai` | Nav More → AI integrations | Configure OpenAI / platform AI modes | All |
| `integrations.ai.byok` | AI integrations page | Bring-your-own API key | Growth+ marketing (BYOK add-on); Business included |
### Email — `/integrations/email`
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `integrations.email` | Nav More → Email sending | Resend or SMTP credentials | All |
| `integrations.email.test` | Test send | Send test email (may dry-run) | Free: dry-run |
| `integrations.email.blast` | Blast dialog | Marketing blast to audience | Free: dry-run forced |
---
## Billing (`billing`)
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `billing.overview` | Nav **Billing** `/billing` | Plan name, credits, SKU usage, subscription status | All |
| `billing.customer_portal` | Billing page | Open Stripe Customer Portal (company admin) | Paid plans with Stripe |
| `billing.quick_upgrade` | Billing quick upgrade cards | Checkout Starter / Growth / Business | Company admin |
| `billing.add_credits` | Billing dialog (if shown) | Purchase extra credits | Paid |
| `billing.plans_compare` | `/plans` (linked from banners) | Compare public ladder, feature matrix | All |
| `billing.checkout` | `/plans` + Billing | Stripe Checkout self-serve upgrade | Company admin; StarterBusiness |
---
## Settings (`settings`)
| feature_key | Tab (`/settings?tab=`) | User actions | Default by plan |
|-------------|------------------------|--------------|-------------------|
| `settings.profile` | `profile` | Name, language, save profile | All |
| `settings.company` | `company` | Company name, locale, branding prefs | Company admin |
| `settings.alerts` | `alerts` | In-app alert preferences (`alert-prefs.ts`) | All |
| `settings.api_keys` | `api-keys` | Create/revoke company API keys | Free marketing: no API; Starter+: read/full per marketing |
| `settings.team` | `team` | Invite members, roles, demote/remove | Company admin |
| `settings.team_invite` | Team tab dialog | Send teammate invite | Company admin |
---
## Support (`support`)
| feature_key | Route | User actions | Default by plan |
|-------------|-------|--------------|-------------------|
| `support.center` | Nav More → Support `/support` | List own tickets | All |
| `support.ticket_create` | `/support/new` | Open new ticket | All |
| `support.ticket_thread` | `/support/[ticketId]` | Reply, read staff messages | All |
---
## Capabilities (`capabilities`) — cross-cutting gates
These map to API enforcement and/or prominent UI locks. Prefer admin toggles here for plan matrices.
| feature_key | Enforced where | Behavior | Suggested defaults |
|-------------|----------------|----------|-------------------|
| `capability.sku_cap` | `AssertCanStartProcessing`, `at_product_limit` on `/api/auth/me` | Blocks new processing when processed SKU count ≥ plan `max_products` | Free 50 · Starter 100 · Plus 400 · Growth 1.2k · Business 4k · Scale 12k · Enterprise unlimited |
| `capability.ai_credits` | Entitlements `can_use_ai`, credit wallet debit | AI jobs need credits or paid plan | Free 0/mo · Starter 100 · Plus 400 · Growth 1.2k · Business 4k · Scale 12k · Enterprise unlimited |
| `capability.ai_processing` | Processing pipeline + products UI | Title/description/enhance/seo AI types | Off on Free unless credits; on paid |
| `capability.eprel` | `CanUseEPREL` (always true) | EU energy label enrichment | **All plans** |
| `capability.normalize_specs_fill` | Pipeline steps without AI | Category, attributes, normalize | **All plans** |
| `capability.campaign_ai` | `campaigns/generate_send.go` | AI campaign copy generation | Starter+ |
| `capability.email_live_send` | `email/service.go` dry-run | Real marketing email delivery | Free dry-run; paid live (unless `EMAIL_DRY_RUN`) |
| `capability.brand_ai_apply` | `AIBrandApplyAllowed` | Brand voice in AI prompts | Paid or trial |
| `capability.seo_ai_rewrite` | SEO API `can_use_ai` | AI meta rewrite on SEO page | Same as AI credits |
| `capability.feed_source_limit` | **Marketing only** (`pricing-data`) | Max import feed sources | Free 1 · Starter 2 · Plus 3 · Growth 5 · Business 8 · Scale 12 · Enterprise unlimited |
| `capability.export_feed_limit` | **Marketing only** | Max export feed definitions | Free 1 · Starter 2 · Plus 4 · Growth 8 · Business unlimited · Scale unlimited · Enterprise unlimited |
| `capability.storage_limit` | **Marketing only** | Storage GB | Free 2 · Starter 20 · Growth 100 · Business 500 · Enterprise unlimited |
| `capability.api_access` | **Marketing only** (keys UI exists for all) | REST / v1 API | Free off · Starter read · Growth+ full |
| `capability.byok` | AI integrations UI | Customer OpenAI key | Growth add-on · Business included · Enterprise |
---
## Already gated in UI (summary)
| Surface | Gate signal | feature_key |
|---------|-------------|-------------|
| Products processing menu | `canUseAI` locks AI options | `catalog.products.process_ai_*` |
| Products / Dashboard / Billing banners | `isFreePlan`, credits, SKU limit | `dashboard.upgrade_banners`, `catalog.products.upgrade_prompt` |
| Campaign wizard | `showUpgrade` on Free | `marketing.campaigns.generate_ai` |
| SEO page | `showUpgrade`, `report.can_use_ai` | `marketing.seo.ai_rewrite` |
| Brand page | `ai_apply_allowed` alert | `marketing.brand_ai_apply` |
| Email integrations | `dry_run_forced` banner | `integrations.email.blast` |
| API processing start | HTTP 402 `plan_gate` codes | `capability.ai_processing`, `capability.sku_cap` |
---
## Routes in dashboard shell but excluded from this catalog
| Path | Reason |
|------|--------|
| `/admin/*` | Platform admin (separate shell) |
| `/login`, `/register`, `/accept-invite` | Auth shell |
| `/`, `/pricing`, `/features`, `/docs`, `/privacy`, `/terms`, `/unsubscribe` | Marketing shell (`MARKETING_PATHS`) |
| `/integrations` | Redirects to `/stores` |
---
## Navigation index (primary + More)
**Primary (`Nav.svelte` `primaryItems`):** Dashboard, Products, Feeds, Stores, Export feeds, Billing.
**More → Setup:** Categories, Attributes, Standard Fields, Brand, Settings, AI integrations, Email sending, Platform admin (platform admins only).
**More → Operate:** Processing, Campaigns, Content calendar, SEO, Reviews, Support.
+120
View File
@@ -0,0 +1,120 @@
[
{ "key": "shell.navigation", "section": "shell", "label": "Sidebar navigation", "route": null, "component": "apps/web/src/lib/components/Nav.svelte" },
{ "key": "shell.command_palette", "section": "shell", "label": "Command palette", "route": null, "component": "apps/web/src/lib/components/CommandPalette.svelte" },
{ "key": "shell.company_switcher", "section": "shell", "label": "Company switcher", "route": null, "component": "apps/web/src/lib/components/CompanySwitcher.svelte" },
{ "key": "shell.support_notifications", "section": "shell", "label": "Support notification bell", "route": null, "component": "apps/web/src/lib/components/SupportNotificationBell.svelte" },
{ "key": "shell.tutorial", "section": "shell", "label": "Product tutorial", "route": null, "component": "apps/web/src/lib/components/tutorial/TutorialOverlay.svelte" },
{ "key": "shell.account_menu", "section": "shell", "label": "Account menu", "route": "/settings", "component": "apps/web/src/routes/+layout.svelte" },
{ "key": "shell.billing_recovery_banner", "section": "shell", "label": "Billing recovery banner", "route": null, "component": "apps/web/src/lib/components/BillingRecoveryBanner.svelte" },
{ "key": "dashboard.overview", "section": "dashboard", "label": "Dashboard overview", "route": "/dashboard", "component": "apps/web/src/routes/dashboard/+page.svelte" },
{ "key": "dashboard.stats", "section": "dashboard", "label": "Stats cards", "route": "/dashboard", "component": "apps/web/src/lib/components/DashboardStats.svelte" },
{ "key": "dashboard.quick_links", "section": "dashboard", "label": "Quick links", "route": "/dashboard", "component": "apps/web/src/routes/dashboard/+page.svelte" },
{ "key": "dashboard.recent_jobs", "section": "dashboard", "label": "Recent jobs", "route": "/dashboard", "component": "apps/web/src/routes/dashboard/+page.svelte" },
{ "key": "dashboard.news_feed", "section": "dashboard", "label": "News feed", "route": "/dashboard", "component": "apps/web/src/lib/components/NewsFeed.svelte" },
{ "key": "dashboard.activation_checklist", "section": "dashboard", "label": "Activation checklist", "route": "/dashboard", "component": "apps/web/src/lib/components/ActivationChecklist.svelte" },
{ "key": "dashboard.migrated_checklist", "section": "dashboard", "label": "Migrated cohort checklist", "route": "/dashboard", "component": "apps/web/src/lib/components/MigratedCohortChecklist.svelte" },
{ "key": "dashboard.etl_gaps", "section": "dashboard", "label": "ETL gaps panel", "route": "/dashboard", "component": "apps/web/src/lib/components/MigratedEtlGapsPanel.svelte" },
{ "key": "dashboard.store_reconnect", "section": "dashboard", "label": "Store reconnect banner", "route": "/dashboard", "component": "apps/web/src/lib/components/stores/StoreReconnectBanner.svelte" },
{ "key": "dashboard.upgrade_banners", "section": "dashboard", "label": "Upgrade banners", "route": "/dashboard", "component": "apps/web/src/lib/components/UpgradeBanner.svelte" },
{ "key": "catalog.products", "section": "catalog", "label": "Products", "route": "/products", "component": "apps/web/src/routes/products/+page.svelte" },
{ "key": "catalog.products.tab_processed", "section": "catalog", "label": "Products — Processed tab", "route": "/products", "component": "apps/web/src/lib/components/products/ProductTabs.svelte" },
{ "key": "catalog.products.tab_needs_review", "section": "catalog", "label": "Products — Needs review tab", "route": "/products", "component": "apps/web/src/lib/components/products/ProductTabs.svelte" },
{ "key": "catalog.products.tab_error", "section": "catalog", "label": "Products — Error tab", "route": "/products", "component": "apps/web/src/lib/components/products/ProductTabs.svelte" },
{ "key": "catalog.products.tab_processing", "section": "catalog", "label": "Products — Processing tab", "route": "/products", "component": "apps/web/src/lib/components/products/ProductTabs.svelte" },
{ "key": "catalog.products.tab_unprocessed", "section": "catalog", "label": "Products — Unprocessed tab", "route": "/products", "component": "apps/web/src/lib/components/products/ProductTabs.svelte" },
{ "key": "catalog.products.process_categories", "section": "catalog", "label": "Process categories", "route": "/products", "component": "apps/web/src/lib/components/products/ProductProcessingActions.svelte" },
{ "key": "catalog.products.process_attributes", "section": "catalog", "label": "Process attributes / specs", "route": "/products", "component": "apps/web/src/lib/components/products/ProductProcessingActions.svelte" },
{ "key": "catalog.products.process_ai_titles", "section": "catalog", "label": "Process AI titles", "route": "/products", "component": "apps/web/src/lib/components/products/ProductProcessingActions.svelte" },
{ "key": "catalog.products.process_ai_descriptions", "section": "catalog", "label": "Process AI descriptions", "route": "/products", "component": "apps/web/src/lib/components/products/ProductProcessingActions.svelte" },
{ "key": "catalog.products.enrichment_review", "section": "catalog", "label": "Enrichment review panel", "route": "/products", "component": "apps/web/src/lib/components/products/ProductEditPanel.svelte" },
{ "key": "catalog.products.export_selection", "section": "catalog", "label": "Export selected products", "route": "/products", "component": "apps/web/src/lib/components/products/ProductProcessingActions.svelte" },
{ "key": "catalog.products.upgrade_prompt", "section": "catalog", "label": "Products upgrade prompts", "route": "/products", "component": "apps/web/src/routes/products/+page.svelte" },
{ "key": "catalog.categories", "section": "catalog", "label": "Categories", "route": "/categories", "component": "apps/web/src/routes/categories/+page.svelte" },
{ "key": "catalog.categories.title_formula", "section": "catalog", "label": "Category title formula", "route": "/categories/[categoryId]/title-formula", "component": "apps/web/src/routes/categories/[categoryId]/title-formula/+page.svelte" },
{ "key": "catalog.categories.description_formula", "section": "catalog", "label": "Category description formula", "route": "/categories/[categoryId]/description-formula", "component": "apps/web/src/routes/categories/[categoryId]/description-formula/+page.svelte" },
{ "key": "catalog.attributes", "section": "catalog", "label": "Attributes", "route": "/attributes", "component": "apps/web/src/routes/attributes/+page.svelte" },
{ "key": "catalog.attributes.bulk_import", "section": "catalog", "label": "Attributes bulk import", "route": "/attributes", "component": "apps/web/src/routes/attributes/+page.svelte" },
{ "key": "catalog.standard_fields", "section": "catalog", "label": "Standard fields", "route": "/standard-fields", "component": "apps/web/src/routes/standard-fields/+page.svelte" },
{ "key": "catalog.standard_fields.groups", "section": "catalog", "label": "Standard field groups", "route": "/standard-fields", "component": "apps/web/src/routes/standard-fields/+page.svelte" },
{ "key": "catalog.structured_descriptions", "section": "catalog", "label": "Structured description fields", "route": "/structured-descriptions", "component": "apps/web/src/routes/structured-descriptions/+page.svelte" },
{ "key": "catalog.vector_categories", "section": "catalog", "label": "Vector categories test", "route": "/vector-categories", "component": "apps/web/src/routes/vector-categories/+page.svelte" },
{ "key": "feeds.list", "section": "feeds", "label": "Import feeds", "route": "/feeds", "component": "apps/web/src/routes/feeds/+page.svelte" },
{ "key": "feeds.add_url", "section": "feeds", "label": "Add feed URL", "route": "/feeds", "component": "apps/web/src/routes/feeds/+page.svelte" },
{ "key": "feeds.add_csv", "section": "feeds", "label": "Add feed CSV upload", "route": "/feeds", "component": "apps/web/src/routes/feeds/+page.svelte" },
{ "key": "feeds.sync", "section": "feeds", "label": "Sync feed", "route": "/feeds", "component": "apps/web/src/routes/feeds/+page.svelte" },
{ "key": "feeds.mapping", "section": "feeds", "label": "Feed mapping", "route": "/feeds/[feedId]/mapping", "component": "apps/web/src/routes/feeds/[feedId]/mapping/+page.svelte" },
{ "key": "feeds.mapping.select_item", "section": "feeds", "label": "Mapping — select item element", "route": "/feeds/[feedId]/mapping", "component": "apps/web/src/routes/feeds/[feedId]/mapping/+page.svelte" },
{ "key": "feeds.mapping.map_fields", "section": "feeds", "label": "Mapping — map fields", "route": "/feeds/[feedId]/mapping", "component": "apps/web/src/routes/feeds/[feedId]/mapping/+page.svelte" },
{ "key": "feeds.export_feeds", "section": "feeds", "label": "Export feeds", "route": "/export-feeds", "component": "apps/web/src/routes/export-feeds/+page.svelte" },
{ "key": "feeds.export_feeds.create", "section": "feeds", "label": "Create export feed", "route": "/export-feeds", "component": "apps/web/src/routes/export-feeds/+page.svelte" },
{ "key": "feeds.export_feeds.generate", "section": "feeds", "label": "Generate export feed", "route": "/export-feeds", "component": "apps/web/src/routes/export-feeds/+page.svelte" },
{ "key": "feeds.uploads", "section": "feeds", "label": "File uploads", "route": "/files", "component": "apps/web/src/routes/files/+page.svelte" },
{ "key": "stores.hub", "section": "stores", "label": "Stores hub", "route": "/stores", "component": "apps/web/src/routes/stores/+page.svelte" },
{ "key": "stores.woocommerce", "section": "stores", "label": "WooCommerce integration", "route": "/woocommerce", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "stores.woocommerce.connection", "section": "stores", "label": "WooCommerce connection", "route": "/woocommerce", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "stores.woocommerce.categories", "section": "stores", "label": "WooCommerce categories sync", "route": "/woocommerce", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "stores.woocommerce.attributes", "section": "stores", "label": "WooCommerce attributes sync", "route": "/woocommerce", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "stores.woocommerce.orders", "section": "stores", "label": "WooCommerce orders", "route": "/woocommerce", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "stores.woocommerce.reviews", "section": "stores", "label": "WooCommerce reviews", "route": "/woocommerce?tab=reviews", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "stores.woocommerce.settings", "section": "stores", "label": "WooCommerce settings", "route": "/woocommerce", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "stores.shopify", "section": "stores", "label": "Shopify integration", "route": "/stores/shopify", "component": "apps/web/src/routes/stores/shopify/+page.svelte" },
{ "key": "stores.shopify.connection", "section": "stores", "label": "Shopify connection", "route": "/stores/shopify", "component": "apps/web/src/routes/stores/shopify/+page.svelte" },
{ "key": "stores.shopify.orders", "section": "stores", "label": "Shopify orders", "route": "/stores/shopify", "component": "apps/web/src/routes/stores/shopify/+page.svelte" },
{ "key": "stores.shopify.settings", "section": "stores", "label": "Shopify settings", "route": "/stores/shopify", "component": "apps/web/src/routes/stores/shopify/+page.svelte" },
{ "key": "processing.monitor", "section": "processing", "label": "Processing monitor", "route": "/processing", "component": "apps/web/src/routes/processing/+page.svelte" },
{ "key": "marketing.campaigns", "section": "marketing", "label": "Campaigns", "route": "/campaigns", "component": "apps/web/src/routes/campaigns/+page.svelte" },
{ "key": "marketing.campaigns.create", "section": "marketing", "label": "New campaign wizard", "route": "/campaigns/new", "component": "apps/web/src/lib/components/campaigns/CampaignWizard.svelte" },
{ "key": "marketing.campaigns.generate_ai", "section": "marketing", "label": "Campaign AI generate", "route": "/campaigns/new", "component": "apps/web/src/lib/components/campaigns/CampaignWizard.svelte" },
{ "key": "marketing.campaigns.send", "section": "marketing", "label": "Campaign send / blast", "route": "/campaigns/[id]", "component": "apps/web/src/routes/campaigns/[id]/+page.svelte" },
{ "key": "marketing.content_calendar", "section": "marketing", "label": "Content calendar", "route": "/marketing/calendar", "component": "apps/web/src/routes/marketing/calendar/+page.svelte" },
{ "key": "marketing.brand_kit", "section": "marketing", "label": "Brand kit", "route": "/brand", "component": "apps/web/src/routes/brand/+page.svelte" },
{ "key": "marketing.brand_ai_apply", "section": "marketing", "label": "Brand AI apply in prompts", "route": "/brand", "component": "apps/web/src/routes/brand/+page.svelte" },
{ "key": "marketing.seo", "section": "marketing", "label": "SEO recommendations", "route": "/seo", "component": "apps/web/src/routes/seo/+page.svelte" },
{ "key": "marketing.seo.template_fill", "section": "marketing", "label": "SEO template fill", "route": "/seo", "component": "apps/web/src/routes/seo/+page.svelte" },
{ "key": "marketing.seo.ai_rewrite", "section": "marketing", "label": "SEO AI rewrite", "route": "/seo", "component": "apps/web/src/routes/seo/+page.svelte" },
{ "key": "marketing.reviews", "section": "marketing", "label": "Reviews", "route": "/woocommerce?tab=reviews", "component": "apps/web/src/routes/woocommerce/+page.svelte" },
{ "key": "integrations.ai", "section": "integrations", "label": "AI integrations", "route": "/integrations/ai", "component": "apps/web/src/routes/integrations/ai/+page.svelte" },
{ "key": "integrations.ai.byok", "section": "integrations", "label": "Bring your own AI key", "route": "/integrations/ai", "component": "apps/web/src/routes/integrations/ai/+page.svelte" },
{ "key": "integrations.email", "section": "integrations", "label": "Email sending", "route": "/integrations/email", "component": "apps/web/src/routes/integrations/email/+page.svelte" },
{ "key": "integrations.email.test", "section": "integrations", "label": "Email test send", "route": "/integrations/email", "component": "apps/web/src/routes/integrations/email/+page.svelte" },
{ "key": "integrations.email.blast", "section": "integrations", "label": "Email blast", "route": "/integrations/email", "component": "apps/web/src/routes/integrations/email/+page.svelte" },
{ "key": "billing.overview", "section": "billing", "label": "Billing overview", "route": "/billing", "component": "apps/web/src/routes/billing/+page.svelte" },
{ "key": "billing.customer_portal", "section": "billing", "label": "Stripe customer portal", "route": "/billing", "component": "apps/web/src/routes/billing/+page.svelte" },
{ "key": "billing.quick_upgrade", "section": "billing", "label": "Quick upgrade checkout", "route": "/billing", "component": "apps/web/src/routes/billing/+page.svelte" },
{ "key": "billing.plans_compare", "section": "billing", "label": "Compare plans", "route": "/plans", "component": "apps/web/src/routes/plans/+page.svelte" },
{ "key": "billing.checkout", "section": "billing", "label": "Stripe checkout", "route": "/plans", "component": "apps/web/src/routes/plans/+page.svelte" },
{ "key": "settings.profile", "section": "settings", "label": "Profile settings", "route": "/settings?tab=profile", "component": "apps/web/src/routes/settings/+page.svelte" },
{ "key": "settings.company", "section": "settings", "label": "Company settings", "route": "/settings?tab=company", "component": "apps/web/src/routes/settings/+page.svelte" },
{ "key": "settings.alerts", "section": "settings", "label": "Alert preferences", "route": "/settings?tab=alerts", "component": "apps/web/src/routes/settings/+page.svelte" },
{ "key": "settings.api_keys", "section": "settings", "label": "API keys", "route": "/settings?tab=api-keys", "component": "apps/web/src/routes/settings/+page.svelte" },
{ "key": "settings.team", "section": "settings", "label": "Team management", "route": "/settings?tab=team", "component": "apps/web/src/routes/settings/+page.svelte" },
{ "key": "settings.team_invite", "section": "settings", "label": "Invite teammate", "route": "/settings?tab=team", "component": "apps/web/src/routes/settings/+page.svelte" },
{ "key": "support.center", "section": "support", "label": "Support center", "route": "/support", "component": "apps/web/src/routes/support/+page.svelte" },
{ "key": "support.ticket_create", "section": "support", "label": "New support ticket", "route": "/support/new", "component": "apps/web/src/routes/support/new/+page.svelte" },
{ "key": "support.ticket_thread", "section": "support", "label": "Support ticket thread", "route": "/support/[ticketId]", "component": "apps/web/src/routes/support/[ticketId]/+page.svelte" },
{ "key": "capability.sku_cap", "section": "capabilities", "label": "Product SKU cap", "route": null, "component": "apps/api/internal/billing/service.go" },
{ "key": "capability.ai_credits", "section": "capabilities", "label": "AI credit wallet", "route": null, "component": "apps/api/internal/billing/entitlements.go" },
{ "key": "capability.ai_processing", "section": "capabilities", "label": "AI processing jobs", "route": null, "component": "apps/api/internal/billing/service.go" },
{ "key": "capability.eprel", "section": "capabilities", "label": "EPREL enrichment", "route": null, "component": "apps/api/internal/billing/entitlements.go" },
{ "key": "capability.normalize_specs_fill", "section": "capabilities", "label": "Normalize / specs / fill", "route": null, "component": "apps/api/internal/processing/steps.go" },
{ "key": "capability.campaign_ai", "section": "capabilities", "label": "Campaign AI generation", "route": null, "component": "apps/api/internal/campaigns/generate_send.go" },
{ "key": "capability.email_live_send", "section": "capabilities", "label": "Live email delivery", "route": null, "component": "apps/api/internal/email/service.go" },
{ "key": "capability.brand_ai_apply", "section": "capabilities", "label": "Brand voice in AI prompts", "route": null, "component": "apps/api/internal/billing/service.go" },
{ "key": "capability.seo_ai_rewrite", "section": "capabilities", "label": "SEO AI rewrite", "route": "/seo", "component": "apps/web/src/routes/seo/+page.svelte" },
{ "key": "capability.feed_source_limit", "section": "capabilities", "label": "Feed source limit (marketing)", "route": null, "component": "apps/web/src/lib/components/pricing/pricing-data.ts" },
{ "key": "capability.export_feed_limit", "section": "capabilities", "label": "Export feed limit (marketing)", "route": null, "component": "apps/web/src/lib/components/pricing/pricing-data.ts" },
{ "key": "capability.storage_limit", "section": "capabilities", "label": "Storage limit (marketing)", "route": null, "component": "apps/web/src/lib/components/pricing/pricing-data.ts" },
{ "key": "capability.api_access", "section": "capabilities", "label": "REST API access (marketing)", "route": "/settings?tab=api-keys", "component": "apps/web/src/lib/components/pricing/pricing-data.ts" },
{ "key": "capability.byok", "section": "capabilities", "label": "Bring your own AI key", "route": "/integrations/ai", "component": "apps/web/src/routes/integrations/ai/+page.svelte" }
]
@@ -0,0 +1,228 @@
{
"agent": "02",
"title": "Plans/packages/permissions extension points",
"sibling_docs_status": "docs/plan-permissions/ empty at write time (no 01-* / 03-* yet)",
"recommended_approach": "Extend Entitlements + Plan/UpsertPlan + AssertCanStartProcessing-style gates; expose via CreditsOverview/me; admin billing UI; do not add a parallel packages/permissions system.",
"extension_points": [
{
"priority": 1,
"role": "capability_source_of_truth",
"path": "apps/api/internal/billing/entitlements.go",
"symbols": [
"Entitlements",
"ComputeEntitlements",
"EntitlementsForCompany",
"IsFreePlanName",
"ProcessingTypeRequiresAI",
"ProcessingTypeRequiresEPREL"
],
"intent": "Add plan feature/section booleans here; keep pure ComputeEntitlements testable."
},
{
"priority": 1,
"role": "plan_catalog_and_meters",
"path": "apps/api/internal/billing/service.go",
"symbols": [
"Plan",
"CreditsOverview",
"defaultPublicPlans",
"IsPublicProductPlan",
"EnsureDefaultPlans",
"ListPlans",
"ListPublicPlans",
"UpsertPlan",
"AssignPlan",
"ProvisionFreePlan",
"AssertCanStartProcessing",
"ProcessingGateOpts",
"AIBrandApplyAllowed",
"ConsumeCredits",
"EnsureDefaultCosts",
"EnterpriseUnlimitedCredits"
],
"intent": "Extend Plan fields / defaults; SKU+AI gates; wallet assignment; public vs deal listing."
},
{
"priority": 1,
"role": "schema",
"path": "apps/api/sql/schema/001_platform.sql",
"symbols": ["plans", "company_plans", "credit_balances", "processing_costs", "billing_cycles"],
"intent": "Add feature/permission columns or JSON on plans; optionally wire unused custom_monthly_credits / custom_max_products."
},
{
"priority": 2,
"role": "schema_stripe",
"path": "apps/api/sql/schema/016_stripe_billing.sql",
"symbols": ["company_plans.stripe_subscription_id", "company_plans.stripe_price_id"],
"intent": "Preserve Stripe linkage when altering company_plans."
},
{
"priority": 1,
"role": "admin_http",
"path": "apps/api/internal/httpapi/billing_handlers.go",
"symbols": [
"handleListPlans",
"handleListPublicPlans",
"handleUpsertPlan",
"handleAssignPlan",
"handleAddCredits",
"handleCreditsOverview"
],
"intent": "Accept/return new plan permission fields on admin upsert and credits overview."
},
{
"priority": 1,
"role": "route_mount",
"path": "apps/api/internal/httpapi/server.go",
"symbols": ["Router", "RequirePlatformAdmin"],
"intent": "Keep /api/admin/plans* under platform admin; tenant /api/billing/plans public ladder."
},
{
"priority": 1,
"role": "processing_enforcement",
"path": "apps/api/internal/processing/pipeline.go",
"symbols": ["StartJob", "processOne"],
"intent": "Primary AssertCanStartProcessing + step AllowAI policy; add section asserts near StartJob."
},
{
"priority": 2,
"role": "http_402_mapping",
"path": "apps/api/internal/httpapi/processing_handlers.go",
"symbols": ["handleStartProcessingJob", "planGateCode"],
"intent": "Reuse 402 + code mapping for new plan_gate errors."
},
{
"priority": 2,
"role": "v1_process",
"path": "apps/api/internal/httpapi/v1_process_handlers.go",
"symbols": ["handleV1StartProcess", "startV1Jobs"],
"intent": "Same gate errors as SPA processing."
},
{
"priority": 2,
"role": "seo_gate",
"path": "apps/api/internal/seo/service.go",
"symbols": ["Report", "Apply"],
"intent": "Section toggle for SEO AI; already uses EntitlementsForCompany + ConsumeCredits."
},
{
"priority": 2,
"role": "seo_http",
"path": "apps/api/internal/httpapi/seo_handlers.go",
"symbols": [],
"intent": "Map billing insufficient / upgrade errors for SEO routes."
},
{
"priority": 2,
"role": "campaigns_gate",
"path": "apps/api/internal/campaigns/generate_send.go",
"symbols": ["Generate"],
"intent": "Section toggle for campaign AI; already checks CanUseAI / IsFreePlan."
},
{
"priority": 2,
"role": "brand_gate",
"path": "apps/api/internal/httpapi/brand_handlers.go",
"symbols": ["brandResponse", "AIBrandApplyAllowed"],
"intent": "Pattern for soft feature flag exposed to UI (ai_apply_allowed)."
},
{
"priority": 1,
"role": "admin_ui",
"path": "apps/web/src/routes/admin/billing/+page.svelte",
"symbols": ["createPlan", "assignPlan", "reload", "customPlanCount", "maxProductsLabel"],
"intent": "Add edit + permission toggles; send full Plan including id/max_products."
},
{
"priority": 2,
"role": "admin_nav",
"path": "apps/web/src/lib/components/AdminNav.svelte",
"symbols": [],
"intent": "Entry to /admin/billing already present."
},
{
"priority": 1,
"role": "tenant_display_and_client_gates",
"path": "apps/web/src/lib/billing-display.ts",
"symbols": [
"canUseAIFromCredits",
"isFreePlan",
"isEnterprisePlan",
"isSelfServeCheckoutPlan",
"formatSkuCap",
"withUpgradeHint"
],
"intent": "Mirror new entitlement flags for UX gating/hints."
},
{
"priority": 2,
"role": "tenant_types",
"path": "apps/web/src/lib/types.ts",
"symbols": [],
"intent": "Extend credits/plan TypeScript shapes."
},
{
"priority": 2,
"role": "tenant_nav",
"path": "apps/web/src/lib/components/Nav.svelte",
"symbols": [],
"intent": "Optional hide/disable tabs from me.credits entitlements."
},
{
"priority": 2,
"role": "me_embedding",
"path": "apps/api/internal/httpapi/auth_handlers.go",
"symbols": ["handleMe"],
"intent": "Already embeds CreditsOverview; new flags flow to SPA automatically if added to struct."
},
{
"priority": 3,
"role": "seed_demo",
"path": "apps/api/cmd/seed-demo/main.go",
"symbols": ["EnsureDefaultPlans", "AssignPlan"],
"intent": "Keep Free=0 / Enterprise demo invariants when changing defaults."
},
{
"priority": 3,
"role": "migrator",
"path": "apps/api/cmd/migrator/main.go",
"symbols": ["loadPlans", "loadCompanyPlans"],
"intent": "Map new plan columns from MySQL if present."
},
{
"priority": 3,
"role": "missing_plans_repair",
"path": "apps/api/internal/billing/missing_plans.go",
"symbols": ["AssignPlanIfMissing", "PlanIDByName", "HasActivePlan"],
"intent": "Assignment helpers for tenants without active plans."
},
{
"priority": 3,
"role": "stripe_assign",
"path": "apps/api/internal/billing/stripe.go",
"symbols": ["applyPlanPurchase", "downgradeToFree", "CreateCheckoutSession"],
"intent": "Checkout must remain public-ladder-only; custom deals stay admin-assign."
},
{
"priority": 3,
"role": "tests",
"path": "apps/api/internal/billing/gate_test.go",
"symbols": [
"TestComputeEntitlements",
"TestDefaultPublicPlansEnterpriseUnlimited",
"TestDefaultPublicPlansFreeZeroCredits"
],
"intent": "Extend entitlement/default-plan tests for new flags."
}
],
"do_not_duplicate": [
"Separate packages table parallel to plans",
"Frontend-only permission checks without EntitlementsForCompany",
"New Free/paid heuristics outside IsFreePlanName / ComputeEntitlements",
"Second SKU limit path ignoring plans.max_products / AssertCanStartProcessing"
],
"unused_schema_to_consider": [
"company_plans.custom_monthly_credits",
"company_plans.custom_max_products"
]
}
@@ -0,0 +1,263 @@
# 02 — Plans / packages / permissions (current system)
Inventory of how plans (“packages”) work today in descrybe-v2.
Sibling folder `docs/plan-permissions/` was empty at write time (no `01-*` / `03-*` artifacts yet). Aligns with existing product docs: `docs/free-tier.md`, `docs/billing-credits-audit.md`.
ASSUMPTION: “packages” in product language maps 1:1 to DB table `plans` + assignment via `company_plans`. There is no separate `packages` table.
---
## 1. Models, tables, DTOs, seeders
### Tables (`apps/api/sql/schema/001_platform.sql`, Stripe extras in `016_stripe_billing.sql`)
| Table | Role |
|---|---|
| `plans` | Catalog of packages: meters + flags |
| `company_plans` | Per-company assignment (active row, trial, Stripe ids, unused override columns) |
| `credit_balances` | Wallet: `total_credits`, `used_credits` |
| `billing_cycles` | Cycle usage counters / invoicing |
| `processing_costs` | Per-feature credit unit costs (`product_processing`, `openai_token_k`, `seo_meta_ai`, `campaign_copy`) |
| `companies.stripe_customer_id` | Stripe customer link |
| `company_plans.stripe_subscription_id` / `stripe_price_id` | Subscription linkage |
**`plans` columns**
- `id`, `name`, `description`
- `monthly_credits`, `yearly_credits` (nullable)
- `max_products` (nullable = unlimited SKU cap)
- `is_custom` (boolean, default false)
- `term` (default `'monthly'`)
- timestamps
**`company_plans` columns (assignment + overrides)**
- `plan_id`, `is_active`, billing/contract dates
- `custom_monthly_credits`, `custom_max_products`**present in schema, unused by Go billing gates**
- `total_credits_allocated`, `contract_reference`, `notes`
- `is_trial`, `trial_ends_at`, `trial_credits`
- Stripe subscription/price ids (migration 016)
### Go DTOs / types (`apps/api/internal/billing/`)
| Symbol | File | Purpose |
|---|---|---|
| `Plan` | `service.go` | JSON DTO for catalog CRUD/list |
| `CreditsOverview` | `service.go` | Wallet + plan snapshot + entitlement booleans for `/api/billing/credits` and `/api/auth/me` |
| `Entitlements` | `entitlements.go` | Pure capability snapshot (`can_use_ai`, `can_use_eprel`, free/paid/trial) |
| `ProcessingGateOpts` | `service.go` | `{RequiresAI, RequiresEPREL}` for start-job gate |
| Web `Plan` / credits types | `apps/web/src/lib/types.ts`, `billing-display.ts`, admin page local types | Mirror API shapes |
### Seed / bootstrap (not SQL seeders)
| Mechanism | Symbol / path | What it does |
|---|---|---|
| Public ladder upsert | `defaultPublicPlans``EnsureDefaultPlans` | Free / Starter / Growth / Business / Enterprise by name |
| Signup Free | `ProvisionFreePlan` | Assign Free if present |
| Credit cost rows | `EnsureDefaultCosts` | Idempotent `processing_costs` insert |
| Demo seed | `apps/api/cmd/seed-demo` | `EnsureDefaultPlans` then assign **Enterprise** to Local Demo Co; asserts Free stays at 0 credits |
| Migrator ETL | `apps/api/cmd/migrator` (`loadPlans`, `loadCompanyPlans`, `company_plans_repair.go`) | MySQL → Postgres plans + repair missing assignments |
| On-demand | Admin/public list handlers call `EnsureDefaultPlans` before list |
**Default public packaging** (`defaultPublicPlans`):
| Name | Monthly credits | Max products | `is_custom` |
|---|---|---|---|
| Free | 0 | 100 | false |
| Starter | 300 | 1_000 | false |
| Growth | 2_000 | 10_000 | false |
| Business | 10_000 | 100_000 | false |
| Enterprise | 1_000_000 (`EnterpriseUnlimitedCredits`) | `null` (unlimited) | **true** |
---
## 2. Existing feature / limit / permission fields
There is **no** plan-scoped permission matrix (no per-tab / per-section toggles, no `plan_permissions` table, no feature-flag JSON on `plans`).
Capabilities today are **derived** or **metered**:
### Entitlement booleans (`ComputeEntitlements` / `CreditsOverview`)
| Field | Meaning (current code) |
|---|---|
| `is_free_plan` / `is_paid_plan` | Name equals `"free"` (or empty) vs not |
| `is_trial` | From active `company_plans.is_trial` |
| `monthly_credits` | From `plans.monthly_credits` |
| `remaining_credits` | `max(0, total - used)` wallet |
| `can_use_ai` | `remaining > 0` **OR** paid plan (not Free). Paid with empty wallet still has `can_use_ai=true`, but AI **jobs** require remaining ≥ batch size |
| `can_use_eprel` | **Always `true`** in code (EU public data). Platform can still disable enricher via settings/env. Note: `docs/free-tier.md` still describes paid-only EPREL — **docs lag code** |
### Hard limits
| Limit | Source | Enforced by |
|---|---|---|
| SKU / product cap | `plans.max_products` (null/≤0 = unlimited) | `AssertCanStartProcessing` vs `count(processed_products)` |
| AI credit wallet | `credit_balances` | Gate on AI job types + `ConsumeCredits` / batch |
| Brand voice in AI | Paid or trial | `AIBrandApplyAllowed` |
### Credit feature keys (`processing_costs.feature_name`)
Metering only (cost per unit), **not** allow/deny:
- `product_processing`
- `openai_token_k`
- `seo_meta_ai`
- `campaign_copy`
### AI-only vs free-path processing types
- `ProcessingTypeRequiresAI`: `enhance`, `enhance_only`, `title`, `description`, `seo`, `seo_ai`
- `ProcessingTypeRequiresEPREL`: `eprel`, `eprel_only`
- Non-AI types (normalize/specs/fill/full with step policy) can run on Free with 0 credits; pipeline may skip AI/EPREL steps
### Membership / platform roles (orthogonal to plans)
Company `admin` / member roles and `users.is_platform_admin` gate **who** can call admin APIs — not which product tabs a plan unlocks.
---
## 3. Default vs custom packages
Two **different** notions exist; do not conflate them.
### A. Public product ladder vs client deals (listing / checkout)
- **Public**: name ∈ `{Free, Starter, Growth, Business, Enterprise}` via `IsPublicProductPlan`
- Exposed to tenants: `GET /api/billing/plans``ListPublicPlans`
- Stripe self-serve checkout also keys off public names
- **Client / custom deals** (A1, Merkur trial, legacy names, admin-created packages): remain in `plans`, visible only via **admin** `GET /api/admin/plans``ListPlans` (all rows)
`EnsureDefaultPlans` syncs only names in `defaultPublicPlans`; named client deals are left untouched.
### B. `plans.is_custom` flag
- Boolean on the row; admin UI Badge “Custom” vs “Standard”
- Admin create dialog defaults `is_custom: true`
- Public **Enterprise** is seeded with `is_custom: true` even though it is a public ladder plan
- **Not** used by `ListPublicPlans` (name whitelist wins)
- **Not** used by entitlement/gates
### Per-company overrides
Schema columns `company_plans.custom_monthly_credits` / `custom_max_products` exist for deal overrides, but **billing Go code does not read them** today. Assignment always copies `plans.monthly_credits` (or trial credits) into the wallet; SKU gate reads `plans.max_products` only.
---
## 4. Admin UI for editing packages
| Path | Role |
|---|---|
| `apps/web/src/routes/admin/billing/+page.svelte` | Platform billing admin page |
| `apps/web/src/lib/components/AdminNav.svelte` | Nav link “Platform billing” → `/admin/billing` |
| Gate | `requirePlatformAdmin` (`$lib/admin-gate`) |
**Capabilities on the page**
- List all plans (`GET /api/admin/plans`)
- **Create** plan dialog (`POST /api/admin/plans`) — body: `name`, `monthly_credits`, `is_custom` (no `max_products` / description / term in UI)
- Assign plan to company (`POST /api/admin/plans/assign`)
- Add credits (`POST /api/admin/credits`)
- Run due billing cycles (`POST /api/admin/billing/run-cycles`)
- Companies tab shows wallet totals from `GET /api/admin/companies`
**Gaps in admin UI**
- No in-place **edit** of existing plan meters (API `UpsertPlan` supports update when `id > 0`, UI never sends `id`)
- No delete plan
- No editors for `max_products`, description, term, yearly credits
- No UI for `company_plans` custom override columns or permission toggles
Related tenant-facing (not admin edit): `/plans`, `/billing`, `/pricing` + `billing-display.ts`.
---
## 5. API endpoints that enforce plan limits
### Catalog / admin (mutate packages & assignment)
Mounted under `/api/admin` with `RequireSession` + `RequirePlatformAdmin` (`server.go`):
| Method | Path | Handler |
|---|---|---|
| GET | `/api/admin/plans` | `handleListPlans` |
| POST | `/api/admin/plans` | `handleUpsertPlan` |
| POST | `/api/admin/plans/assign` | `handleAssignPlan` |
| POST | `/api/admin/credits` | `handleAddCredits` |
| POST | `/api/admin/billing/run-cycles` | `handleRunBillingCycles` |
### Tenant billing read / Stripe
Under `/api` + session + company:
| Method | Path | Notes |
|---|---|---|
| GET | `/api/billing/credits` | `CreditsOverview` (includes entitlement flags + SKU usage) |
| GET | `/api/billing/usage` | Usage summary |
| GET | `/api/billing/plans` | Public ladder only |
| POST | `/api/billing/checkout` / `portal` | Stripe; public plans |
| GET | `/api/auth/me` | Embeds `credits` overview |
### Enforcement call sites (402 Payment Required pattern)
Central gate: `Service.AssertCanStartProcessing` → called from `processing.Pipeline.StartJob`.
| Surface | How limits apply |
|---|---|
| `POST` processing jobs (`processing_handlers`, `v1_process_handlers`) | StartJob → AssertCanStartProcessing; maps `ErrInsufficientCredits` / `ErrProductLimitExceeded` / `ErrAIRequiresUpgrade` / `ErrEPRELRequiresUpgrade`**402** + `upgrade_url` |
| Pipeline `processOne` | Step policy: AI only if `CanUseAI && RemainingCredits > 0`; `ConsumeCredits` |
| SEO AI apply (`seo/service.go` + handlers) | Entitlements + `ConsumeCredits("seo_meta_ai")` |
| Campaign AI generate (`campaigns/generate_send.go`) | Entitlements; Free blocked; `ConsumeCredits("campaign_copy")` |
| Brand kit (`brand_handlers.go`) | `ai_apply_allowed` via `AIBrandApplyAllowed` (edit kit allowed on Free; AI inject gated) |
| Feed sample sync+process | Shares StartJob path (`handleSyncAndProcessSample`) |
Wallet debit: `ConsumeCredits` / `ConsumeCreditsBatch` (atomic remaining check).
---
## 6. Gaps vs per-tab / section permission toggles
| Need | Current state |
|---|---|
| Toggle “Feeds / Products / SEO / Campaigns / Exports / …” per plan | **Missing** — nav is role-based, not plan-based |
| Boolean feature flags on `plans` | **Missing** — only meters + `is_custom` + name heuristics |
| Per-company override of feature set | Schema has unused credit/SKU override columns; **no** feature override |
| Distinguish marketing “custom package” vs Enterprise `is_custom` | Confusing: Enterprise is public **and** `is_custom` |
| Admin edit of full plan surface | Create-only UI; no permission matrix editor |
| Server enforcement for “section disabled” | Only AI/SKU/credit gates — disabled sections would still be reachable unless added |
| Docs drift | `free-tier.md` EPREL paid-only vs code `CanUseEPREL=true` |
---
## 7. Recommended extension points (reuse, dont duplicate)
Prefer extending the existing entitlements + plan catalog path over a parallel permissions subsystem.
1. **Catalog shape** — Extend `plans` (or a JSON `features` / `permissions` column) and `billing.Plan` + `UpsertPlan` / `EnsureDefaultPlans` / migrator load. Keep `is_custom` as “deal packaging” metadata; use **name whitelist or a new `is_public`** for self-serve listing if `is_custom` remains overloaded.
2. **Capability resolution** — Extend `Entitlements` + `ComputeEntitlements` / `EntitlementsForCompany` so one function remains the source of truth. Expose via `CreditsOverview` and `/api/auth/me` (web already reads `can_use_*` from credits).
3. **Server enforcement** — Add small helpers next to `AssertCanStartProcessing` / `AIBrandApplyAllowed` (e.g. `AssertFeature(ctx, companyID, "seo")`) and call from handlers that own each surface (processing, seo, campaigns, feeds, exports). Reuse 402 + `planGateCode` mapping in `processing_handlers.go`.
4. **Optional per-company overrides** — Wire `company_plans.custom_*` (and any new feature override) **into** `EntitlementsForCompany` / SKU query instead of inventing a second assignment table.
5. **Admin UI** — Extend `admin/billing/+page.svelte` create/edit dialog and table (send full `Plan` including `id` for update). Add toggle group UI bound to the same fields `UpsertPlan` persists.
6. **Web UX** — Gate nav/sections from `me.credits` / entitlements in `Nav.svelte` + page loads; keep display helpers in `billing-display.ts`.
7. **Do not** invent a second “packages” model, duplicate Free/paid heuristics outside `IsFreePlanName` / `ComputeEntitlements`, or add ad-hoc flags only in the frontend.
---
## Quick reference — key files
| Area | Path |
|---|---|
| Schema | `apps/api/sql/schema/001_platform.sql`, `016_stripe_billing.sql` |
| Service / Plan / gates | `apps/api/internal/billing/service.go` |
| Entitlements | `apps/api/internal/billing/entitlements.go` |
| HTTP billing/admin | `apps/api/internal/httpapi/billing_handlers.go`, `server.go` |
| Processing gate | `apps/api/internal/processing/pipeline.go` |
| Admin UI | `apps/web/src/routes/admin/billing/+page.svelte` |
| Display helpers | `apps/web/src/lib/billing-display.ts` |
| Seed | `apps/api/cmd/seed-demo/main.go` |
@@ -0,0 +1,228 @@
{
"agent": "03",
"title": "Plan permission contract",
"status": "design_only",
"version": "1.0.0",
"coordinates_with": [
"docs/plan-permissions/01-dashboard-feature-catalog.md",
"docs/plan-permissions/01-feature-keys.json",
"docs/plan-permissions/02-plans-permissions-current.md",
"docs/plan-permissions/02-extension-points.json"
],
"assumptions": [
"Package equals plans row; no separate packages table",
"is_custom=true (including seeded Enterprise) defaults all features ON unless override false",
"Meters (max_products, credits, can_use_ai) remain; feature keys gate surfaces/actions",
"Client canFeature fail-open when features map missing until API cutover"
],
"runtime_formula": {
"effective": "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 plans.is_custom -> true",
"else -> DefaultMatrix[normalizePlanName(name)][key] (unknown -> false unless catalog All-plans)"
],
"global_missing_default": true,
"parent_prefix_rule": "UI treats disabled parent as disabling children; Assert uses specific action key"
},
"data_model": {
"plans_features_column": {
"table": "plans",
"column": "features",
"type": "JSONB NOT NULL DEFAULT '{}'",
"semantics": "sparse overrides only; not full expanded matrix"
},
"platform_feature_gates": {
"table": "platform_feature_gates",
"columns": {
"gate_key": "TEXT PRIMARY KEY",
"kind": "TEXT CHECK IN ('section','feature')",
"enabled": "BOOLEAN NOT NULL DEFAULT true",
"updated_at": "TIMESTAMPTZ",
"updated_by": "UUID NULL REFERENCES users(id)"
},
"section_keys": [
"shell",
"dashboard",
"catalog",
"feeds",
"stores",
"processing",
"marketing",
"integrations",
"billing",
"settings",
"support",
"capabilities"
]
},
"do_not_add": [
"packages table",
"parallel permissions package outside billing",
"v1 company-level feature overrides"
]
},
"feature_key_namespace": {
"authority": "docs/plan-permissions/01-feature-keys.json",
"pattern": "section.leaf(.subleaf)* snake_case",
"capability_prefix": "capability.",
"exclude": [
"/admin/*"
]
},
"default_matrix_sketch": {
"custom_and_enterprise_is_custom": "all_true_unless_override",
"free_denies": [
"catalog.products.process_ai_titles",
"catalog.products.process_ai_descriptions",
"marketing.campaigns.generate_ai",
"marketing.campaigns.send",
"marketing.brand_ai_apply",
"marketing.seo.ai_rewrite",
"integrations.ai.byok",
"settings.api_keys",
"capability.ai_processing",
"capability.campaign_ai",
"capability.email_live_send",
"capability.brand_ai_apply",
"capability.seo_ai_rewrite",
"capability.api_access",
"capability.byok"
],
"starter_plus_allows_ai_surfaces": true,
"growth_byok_default": true,
"all_plans_core": [
"shell.navigation",
"dashboard.overview",
"catalog.products",
"catalog.products.process_categories",
"catalog.products.process_attributes",
"feeds.list",
"feeds.export_feeds",
"stores.hub",
"processing.monitor",
"billing.overview",
"settings.profile",
"support.center",
"capability.normalize_specs_fill",
"capability.eprel",
"capability.sku_cap",
"capability.ai_credits"
],
"notes": [
"Expand leaves from 01 catalog; parent deny implies children deny for UI",
"capability.sku_cap / ai_credits enable hooks; numeric limits stay on plans meters"
]
},
"api": {
"admin_plans": {
"list": "GET /api/admin/plans",
"upsert": "POST /api/admin/plans",
"plan_fields_added": [
"features",
"resolved_features"
],
"features_write_semantics": "replace stored overrides object"
},
"admin_feature_gates": {
"list": "GET /api/admin/feature-gates",
"put": "PUT /api/admin/feature-gates",
"put_section": "PUT /api/admin/feature-gates/sections/{section}",
"auth": "RequirePlatformAdmin",
"response_shape": {
"sections": {
"marketing": true
},
"features": {
"capability.byok": false
}
}
},
"user_capabilities": {
"embed_in_credits_overview": true,
"endpoints": [
"GET /api/billing/credits",
"GET /api/auth/me",
"GET /api/billing/capabilities"
],
"credits_overview_fields_added": [
"features",
"disabled_features",
"feature_etag"
],
"capabilities_response": {
"plan_id": "int64",
"plan_name": "string",
"is_custom": "bool",
"features": "map[string]bool effective full registry",
"sections": "map[string]bool global section enables",
"entitlements": {
"can_use_ai": "bool",
"can_use_eprel": "bool",
"is_free_plan": "bool",
"is_paid_plan": "bool"
}
}
},
"enforcement_error": {
"http_status": 402,
"body": {
"error": "feature_disabled",
"code": "plan_gate",
"feature": "marketing.campaigns.generate_ai",
"upgrade_url": "/plans"
}
}
},
"backend_symbols_to_extend": {
"reuse": [
"apps/api/internal/billing/entitlements.go#EntitlementsForCompany",
"apps/api/internal/billing/entitlements.go#ComputeEntitlements",
"apps/api/internal/billing/service.go#Plan",
"apps/api/internal/billing/service.go#CreditsOverview",
"apps/api/internal/billing/service.go#UpsertPlan",
"apps/api/internal/billing/service.go#EnsureDefaultPlans",
"apps/api/internal/billing/service.go#AssertCanStartProcessing",
"apps/api/internal/httpapi/billing_handlers.go",
"apps/web/src/routes/admin/billing/+page.svelte",
"apps/web/src/lib/billing-display.ts",
"apps/web/src/lib/plan-gates.ts",
"apps/web/src/lib/components/Nav.svelte"
],
"new_symbols": [
"DefaultPlanFeatures",
"ResolveFeatures",
"FeaturesForCompany",
"AssertFeature",
"canFeature"
]
},
"migration": {
"file_suggestion": "apps/api/sql/schema/026_plan_features.sql",
"idempotent": true,
"destructive": false,
"steps": [
"ALTER TABLE plans ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '{}'",
"CREATE TABLE IF NOT EXISTS platform_feature_gates (...)",
"EnsureDefaultPlans must not clobber non-empty features",
"Migrator inserts '{}' when source lacks column"
]
},
"frontend_gating": {
"helper": "canFeature(credits, key)",
"nav_filter": "Nav.svelte primaryItems + moreItemsAll by parent feature_key",
"fail_open_until_cutover": true,
"optional_env": "FEATURES_ENFORCE=1"
},
"breaking_changes": [],
"breaking_changes_policy": "prefer_none_additive_only",
"preserve_contracts": [
"can_use_ai",
"can_use_eprel",
"is_free_plan",
"is_paid_plan",
"public plan names Free/Starter/Growth/Business/Enterprise",
"Stripe checkout public-ladder-only",
"402 plan_gate for existing credit/SKU errors"
]
}
@@ -0,0 +1,264 @@
# 03 — Plan permission CONTRACT (agent 3/10)
**Status:** Design only — implementers own schema/API/UI.
**Coordinates with:** `01-dashboard-feature-catalog.md`, `01-feature-keys.json`, `02-plans-permissions-current.md`, `02-extension-points.json`.
**Machine-readable twin:** `03-permission-contract.json`.
---
## PROBLEM
Admins need to enable/disable every dashboard tab/section/functionality **per plan**, seed sensible defaults for the public ladder, default **all-on** for custom deals, flip **global section master switches**, and have runtime checks enforce:
```
effective(feature) = plan_allows(feature) AND global_section_enabled(section(feature)) AND global_feature_enabled(feature)
```
UI may hide; **API must fail closed** (reuse 402 `plan_gate` where an action is blocked).
---
## CONTEXT (tools / sources)
| Source | Finding |
|--------|---------|
| codehelper kickoff/investigate | Reuse `Entitlements` / `ComputeEntitlements` / `EntitlementsForCompany`, `Plan` / `UpsertPlan` / `EnsureDefaultPlans`, `CreditsOverview`, `AssertCanStartProcessing` |
| Agent 1 | Canonical `feature_key` catalog + section groups + default-by-plan hints |
| Agent 2 | No permission matrix today; capabilities derived; `plans.is_custom` exists; unused `company_plans.custom_*`; extension points list |
| Schema | `plans` has meters only — no features column yet |
| Platform settings | SystemCompanyID JSON exists, but **dedicated gate table** preferred for typed admin UX |
**ASSUMPTION:** "Package" equals a `plans` row (no separate packages table) — per agent 2.
**ASSUMPTION:** Enterprise remains public ladder + `is_custom=true`; custom-package "all features on" applies to any `is_custom=true` including Enterprise unless a key is explicitly `false` in `plans.features`.
**ASSUMPTION:** Metering (`max_products`, credit wallet, `can_use_ai`) stays as today; feature keys gate access/visibility and compose with entitlement booleans.
---
## 1. Runtime resolution (normative)
```
plan_allows(key) =
if key present in plans.features -> plans.features[key]
else if plans.is_custom -> true
else -> DefaultMatrix[normalizePlanName(plans.name)][key]
(missing matrix entry -> false for unknown keys;
true only if catalog marks "All plans")
global_section_enabled(section) =
if section present in platform_feature_gates (kind=section) -> enabled
else -> true # migration-safe: globals default ON
global_feature_enabled(key) =
if key present in platform_feature_gates (kind=feature) -> enabled
else -> true
effective(key) = plan_allows(key)
AND global_section_enabled(section_of(key))
AND global_feature_enabled(key)
```
**Parent implication (UI convenience, not storage):** If a parent key is disabled (e.g. `catalog.products`), children under that prefix SHOULD be treated as disabled for nav/tabs even if a child row is `true`. Server enforcement uses the **specific** action key; also check the nearest nav parent for route-level capabilities.
**Orthogonal (unchanged):** company role / `is_platform_admin`, Stripe state, env/platform enricher flags.
---
## 2. Data model
### 2.1 `plans.features` (per-plan overrides)
| Column | Type | Default | Notes |
|--------|------|---------|-------|
| `features` | `JSONB NOT NULL` | `'{}'` | Sparse map `feature_key -> boolean` overrides only |
Extend Go `Plan` with `Features map[string]bool \`json:"features,omitempty"\``.
`UpsertPlan` / list handlers read/write the column. Do **not** store the full expanded matrix on every row.
### 2.2 `platform_feature_gates` (global master switches)
```sql
CREATE TABLE IF NOT EXISTS platform_feature_gates (
gate_key TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('section', 'feature')),
enabled BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID NULL REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS platform_feature_gates_kind_idx
ON platform_feature_gates (kind);
```
- **Section master:** `kind='section'`, keys from agent 1: `shell`, `dashboard`, `catalog`, `feeds`, `stores`, `processing`, `marketing`, `integrations`, `billing`, `settings`, `support`, `capabilities`.
- **Optional feature master:** `kind='feature'`, full feature key kill-switch across all plans.
- Missing row => enabled (non-breaking).
### 2.3 Do not add
| Avoid | Why |
|-------|-----|
| Separate `packages` table | packages ≡ `plans` |
| Parallel permission service outside `billing` | Keep entitlements as SOT |
| Per-company feature overrides in v1 | plan ∩ global only |
| Replacing meters with feature keys alone | SKU/credits stay |
---
## 3. Feature key namespace
**Authority:** `01-feature-keys.json` + `01-dashboard-feature-catalog.md`.
- Dotted segments, `snake_case` leaves (`marketing.campaigns.generate_ai`)
- First segment = section
- Nav/route parents are first-class keys
- Cross-cutting gates under `capability.*`
- `/admin/*` excluded from tenant matrix
- Admin write rejects unknown keys (`400`); unknown stored keys ignored at resolve, stripped on validated upsert
---
## 4. Default matrix sketch
Custom (`is_custom`) / Enterprise (seeded `is_custom=true`) = **all ON** unless override.
| Key group | Free | Starter | Growth | Business | Custom |
|-----------|------|---------|--------|----------|--------|
| `shell.*`, `dashboard.*` | ON | ON | ON | ON | ON |
| `catalog.products` + non-AI process | ON | ON | ON | ON | ON |
| `catalog.products.process_ai_*` | OFF | ON | ON | ON | ON |
| `catalog.categories` / attributes / standard_fields | ON | ON | ON | ON | ON |
| `feeds.*`, `stores.*`, `processing.monitor` | ON | ON | ON | ON | ON |
| `marketing.campaigns` list/edit | ON | ON | ON | ON | ON |
| `marketing.campaigns.generate_ai` / live `send` | OFF | ON | ON | ON | ON |
| `marketing.brand_ai_apply` / `seo.ai_rewrite` | OFF | ON | ON | ON | ON |
| `integrations.ai` / `email` | ON | ON | ON | ON | ON |
| `integrations.ai.byok` | OFF | OFF | ON | ON | ON |
| `billing.*`, `support.*` | ON | ON | ON | ON | ON |
| `settings.profile` / company / alerts / team | ON | ON | ON | ON | ON |
| `settings.api_keys` | OFF | ON | ON | ON | ON |
| `capability.normalize_specs_fill` / `eprel` / `sku_cap` / `ai_credits` | ON | ON | ON | ON | ON |
| `capability.ai_processing` / `campaign_ai` / `email_live_send` / `brand_ai_apply` / `seo_ai_rewrite` | OFF | ON | ON | ON | ON |
| `capability.api_access` | OFF | ON | ON | ON | ON |
| `capability.byok` | OFF | OFF | ON | ON | ON |
AI feature keys compose with existing `can_use_ai` + wallet. Numeric SKU/credit limits remain on `plans` meters.
`DefaultPlanFeatures(name, isCustom)` lives next to `defaultPublicPlans`. **`EnsureDefaultPlans` must not clobber non-empty `features`.**
---
## 5. API shapes
### Admin plans (extend existing)
`GET /api/admin/plans` / `POST /api/admin/plans` — add:
```json
{
"features": { "settings.api_keys": true },
"resolved_features": { "catalog.products": true, "marketing.campaigns.generate_ai": true }
}
```
- `features` = stored overrides (may be `{}`)
- `resolved_features` = `plan_allows` only (ignore globals so admin sees package intent)
- Upsert **replaces** the overrides object; validate registry keys
### Admin global gates (new)
| Method | Path |
|--------|------|
| `GET` | `/api/admin/feature-gates` |
| `PUT` | `/api/admin/feature-gates` |
| `PUT` | `/api/admin/feature-gates/sections/{section}` |
Response/body: `{ "sections": { "marketing": true }, "features": { "capability.byok": false } }`.
Auth: `RequirePlatformAdmin`.
### User capabilities
Extend `CreditsOverview` (`GET /api/billing/credits`, `/api/auth/me`):
```json
{
"can_use_ai": false,
"features": { "catalog.products": true, "marketing.campaigns.generate_ai": false },
"disabled_features": ["marketing.campaigns.generate_ai"],
"feature_etag": "sha256:…"
}
```
Also: `GET /api/billing/capabilities` with full effective registry + `sections` + nested `entitlements`.
### Enforcement error
```json
{
"error": "feature_disabled",
"code": "plan_gate",
"feature": "marketing.campaigns.generate_ai",
"upgrade_url": "/plans"
}
```
HTTP 402; map beside existing plan_gate codes.
---
## 6. Backend placement
| Concern | Where |
|---------|--------|
| Resolve | `billing.ResolveFeatures` |
| Load | Wire into `CreditsOverview` / `EntitlementsForCompany` assembly (additive fields) |
| Defaults | `DefaultPlanFeatures` beside `defaultPublicPlans` |
| Assert | `AssertFeature(ctx, companyID, key)` at agent-2 extension points |
| Admin HTTP | New handlers under `/api/admin` |
UI-only checks without server `AssertFeature` are out of contract.
---
## 7. Migration (idempotent, non-destructive)
File suggestion: `apps/api/sql/schema/026_plan_features.sql`
1. `ALTER TABLE plans ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '{}'::jsonb`
2. `CREATE TABLE IF NOT EXISTS platform_feature_gates (...)`
3. No meter rewrites; ETL inserts `'{}'` if source lacks column
4. Down migration optional; prefer leave additive artifacts
---
## 8. Frontend gating pattern
1. Read `credits.features` from `/api/auth/me`
2. Helper `canFeature(credits, key)` beside `plan-gates.ts` / `billing-display.ts`
3. Filter `Nav.svelte` items via parent feature keys (`href -> feature_key` map)
4. Gate tabs/actions; show upgrade CTA when false
5. Fail-open in helper when `features` missing (pre-cutover); optional `FEATURES_ENFORCE=1` for new 402s
---
## 9. BREAKING changes
**None preferred.** Additive columns, fields, and routes only. Preserve `can_use_ai`, `can_use_eprel`, free/paid flags, public plan names, Stripe public-ladder checkout.
---
## 10. Verification checklist
- [ ] Migrate empty + existing DB
- [ ] `TestResolveFeatures_*` for Free / custom / global section off
- [ ] Admin upsert round-trip; `EnsureDefaultPlans` does not clobber overrides
- [ ] Global section off affects all plans
- [ ] Custom plan => all registry keys effective true
- [ ] `/api/auth/me` carries effective `features`
- [ ] Mutating AI/campaign/SEO/API-key paths call `AssertFeature` plus existing meters
---
## Open questions (defaults chosen)
1. Full registry on `/me` for v1 (not sparse).
2. Enterprise follows custom-all-on via `is_custom`.
3. Client fail-open until features payload exists.
+99
View File
@@ -0,0 +1,99 @@
# 04 — Backend model (agent 4/10)
Implements storage + billing service APIs for plan dashboard permissions and global section/feature master switches, aligned with `03-permission-contract.md`.
**BREAKING:** none (additive schema + fields + methods only).
---
## Schema (`apps/api/sql/schema/026_plan_features.sql`)
| Artifact | Purpose |
|----------|---------|
| `plans.features` | `JSONB NOT NULL DEFAULT '{}'` — sparse `feature_key → bool` overrides |
| `platform_feature_gates` | Global masters: `gate_key`, `kind` (`section`\|`feature`), `enabled`, `updated_at`, `updated_by` |
Missing gate rows ⇒ enabled (migration-safe). Goose Up/Down included.
---
## Go package (`apps/api/internal/billing/`)
| File | Role |
|------|------|
| `feature_catalog.go` | Registry keys, sections, Free/Starter deny lists |
| `plan_features.go` | Resolve + admin CRUD (`Get/SetFeatureGates`, `Get/SetPlanFeatures`, `CapabilitiesForCompany`, …) |
| `features_api.go` | Task-named surface: `ListFeatures`, `IsAllowed`, `SetPlanFeature`, `SetGlobalFeature`, `EnableAllForPlan`, `ApplyDefaultMatrix`, `AssertFeature`, `ResolveFeatures` |
| `default_plan_features_seed.go` | `EnsureDefaultFeatureSeeds` (idempotent section rows; never clobber non-empty plan overrides) |
| `service.go` | `Plan.Features` / `ResolvedFeatures`; `ListPlans` / `UpsertPlan` read/write JSON; `CreditsOverview` embeds effective features |
| `client_errors.go` | Maps feature gate errors for HTTP clients |
| `features_api_test.go` | Pure resolve/default/validate tests |
`EnsureDefaultPlans` still updates meters only — **does not** overwrite `features`.
---
## Resolution (normative)
```
effective(key) = plan_allows(key)
AND global_section_enabled(section(key))
AND global_feature_enabled(key)
```
- `plan_allows`: override in `plans.features` if present; else `is_custom` ⇒ true; else `DefaultPlanFeatures(name, false)`.
- Globals: missing row ⇒ true.
---
## How to use the service
```go
svc := &billing.Service{Pool: pool}
// Catalog
defs, _ := svc.ListFeatures(ctx)
// Effective check (company's active plan ∧ globals)
ok, err := svc.IsAllowed(ctx, companyID, "marketing.campaigns.generate_ai")
if err := svc.AssertFeature(ctx, companyID, "marketing.campaigns.generate_ai"); err != nil {
// errors.Is(err, billing.ErrFeatureDisabled) → HTTP 402 plan_gate
}
// Admin: per-plan override merge / replace / all-on / reset defaults
_ = svc.SetPlanFeature(ctx, planID, "settings.api_keys", true)
_, _ = svc.SetPlanFeatures(ctx, planID, map[string]bool{"settings.api_keys": true})
_ = svc.EnableAllForPlan(ctx, planID)
_ = svc.ApplyDefaultMatrix(ctx, planID) // sparse Free/Starter offs; custom → {}
// Admin: global masters (section or feature key)
_ = svc.SetGlobalFeature(ctx, "marketing", false, &adminUserID)
_ = svc.SetGlobalFeature(ctx, "capability.byok", false, &adminUserID)
gates, _ := svc.GetFeatureGates(ctx)
// Tenant payload (also on CreditsOverview /auth/me)
caps, _ := svc.CapabilitiesForCompany(ctx, companyID)
// caps.Features, caps.DisabledFeatures, caps.FeatureETag, caps.Entitlements
```
### Pure helpers (no DB)
- `DefaultPlanFeatures(name, isCustom)`
- `PlanAllowsFeature(name, isCustom, overrides, key)`
- `ResolveFeatures` / `ResolveEffectiveFeatures`
- `SparseDefaultOverrides(name, isCustom)`
- `IsKnownFeatureKey` / `IsKnownFeatureSection` / `SectionOfFeature`
---
## Seed / bootstrap
Call `EnsureDefaultFeatureSeeds` after migrations (alongside `EnsureDefaultPlans` if desired). Seeds section gates ON; leaves empty `{}` plan overrides so runtime defaults apply.
---
## Out of scope (other agents)
- HTTP routes under `/api/admin/feature-gates` and capabilities endpoints
- Frontend / admin UI toggles
- Wiring `AssertFeature` into every handler (processing, SEO, campaigns, …)
+128
View File
@@ -0,0 +1,128 @@
# 05 — Plan permissions API (agent 5/10)
Session/dashboard HTTP surface for plan feature permissions. Aligns with
`03-permission-contract.md`. Public `/api/v1` OpenAPI is unchanged (API-key product
API); schemas below are the session contract for admin UI + dashboard gating.
Backend storage + resolve live in `billing` per `04-backend-model.md` /
contract §2 / §6 (`plans.features` JSONB + `platform_feature_gates`). Migration:
`apps/api/sql/schema/026_plan_features.sql`. Apply with `make migrate` /
`.\scripts\migrate.ps1` before relying on writes.
Handlers call: `CapabilitiesForCompany`, `GetPlanFeatures`, `SetPlanFeatures`,
`EnableAllPlanFeatures`, `GetFeatureGates`, `SetFeatureGates`, `SetSectionGate`
(and agent-4 aliases `EnableAllForPlan` / `SetGlobalFeature` / `AssertFeature`).
---
## Endpoint list + auth
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `GET` | `/api/billing/capabilities` | Session + company | Effective features = plan ∩ global |
| `GET` | `/api/billing/credits` | Session + company | Existing credits overview **plus** `features`, `disabled_features`, `feature_etag` |
| `GET` | `/api/auth/me` | Session | Embeds credits overview (same feature fields when credits load) |
| `GET` | `/api/admin/plans` | Session + **platform admin** | Plans include `features` (overrides) + `resolved_features` (plan_allows) |
| `POST` | `/api/admin/plans` | Session + **platform admin** | Upsert plan; optional `features` replaces overrides when present |
| `GET` | `/api/admin/plans/{planID}/features` | Session + **platform admin** | Plan feature editor payload |
| `PUT` | `/api/admin/plans/{planID}/features` | Session + **platform admin** | Replace overrides `{ "features": { "key": true } }` |
| `POST` | `/api/admin/plans/{planID}/features/enable-all` | Session + **platform admin** | Set every registry key `true` (custom packages) |
| `GET` | `/api/admin/feature-gates` | Session + **platform admin** | Global section + feature master switches |
| `PUT` | `/api/admin/feature-gates` | Session + **platform admin** | Partial upsert `{ "sections": {...}, "features": {...} }` |
| `PUT` | `/api/admin/feature-gates/sections/{section}` | Session + **platform admin** | Enable/disable one section for **ALL** plans |
Mutations require CSRF (dashboard session stack) + platform admin for `/api/admin/*`.
Unknown feature keys / sections → `400` `{ "error": "…" }` via `billing.ClientError`.
---
## Response shapes
### `GET /api/billing/capabilities`
```json
{
"plan_id": 3,
"plan_name": "Starter",
"is_custom": false,
"has_active_plan": true,
"features": { "catalog.products": true, "settings.api_keys": true },
"sections": { "catalog": true, "marketing": true },
"disabled_features": ["integrations.ai.byok"],
"feature_etag": "sha256:…",
"entitlements": {
"plan_name": "Starter",
"is_free_plan": false,
"is_paid_plan": true,
"can_use_ai": true,
"can_use_eprel": true
}
}
```
### `GET|PUT /api/admin/plans/{planID}/features`
```json
{
"plan_id": 3,
"plan_name": "Starter",
"is_custom": false,
"features": { "settings.api_keys": true },
"resolved_features": { "catalog.products": true, "integrations.ai.byok": false }
}
```
- `features` = stored sparse overrides (`{}` if none)
- `resolved_features` = full registry via `plan_allows` only (globals ignored)
### `GET|PUT /api/admin/feature-gates`
```json
{
"sections": { "shell": true, "marketing": false },
"features": { "capability.byok": false }
}
```
Missing gate rows default **enabled** (migration-safe).
### `PUT /api/admin/feature-gates/sections/{section}`
```json
{ "enabled": false }
```
Returns full `FeatureGatesView`.
---
## OpenAPI (session fragment)
See `docs/plan-permissions/05-openapi-fragment.yaml` for OpenAPI 3.1 path/components
suitable for admin/dashboard clients. Not mounted on `/api/v1/openapi.yaml`.
---
## Files touched (agent 5)
| File | Intent |
|------|--------|
| `apps/api/internal/httpapi/plan_features_handlers.go` | Handlers |
| `apps/api/internal/httpapi/server.go` | Route mount |
| `apps/api/internal/billing/plan_features.go` | Service + DTOs (shared with agent 4) |
| `apps/api/internal/billing/feature_catalog.go` | Registry from `01-feature-keys.json` |
| `apps/api/internal/billing/service.go` | `Plan`/`CreditsOverview`/`ListPlans`/`UpsertPlan` |
| `apps/api/internal/billing/client_errors.go` | Client-facing feature errors |
| `apps/api/sql/schema/026_plan_features.sql` | Additive migration |
---
## Manual verify
1. `.\scripts\migrate.ps1` (or `make migrate`)
2. Platform admin: `GET /api/admin/feature-gates` → all sections true
3. `PUT /api/admin/feature-gates/sections/marketing` `{ "enabled": false }`
4. Tenant session: `GET /api/billing/capabilities` → marketing keys in `disabled_features`
5. `POST /api/admin/plans/{id}/features/enable-all` then `GET` → all `resolved_features` true
6. Non-admin `PUT /api/admin/feature-gates` → 403
@@ -0,0 +1,243 @@
openapi: 3.1.0
info:
title: Descrybe plan feature permissions (session API)
version: "1.0.0"
description: >
Dashboard/admin session endpoints for plan ∩ global feature permissions.
Not part of the public /api/v1 OpenAPI document.
paths:
/api/billing/capabilities:
get:
operationId: getBillingCapabilities
tags: [billing]
security: [{ sessionCookie: [] }]
summary: Effective plan ∩ global feature matrix for the active company
responses:
"200":
description: Capabilities
content:
application/json:
schema:
$ref: "#/components/schemas/Capabilities"
"401":
$ref: "#/components/responses/Error"
/api/admin/plans/{planID}/features:
parameters:
- $ref: "#/components/parameters/PlanID"
get:
operationId: adminGetPlanFeatures
tags: [admin]
security: [{ sessionCookie: [], platformAdmin: [] }]
summary: Plan feature overrides + resolved plan_allows matrix
responses:
"200":
description: Plan features
content:
application/json:
schema:
$ref: "#/components/schemas/PlanFeaturesView"
"400":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
put:
operationId: adminPutPlanFeatures
tags: [admin]
security: [{ sessionCookie: [], platformAdmin: [] }]
summary: Replace plan feature overrides
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PlanFeaturesUpdate"
responses:
"200":
description: Updated plan features
content:
application/json:
schema:
$ref: "#/components/schemas/PlanFeaturesView"
"400":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
/api/admin/plans/{planID}/features/enable-all:
parameters:
- $ref: "#/components/parameters/PlanID"
post:
operationId: adminEnableAllPlanFeatures
tags: [admin]
security: [{ sessionCookie: [], platformAdmin: [] }]
summary: Enable every registry feature on the plan (custom packages)
responses:
"200":
description: Updated plan features
content:
application/json:
schema:
$ref: "#/components/schemas/PlanFeaturesView"
"400":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
/api/admin/feature-gates:
get:
operationId: adminGetFeatureGates
tags: [admin]
security: [{ sessionCookie: [], platformAdmin: [] }]
summary: Global section and feature master switches
responses:
"200":
description: Feature gates
content:
application/json:
schema:
$ref: "#/components/schemas/FeatureGatesView"
"403":
$ref: "#/components/responses/Error"
put:
operationId: adminPutFeatureGates
tags: [admin]
security: [{ sessionCookie: [], platformAdmin: [] }]
summary: Partial upsert of global gates
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/FeatureGatesUpdate"
responses:
"200":
description: Updated gates
content:
application/json:
schema:
$ref: "#/components/schemas/FeatureGatesView"
"400":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
/api/admin/feature-gates/sections/{section}:
parameters:
- name: section
in: path
required: true
schema:
type: string
enum: [shell, dashboard, catalog, feeds, stores, processing, marketing, integrations, billing, settings, support, capabilities]
put:
operationId: adminPutFeatureGateSection
tags: [admin]
security: [{ sessionCookie: [], platformAdmin: [] }]
summary: Enable or disable a section for ALL plans
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SectionGateUpdate"
responses:
"200":
description: Updated gates
content:
application/json:
schema:
$ref: "#/components/schemas/FeatureGatesView"
"400":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: session
platformAdmin:
type: http
scheme: bearer
description: Requires users.is_platform_admin (session-derived; bearer shown for tooling only)
parameters:
PlanID:
name: planID
in: path
required: true
schema:
type: integer
format: int64
minimum: 1
responses:
Error:
description: Error
content:
application/json:
schema:
type: object
required: [error]
properties:
error:
type: string
schemas:
Capabilities:
type: object
properties:
plan_id: { type: integer, format: int64 }
plan_name: { type: string }
is_custom: { type: boolean }
has_active_plan: { type: boolean }
features:
type: object
additionalProperties: { type: boolean }
sections:
type: object
additionalProperties: { type: boolean }
disabled_features:
type: array
items: { type: string }
feature_etag: { type: string }
entitlements:
type: object
additionalProperties: true
PlanFeaturesView:
type: object
properties:
plan_id: { type: integer, format: int64 }
plan_name: { type: string }
is_custom: { type: boolean }
features:
type: object
additionalProperties: { type: boolean }
resolved_features:
type: object
additionalProperties: { type: boolean }
PlanFeaturesUpdate:
type: object
required: [features]
properties:
features:
type: object
additionalProperties: { type: boolean }
FeatureGatesView:
type: object
properties:
sections:
type: object
additionalProperties: { type: boolean }
features:
type: object
additionalProperties: { type: boolean }
FeatureGatesUpdate:
type: object
properties:
sections:
type: object
additionalProperties: { type: boolean }
features:
type: object
additionalProperties: { type: boolean }
SectionGateUpdate:
type: object
required: [enabled]
properties:
enabled: { type: boolean }
@@ -0,0 +1,717 @@
{
"agent": "06",
"source": [
"docs/plan-permissions/01-feature-keys.json",
"docs/plan-permissions/03-permission-contract.md",
"apps/api/internal/billing/feature_catalog.go",
"apps/api/internal/billing/plan_features.go#DefaultPlanFeatures",
"apps/web/src/lib/components/pricing/pricing-data.ts",
"apps/web/src/lib/billing-display.ts",
"docs/free-tier.md"
],
"assumptions": [
"No plans.features_customized flag; non-empty plans.features = admin customization and is never wiped by EnsureDefaultFeatureSeeds",
"Empty plans.features '{}' is unset; resolve uses DefaultPlanFeatures(name, is_custom)",
"Enterprise is seeded is_custom=true so all features ON (custom path)",
"Global section gates missing row => enabled; seeder inserts section rows as enabled ON CONFLICT DO NOTHING",
"capability.eprel ON for all plans (matches CanUseEPREL always true; free-tier.md lag noted)",
"Meters (SKU/credits/feed counts) stay on plans columns / marketing copy \u2014 feature keys gate access only"
],
"global_sections_default": {
"shell": true,
"dashboard": true,
"catalog": true,
"feeds": true,
"stores": true,
"processing": true,
"marketing": true,
"integrations": true,
"billing": true,
"settings": true,
"support": true,
"capabilities": true
},
"denied_by_plan": {
"free": [
"capability.ai_processing",
"capability.api_access",
"capability.brand_ai_apply",
"capability.byok",
"capability.campaign_ai",
"capability.email_live_send",
"capability.seo_ai_rewrite",
"catalog.products.process_ai_descriptions",
"catalog.products.process_ai_titles",
"integrations.ai.byok",
"marketing.brand_ai_apply",
"marketing.campaigns.generate_ai",
"marketing.campaigns.send",
"marketing.seo.ai_rewrite",
"settings.api_keys"
],
"starter": [
"capability.byok",
"integrations.ai.byok"
],
"growth": [],
"business": [],
"enterprise": [],
"custom": []
},
"matrices": {
"free": {
"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": false,
"catalog.products.process_ai_descriptions": false,
"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": false,
"marketing.campaigns.send": false,
"marketing.content_calendar": true,
"marketing.brand_kit": true,
"marketing.brand_ai_apply": false,
"marketing.seo": true,
"marketing.seo.template_fill": true,
"marketing.seo.ai_rewrite": false,
"marketing.reviews": true,
"integrations.ai": true,
"integrations.ai.byok": false,
"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": false,
"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": false,
"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
},
"starter": {
"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": false,
"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": false
},
"growth": {
"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
},
"business": {
"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
},
"enterprise": {
"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
},
"custom": {
"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
}
}
}

Some files were not shown because too many files have changed in this diff Show More