Compare commits

...

8 Commits

Author SHA1 Message Date
6baca1a459 Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m35s
2026-05-24 00:21:29 +02:00
faf49119ea Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-23 23:50:42 +02:00
ce70fe8480 Phase1: Schema + skill event tracking
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-23 23:45:04 +02:00
55571b1e59 Threema UX: static file middleware fix, *AIAGENT display, info banner
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 17:29:25 +02:00
c0ff22394c Threema QR: on-demand modal + auto-open on first add
All checks were successful
Build and Push / build (push) Successful in 1m29s
2026-05-17 17:13:44 +02:00
395d2f43cc Threema: customer-friendly texts + QR setup component
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-17 16:50:23 +02:00
6f42b56ad5 Threema package: relay-managed channel users + provisioning endpoints
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 11:10:40 +02:00
85c4302f7a Threema Gateway
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-05-16 22:00:27 +02:00
26 changed files with 2382 additions and 103 deletions

View File

@@ -17,3 +17,7 @@ LITELLM_MASTER_KEY=
# Portal Database (CloudNativePG)
DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal
# Threema relay (central pieced-threema-gateway)
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__

134
README.md
View File

@@ -1,100 +1,54 @@
# PieCed Portal
# PieCed Portal — Billing Phase 1 patch (suspend-via-admin fix)
Customer self-service portal for the PieCed IT multi-tenant OpenClaw platform.
Single-file fix on top of the Phase 1 v2 drop.
## Stack
## What it fixes
| Layer | Choice |
|-------|--------|
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) |
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) |
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) |
| Usage data | LiteLLM `/team/info` + `/global/spend/logs` |
| i18n | next-intl 4.x (en/de) |
| Styling | Tailwind CSS 4 |
| Deployment | Container in `pieced-system`, exposed at `app.pieced.ch` |
The admin panel's suspend/resume button hits
`/api/admin/tenants/[name]/suspend` (a different route from the
customer-side `/api/tenants/[name]/suspend`). The v2 drop only
hooked the customer route — admin suspends were going to K8s
without producing a row in `tenant_suspension_events`.
## Setup
This patch adds the same `recordSuspensionEvent` hook to the
admin route. No other code paths affected; no schema changes.
### 1. ZITADEL Application
In ZITADEL console (`auth.pieced.ch`), project "OpenClaw Platform":
1. Create Application → **PieCed Portal** → Web → Authentication Method: **CODE**
2. Redirect URI: `https://app.pieced.ch/api/auth/callback/zitadel`
3. Post-logout URI: `https://app.pieced.ch/login`
4. Note Client ID and Client Secret
### 2. OpenBao Secrets
```bash
bao kv put pieced/portal/oidc \
client_id="<from step 1>" \
client_secret="<from step 1>" \
nextauth_secret="$(openssl rand -base64 32)"
```
### 3. Build & Push
```bash
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.0 .
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.0
```
Update image tag in `pieced-gitops/apps/portal/deployment.yaml`, push, ArgoCD syncs.
### 4. DNS
Ensure `app.pieced.ch` A record → MetalLB ingress IP (or ExternalDNS handles it).
## Local Development
```bash
cp .env.example .env.local
# Fill in values — K8s client uses ~/.kube/config locally
npm install
npm run dev
```
## Project Structure
## Files
```
src/
├── app/
│ ├── api/
│ │ ├── auth/[...nextauth]/route.ts # NextAuth handler
│ │ ├── tenants/route.ts # Tenant CRUD (K8s API)
│ │ └── usage/route.ts # Usage stub
│ ├── [locale]/
│ │ ├── layout.tsx # Locale layout + NavShell
│ │ ├── page.tsx # Redirect → /dashboard
│ │ ├── login/page.tsx # ZITADEL sign-in
│ │ ├── dashboard/page.tsx # Customer dashboard
│ │ └── admin/page.tsx # Platform admin tenant list
│ ├── layout.tsx # Root layout
│ └── globals.css # Tailwind 4 theme
├── components/
│ ├── layout/nav-shell.tsx # Header + navigation
│ └── ui/ # Reusable UI components
├── i18n/
│ ├── routing.ts # next-intl 4.x routing config
│ ├── navigation.ts # Localized Link, redirect, etc.
│ └── request.ts # Server-side i18n config
├── lib/
│ ├── auth.ts # NextAuth v5 + ZITADEL config
│ ├── k8s.ts # K8s client for PiecedTenant CRs
│ ├── litellm.ts # LiteLLM API client
│ └── session.ts # Session helpers
├── messages/
│ ├── en.json
│ └── de.json
└── types/index.ts # Shared TypeScript types
src/app/api/admin/tenants/[name]/suspend/route.ts MODIFIED
```
## Session Roadmap
## Deploy
- **6.1** ← This session: scaffold, auth, basic pages
- **6.2**: Instance management, package config, usage display
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md)
- **6.5**: Admin panel (tenant lifecycle, billing overview)
Extract over your `pieced-portal/` tree, rebuild, redeploy as
usual. After the new image is running, verify:
1. Suspend any test tenant from the `/admin` panel.
2. Check the events table:
```bash
kubectl -n pieced-system exec -it portal-db-1 -- psql -U postgres -d portal -c \
"SELECT * FROM tenant_suspension_events ORDER BY id DESC LIMIT 5;"
```
Expect a fresh `suspended` row for the tenant you just toggled.
3. Resume → expect a `resumed` row.
## Why I missed this
Both routes share the same shape (PATCH/POST that sets
`spec.suspend`), but they differ on:
- URL path (`/api/admin/tenants/...` vs `/api/tenants/...`)
- Method (POST vs PATCH)
- Authorization (platform-only vs owner+platform)
- Caller (admin panel vs customer cancel button)
When I grepped for the suspend hook target I matched on the
customer endpoint and didn't audit cross-cutting admin
duplicates. I've since checked every site that calls
`patchTenantSpec`, `createTenant`, or `deleteTenant` — this was
the only missed billing-relevant one. Other `patchTenantSpec`
sites are confirmed non-billing (openClawImage, channelUsers).

70
deploy/README-threema.md Normal file
View File

