diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index 0a73092..6806811 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -4,14 +4,12 @@ import { getTenantRequestById, updateTenantRequestStatus, clearEncryptedSecrets, - recordTenantCreated, - recordSkillEvents, - recordSuspensionEvent, } from "@/lib/db"; import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s"; import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email"; import { decryptSecrets } from "@/lib/crypto"; import { writePackageSecrets } from "@/lib/openbao"; +import { createRoute as createRelayRoute } from "@/lib/threema-relay"; import { getDefaultSoulMd, getDefaultAgentsMd, @@ -88,23 +86,6 @@ 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 @@ -197,6 +178,29 @@ export async function POST( ? tenantRequest.contactName || "Assistant" : tenantRequest.companyName; + // Phase 9b: split the customer's initial channel-user ids into + // (a) ids the operator needs in spec.channelUsers (telegram, + // discord, …) — passed straight into createTenant + // (b) Threema ids that ALSO need a relay route registered so + // inbound messages reach this tenant. Threema is in (a) + // AND (b): spec.channelUsers tells the operator the id is + // authorized; the relay's route maps inbound traffic from + // that id to this tenant. + const initialChannelUsers = tenantRequest.channelUsers ?? {}; + // Strip channels the customer didn't actually enable (defensive + // — the wizard already filters this, but the row could carry + // stale data if the customer edited their request post-submit). + const filteredChannelUsers: Record = {}; + for (const [channel, ids] of Object.entries(initialChannelUsers)) { + if (!packages.includes(channel)) continue; + const cleaned = (ids ?? []) + .map((s) => (s ?? "").trim()) + .filter((s) => s.length > 0); + if (cleaned.length > 0) { + filteredChannelUsers[channel] = cleaned; + } + } + await createTenant( tenantName, { @@ -204,6 +208,9 @@ export async function POST( agentName: tenantRequest.agentName, packages, workspaceFiles, + ...(Object.keys(filteredChannelUsers).length > 0 + ? { channelUsers: filteredChannelUsers } + : {}), }, { "pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId, @@ -219,33 +226,33 @@ 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 - ); + // Threema: register relay routes for each id the customer + // entered. Best-effort — a route failure doesn't unwind the + // tenant creation (admin can retry from the tenant page later). + // The Threema package itself isn't enabled on the tenant until + // the customer toggles it from the tenant detail page (which + // also mints the per-tenant token); the routes here pre-warm + // the relay so the first toggle works without re-typing the id. + if ( + packages.includes("threema") && + filteredChannelUsers.threema && + filteredChannelUsers.threema.length > 0 + ) { + for (const tid of filteredChannelUsers.threema) { + try { + const res = await createRelayRoute(tenantName, tid); + if (!res.ok) { + console.warn( + `[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}` + ); + } + } catch (e) { + console.error( + `[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`, + e + ); + } + } } // Step 5: Update request status — clear admin notes on re-approval diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 02632ed..510874c 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -208,6 +208,7 @@ export async function POST(request: Request) { const input: OnboardingInput & { packageSecrets?: Record>; + channelUsers?: Record; } = parsed.data; // Look up an existing approved request for this org to inherit @@ -443,6 +444,7 @@ export async function POST(request: Request) { billingNotes, encryptedSecrets, isPersonal, + channelUsers: input.channelUsers ?? {}, }); try { await sendAdminNotificationEmail( @@ -487,6 +489,7 @@ export async function POST(request: Request) { billingNotes, encryptedSecrets, isPersonal, + channelUsers: input.channelUsers ?? {}, }); // Derive the future tenant_name — needed on the invoice line so diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx index d72feec..0ce9cfe 100644 --- a/src/components/onboarding/wizard.tsx +++ b/src/components/onboarding/wizard.tsx @@ -255,6 +255,14 @@ export function OnboardingWizard({ const [disclaimerAccepted, setDisclaimerAccepted] = useState< Record >({}); + // Phase 9b: per-channel customer user id collected at onboarding. + // Keyed by package id (e.g. "telegram" → "1234567"). Applied on + // admin approval — see /api/admin/requests/[id]/approve. Optional + // per channel; the customer can also leave it blank and add their + // id later from the tenant's channel-users page. + const [channelUserIds, setChannelUserIds] = useState>( + {} + ); // Fetch DB-stored defaults on mount useEffect(() => { @@ -474,6 +482,20 @@ export function OnboardingWizard({ })() : config; + // Phase 9b: build the channelUsers payload from the per-package + // ids collected during onboarding. Only include channels that + // (a) are enabled in the wizard's packages list AND + // (b) have a non-empty id entered. + // Shape matches PiecedTenantSpec.channelUsers — { channel: [id] } + // — so the approve handler can pass it straight through. + const channelUsersPayload: Record = {}; + for (const [pkgId, rawId] of Object.entries(channelUserIds)) { + const trimmed = (rawId ?? "").trim(); + if (!trimmed) continue; + if (!config.packages.includes(pkgId)) continue; + channelUsersPayload[pkgId] = [trimmed]; + } + const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, @@ -483,6 +505,10 @@ export function OnboardingWizard({ Object.keys(secretsPayload).length > 0 ? secretsPayload : undefined, + channelUsers: + Object.keys(channelUsersPayload).length > 0 + ? channelUsersPayload + : undefined, }), }); @@ -877,6 +903,46 @@ export function OnboardingWizard({ ))} + {/* Phase 9b: channel-user-id capture + during onboarding. For channels + where the customer's own user id + is needed for routing (Telegram, + Discord, Threema), collect it here + so the assistant is usable + immediately on provisioning. The + help text comes from the existing + channelUsers.IdHelp keys + (same copy as the post-provisioning + page uses). Field is optional — + blank means "I'll add it later". */} + {pkg.collectsChannelUserId && ( + + )} + {pkg.disclaimerKey && (