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