@@ -0,0 +1,70 @@
# Wiring Threema relay into the portal
Drop-in files in this archive:
```
src/lib/packages.ts # add 'threema' to catalog + customProvisioning flag
src/lib/threema-relay.ts # new — admin API client
src/app/api/tenants/[name]/threema/route.ts # new — POST provision / DELETE deprovision
src/app/api/tenants/[name]/threema/routes/route.ts # new — atomic add/remove of a single Threema ID
src/components/channel-users/channel-users.tsx # branch threema through relay-managed endpoint
src/components/packages/package-card.tsx # handle customProvisioning enable/disable
deploy/patch-i18n-threema.mjs # idempotent i18n key injection
```
## Manual steps after dropping in
1. `.env` (and `.env.example`) — add:
```
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
```
The portal pod's OpenBao client should also read `secret/data/threema-gateway/admin` and surface `token` as this env var (existing ESO pattern in the portal's Helm chart).
2. Patch the message files (one-time):
```bash
node deploy/patch-i18n-threema.mjs
```
3. Re-export `CHANNEL_PACKAGE_IDS` is unchanged in source; verify
`tenants/[name]/page.tsx` still derives the enabled-channels list
from it — it should now include `threema` automatically once a
tenant has it in `spec.packages`.
4. Type-check:
```bash
npx tsc --noEmit
```
## Flow summary
### Enabling Threema for a tenant
1. Customer toggles the threema package card.
2. PackageCard sees `customProvisioning: true` → POSTs `/api/tenants/<name>/threema`.
3. Handler calls relay `POST /admin/tokens` → gets `{token, hmacSecret}`.
4. Handler writes them to OpenBao at `secret/data/tenants/tenant-<name>/threema-relay`.
5. PackageCard then PATCHes `tenant.spec.packages` to include `threema`.
6. Operator reconciles: ExternalSecret syncs OpenBao → Secret; OpenClaw pod restarts with `THREEMA_RELAY_*` env vars; plugin registers `threema` channel.
### Customer adds a Threema ID
1. UI calls `POST /api/tenants/<name>/threema/routes` with `{threemaId}`.
2. Handler calls relay `POST /admin/routes` (uniqueness enforced at PK).
3. On 201 or 409-from-same-tenant: handler patches K8s `spec.channelUsers.threema`.
4. On 409-from-other-tenant: 409 to client with explanation.
5. On K8s patch failure after relay success: handler compensates by `DELETE /admin/routes/...` at the relay.
### Customer removes a Threema ID
1. UI calls `DELETE /api/tenants/<name>/threema/routes?threemaId=...`.
2. Handler patches K8s `spec.channelUsers.threema` to drop the ID.
3. Handler calls relay `DELETE /admin/routes/...` (404 = idempotent OK).
4. If relay drop fails: K8s already updated, surface warning but treat as success — relay deletes are idempotent on retry.
### Disabling Threema for a tenant
1. Customer disables the threema card.
2. PackageCard DELETEs `/api/tenants/<name>/threema`.
3. Handler calls relay `DELETE /admin/tokens/<name>` (cascades to all routes for this tenant).
4. Handler deletes OpenBao secret at `secret/data/tenants/tenant-<name>/threema-relay`.
5. PackageCard then PATCHes `tenant.spec.packages` to drop `threema`.
6. Operator reconciles: ExternalSecret targets a missing OpenBao path → Secret deleted → OpenClaw pod restarts without `threema` channel.
There's a small window (between step 4 and the operator's reconcile) where the pod still thinks it has a relay token but the relay has revoked it. Outbound during that window returns 401 from the relay; inbound is blackholed at the relay because routes are gone. Both are graceful failures.

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Run: node deploy/patch-i18n-threema.mjs
*
* Idempotently injects (or overwrites) customer-facing Threema texts:
* - packages.threema.{description, instructions, disclaimer}
* - channelUsers.threemaIdHelp
* - channelUsers.threemaSetup.{title, step1, step2, step3, qrAlt}
*
* Replaces the earlier version of this script entirely. The new texts:
* - Drop "Gateway account" jargon (customers don't know it)
* - Drop asterisk-prefix references (customers don't see / type it)
* - Tell the customer to add their OWN Threema ID, not someone else's
* - Disclose that Threema charges per message via the gateway
* - Walk through the QR-scan + add-your-ID flow explicitly
*
* Re-running is safe — keys are set, not merged, so this is the
* source of truth for the values it touches.
*/
import { readFileSync, writeFileSync } from "fs";
const i18n = {
en: {
pkg: {
description:
"Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
instructions:
"1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
disclaimer:
"Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates.",
},
channelHelp:
"Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
setup: {
title: "Add the assistant to your Threema",
step1: "Open Threema on your phone.",
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
step3: "Then add your own Threema ID below.",
qrAlt: "QR code to add {gateway} as a Threema contact",
bannerTitle: "Set up Threema",
bannerBody: "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
bannerButton: "Show QR code",
},
},
de: {
pkg: {
description:
"Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
instructions:
"1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
disclaimer:
"Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan.",
},
channelHelp:
"Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
setup: {
title: "Assistenten zu Threema hinzufügen",
step1: "Öffnen Sie Threema auf Ihrem Telefon.",
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
bannerTitle: "Threema einrichten",
bannerBody: "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
bannerButton: "QR-Code anzeigen",
},
},
fr: {
pkg: {
description:
"Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
instructions:
"1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
disclaimer:
"Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur.",
},
channelHelp:
"Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
setup: {
title: "Ajouter l'assistant à Threema",
step1: "Ouvrez Threema sur votre téléphone.",
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
bannerTitle: "Configurer Threema",
bannerBody: "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
bannerButton: "Afficher le QR code",
},
},
it: {
pkg: {
description:
"Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
instructions:
"1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
disclaimer:
"I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali.",
},
channelHelp:
"Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
setup: {
title: "Aggiungi l'assistente a Threema",
step1: "Apri Threema sul tuo telefono.",
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
bannerTitle: "Configura Threema",
bannerBody: "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
bannerButton: "Mostra QR code",
},
},
};
for (const [lang, entries] of Object.entries(i18n)) {
const path = `src/messages/${lang}.json`;
const json = JSON.parse(readFileSync(path, "utf8"));
json.packages = json.packages ?? {};
json.packages.threema = {
description: entries.pkg.description,
instructions: entries.pkg.instructions,
disclaimer: entries.pkg.disclaimer,
};
json.channelUsers = json.channelUsers ?? {};
json.channelUsers.threemaIdHelp = entries.channelHelp;
json.channelUsers.threemaSetup = entries.setup;
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
console.log(`Patched ${path}`);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { backfillTenantBillingLifecycle } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/backfill
*
* One-off bootstrap that reads every live PiecedTenant CR and
* mirrors it into the Phase 1 billing tables:
* - tenant_billing_lifecycle.created_at ← CR's creationTimestamp
* - tenant_skill_events: one 'enabled' event per package in
* spec.packages, anchored at the CR's creationTimestamp
* - tenant_suspension_events: one 'suspended' event if the CR is
* currently suspended (anchored at status.suspendedAt)
*
* Idempotent — re-running is safe. The helper only inserts rows
* for tenants that have no lifecycle row / no events yet; running
* twice produces zero additional rows.
*
* Authorization: platform role only. The body of the request is
* ignored.
*
* Response: counts of rows inserted, mostly for sanity-checking
* (expect non-zero on first run, zero on subsequent runs).
*
* Phase 2 will surface this behind an admin UI button.
*/
export async function POST() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const tenants = await listTenants();
const result = await backfillTenantBillingLifecycle(
tenants.map((t) => ({
name: t.metadata.name,
// Tenants without the org label exist as a pre-Slice-3
// artifact; we still record them but with 'unknown' as the
// org id, which surfaces them in admin reports for manual
// labelling. Per-org billing computation skips rows with
// org id = 'unknown'.
zitadelOrgId:
t.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? "unknown",
createdAt: t.metadata.creationTimestamp
? new Date(t.metadata.creationTimestamp)
: new Date(),
packages: t.spec.packages ?? [],
suspendedAt: t.status?.suspendedAt
? new Date(t.status.suspendedAt)
: null,
}))
);
return NextResponse.json({
message: "Backfill complete.",
tenantsExamined: tenants.length,
...result,
});
} catch (e: any) {
console.error("Backfill failed:", e);
return NextResponse.json(
{ error: safeError(e, "Backfill failed") },
{ status: 500 }
);
}
}

View File

@@ -4,6 +4,9 @@ import {
getTenantRequestById,
updateTenantRequestStatus,
clearEncryptedSecrets,
recordTenantCreated,
recordSkillEvents,
recordSuspensionEvent,
} from "@/lib/db";
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
@@ -85,6 +88,23 @@ export async function POST(
}
try {
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
// Billing — Phase 1: record the resume so monthly proration
// counts the suspended segment correctly. Best-effort; if
// logging fails, the approval still succeeds.
try {
await recordSuspensionEvent(
tenantRequest.tenantName,
tenantRequest.zitadelOrgId,
"resumed"
);
} catch (e) {
console.error(
"billing: failed to record resumed suspension event:",
e
);
}
// Clear the annotation that pauses the operator's 60-day TTL.
// Best-effort — annotation cleanup is also done by the operator
// when it sees suspend=false on the next reconcile (it clears
@@ -199,6 +219,35 @@ export async function POST(
}
);
// Billing — Phase 1: record the tenant's creation and initial
// package state. Anchored at "now" rather than the CR's
// creationTimestamp because we don't get the timestamp back from
// createTenant — the few-millisecond skew vs the CR's actual
// creationTimestamp is irrelevant for monthly billing.
//
// Best-effort: tracking failures must never block provisioning.
// The backfill helper can repair any gaps later if needed.
const billingAnchor = new Date();
try {
await recordTenantCreated(
tenantName,
tenantRequest.zitadelOrgId,
billingAnchor
);
await recordSkillEvents(
tenantName,
tenantRequest.zitadelOrgId,
packages,
[],
billingAnchor
);
} catch (e) {
console.error(
"billing: failed to record tenant creation / initial skill events:",
e
);
}
// Step 5: Update request status — clear admin notes on re-approval
const updated = await updateTenantRequestStatus(id, "provisioning", {
adminNotes: isReApproval ? null : adminNotes,

View File

@@ -4,6 +4,7 @@ import { getTenant, deleteTenant } from "@/lib/k8s";
import {
markTenantRequestDeletedByTenantName,
removeAllAssignmentsForTenant,
recordTenantDeleted,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
@@ -49,6 +50,15 @@ export async function POST(
console.error("Failed to clean up tenant assignments:", e)
);
// Billing — Phase 1: stamp deletion timestamp on the lifecycle
// row so the final invoice covering the deletion month can
// prorate correctly. Idempotent at the DB layer; a missing
// lifecycle row (e.g. pre-Phase-1 tenants that haven't been
// backfilled yet) makes this a no-op.
await recordTenantDeleted(name).catch((e) =>
console.error("billing: failed to stamp tenant deletion:", e)
);
return NextResponse.json({
message: "Tenant deletion initiated. The operator will clean up all resources.",
});

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { recordSuspensionEvent } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
@@ -29,6 +30,32 @@ export async function POST(
try {
const updated = await patchTenantSpec(name, { suspend });
// Billing — Phase 1: record the transition. Mirrors the same
// hook in the customer-side suspend route so admin actions
// also produce events. Best-effort; logging failures don't
// block the response.
try {
const orgId =
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
await recordSuspensionEvent(
name,
orgId,
suspend ? "suspended" : "resumed"
);
} else {
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record suspension event for ${name}:`,
e
);
}
return NextResponse.json({
message: suspend ? "Tenant suspended." : "Tenant resumed.",
tenant: updated,

View File

@@ -3,6 +3,7 @@ import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { recordSkillEvents } from "@/lib/db";
import { safeError } from "@/lib/errors";
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
@@ -187,6 +188,50 @@ export async function PATCH(
}
const updated = await patchTenantSpec(name, specPatch);
// Billing — Phase 1: if packages changed, record enable/disable
// events. The diff is computed against the patched CR (the
// returned state) rather than `existing` so the events match
// what K8s actually committed. Best-effort: a logging failure
// never poisons the PATCH response — drift would be reconciled
// on the next backfill or by the next normal toggle.
//
// Note on races: two concurrent PATCHes could each see the
// same `existing` and both succeed at the K8s layer (last write
// wins for spec.packages, which is replaced wholesale). The
// events from the losing PATCH would then describe a transition
// that no longer reflects reality. Acceptable trade-off for v1
// — the toggle UI sends one request at a time and races would
// only matter for adjacent same-day toggles, which the billing
// computation collapses to a single billable day anyway.
if (specPatch.packages !== undefined) {
try {
const orgId =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
const oldSet = new Set<string>(existing.spec.packages ?? []);
const newSet = new Set<string>(updated.spec.packages ?? []);
const added = [...newSet].filter((x) => !oldSet.has(x));
const removed = [...oldSet].filter((x) => !newSet.has(x));
if (added.length > 0 || removed.length > 0) {
await recordSkillEvents(name, orgId, added, removed);
}
} else {
// A tenant without the org label is a pre-Slice-3 artifact
// — we can't attribute its skill events to any org. Log
// and skip rather than guess.
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; skill events not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record skill events for ${name}:`,
e
);
}
}
return NextResponse.json(updated);
} catch (e: any) {
return NextResponse.json(

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { recordSuspensionEvent } from "@/lib/db";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
@@ -101,6 +102,33 @@ export async function PATCH(
try {
await patchTenantSpec(name, { suspend });
// Billing — Phase 1: record the transition so monthly proration
// can exclude suspended days from the fixed fee. The portal
// commands this transition; the operator's status.suspendedAt
// lags by a reconcile cycle (seconds), which is irrelevant for
// monthly billing. Best-effort: a logging failure never blocks
// the suspend/resume itself.
try {
const orgId =
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
await recordSuspensionEvent(
name,
orgId,
suspend ? "suspended" : "resumed"
);
} else {
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record suspension event for ${name}:`,
e
);
}
// On admin-side resume, also clear the pending-resume-request
// annotation if it exists. Belt-and-suspenders: the admin-approve
// endpoint already clears it on its happy path, but a platform

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import {
writePackageSecrets,
deletePackageSecrets,
} from "@/lib/openbao";
import { mintToken, revokeToken } from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema package provisioning — special-cased because the credentials
* are platform-issued (relay mints them), not customer-supplied.
*
* POST /api/tenants/:name/threema
* - Mints a per-tenant bearer + HMAC secret from the central relay.
* - Writes both to OpenBao under
* secret/data/tenants/<tenant-{name}>/threema-relay so the
* operator's ExternalSecret can sync them into the tenant
* namespace alongside other channel secrets.
* - Returns 200 on success. The caller (PackageCard) then PATCHes
* tenant.spec.packages to add "threema".
*
* DELETE /api/tenants/:name/threema
* - Revokes the per-tenant token at the relay (cascades to all
* routes — the relay's tokens.deleteToken also deletes routes).
* - Deletes the OpenBao secret so the ExternalSecret/operator can
* converge cleanly.
* - Returns 200 on success even if no token existed (idempotent).
*
* Failure semantics
* -----------------
* On POST: if minting succeeds but the OpenBao write fails, we attempt
* to revoke the just-minted token before returning the error. That way
* the relay doesn't keep an orphan token row that nothing can use.
* Best-effort cleanup; if the revoke also fails, the relay admin can
* use DELETE /admin/tokens/<name> manually.
*
* On DELETE: we revoke FIRST, then delete OpenBao. If revoke fails we
* return the error and stop — leaving OpenBao alone means the pod's
* still-mounted secret keeps working in the brief window between
* "customer hits disable" and "operator reconciles spec without threema",
* which is more graceful than yanking the secret out from under a
* running pod.
*/
const VAULT_SUFFIX = "threema-relay";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canMutate(user))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const minted = await mintToken(name);
if (!minted.ok) {
return NextResponse.json(
{ error: `Relay mint failed: ${minted.message}` },
{ status: minted.kind === "http" ? 502 : 503 },
);
}
try {
await writePackageSecrets(`tenant-${name}`, VAULT_SUFFIX, {
token: minted.token,
"hmac-secret": minted.hmacSecret,
});
} catch (e) {
// Compensate: revoke the just-minted token so the relay doesn't
// hold an orphan. Best-effort — log and continue surfacing the
// original error.
const revoke = await revokeToken(name);
if (!revoke.ok) {
console.error(
`[threema/provision] Compensating revoke failed for ${name}: ${revoke.message}`,
);
}
return NextResponse.json(
{ error: `OpenBao write failed: ${safeError(e, "secret store unavailable")}` },
{ status: 503 },
);
}
return NextResponse.json({ ok: true });
} catch (e) {
console.error("[threema/provision]", e);
return NextResponse.json(
{ error: safeError(e, "Provisioning failed") },
{ status: 500 },
);
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canMutate(user))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const revoke = await revokeToken(name);
// 404 from relay = nothing to revoke = idempotent success.
if (!revoke.ok && !(revoke.kind === "http" && revoke.status === 404)) {
return NextResponse.json(
{ error: `Relay revoke failed: ${revoke.message}` },
{ status: revoke.kind === "http" ? 502 : 503 },
);
}
// Delete the OpenBao secret. Idempotent — deletePackageSecrets
// tolerates 404.
try {
await deletePackageSecrets(`tenant-${name}`, VAULT_SUFFIX);
} catch (e) {
// Already revoked at the relay — surface the openbao failure
// but keep the partial-success state visible.
return NextResponse.json(
{
error: `Token revoked, but OpenBao delete failed: ${safeError(e, "secret store unavailable")}`,
partial: true,
},
{ status: 503 },
);
}
return NextResponse.json({
ok: true,
deletedRoutes: revoke.ok ? revoke.deletedRoutes : 0,
});
} catch (e) {
console.error("[threema/deprovision]", e);
return NextResponse.json(
{ error: safeError(e, "Deprovisioning failed") },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import {
createRoute,
deleteRoute,
isRouteConflictForOtherTenant,
isRouteConflictForSameTenant,
listRoutes,
} from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema route management — keeps three places in sync:
*
* 1. Relay DB (`routes` table) — source of truth for uniqueness
* 2. K8s spec.channelUsers.threema — what the operator sees
* 3. Customer UI — derived from (2)
*
* Add (POST) order: relay first (to claim uniqueness atomically), then
* K8s. On K8s failure we compensate by deleting the relay route.
*
* Remove (DELETE) order: K8s first (UI shows it gone immediately, which
* is the customer-facing semantic that matters), then relay. On relay
* failure we DO NOT rollback K8s — the customer wanted it gone, and
* the relay's stale route will be cleaned up on the next retry (deletes
* are idempotent at the relay).
*
* Read-modify-write race: patchTenantSpec uses K8s merge-patch on
* spec.channelUsers.threema, which REPLACES the entire array. We GET
* the latest array, mutate it, then PATCH. Two concurrent adds from the
* same customer's tabs can lose one of them. Acceptable at pilot scale
* (single-digit customers, low concurrency); revisit with SSA + field
* managers if it ever bites.
*/
const ROUTE_BODY = z.object({
threemaId: z
.string()
.regex(/^[A-Z0-9]{8}$/, "Threema ID must be 8 uppercase alphanumeric chars (no asterisk)"),
});
// ---- helpers --------------------------------------------------------------
async function loadTenantOrError(name: string, user: Awaited<ReturnType<typeof getSessionUser>>) {
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
if (!canMutate(user))
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
const tenant = await getTenant(name);
if (!tenant)
return { error: NextResponse.json({ error: "Not found" }, { status: 404 }) };
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { tenant };
}
function currentThreemaIds(tenantSpec: any): string[] {
const cu = tenantSpec?.channelUsers ?? {};
const ids = cu.threema;
return Array.isArray(ids) ? ids.filter((x) => typeof x === "string") : [];
}
// ---- GET ------------------------------------------------------------------
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const res = await listRoutes(name);
if (!res.ok) {
return NextResponse.json(
{ error: `Relay list failed: ${res.message}` },
{ status: res.kind === "http" ? 502 : 503 },
);
}
return NextResponse.json({ routes: res.routes });
}
// ---- POST -----------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
const body = await req.json().catch(() => null);
const parsed = ROUTE_BODY.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
const threemaId = parsed.data.threemaId;
// Step 1: claim at the relay. Uniqueness is enforced here.
const claim = await createRoute(name, threemaId);
if (!claim.ok) {
if (isRouteConflictForOtherTenant(claim, name)) {
return NextResponse.json(
{ error: "This Threema ID is already registered to another tenant" },
{ status: 409 },
);
}
if (!isRouteConflictForSameTenant(claim, name)) {
// Genuine non-409 failure
return NextResponse.json(
{ error: `Relay create failed: ${claim.message}` },
{ status: claim.kind === "http" ? claim.status : 503 },
);
}
// Idempotent self-claim — continue to step 2 to ensure K8s mirrors it.
}
// Step 2: add to K8s spec.channelUsers.threema (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (!existing.includes(threemaId)) {
const next = [...existing, threemaId];
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
// Compensate: drop the relay route so we don't leave an orphan.
const compensate = await deleteRoute(name, threemaId);
if (!compensate.ok) {
console.error(
`[threema/routes] Compensating route delete failed for ${name}/${threemaId}: ${compensate.message}`,
);
}
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
return NextResponse.json({ ok: true, threemaId });
}
// ---- DELETE ---------------------------------------------------------------
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
// threemaId arrives as ?threemaId=... since DELETE bodies are uneven across clients.
const threemaId = new URL(req.url).searchParams.get("threemaId") ?? "";
const parsed = ROUTE_BODY.safeParse({ threemaId });
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
// Step 1: drop from K8s (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (existing.includes(parsed.data.threemaId)) {
const next = existing.filter((id) => id !== parsed.data.threemaId);
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
// Step 2: drop at relay (also idempotent — 404 is fine).
const dropped = await deleteRoute(name, parsed.data.threemaId);
if (!dropped.ok && !(dropped.kind === "http" && dropped.status === 404)) {
// K8s is already updated; surface but don't rollback. Next time the
// user toggles, both will converge.
return NextResponse.json(
{
ok: true,
threemaId: parsed.data.threemaId,
warning: `Removed from K8s but relay drop failed: ${dropped.message}`,
},
{ status: 200 },
);
}
return NextResponse.json({ ok: true, threemaId: parsed.data.threemaId });
}

View File

@@ -3,17 +3,32 @@
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { ThreemaQrModal } from "./threema-qr-modal";
/** Maps channel IDs to the instructions for finding the user ID. */
const CHANNEL_ID_HELP: Record<string, string> = {
telegram: "telegramIdHelp",
discord: "discordIdHelp",
threema: "threemaIdHelp",
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
// the `mail` skill (category=skill, not channel), so it never appears
// in `enabledChannels`. If a future channel is added to the catalog,
// give it an entry here so the help blurb renders.
};
/**
* Channels whose user list is managed through a dedicated endpoint
* instead of the generic PATCH /api/tenants/:name flow.
*
* Threema is the only one today — adding/removing a Threema ID has to
* synchronise with the central pieced-threema-gateway relay's `routes`
* table (uniqueness enforced there, not in K8s). The
* /api/tenants/:name/threema/routes endpoint owns that two-step
* coordination (relay first, then K8s, with compensation on K8s
* failure). Other channels just patch the K8s spec directly.
*/
const RELAY_MANAGED_CHANNELS = new Set(["threema"]);
interface ChannelUsersProps {
tenantName: string;
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
@@ -37,6 +52,14 @@ export function ChannelUsers({
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [channelUsers, setChannelUsers] =
useState<Record<string, string[]>>(initialChannelUsers);
/** Which channel's QR helper modal is open, if any. */
const [showQrFor, setShowQrFor] = useState<string | null>(null);
/**
* Tracks channels for which we've already auto-opened the helper
* modal on this page load. Prevents the modal from re-popping every
* time the user refocuses the input after dismissing it.
*/
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
const updateChannelUsers = useCallback(
async (updated: Record<string, string[]>) => {
@@ -63,6 +86,70 @@ export function ChannelUsers({
[tenantName, router]
);
/**
* Threema (and any future relay-managed channel) uses a dedicated
* endpoint that synchronises the central relay's routes table with
* the K8s spec atomically. We call it per-ID rather than sending the
* whole array because uniqueness is enforced ID-by-ID at the relay,
* and the error UX of "this ID is taken" is per-add.
*/
const addToRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${tenantName}/${channel}/routes`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threemaId }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Add failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
if (current.includes(threemaId)) return prev;
return { ...prev, [channel]: [...current, threemaId] };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const removeFromRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const url = `/api/tenants/${tenantName}/${channel}/routes?threemaId=${encodeURIComponent(threemaId)}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Remove failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
return { ...prev, [channel]: current.filter((id) => id !== threemaId) };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const handleAdd = useCallback(
(channel: string) => {
const userId = inputValues[channel]?.trim();
@@ -74,18 +161,27 @@ export function ChannelUsers({
return;
}
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
setInputValues((prev) => ({ ...prev, [channel]: "" }));
updateChannelUsers(updated);
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void addToRelayChannel(channel, userId);
} else {
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
updateChannelUsers(updated);
}
},
[channelUsers, inputValues, updateChannelUsers, t]
[channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
);
const handleRemove = useCallback(
(channel: string, userId: string) => {
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void removeFromRelayChannel(channel, userId);
return;
}
const current = channelUsers[channel] || [];
const updated = {
...channelUsers,
@@ -93,7 +189,7 @@ export function ChannelUsers({
};
updateChannelUsers(updated);
},
[channelUsers, updateChannelUsers]
[channelUsers, updateChannelUsers, removeFromRelayChannel]
);
if (enabledChannels.length === 0) return null;
@@ -137,6 +233,39 @@ export function ChannelUsers({
</span>
</div>
{channel === "threema" && (
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
<div className="flex items-start gap-2 flex-1">
<svg
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
/>
</svg>
<div className="text-xs text-text-secondary leading-relaxed">
<p className="font-medium text-text-primary mb-0.5">
{t("threemaSetup.bannerTitle")}
</p>
<p>{t("threemaSetup.bannerBody")}</p>
</div>
</div>
<button
onClick={() => setShowQrFor("threema")}
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
>
{t("threemaSetup.bannerButton")}
</button>
</div>
)}
{helpKey && (
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
{t(helpKey)}
@@ -179,6 +308,17 @@ export function ChannelUsers({
[channel]: e.target.value,
}))
}
onFocus={() => {
// For threema specifically, open the QR helper the
// first time the user clicks into the input on this
// page load. We don't repeat after dismiss — the
// "Show QR" button next to the channel name covers
// re-opens on demand.
if (channel === "threema" && !autoOpened.has("threema")) {
setShowQrFor("threema");
setAutoOpened((prev) => new Set(prev).add("threema"));
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
@@ -197,6 +337,11 @@ export function ChannelUsers({
</div>
);
})}
<ThreemaQrModal
open={showQrFor === "threema"}
onClose={() => setShowQrFor(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
interface ThreemaQrModalProps {
open: boolean;
onClose: () => void;
}
/**
* On-demand modal showing the QR for adding the assistant on Threema.
* Triggered by the "Show QR" button in the threema channel card and
* closes on overlay click, ESC, or the close button.
*
* Uses a plain <img> not next/image — image optimization adds nothing
* for a 57KB static PNG and removes a potential source of rendering
* bugs in the Next.js standalone build.
*/
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
const t = useTranslations("channelUsers.threemaSetup");
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-text-primary">
{t("title")}
</h3>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
width={220}
height={220}
style={{ display: "block" }}
/>
</div>
</div>
<p className="text-center text-xs text-text-muted font-mono">
{THREEMA_GATEWAY.displayName}
</p>
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
<li>{t("step1")}</li>
<li>{t("step2")}</li>
<li>{t("step3")}</li>
</ol>
</div>
</div>
);
}

View File

@@ -30,6 +30,26 @@ export function PackageCard({
const [error, setError] = useState<string | null>(null);
async function handleEnable() {
if (pkg.customProvisioning) {
// Platform-side provisioning, then add to packages list.
setSaving(true);
setError(null);
try {
const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "POST",
});
if (!provRes.ok) {
const err = await provRes.json().catch(() => ({}));
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
}
await togglePackage(true);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
return;
}
if (pkg.requiresSecrets) {
setShowModal(true);
setSecrets({});
@@ -40,6 +60,34 @@ export function PackageCard({
await togglePackage(true);
}
async function handleDisable() {
setSaving(true);
setError(null);
try {
if (pkg.customProvisioning) {
// Revoke platform-side credentials FIRST so the relay drops
// routes before the operator removes the channel from the
// OpenClaw config. Partial-success (token revoked, OpenBao
// delete failed) returns 503 with partial=true and we surface
// the error rather than continuing — the secret may still be
// valid in OpenBao and rolling back the relay revoke isn't
// possible (it cascaded to routes).
const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "DELETE",
});
if (!deprovRes.ok) {
const err = await deprovRes.json().catch(() => ({}));
throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`);
}
}
await togglePackage(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
async function togglePackage(enable: boolean) {
setSaving(true);
try {
@@ -124,7 +172,7 @@ export function PackageCard({
)}
{canEdit ? (
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled

View File

@@ -272,6 +272,252 @@ const MIGRATION_SQL = `
END $$;
CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket
ON support_ticket_comments(ticket_id, created_at);
-- =========================================================================
-- Billing — Phase 1: pricing, lifecycle, and events
-- =========================================================================
--
-- This block introduces the schema for the consolidated billing
-- subsystem. Phase 1 lands the schema + writes the lifecycle and
-- skill-event rows that downstream phases consume; invoice
-- generation, PDF rendering, Stripe wiring and reminders are added
-- in later phases. The invoice/line/reminder tables are created
-- here so all billing schema lives in a single migration block,
-- but no code writes to them until Phase 2.
--
-- Money columns: NUMERIC(10,2) for CHF amounts (max ~99 million.99
-- which is fine for the foreseeable future) and NUMERIC(10,5) for
-- per-unit prices (Threema messages and skill-days can have
-- sub-rappen unit prices — 0.00012 CHF/message is the kind of
-- granularity we want to keep precise across multiplication).
--
-- All timestamps are TIMESTAMPTZ. Billing-day computations use UTC
-- by convention (matches all other stored timestamps; avoids DST
-- ambiguity around month boundaries).
-- Single-row platform pricing config. The id=1 CHECK and explicit
-- DEFAULT 1 make accidental multi-row inserts impossible, and
-- callers can always SELECT * FROM platform_pricing WHERE id = 1.
-- (Could use a settings KV table; this shape is clearer for
-- typed columns that the admin UI binds to directly.)
CREATE TABLE IF NOT EXISTS platform_pricing (
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
tenant_monthly_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0,
tenant_setup_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0,
-- Single price per Threema message; the relay reports in+out
-- separately but the spec ("billed per Message") treats both
-- directions identically. If asymmetric pricing is needed
-- later, split into in/out columns — additive change.
threema_message_chf NUMERIC(10,5) NOT NULL DEFAULT 0,
-- Default VAT rate for CH/LI customers, as percent (e.g. 8.10).
-- Foreign customers' effective rate is computed at invoice time
-- from billing address + VAT number (reverse-charge logic).
vat_rate_chli NUMERIC(5,2) NOT NULL DEFAULT 8.10,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Ensure the single row exists. ON CONFLICT DO NOTHING is idempotent
-- on every migration run.
INSERT INTO platform_pricing (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Per-package optional daily price. Any package id can have a row;
-- the admin UI in Phase 2 will only expose skill-category packages
-- because that's what the spec called for, but nothing here
-- prevents pricing other categories later.
--
-- "skill" naming follows the spec ("custom pricing also for skills").
CREATE TABLE IF NOT EXISTS skill_pricing (
skill_id TEXT PRIMARY KEY,
daily_price_chf NUMERIC(10,5) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One row per tenant. created_at anchors first-month proration;
-- deleted_at (nullable, stamped on delete) anchors last-month
-- proration. The PiecedTenant CR is the source of truth for
-- existence, but once the CR is deleted we lose its
-- creationTimestamp — so we mirror those two bookends here.
CREATE TABLE IF NOT EXISTS tenant_billing_lifecycle (
tenant_name TEXT PRIMARY KEY,
zitadel_org_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_tenant_billing_lifecycle_org
ON tenant_billing_lifecycle(zitadel_org_id);
-- Skill enable/disable events. One row per state change; same-day
-- toggles still produce multiple rows, and the billing computation
-- collapses to distinct UTC days at compute time. This append-only
-- log preserves history for audit and lets us re-bill historical
-- months reproducibly.
--
-- skill_id is the package id from PACKAGE_CATALOG. We store
-- events for ALL package toggles, not just skill-category — the
-- channel/core toggles are cheap to record and may become billable
-- in the future without a schema change.
CREATE TABLE IF NOT EXISTS tenant_skill_events (
id BIGSERIAL PRIMARY KEY,
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
event_kind TEXT NOT NULL CHECK (event_kind IN ('enabled', 'disabled')),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_skill_events_tenant_skill
ON tenant_skill_events(tenant_name, skill_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_tenant_skill_events_org_time
ON tenant_skill_events(zitadel_org_id, occurred_at);
-- Suspend/resume transitions. Same shape as skill events. Reading
-- these in order reconstructs the suspended-state segments for
-- monthly fee proration: a tenant in 'suspended' state pays no
-- monthly fee for the days it was suspended.
--
-- The portal commands the transition (PATCH spec.suspend); the
-- operator observes and stamps PiecedTenantStatus.suspendedAt
-- after reconcile. We record the event at command time — billing
-- is monthly so the few-second reconcile lag is irrelevant.
CREATE TABLE IF NOT EXISTS tenant_suspension_events (
id BIGSERIAL PRIMARY KEY,
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
event_kind TEXT NOT NULL CHECK (event_kind IN ('suspended','resumed')),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_suspension_events_tenant
ON tenant_suspension_events(tenant_name, occurred_at);
-- Per-org billing configuration. Distinct from org_billing
-- (address/VAT/email): that table is customer-editable, this one
-- is admin-controlled and holds the payment posture.
--
-- Defaults: a new org has pay_by_invoice = false (must use a
-- credit card per the onboarding gate in Phase 4) and auto
-- billing/reminders enabled. Admin can flip pay_by_invoice on
-- per customer, after which approval no longer requires a card.
CREATE TABLE IF NOT EXISTS org_billing_config (
zitadel_org_id TEXT PRIMARY KEY,
pay_by_invoice BOOLEAN NOT NULL DEFAULT FALSE,
stripe_customer_id TEXT,
auto_invoice_enabled BOOLEAN NOT NULL DEFAULT TRUE,
auto_reminders_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Stripe payment methods. Populated by the Phase 4 webhook handler.
-- Created in Phase 1 so all billing schema is together; rows are
-- empty until Phase 4 ships.
CREATE TABLE IF NOT EXISTS org_payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL,
stripe_payment_method_id TEXT NOT NULL UNIQUE,
brand TEXT,
last4 TEXT,
exp_month INT,
exp_year INT,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_org_payment_methods_org
ON org_payment_methods(zitadel_org_id);
-- At most one default payment method per org. Partial unique index
-- so non-default rows don't conflict with each other.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_org_payment_methods_default
ON org_payment_methods(zitadel_org_id) WHERE is_default = TRUE;
-- Gapless per-year invoice number counter (Art. 957a OR
-- compliance). A Postgres SEQUENCE would be faster but allows
-- gaps on rollback; this counter table is SELECT FOR UPDATE-able
-- and produces gapless numbers when the invoice insert is in the
-- same transaction. Populated lazily — the first invoice of each
-- year inserts its row.
CREATE TABLE IF NOT EXISTS invoice_number_counters (
year INT PRIMARY KEY,
last_number INT NOT NULL DEFAULT 0
);
-- Issued invoices. Immutable once status leaves 'draft'.
-- billing_snapshot captures the address/VAT/email at issue time
-- so subsequent edits to org_billing don't mutate historical
-- invoices.
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_number TEXT NOT NULL UNIQUE,
zitadel_org_id TEXT NOT NULL,
-- Billing period as DATEs (not timestamps): a calendar month.
-- period_end is the last day of the month, inclusive.
period_start DATE NOT NULL,
period_end DATE NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
due_at DATE NOT NULL,
subtotal_chf NUMERIC(10,2) NOT NULL,
vat_rate NUMERIC(5,2) NOT NULL,
vat_amount_chf NUMERIC(10,2) NOT NULL,
total_chf NUMERIC(10,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (
status IN ('draft','open','paid','overdue','void','uncollectible')
),
billing_snapshot JSONB NOT NULL,
payment_method TEXT NOT NULL CHECK (payment_method IN ('invoice','card')),
stripe_payment_intent_id TEXT,
pdf_data BYTEA,
pdf_filename TEXT,
admin_notes TEXT,
paid_at TIMESTAMPTZ,
paid_by TEXT,
paid_method_detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_invoices_org
ON invoices(zitadel_org_id, issued_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_status
ON invoices(status, due_at);
-- One invoice per org per billing month — protects the monthly
-- cron from double-issuing if it gets retried mid-run.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
ON invoices(zitadel_org_id, period_start);
-- Invoice line items. The kind column lets the PDF renderer
-- group lines (all monthly fees together, all AI usage together,
-- etc.) and the admin UI filter by category.
CREATE TABLE IF NOT EXISTS invoice_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
-- NULL for org-wide items; tenant name for per-tenant breakdowns.
tenant_name TEXT,
kind TEXT NOT NULL CHECK (kind IN (
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','adjustment'
)),
description TEXT NOT NULL,
quantity NUMERIC(12,4) NOT NULL DEFAULT 1,
unit_label TEXT,
unit_price_chf NUMERIC(10,5) NOT NULL,
amount_chf NUMERIC(10,2) NOT NULL,
-- Per-kind audit metadata (e.g. {proration_days, days_in_month}
-- for tenant_monthly; {in_count, out_count} for threema_messages).
metadata JSONB,
display_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice
ON invoice_lines(invoice_id, display_order);
-- Reminders fired against open/overdue invoices. Level 3 = final.
-- One PDF per reminder, stored alongside.
CREATE TABLE IF NOT EXISTS invoice_reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
level INT NOT NULL CHECK (level IN (1, 2, 3)),
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_by TEXT NOT NULL,
pdf_data BYTEA,
pdf_filename TEXT,
email_sent_to TEXT,
UNIQUE (invoice_id, level)
);
CREATE INDEX IF NOT EXISTS idx_invoice_reminders_invoice
ON invoice_reminders(invoice_id, level);
`;
let migrated = false;
@@ -1336,3 +1582,545 @@ export async function updateSupportTicket(
);
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
}
// ---------------------------------------------------------------------------
// Billing — Phase 1: pricing, lifecycle, and skill events
// ---------------------------------------------------------------------------
//
// All helpers are intentionally narrow CRUD — no business logic.
// Higher-level operations (compute monthly proration, build an
// invoice from raw signals) belong in a future lib/billing.ts
// introduced by Phase 2.
//
// Hook callers should treat every write here as best-effort: if
// recording a lifecycle/event row fails, log and continue — never
// fail the underlying K8s mutation. Drift is corrected by the
// idempotent backfill helper at the bottom of this section.
import type {
PlatformPricing,
SkillPricing,
OrgBillingConfig,
TenantBillingLifecycle,
TenantSkillEvent,
TenantSuspensionEvent,
} from "@/types";
// --- platform_pricing ------------------------------------------------------
function rowToPlatformPricing(row: any): PlatformPricing {
return {
tenantMonthlyFeeChf: Number(row.tenant_monthly_fee_chf),
tenantSetupFeeChf: Number(row.tenant_setup_fee_chf),
threemaMessageChf: Number(row.threema_message_chf),
vatRateChli: Number(row.vat_rate_chli),
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
/**
* Read the single-row platform pricing config. The migration seeds
* the row with zeros on first run, so this never returns null on a
* properly migrated database.
*/
export async function getPlatformPricing(): Promise<PlatformPricing> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM platform_pricing WHERE id = 1"
);
if (result.rows.length === 0) {
// Defensive: re-seed if the row went missing (manual DELETE,
// partial restore, etc.). The migration's INSERT ON CONFLICT
// should have ensured this, but a stale row state shouldn't
// crash the helper.
await getPool().query(
"INSERT INTO platform_pricing (id) VALUES (1) ON CONFLICT DO NOTHING"
);
const retry = await getPool().query(
"SELECT * FROM platform_pricing WHERE id = 1"
);
return rowToPlatformPricing(retry.rows[0]);
}
return rowToPlatformPricing(result.rows[0]);
}
/**
* Update one or more pricing fields. Pass only the fields to change;
* unspecified fields are left as-is. `updated_at` is always refreshed.
*/
export async function updatePlatformPricing(changes: {
tenantMonthlyFeeChf?: number;
tenantSetupFeeChf?: number;
threemaMessageChf?: number;
vatRateChli?: number;
}): Promise<PlatformPricing> {
await ensureSchema();
const sets: string[] = ["updated_at = now()"];
const values: any[] = [];
let idx = 1;
if (changes.tenantMonthlyFeeChf !== undefined) {
sets.push(`tenant_monthly_fee_chf = $${idx++}`);
values.push(changes.tenantMonthlyFeeChf);
}
if (changes.tenantSetupFeeChf !== undefined) {
sets.push(`tenant_setup_fee_chf = $${idx++}`);
values.push(changes.tenantSetupFeeChf);
}
if (changes.threemaMessageChf !== undefined) {
sets.push(`threema_message_chf = $${idx++}`);
values.push(changes.threemaMessageChf);
}
if (changes.vatRateChli !== undefined) {
sets.push(`vat_rate_chli = $${idx++}`);
values.push(changes.vatRateChli);
}
// Only updated_at would change → still execute so the caller's
// intent ("touch the row") isn't silently dropped, but make it
// an explicit no-op if no fields were provided.
if (sets.length === 1 && values.length === 0) {
return getPlatformPricing();
}
const result = await getPool().query(
`UPDATE platform_pricing SET ${sets.join(", ")} WHERE id = 1 RETURNING *`,
values
);
return rowToPlatformPricing(result.rows[0]);
}
// --- skill_pricing ---------------------------------------------------------
function rowToSkillPricing(row: any): SkillPricing {
return {
skillId: row.skill_id,
dailyPriceChf: Number(row.daily_price_chf),
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
export async function listSkillPricing(): Promise<SkillPricing[]> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM skill_pricing ORDER BY skill_id"
);
return result.rows.map(rowToSkillPricing);
}
export async function getSkillPricing(
skillId: string
): Promise<SkillPricing | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM skill_pricing WHERE skill_id = $1",
[skillId]
);
return result.rows.length > 0 ? rowToSkillPricing(result.rows[0]) : null;
}
/**
* Upsert a daily price for a package. Setting a price activates
* usage-based billing for the (tenant, skill) pair: every UTC day
* the package was enabled in the billing month is one unit on the
* invoice.
*/
export async function setSkillPricing(
skillId: string,
dailyPriceChf: number
): Promise<SkillPricing> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO skill_pricing (skill_id, daily_price_chf)
VALUES ($1, $2)
ON CONFLICT (skill_id) DO UPDATE SET
daily_price_chf = EXCLUDED.daily_price_chf,
updated_at = now()
RETURNING *`,
[skillId, dailyPriceChf]
);
return rowToSkillPricing(result.rows[0]);
}
/**
* Remove the price for a package. Day-tracking events continue to
* be recorded (cheap append-only) but the package becomes free
* effective immediately. Historical invoices already issued are
* unaffected.
*/
export async function removeSkillPricing(skillId: string): Promise<void> {
await ensureSchema();
await getPool().query("DELETE FROM skill_pricing WHERE skill_id = $1", [
skillId,
]);
}
// --- tenant_billing_lifecycle ---------------------------------------------
function rowToTenantBillingLifecycle(row: any): TenantBillingLifecycle {
return {
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
deletedAt: row.deleted_at?.toISOString?.() ?? row.deleted_at ?? null,
};
}
export async function getTenantBillingLifecycle(
tenantName: string
): Promise<TenantBillingLifecycle | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_billing_lifecycle WHERE tenant_name = $1",
[tenantName]
);
return result.rows.length > 0
? rowToTenantBillingLifecycle(result.rows[0])
: null;
}
/**
* Record a tenant's creation for billing purposes. Idempotent on
* `tenant_name` — re-running with a different created_at is a no-op
* so re-approvals don't move the proration anchor. Pair with
* recordInitialSkillEvents() at the same call site.
*/
export async function recordTenantCreated(
tenantName: string,
zitadelOrgId: string,
createdAt?: Date
): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO tenant_billing_lifecycle (tenant_name, zitadel_org_id, created_at)
VALUES ($1, $2, COALESCE($3::timestamptz, now()))
ON CONFLICT (tenant_name) DO NOTHING`,
[tenantName, zitadelOrgId, createdAt ?? null]
);
}
/**
* Stamp deletion timestamp. Idempotent — calling twice keeps the
* first deletion's timestamp. Pair with closing-skill-disabled
* events at the same call site if you want the events log to
* reflect that everything is now off.
*
* We deliberately don't delete the row — the lifecycle record is
* needed for any final invoice covering the deletion month.
*/
export async function recordTenantDeleted(
tenantName: string,
deletedAt?: Date
): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE tenant_billing_lifecycle
SET deleted_at = COALESCE(deleted_at, COALESCE($2::timestamptz, now()))
WHERE tenant_name = $1`,
[tenantName, deletedAt ?? null]
);
}
// --- tenant_skill_events --------------------------------------------------
function rowToTenantSkillEvent(row: any): TenantSkillEvent {
return {
id: String(row.id),
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
skillId: row.skill_id,
eventKind: row.event_kind as "enabled" | "disabled",
occurredAt: row.occurred_at?.toISOString?.() ?? row.occurred_at,
};
}
/**
* Append a batch of enabled/disabled events. Single multi-row INSERT
* so all rows share an effectively-identical occurred_at when
* `occurredAt` is omitted (otherwise they'd be a few microseconds
* apart, which would skew the "is this skill on for day D" computation
* around midnight UTC).
*
* Empty arrays are a no-op (no SQL fired).
*/
export async function recordSkillEvents(
tenantName: string,
zitadelOrgId: string,
added: string[],
removed: string[],
occurredAt?: Date
): Promise<void> {
if (added.length === 0 && removed.length === 0) return;
await ensureSchema();
const at = occurredAt ?? new Date();
// Build placeholders. Each row uses 5 placeholders.
const values: any[] = [];
const rows: string[] = [];
let idx = 1;
for (const skillId of added) {
rows.push(`($${idx++}, $${idx++}, $${idx++}, 'enabled', $${idx++})`);
values.push(tenantName, zitadelOrgId, skillId, at);
}
for (const skillId of removed) {
rows.push(`($${idx++}, $${idx++}, $${idx++}, 'disabled', $${idx++})`);
values.push(tenantName, zitadelOrgId, skillId, at);
}
await getPool().query(
`INSERT INTO tenant_skill_events
(tenant_name, zitadel_org_id, skill_id, event_kind, occurred_at)
VALUES ${rows.join(", ")}`,
values
);
}
/**
* Read events for a tenant within a half-open interval — `from`
* inclusive, `to` exclusive. Used by Phase 2's billing computation
* to collapse to billable days. Returned in chronological order.
*/
export async function listSkillEventsForTenant(
tenantName: string,
from: Date,
to: Date
): Promise<TenantSkillEvent[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_skill_events
WHERE tenant_name = $1
AND occurred_at >= $2
AND occurred_at < $3
ORDER BY occurred_at, id`,
[tenantName, from, to]
);
return result.rows.map(rowToTenantSkillEvent);
}
/**
* Read the most recent event for each (tenant, skill) pair as of
* a moment in time. Phase 2 uses this to know which skills were
* enabled at the start of a billing window.
*
* Implemented with DISTINCT ON for a single round-trip; the
* (tenant_name, skill_id, occurred_at) index supports the sort.
*/
export async function getSkillStateAt(
tenantName: string,
asOf: Date
): Promise<Record<string, "enabled" | "disabled">> {
await ensureSchema();
const result = await getPool().query(
`SELECT DISTINCT ON (skill_id) skill_id, event_kind
FROM tenant_skill_events
WHERE tenant_name = $1 AND occurred_at <= $2
ORDER BY skill_id, occurred_at DESC, id DESC`,
[tenantName, asOf]
);
const out: Record<string, "enabled" | "disabled"> = {};
for (const row of result.rows) {
out[row.skill_id] = row.event_kind as "enabled" | "disabled";
}
return out;
}
// --- tenant_suspension_events ---------------------------------------------
function rowToTenantSuspensionEvent(row: any): TenantSuspensionEvent {
return {
id: String(row.id),
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
eventKind: row.event_kind as "suspended" | "resumed",
occurredAt: row.occurred_at?.toISOString?.() ?? row.occurred_at,
};
}
export async function recordSuspensionEvent(
tenantName: string,
zitadelOrgId: string,
eventKind: "suspended" | "resumed",
occurredAt?: Date
): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO tenant_suspension_events
(tenant_name, zitadel_org_id, event_kind, occurred_at)
VALUES ($1, $2, $3, COALESCE($4::timestamptz, now()))`,
[tenantName, zitadelOrgId, eventKind, occurredAt ?? null]
);
}
export async function listSuspensionEventsForTenant(
tenantName: string,
from: Date,
to: Date
): Promise<TenantSuspensionEvent[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_suspension_events
WHERE tenant_name = $1
AND occurred_at >= $2
AND occurred_at < $3
ORDER BY occurred_at, id`,
[tenantName, from, to]
);
return result.rows.map(rowToTenantSuspensionEvent);
}
// --- org_billing_config ---------------------------------------------------
function rowToOrgBillingConfig(row: any): OrgBillingConfig {
return {
zitadelOrgId: row.zitadel_org_id,
payByInvoice: row.pay_by_invoice,
stripeCustomerId: row.stripe_customer_id ?? null,
autoInvoiceEnabled: row.auto_invoice_enabled,
autoRemindersEnabled: row.auto_reminders_enabled,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
/**
* Get config for an org, auto-creating with defaults if missing.
* Returning the row (vs null) simplifies callers: the gate logic in
* Phase 4 ("approve only if pay_by_invoice OR has card") doesn't
* need a "what if no row" branch.
*
* Defaults are baked into the table's column defaults, so the
* INSERT here only needs the primary key.
*/
export async function getOrgBillingConfig(
zitadelOrgId: string
): Promise<OrgBillingConfig> {
await ensureSchema();
await getPool().query(
`INSERT INTO org_billing_config (zitadel_org_id)
VALUES ($1)
ON CONFLICT (zitadel_org_id) DO NOTHING`,
[zitadelOrgId]
);
const result = await getPool().query(
"SELECT * FROM org_billing_config WHERE zitadel_org_id = $1",
[zitadelOrgId]
);
return rowToOrgBillingConfig(result.rows[0]);
}
export async function updateOrgBillingConfig(
zitadelOrgId: string,
changes: {
payByInvoice?: boolean;
stripeCustomerId?: string | null;
autoInvoiceEnabled?: boolean;
autoRemindersEnabled?: boolean;
}
): Promise<OrgBillingConfig> {
await ensureSchema();
// Ensure row exists first — mirrors getOrgBillingConfig's
// auto-create.
await getPool().query(
`INSERT INTO org_billing_config (zitadel_org_id)
VALUES ($1)
ON CONFLICT (zitadel_org_id) DO NOTHING`,
[zitadelOrgId]
);
const sets: string[] = ["updated_at = now()"];
const values: any[] = [zitadelOrgId];
let idx = 2;
if (changes.payByInvoice !== undefined) {
sets.push(`pay_by_invoice = $${idx++}`);
values.push(changes.payByInvoice);
}
if (changes.stripeCustomerId !== undefined) {
sets.push(`stripe_customer_id = $${idx++}`);
values.push(changes.stripeCustomerId);
}
if (changes.autoInvoiceEnabled !== undefined) {
sets.push(`auto_invoice_enabled = $${idx++}`);
values.push(changes.autoInvoiceEnabled);
}
if (changes.autoRemindersEnabled !== undefined) {
sets.push(`auto_reminders_enabled = $${idx++}`);
values.push(changes.autoRemindersEnabled);
}
const result = await getPool().query(
`UPDATE org_billing_config
SET ${sets.join(", ")}
WHERE zitadel_org_id = $1
RETURNING *`,
values
);
return rowToOrgBillingConfig(result.rows[0]);
}
// --- Backfill -------------------------------------------------------------
//
// Idempotent one-time bootstrap for tenants that existed before
// Phase 1 shipped. Phase 2 wraps this in an admin endpoint; for now
// it can be invoked from a one-off node script.
//
// For each PiecedTenant CR:
// - If no tenant_billing_lifecycle row exists, insert one with
// created_at = metadata.creationTimestamp.
// - If no tenant_skill_events row exists for the tenant, insert
// 'enabled' events at the same timestamp for every package
// currently in spec.packages.
// Tenants suspended at backfill time get a 'suspended' event at
// status.suspendedAt (operator-stamped); resumed tenants get nothing
// extra (default state is "running").
/**
* Returns a count of lifecycle rows inserted and skill events
* recorded — both expected to be zero on a second run.
*/
export async function backfillTenantBillingLifecycle(tenants: {
name: string;
zitadelOrgId: string;
createdAt: Date;
packages: string[];
suspendedAt: Date | null;
}[]): Promise<{ lifecycleInserted: number; eventsInserted: number; suspensionEventsInserted: number }> {
await ensureSchema();
let lifecycleInserted = 0;
let eventsInserted = 0;
let suspensionEventsInserted = 0;
for (const t of tenants) {
// Lifecycle row — idempotent.
const existing = await getTenantBillingLifecycle(t.name);
if (!existing) {
await recordTenantCreated(t.name, t.zitadelOrgId, t.createdAt);
lifecycleInserted++;
}
// Initial skill events — only if the tenant has zero events at
// all. We don't want to add to an active event stream.
const eventsRow = await getPool().query(
"SELECT 1 FROM tenant_skill_events WHERE tenant_name = $1 LIMIT 1",
[t.name]
);
if (eventsRow.rows.length === 0 && t.packages.length > 0) {
await recordSkillEvents(
t.name,
t.zitadelOrgId,
t.packages,
[],
t.createdAt
);
eventsInserted += t.packages.length;
}
// Suspension state — only if the tenant has zero suspension
// events. If it's currently suspended, record one 'suspended'
// event at the operator-stamped time so proration sees it.
const susRow = await getPool().query(
"SELECT 1 FROM tenant_suspension_events WHERE tenant_name = $1 LIMIT 1",
[t.name]
);
if (susRow.rows.length === 0 && t.suspendedAt) {
await recordSuspensionEvent(
t.name,
t.zitadelOrgId,
"suspended",
t.suspendedAt
);
suspensionEventsInserted++;
}
}
return { lifecycleInserted, eventsInserted, suspensionEventsInserted };
}

View File

@@ -19,6 +19,17 @@
* pieced-tts-talk).
* - channel — messaging integration.
* - skill — ClawHub skill install.
*
* Custom provisioning (Threema):
* The `threema` channel sets `requiresSecrets: false` because its
* credentials are platform-issued, not customer-entered. Enabling
* threema goes through a dedicated endpoint
* (/api/tenants/:name/threema) that mints token + HMAC secret from
* the central pieced-threema-gateway relay and writes them to OpenBao
* at secret/data/tenants/<name>/threema-relay before the package is
* added to spec.packages. Disabling reverses both steps. The
* `customProvisioning` flag here tells the package-card UI to use
* that endpoint instead of the standard /secrets+PATCH dance.
*/
export interface PackageSecretField {
@@ -38,6 +49,14 @@ export interface PackageDef {
instructionsKey?: string;
disclaimerKey?: string;
category: PackageCategory;
/**
* When true, enabling/disabling this package goes through
* /api/tenants/:name/<id> (POST/DELETE) instead of the generic
* /secrets+PATCH flow. The handler at that path does platform-side
* provisioning (mint credentials, register with sibling services, etc.)
* that the customer is not aware of.
*/
customProvisioning?: boolean;
}
export const PACKAGE_CATALOG: PackageDef[] = [
@@ -121,6 +140,21 @@ export const PACKAGE_CATALOG: PackageDef[] = [
disclaimerKey: "packages.discord.disclaimer",
category: "channel",
},
{
id: "threema",
name: "Threema",
descriptionKey: "packages.threema.description",
// No customer-entered secrets. The token + hmac secret are minted
// server-side by the relay's /admin/tokens endpoint when the
// package is enabled, and stored in OpenBao by the portal. The
// `customProvisioning` flag steers the PackageCard UI through the
// dedicated /api/tenants/:name/threema endpoint instead.
requiresSecrets: false,
customProvisioning: true,
instructionsKey: "packages.threema.instructions",
disclaimerKey: "packages.threema.disclaimer",
category: "channel",
},
// -------------------------------------------------------------------------
// SKILLS

View File

@@ -0,0 +1,31 @@
/**
* Threema central gateway info shown to customers.
*
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
* every tenant talks to that. The constants below are hardcoded for
* that account. The values are intentionally kept here (and not split
* across i18n / env / runtime config) so when we move to multiple
* gateway accounts there's a single file to refactor.
*
* To go dynamic (future):
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
* either per-tenant from the relay's admin API, or from an
* env var that lists the active account.
* 2. Move the QR PNG into a server-rendered route that takes a
* gateway ID query param.
* 3. Update consumers to accept the gateway info as a prop and pass
* it from a server component.
*/
export const THREEMA_GATEWAY = {
/** Technical Threema Gateway ID, with leading asterisk. */
id: "*AIAGENT",
/**
* Display name shown to customers. INCLUDES the leading asterisk —
* customers need to recognise this exact string in their Threema
* contacts after scanning the QR, so we don't strip it.
*/
displayName: "*AIAGENT",
/** Public path to the QR code PNG served from `public/`. */
qrCodePath: "/threema/qr_code_AIAGENT.png",
} as const;

202
src/lib/threema-relay.ts Normal file
View File

@@ -0,0 +1,202 @@
/**
* Admin client for the central pieced-threema-gateway relay.
*
* All calls authenticate with a static admin bearer (THREEMA_RELAY_ADMIN_TOKEN
* from env, sourced from OpenBao at secret/data/threema-gateway/admin). The
* portal is the only caller; never expose these functions through a public
* HTTP surface.
*
* Resilience: every method returns a strongly-typed result rather than
* throwing. Network errors surface as `{ ok: false, kind: 'network' }`,
* 4xx/5xx as `{ ok: false, kind: 'http', status, ... }`. Callers that need
* to compensate (e.g. delete a route after a K8s patch failure) can
* inspect `kind` to decide whether retry is sensible.
*/
const RELAY_URL = (process.env.THREEMA_RELAY_URL ?? "").replace(/\/+$/, "");
const ADMIN_TOKEN = process.env.THREEMA_RELAY_ADMIN_TOKEN ?? "";
function assertConfigured(): void {
if (!RELAY_URL) throw new Error("THREEMA_RELAY_URL not set");
if (!ADMIN_TOKEN) throw new Error("THREEMA_RELAY_ADMIN_TOKEN not set");
}
type RelayError =
| { ok: false; kind: "network"; message: string }
| { ok: false; kind: "http"; status: number; message: string; body?: unknown };
export type RelayResult<T> = ({ ok: true } & T) | RelayError;
async function call<T>(
path: string,
init: RequestInit,
): Promise<RelayResult<T>> {
assertConfigured();
try {
const res = await fetch(`${RELAY_URL}${path}`, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${ADMIN_TOKEN}`,
"Content-Type": "application/json",
},
});
let body: unknown = null;
const text = await res.text().catch(() => "");
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const message =
(body && typeof body === "object" && "error" in body && typeof (body as Record<string, unknown>).error === "string"
? ((body as Record<string, unknown>).error as string)
: `HTTP ${res.status}`);
return { ok: false, kind: "http", status: res.status, message, body };
}
return { ok: true, ...(body as Record<string, unknown>) } as RelayResult<T>;
} catch (e) {
return { ok: false, kind: "network", message: (e as Error).message };
}
}
// ---------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------
/** Mint (or rotate) per-tenant bearer + HMAC secret. */
export function mintToken(
tenantName: string,
): Promise<RelayResult<{ token: string; hmacSecret: string }>> {
return call("/admin/tokens", {
method: "POST",
body: JSON.stringify({ tenantName }),
});
}
/**
* Revoke per-tenant token. Cascades — relay also deletes all routes
* owned by this tenant.
*/
export function revokeToken(
tenantName: string,
): Promise<RelayResult<{ deletedRoutes: number }>> {
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
method: "DELETE",
});
}
export function tokenExists(
tenantName: string,
): Promise<RelayResult<{ exists: true }>> {
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
method: "GET",
});
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
export interface RouteRow {
threema_id: string;
tenant_name: string;
created_at: string;
created_by: string;
}
export function listRoutes(
tenantName: string,
): Promise<RelayResult<{ routes: RouteRow[] }>> {
return call(`/admin/routes?tenant=${encodeURIComponent(tenantName)}`, {
method: "GET",
});
}
/**
* Create a route. The relay enforces uniqueness — on conflict returns
* status 409 with body `{ ownedBy: <tenantName> | null }`. Callers
* should inspect `kind === 'http'` + status 409 + body.ownedBy to
* distinguish idempotent self-claim from real conflict.
*/
export function createRoute(
tenantName: string,
threemaId: string,
): Promise<RelayResult<{ ok: true }>> {
return call("/admin/routes", {
method: "POST",
body: JSON.stringify({ tenantName, threemaId }),
});
}
export function deleteRoute(
tenantName: string,
threemaId: string,
): Promise<RelayResult<{ ok: true }>> {
return call(
`/admin/routes/${encodeURIComponent(threemaId)}?tenant=${encodeURIComponent(tenantName)}`,
{ method: "DELETE" },
);
}
// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------
export interface UsageBreakdown {
tenant: string;
from: string;
to: string;
totals: { in: number; out: number };
daily: Array<{ day: string; direction: "in" | "out"; count: number }>;
}
export function getUsage(
tenantName: string,
from?: Date,
to?: Date,
): Promise<RelayResult<UsageBreakdown>> {
const qs = new URLSearchParams({ tenant: tenantName });
if (from) qs.set("from", from.toISOString());
if (to) qs.set("to", to.toISOString());
return call(`/admin/usage?${qs.toString()}`, { method: "GET" });
}
// ---------------------------------------------------------------------------
// Health
// ---------------------------------------------------------------------------
export function health(): Promise<RelayResult<{ credits: number }>> {
return call("/admin/health", { method: "GET" });
}
// ---------------------------------------------------------------------------
// Helpers for caller code
// ---------------------------------------------------------------------------
/**
* Type guard: did this route-create fail because the ID is owned by
* someone else? Distinguishes from "owned by SAME tenant" (idempotent).
*/
export function isRouteConflictForOtherTenant(
result: RelayResult<{ ok: true }>,
tenantName: string,
): boolean {
if (result.ok) return false;
if (result.kind !== "http" || result.status !== 409) return false;
const body = result.body as { ownedBy?: string | null } | undefined;
return !!body?.ownedBy && body.ownedBy !== tenantName;
}
export function isRouteConflictForSameTenant(
result: RelayResult<{ ok: true }>,
tenantName: string,
): boolean {
if (result.ok) return false;
if (result.kind !== "http" || result.status !== 409) return false;
const body = result.body as { ownedBy?: string | null } | undefined;
return body?.ownedBy === tenantName;
}

View File

@@ -306,6 +306,11 @@
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Für Gmail: Aktivieren Sie die 2-Faktor-Authentifizierung, erstellen Sie dann unter https://myaccount.google.com/apppasswords ein App-Passwort und verwenden Sie es als IMAP- und SMTP-Passwort.\n2. Für Outlook / Microsoft 365 mit MFA: Generieren Sie ein App-Passwort in den Sicherheitseinstellungen Ihres Kontos.\n3. Für andere Anbieter: Konsultieren Sie deren IMAP/SMTP-Dokumentation für Hostnamen und Ports.\n4. Typische IMAP-Hosts: imap.gmail.com, outlook.office365.com.\n5. Typische SMTP-Hosts: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "Der Assistent erhält Lese- und Schreibzugriff auf das von Ihnen konfigurierte Postfach. Verwenden Sie eine dedizierte Adresse anstelle eines persönlichen Postfachs, wenn Sie den Umfang einschränken möchten."
},
"threema": {
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
}
},
"admin": {
@@ -390,7 +395,18 @@
"remove": "Entfernen",
"alreadyAdded": "Diese Benutzer-ID ist bereits autorisiert.",
"telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein"
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
"threemaIdHelp": "Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
"threemaSetup": {
"title": "Assistenten zu Threema hinzufügen",
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
"bannerTitle": "Threema einrichten",
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
"bannerButton": "QR-Code anzeigen"
}
},
"team": {
"title": "Team",

View File

@@ -306,6 +306,11 @@
"smtpPassPlaceholder": "••••••••",
"instructions": "1. For Gmail: enable 2-Step Verification, then create an App Password at https://myaccount.google.com/apppasswords and use it as both IMAP and SMTP password.\n2. For Outlook / Microsoft 365 with MFA: generate an app password in your account's security settings.\n3. For other providers: refer to their IMAP/SMTP documentation for host names and ports.\n4. Typical IMAP hosts: imap.gmail.com, outlook.office365.com.\n5. Typical SMTP hosts: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "The assistant gains read/write access to the mailbox you configure. Consider using a dedicated address rather than a personal inbox if you want to limit scope."
},
"threema": {
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
}
},
"admin": {
@@ -390,7 +395,18 @@
"remove": "Remove",
"alreadyAdded": "This user ID is already authorized.",
"telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here"
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
"threemaIdHelp": "Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
"threemaSetup": {
"title": "Add the assistant to your Threema",
"step1": "Open Threema on your phone.",
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
"step3": "Then add your own Threema ID below.",
"qrAlt": "QR code to add {gateway} as a Threema contact",
"bannerTitle": "Set up Threema",
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
"bannerButton": "Show QR code"
}
},
"team": {
"title": "Team",

View File

@@ -306,6 +306,11 @@
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Pour Gmail : activez la validation en deux étapes, puis créez un mot de passe d'application sur https://myaccount.google.com/apppasswords et utilisez-le comme mot de passe IMAP et SMTP.\n2. Pour Outlook / Microsoft 365 avec MFA : générez un mot de passe d'application dans les paramètres de sécurité de votre compte.\n3. Pour les autres fournisseurs : consultez leur documentation IMAP/SMTP pour les noms d'hôte et les ports.\n4. Hôtes IMAP typiques : imap.gmail.com, outlook.office365.com.\n5. Hôtes SMTP typiques : smtp.gmail.com, smtp.office365.com.",
"disclaimer": "L'assistant obtient un accès en lecture/écriture à la boîte aux lettres que vous configurez. Envisagez d'utiliser une adresse dédiée plutôt qu'une boîte personnelle si vous souhaitez limiter la portée."
},
"threema": {
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
}
},
"admin": {
@@ -390,7 +395,18 @@
"remove": "Supprimer",
"alreadyAdded": "Cet identifiant est déjà autorisé.",
"telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici"
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
"threemaIdHelp": "Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
"threemaSetup": {
"title": "Ajouter l'assistant à Threema",
"step1": "Ouvrez Threema sur votre téléphone.",
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
"bannerTitle": "Configurer Threema",
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
"bannerButton": "Afficher le QR code"
}
},
"team": {
"title": "Équipe",

View File

@@ -306,6 +306,11 @@
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Per Gmail: abilita la verifica in due passaggi, quindi crea una password per app su https://myaccount.google.com/apppasswords e usala come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: genera una password per app nelle impostazioni di sicurezza del tuo account.\n3. Per altri provider: consulta la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuoi limitare la portata."
},
"threema": {
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
}
},
"admin": {
@@ -390,7 +395,18 @@
"remove": "Rimuovi",
"alreadyAdded": "Questo ID utente è già autorizzato.",
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui"
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
"threemaIdHelp": "Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
"threemaSetup": {
"title": "Aggiungi l'assistente a Threema",
"step1": "Apri Threema sul tuo telefono.",
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
"bannerTitle": "Configura Threema",
"bannerBody": "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
"bannerButton": "Mostra QR code"
}
},
"team": {
"title": "Team",

View File

@@ -40,5 +40,10 @@ export default async function middleware(request: NextRequest) {
}
export const config = {
matcher: ["/((?!_next|favicon.ico|api).*)"],
// Excludes _next/* internal routes, the favicon, api routes, AND any
// path containing a dot (covers all static files served from public/,
// e.g. /threema/qr_code_AIAGENT.png). Without the dot exclusion, the
// i18n middleware prepends the locale ("/en/threema/qr_code_AIAGENT.png")
// and the file is not found.
matcher: ["/((?!_next|favicon.ico|api|.*\\..*).*)"],
};

View File

@@ -412,3 +412,111 @@ export interface SupportTicketDetail {
ticket: SupportTicket;
comments: SupportTicketComment[];
}
// ---------------------------------------------------------------------------
// Billing — Phase 1: pricing, lifecycle, and events
// ---------------------------------------------------------------------------
//
// All money values are numbers (CHF). The DB stores NUMERIC and the
// helpers coerce to Number on read. JS floats are exact for integers
// up to 2^53; at this domain (CHF amounts to 2 decimals, unit prices
// to 5 decimals) precision is fine. The Phase 2 billing computation
// will still do arithmetic carefully (sum in cents, round at the
// end) to avoid 0.1 + 0.2 surprises.
/**
* Single-row platform pricing config. Editable via the admin
* pricing page (Phase 2). The `vatRateChli` field is the rate
* applied to invoices whose billing address resolves to CH/LI;
* foreign customers' rates are decided per-invoice from address +
* VAT number, not from this config.
*/
export interface PlatformPricing {
tenantMonthlyFeeChf: number;
tenantSetupFeeChf: number;
threemaMessageChf: number;
vatRateChli: number;
updatedAt: string;
}
/**
* Per-package daily price. Phase 2's admin UI restricts setting
* these to skill-category packages, but the schema accepts any
* package id. A row's existence is what activates billing for that
* package; deleting the row makes it free without affecting the
* append-only event log.
*/
export interface SkillPricing {
skillId: string;
dailyPriceChf: number;
createdAt: string;
updatedAt: string;
}
/**
* Tenant lifecycle bookends mirrored from K8s into Postgres so
* deleted tenants still have a billing record for their final
* invoice. `createdAt` matches PiecedTenant.metadata.creationTimestamp
* at the moment of approval; `deletedAt` is stamped when the admin
* delete endpoint runs.
*/
export interface TenantBillingLifecycle {
tenantName: string;
zitadelOrgId: string;
createdAt: string;
deletedAt: string | null;
}
/**
* Append-only enable/disable event for a package on a tenant.
* Phase 2's billing computation reads the event stream within the
* billing window and collapses it to a set of UTC days during
* which the package was active.
*/
export interface TenantSkillEvent {
id: string;
tenantName: string;
zitadelOrgId: string;
skillId: string;
eventKind: "enabled" | "disabled";
occurredAt: string;
}
/**
* Append-only suspend/resume event. Recorded by the portal at
* command time (when PATCH spec.suspend lands), not at operator
* reconcile time. The few-second delta is irrelevant for monthly
* billing.
*/
export interface TenantSuspensionEvent {
id: string;
tenantName: string;
zitadelOrgId: string;
eventKind: "suspended" | "resumed";
occurredAt: string;
}
/**
* Per-org billing posture and Stripe linkage. Distinct from
* OrgBilling (which is the customer-editable address/VAT block):
* this one is admin-controlled.
*
* `payByInvoice` flips the onboarding gate (Phase 4): when true,
* tenant requests for this org are approvable without a card on
* file. When false, the customer must have a validated Stripe
* payment method before admin approval is allowed.
*
* `stripeCustomerId` is populated by Phase 4's onboarding flow.
* `autoInvoiceEnabled` / `autoRemindersEnabled` give admin per-org
* kill switches for the Phase 6 cron without disabling the cron
* globally.
*/
export interface OrgBillingConfig {
zitadelOrgId: string;
payByInvoice: boolean;
stripeCustomerId: string | null;
autoInvoiceEnabled: boolean;
autoRemindersEnabled: boolean;
createdAt: string;
updatedAt: string;
}