Phase1: Schema + skill event tracking
Some checks failed
Build and Push / build (push) Failing after 38s
Some checks failed
Build and Push / build (push) Failing after 38s
This commit is contained in:
367
README.md
367
README.md
@@ -1,77 +1,326 @@
|
||||
# Threema UX v3 — three real fixes
|
||||
# PieCed Portal — Billing Phase 1 (drop-in replacement)
|
||||
|
||||
## What this fixes vs v2
|
||||
Schema + event tracking. No UI yet (that lands in Phase 2).
|
||||
This zip mirrors the `pieced-portal/` repo root — extract over your
|
||||
existing source tree to apply.
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|-------|-----|
|
||||
| 1 | QR image 404'd as `GET /en/threema/qr_code_AIAGENT.png` | Middleware matcher now excludes paths with file extensions, so static files in `public/` are not locale-prefixed |
|
||||
| 2 | Displayed gateway name as `AIAGENT` (without asterisk) | `displayName` is now `*AIAGENT` (with asterisk) — what users actually see in their Threema contacts |
|
||||
| 3 | "Show QR" hyperlink — too small, unclear what it does | Replaced with a proper accent-bordered info banner: icon + title + body explaining what to do + prominent "Show QR code" button |
|
||||
---
|
||||
|
||||
## Files
|
||||
## Files in this drop
|
||||
|
||||
```
|
||||
src/middleware.ts # MODIFIED — matcher excludes dot-paths (static files)
|
||||
src/lib/threema-gateway-config.ts # MODIFIED — displayName: "*AIAGENT"
|
||||
src/components/channel-users/channel-users.tsx # MODIFIED — banner replaces inline hyperlink
|
||||
src/components/channel-users/threema-qr-modal.tsx # UNCHANGED from v2 (label now reads "*AIAGENT" automatically via config)
|
||||
deploy/patch-i18n-threema.mjs # MODIFIED — new bannerTitle/bannerBody/bannerButton keys, showQr dropped
|
||||
public/threema/qr_code_AIAGENT.png # UNCHANGED
|
||||
src/lib/db.ts MODIFIED
|
||||
src/types/index.ts MODIFIED
|
||||
src/app/api/admin/requests/[id]/approve/route.ts MODIFIED
|
||||
src/app/api/tenants/[name]/route.ts MODIFIED
|
||||
src/app/api/tenants/[name]/suspend/route.ts MODIFIED
|
||||
src/app/api/admin/tenants/[name]/delete/route.ts MODIFIED
|
||||
src/app/api/admin/billing/backfill/route.ts NEW
|
||||
```
|
||||
|
||||
## Apply
|
||||
No `package.json` changes — Phase 1 uses only deps already present.
|
||||
|
||||
```bash
|
||||
cd /path/to/pieced-portal
|
||||
### What changed
|
||||
|
||||
unzip -o /path/to/threema-ux-v3.zip
|
||||
node deploy/patch-i18n-threema.mjs
|
||||
npx tsc --noEmit
|
||||
git add -A
|
||||
git status # eyeball
|
||||
git commit -m "Threema UX: middleware static fix, *AIAGENT display, info banner"
|
||||
git push
|
||||
```
|
||||
`src/lib/db.ts`
|
||||
- Extended `MIGRATION_SQL` with 11 new tables (idempotent — uses
|
||||
`CREATE TABLE IF NOT EXISTS`)
|
||||
- Added a new "Billing — Phase 1" section at the bottom with ~25
|
||||
helper functions
|
||||
|
||||
## Layout after redeploy
|
||||
`src/types/index.ts`
|
||||
- 6 new interfaces appended at the bottom
|
||||
|
||||
`src/app/api/admin/requests/[id]/approve/route.ts`
|
||||
- Imports `recordTenantCreated`, `recordSkillEvents`,
|
||||
`recordSuspensionEvent` from `@/lib/db`
|
||||
- Resume path: records a `resumed` suspension event after the
|
||||
`patchTenantSpec({suspend: false})` call
|
||||
- Provision path: records `recordTenantCreated` + initial `enabled`
|
||||
events after `createTenant`
|
||||
|
||||
`src/app/api/tenants/[name]/route.ts`
|
||||
- Imports `recordSkillEvents`
|
||||
- After `patchTenantSpec` succeeds and the patch touched
|
||||
`packages`, computes the diff (added/removed) and writes events.
|
||||
Diff is computed against the patched CR (the returned state) so
|
||||
events match what K8s committed.
|
||||
|
||||
`src/app/api/tenants/[name]/suspend/route.ts`
|
||||
- Imports `recordSuspensionEvent`
|
||||
- Records `suspended` or `resumed` after the patch succeeds
|
||||
|
||||
`src/app/api/admin/tenants/[name]/delete/route.ts`
|
||||
- Imports `recordTenantDeleted`
|
||||
- Stamps `deleted_at` on the lifecycle row after `deleteTenant`
|
||||
|
||||
`src/app/api/admin/billing/backfill/route.ts` (new)
|
||||
- `POST /api/admin/billing/backfill` — platform-only, idempotent
|
||||
- Reads every live PiecedTenant CR, mirrors creationTimestamp,
|
||||
current `spec.packages`, and `status.suspendedAt` into the new
|
||||
tables. Run once after deploy to bootstrap historical data.
|
||||
|
||||
### Tables added (Postgres, all idempotent)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ threema 2 users │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ╔═══════════════════════════════════════════════════════╗│
|
||||
│ ║ [icon] Set up Threema [ Show QR code ] ║│ ← prominent banner, accent border
|
||||
│ ║ Open Threema on your phone and scan our ║│
|
||||
│ ║ QR code to add the assistant as a contact. ║│
|
||||
│ ║ Then add your own Threema ID below. ║│
|
||||
│ ╚═══════════════════════════════════════════════════════╝│
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ <help: "Enter your own Threema ID..."> │
|
||||
│ ┌───────┐ ┌───────┐ │
|
||||
│ │USER01 ✕│ │USER02 ✕│ │
|
||||
│ └───────┘ └───────┘ │
|
||||
│ [ A8K2P3X7 ] [ Add ] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
platform_pricing single-row pricing config
|
||||
skill_pricing per-package daily price (optional)
|
||||
tenant_billing_lifecycle per-tenant created_at + deleted_at
|
||||
tenant_skill_events append-only enable/disable log
|
||||
tenant_suspension_events append-only suspend/resume log
|
||||
org_billing_config per-org billing posture (pay-by-invoice,
|
||||
stripe id, auto-cron toggles)
|
||||
org_payment_methods Stripe payment methods (Phase 4)
|
||||
invoice_number_counters gapless per-year counter
|
||||
invoices immutable issued invoices (Phase 2)
|
||||
invoice_lines invoice line items (Phase 2)
|
||||
invoice_reminders sent reminders + their PDFs (Phase 6)
|
||||
```
|
||||
|
||||
The banner is always visible whenever the threema channel is enabled.
|
||||
Clicking "Show QR code" opens the modal with the QR and 3-step
|
||||
instructions. ESC, overlay click, or × button closes.
|
||||
The invoice/lines/reminders tables ship now so Phase 2 doesn't need
|
||||
a second migration, but no code writes to them until Phase 2.
|
||||
|
||||
Auto-open on first focus of the add-ID input is preserved from v2 —
|
||||
the modal pops once when a customer clicks into the input to add their
|
||||
first ID, so a brand-new customer who skipped the banner still gets
|
||||
the QR right when they need it.
|
||||
### Design properties
|
||||
|
||||
## Verification
|
||||
* Every billing-tracking call is wrapped in `try/catch`. A logging
|
||||
failure never blocks the K8s operation.
|
||||
* PATCH-diff is computed against the *returned* CR state, not the
|
||||
pre-patch state, so events match what K8s actually committed.
|
||||
* Event tables are append-only. Historical billing can be
|
||||
recomputed reproducibly.
|
||||
* `tenant_billing_lifecycle` mirrors created_at + deleted_at so
|
||||
deleted tenants still have a final-invoice anchor.
|
||||
* All money is `NUMERIC`: 10,2 for CHF amounts, 10,5 for per-unit
|
||||
prices.
|
||||
|
||||
After redeploy:
|
||||
---
|
||||
|
||||
1. Open `https://app.pieced.ch/en/tenants/acme-gmbh-2acf4612` in the browser.
|
||||
2. Scroll to the **Authorized Users → threema** card.
|
||||
3. Visible banner with icon, title "Set up Threema", body text, and a
|
||||
clearly clickable "Show QR code" button on the right.
|
||||
4. Click the button → modal with the QR shows.
|
||||
5. The label under the QR reads `*AIAGENT` (with asterisk).
|
||||
6. Browser DevTools → Network → `GET /threema/qr_code_AIAGENT.png` is
|
||||
`200`, not `404`, and not redirected to `/en/threema/...`.
|
||||
## Deploy
|
||||
|
||||
1. Extract this zip over your `pieced-portal/` source tree
|
||||
2. Build & push:
|
||||
```
|
||||
./buildanddeploy.sh # or your usual flow
|
||||
```
|
||||
3. Bump the image tag in `gitops/apps/portal/deployment.yaml`,
|
||||
commit, push. ArgoCD picks it up.
|
||||
4. On pod boot, the next DB query auto-runs `MIGRATION_SQL` (your
|
||||
existing `ensureSchema` pattern). No manual `psql` needed.
|
||||
|
||||
---
|
||||
|
||||
## Testing (in order — don't skip steps)
|
||||
|
||||
### Step 1 — Migration ran
|
||||
|
||||
After the new pod is `Ready`, exec into the portal DB and verify
|
||||
all 11 new tables exist:
|
||||
|
||||
```
|
||||
kubectl -n portal exec -it portal-db-1 -- \
|
||||
psql -U portal -d portal -c "\dt"
|
||||
```
|
||||
|
||||
You should see the new tables alongside the existing ones.
|
||||
|
||||
Sanity-check the single-row pricing config seed:
|
||||
|
||||
```
|
||||
kubectl -n portal exec -it portal-db-1 -- \
|
||||
psql -U portal -d portal -c "SELECT * FROM platform_pricing;"
|
||||
```
|
||||
|
||||
Expected: one row, all zeros, vat_rate_chli=8.10.
|
||||
|
||||
### Step 2 — Backfill existing tenants
|
||||
|
||||
Run the backfill once. From your laptop (with a logged-in admin
|
||||
session cookie) or from another pod with cluster access:
|
||||
|
||||
```
|
||||
curl -X POST \
|
||||
-H "Cookie: <your authjs session cookie>" \
|
||||
https://app.pieced.ch/api/admin/billing/backfill
|
||||
```
|
||||
|
||||
Or, easier, hit the URL from a logged-in browser tab and use the
|
||||
DevTools console:
|
||||
|
||||
```
|
||||
await fetch('/api/admin/billing/backfill', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
```
|
||||
|
||||
Expected response (numbers will vary):
|
||||
```
|
||||
{
|
||||
"message": "Backfill complete.",
|
||||
"tenantsExamined": 4,
|
||||
"lifecycleInserted": 4,
|
||||
"eventsInserted": 12,
|
||||
"suspensionEventsInserted": 0
|
||||
}
|
||||
```
|
||||
|
||||
Run it a SECOND time — all three "Inserted" counts should be 0
|
||||
(idempotency check):
|
||||
|
||||
```
|
||||
{
|
||||
"message": "Backfill complete.",
|
||||
"tenantsExamined": 4,
|
||||
"lifecycleInserted": 0,
|
||||
"eventsInserted": 0,
|
||||
"suspensionEventsInserted": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3 — Verify backfill data
|
||||
|
||||
```
|
||||
kubectl -n portal exec -it portal-db-1 -- psql -U portal -d portal
|
||||
```
|
||||
|
||||
```sql
|
||||
-- All tenants have lifecycle rows
|
||||
SELECT tenant_name, zitadel_org_id, created_at, deleted_at
|
||||
FROM tenant_billing_lifecycle ORDER BY created_at;
|
||||
|
||||
-- Initial skill events match each tenant's current spec.packages
|
||||
SELECT tenant_name, skill_id, event_kind, occurred_at
|
||||
FROM tenant_skill_events ORDER BY tenant_name, occurred_at;
|
||||
```
|
||||
|
||||
Cross-check against the live CR:
|
||||
```
|
||||
kubectl get piecedtenants -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.packages}{"\n"}{end}'
|
||||
```
|
||||
|
||||
Every package currently in `spec.packages` should have a matching
|
||||
`enabled` event row.
|
||||
|
||||
### Step 4 — Live skill toggle
|
||||
|
||||
Open the customer-facing tenant detail page for a test tenant.
|
||||
Enable a package (e.g. `searxng-local-search`) that wasn't on
|
||||
before.
|
||||
|
||||
```sql
|
||||
SELECT * FROM tenant_skill_events
|
||||
WHERE tenant_name = 'your-test-tenant'
|
||||
ORDER BY id DESC LIMIT 3;
|
||||
```
|
||||
|
||||
You should see a fresh `enabled` row with the package id and a
|
||||
timestamp matching the toggle.
|
||||
|
||||
Disable the same package, re-check — you should now see a
|
||||
`disabled` row added on top.
|
||||
|
||||
### Step 5 — Live suspend toggle
|
||||
|
||||
From the customer-side cancel button on a test tenant, suspend it:
|
||||
|
||||
```sql
|
||||
SELECT * FROM tenant_suspension_events
|
||||
WHERE tenant_name = 'your-test-tenant'
|
||||
ORDER BY id DESC LIMIT 3;
|
||||
```
|
||||
|
||||
Expect a `suspended` row.
|
||||
|
||||
As platform admin, resume it via the resume-request flow. Expect a
|
||||
`resumed` row to land.
|
||||
|
||||
### Step 6 — Live delete
|
||||
|
||||
Delete a test tenant from the admin panel. Check:
|
||||
|
||||
```sql
|
||||
SELECT tenant_name, created_at, deleted_at
|
||||
FROM tenant_billing_lifecycle
|
||||
WHERE tenant_name = 'your-deleted-tenant';
|
||||
```
|
||||
|
||||
`deleted_at` should be stamped with roughly "now".
|
||||
|
||||
### Step 7 — Pricing helpers (smoke test)
|
||||
|
||||
Optional sanity check — set a price for a skill and a monthly fee
|
||||
via direct SQL (Phase 2 will add a UI for this):
|
||||
|
||||
```sql
|
||||
UPDATE platform_pricing
|
||||
SET tenant_monthly_fee_chf = 49.00,
|
||||
tenant_setup_fee_chf = 99.00,
|
||||
threema_message_chf = 0.005
|
||||
WHERE id = 1;
|
||||
|
||||
INSERT INTO skill_pricing (skill_id, daily_price_chf)
|
||||
VALUES ('searxng-local-search', 0.10)
|
||||
ON CONFLICT (skill_id) DO UPDATE
|
||||
SET daily_price_chf = EXCLUDED.daily_price_chf;
|
||||
```
|
||||
|
||||
No application behaviour changes from these — they're inert until
|
||||
Phase 2 starts computing invoices. The check is just "the rows
|
||||
land and survive a restart".
|
||||
|
||||
### Step 8 — Org billing config auto-create
|
||||
|
||||
Verify the org_billing_config auto-creates on first read. Call the
|
||||
helper indirectly: open the admin panel (which will eventually
|
||||
read this); for now, force it via SQL:
|
||||
|
||||
```sql
|
||||
-- Pick any org id from your tenant labels
|
||||
SELECT zitadel_org_id, pay_by_invoice, auto_invoice_enabled,
|
||||
auto_reminders_enabled
|
||||
FROM org_billing_config;
|
||||
```
|
||||
|
||||
At this point this table is empty (Phase 2's UI will populate it).
|
||||
That's expected and fine.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If anything misbehaves, the migration is additive — no existing
|
||||
columns/tables touched. To roll back:
|
||||
|
||||
1. Re-deploy the previous portal image (revert the tag in gitops)
|
||||
2. The new tables remain in the DB but are unreferenced. Leave them
|
||||
in place — Phase 2 will use them again. Or drop them if you
|
||||
want a clean slate:
|
||||
```sql
|
||||
DROP TABLE IF EXISTS invoice_reminders, invoice_lines, invoices,
|
||||
invoice_number_counters, org_payment_methods, org_billing_config,
|
||||
tenant_suspension_events, tenant_skill_events,
|
||||
tenant_billing_lifecycle, skill_pricing, platform_pricing CASCADE;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's NOT in this phase (by design)
|
||||
|
||||
* No customer-facing /billing page
|
||||
* No admin pricing UI
|
||||
* No invoice generation
|
||||
* No PDF rendering
|
||||
* No Stripe wiring
|
||||
* No reminders or cron
|
||||
|
||||
These are Phases 2-6.
|
||||
|
||||
## What to expect in Phase 2
|
||||
|
||||
* `/admin/billing` page: platform pricing editor, per-skill pricing,
|
||||
per-org config
|
||||
* "Generate invoice for org X / month M" admin button (testing tool
|
||||
+ foundation for the Phase 6 monthly cron)
|
||||
* `lib/billing.ts` introducing the invoice computation pipeline
|
||||
(LiteLLM spend pull, Threema usage pull, skill day collapse,
|
||||
monthly-fee proration, suspended-day exclusion, VAT calc)
|
||||
* PDF rendering via `@react-pdf/renderer` (adds one dep)
|
||||
* Admin "mark paid" action for the bill-pay flow
|
||||
|
||||
Reference in New Issue
Block a user