@aglyn/plugins-ai 1.0.0-beta.225 → 1.0.0-beta.227
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +17 -17
- package/src/lib/billing/ai-month-meter.js +5 -3
- package/src/lib/billing/ai-month-meter.js.map +1 -1
- package/src/lib/billing/ai-overage-ledger.js +3 -1
- package/src/lib/billing/ai-overage-ledger.js.map +1 -1
- package/src/lib/components/ai-brief-dialog.component.d.ts +6 -2
- package/src/lib/components/ai-brief-dialog.component.js +35 -17
- package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-describe-automation.component.js +17 -2
- package/src/lib/components/ai-describe-automation.component.js.map +1 -1
- package/src/lib/components/ai-insight-dialog.component.js +24 -2
- package/src/lib/components/ai-insight-dialog.component.js.map +1 -1
- package/src/lib/components/ai-job-events.d.ts +14 -0
- package/src/lib/components/ai-job-events.js +76 -0
- package/src/lib/components/ai-job-events.js.map +1 -0
- package/src/lib/components/ai-job-follow.component.d.ts +49 -0
- package/src/lib/components/ai-job-follow.component.js +210 -0
- package/src/lib/components/ai-job-follow.component.js.map +1 -0
- package/src/lib/components/ai-job-links.d.ts +42 -0
- package/src/lib/components/ai-job-links.js +140 -0
- package/src/lib/components/ai-job-links.js.map +1 -0
- package/src/lib/components/ai-job-plan.component.d.ts +13 -6
- package/src/lib/components/ai-job-plan.component.js +29 -3
- package/src/lib/components/ai-job-plan.component.js.map +1 -1
- package/src/lib/components/ai-job-requests.d.ts +24 -0
- package/src/lib/components/ai-job-requests.js +76 -0
- package/src/lib/components/ai-job-requests.js.map +1 -0
- package/src/lib/components/ai-jobs-indicator.component.d.ts +25 -0
- package/src/lib/components/ai-jobs-indicator.component.js +80 -0
- package/src/lib/components/ai-jobs-indicator.component.js.map +1 -0
- package/src/lib/components/ai-jobs-store.d.ts +81 -0
- package/src/lib/components/ai-jobs-store.js +218 -0
- package/src/lib/components/ai-jobs-store.js.map +1 -0
- package/src/lib/components/ai-page-brief-dialog.component.d.ts +3 -3
- package/src/lib/components/ai-page-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-site-build-page.component.d.ts +23 -0
- package/src/lib/components/ai-site-build-page.component.js +377 -0
- package/src/lib/components/ai-site-build-page.component.js.map +1 -0
- package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
- package/src/lib/components/ai-site-start-card.component.js +522 -130
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-starter-fallback.component.d.ts +24 -0
- package/src/lib/components/ai-site-starter-fallback.component.js +112 -0
- package/src/lib/components/ai-site-starter-fallback.component.js.map +1 -0
- package/src/lib/components/assist-jobs-drawer.component.d.ts +18 -20
- package/src/lib/components/assist-jobs-drawer.component.js +152 -172
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +62 -6
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/components/staff-ai-credit-return.component.d.ts +55 -0
- package/src/lib/components/staff-ai-credit-return.component.js +346 -0
- package/src/lib/components/staff-ai-credit-return.component.js.map +1 -0
- package/src/lib/components/staff-org-ai-card.component.js +364 -279
- package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
- package/src/lib/components/staff-user-ai-usage-card.component.d.ts +4 -0
- package/src/lib/components/staff-user-ai-usage-card.component.js +83 -28
- package/src/lib/components/staff-user-ai-usage-card.component.js.map +1 -1
- package/src/lib/components/use-ai-job-run.js +5 -18
- package/src/lib/components/use-ai-job-run.js.map +1 -1
- package/src/lib/jobs/ai-job-auto-confirm.d.ts +52 -0
- package/src/lib/jobs/ai-job-auto-confirm.js +62 -0
- package/src/lib/jobs/ai-job-auto-confirm.js.map +1 -0
- package/src/lib/jobs/ai-job-plan-step.d.ts +33 -2
- package/src/lib/jobs/ai-job-plan-step.js +161 -17
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +7 -0
- package/src/lib/jobs/ai-job-site-step.js +34 -4
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +7 -0
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs-notify.d.ts +37 -0
- package/src/lib/jobs/ai-jobs-notify.js +51 -0
- package/src/lib/jobs/ai-jobs-notify.js.map +1 -0
- package/src/lib/jobs/ai-jobs.d.ts +31 -4
- package/src/lib/jobs/ai-jobs.js +196 -32
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/model/ai-job-activity.d.ts +112 -0
- package/src/lib/model/ai-job-activity.js +144 -0
- package/src/lib/model/ai-job-activity.js.map +1 -0
- package/src/lib/model/ai-job-failure-copy.d.ts +69 -0
- package/src/lib/model/ai-job-failure-copy.js +78 -0
- package/src/lib/model/ai-job-failure-copy.js.map +1 -0
- package/src/lib/model/ai-job-notice.d.ts +61 -0
- package/src/lib/model/ai-job-notice.js +112 -0
- package/src/lib/model/ai-job-notice.js.map +1 -0
- package/src/lib/model/ai-jobs.types.d.ts +26 -0
- package/src/lib/model/ai-jobs.types.js +1 -0
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-plan-capabilities.d.ts +7 -0
- package/src/lib/model/ai-plan-capabilities.js.map +1 -1
- package/src/lib/model/ai-site-build-progress.d.ts +45 -0
- package/src/lib/model/ai-site-build-progress.js +76 -0
- package/src/lib/model/ai-site-build-progress.js.map +1 -0
- package/src/lib/model/ai-site-inventory.d.ts +9 -0
- package/src/lib/model/ai-site-inventory.js +7 -2
- package/src/lib/model/ai-site-inventory.js.map +1 -1
- package/src/lib/model/ai-site-job.d.ts +93 -2
- package/src/lib/model/ai-site-job.js +88 -7
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-site-start.d.ts +3 -1
- package/src/lib/model/ai-site-start.js +6 -4
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/model/ai-template-subjects.d.ts +4 -3
- package/src/lib/model/ai-template-subjects.js +5 -4
- package/src/lib/model/ai-template-subjects.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +3 -3
- package/src/lib/plan-entitlements.js +3 -3
- package/src/lib/plan-entitlements.js.map +1 -1
- package/src/lib/plugin.js +28 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/runtime/ai-abuse-guards.d.ts +9 -6
- package/src/lib/runtime/ai-abuse-guards.js +10 -7
- package/src/lib/runtime/ai-abuse-guards.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.js +31 -4
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-gate.d.ts +3 -2
- package/src/lib/runtime/ai-gate.js +3 -2
- package/src/lib/runtime/ai-gate.js.map +1 -1
- package/src/lib/runtime/ai-palette.generated.js +12 -4
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/site-inventory.d.ts +16 -1
- package/src/lib/runtime/site-inventory.js +43 -3
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-admin-credits.d.ts +21 -0
- package/src/lib/server/ai-admin-credits.js +213 -0
- package/src/lib/server/ai-admin-credits.js.map +1 -0
- package/src/lib/server/ai-admin-org.js +12 -5
- package/src/lib/server/ai-admin-org.js.map +1 -1
- package/src/lib/server/ai-admin-signals.js +3 -1
- package/src/lib/server/ai-admin-signals.js.map +1 -1
- package/src/lib/server/ai-admin-user.js +12 -1
- package/src/lib/server/ai-admin-user.js.map +1 -1
- package/src/lib/server/ai-allotments.js +3 -2
- package/src/lib/server/ai-allotments.js.map +1 -1
- package/src/lib/server/ai-jobs-route.js +20 -2
- package/src/lib/server/ai-jobs-route.js.map +1 -1
- package/src/lib/server/assist-chat.js +3 -2
- package/src/lib/server/assist-chat.js.map +1 -1
- package/src/lib/server/billing-credits.js +4 -3
- package/src/lib/server/billing-credits.js.map +1 -1
- package/src/lib/server/billing-overage.js +3 -2
- package/src/lib/server/billing-overage.js.map +1 -1
- package/src/lib/server.js +11 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/usage/assist-credit-returns-write.d.ts +75 -0
- package/src/lib/usage/assist-credit-returns-write.js +150 -0
- package/src/lib/usage/assist-credit-returns-write.js.map +1 -0
- package/src/lib/usage/assist-credit-returns.d.ts +68 -0
- package/src/lib/usage/assist-credit-returns.js +79 -0
- package/src/lib/usage/assist-credit-returns.js.map +1 -0
- package/src/lib/usage/assist-free-taste.js +6 -3
- package/src/lib/usage/assist-free-taste.js.map +1 -1
- package/src/lib/usage/assist-job-refund.d.ts +100 -0
- package/src/lib/usage/assist-job-refund.js +109 -0
- package/src/lib/usage/assist-job-refund.js.map +1 -0
- package/src/lib/usage/assist-usage.js +5 -2
- package/src/lib/usage/assist-usage.js.map +1 -1
- package/src/lib/usage/staff-org-ai.d.ts +31 -1
- package/src/lib/usage/staff-org-ai.js +19 -2
- package/src/lib/usage/staff-org-ai.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-org.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n isLiveSubscriptionStatus,\n ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n orgCogsInputFrom,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n readOrgBilling,\n} from '@aglyn/tenant-data-admin'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { readOrgAiUsageByUser } from '../usage/ai-usage-by-user'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { addonKindFromPriceId } from '@aglyn/tenant-data-admin/server/billing-addons'\nimport { orgMarginRow } from '@aglyn/aglyn/app-utils/margin-utilization'\nimport {\n assistCogsAlertThresholdUsd,\n assistMarginMultiple,\n} from '../usage/assist-spend-guards'\nimport {\n composeStaffOrgAiAddon,\n composeStaffOrgAiMargin,\n composeStaffOrgAiOverage,\n composeStaffOrgAiPool,\n composeStaffOrgAiTokens,\n emptyJobCounts,\n STAFF_ORG_AI_JOB_STATUSES,\n type StaffOrgAiAddon,\n type StaffOrgAiJob,\n type StaffOrgAiJobs,\n type StaffOrgAiResponse,\n type StaffOrgAiUser,\n} from '../usage/staff-org-ai'\n\n/**\n * THE STAFF AI CARD'S BACKING READ (AGL-2930).\n *\n * One request composes everything the card on the staff org page shows: the\n * add-on and when it was bought, the credit pool and its parts, the month's\n * overage and the org's own ceilings, the refusal counter, the generation\n * jobs, the per-user rollup and the margin. Staff-gated exactly as\n * `/api/admin/org-usage` is, and read-only — every access below is a `get()`.\n *\n * ## The reads, stated\n *\n * The org document and its billing mirror; this month's `assistUsage`\n * document; the newest usage rollup (for the contribution margin, the same\n * read the margin route makes); a bounded page of `aiJobs`; the per-user\n * rollup for the month; and, when the add-on is on, ONE Stripe subscription\n * lookup that is cached in-process — see `addonSinceFor`.\n *\n * ## No audit row\n *\n * The card loads every time the staff org page opens, so an `adminAudit` row\n * per load recorded page views rather than decisions and buried the org's\n * real admin actions under them. The route only reads.\n */\n\n/** Generation jobs read per request; the counts are a floor past it. */\nconst JOBS_SCAN = 200\n\n/** How many jobs the card lists. */\nconst JOBS_RECENT = 10\n\n/** How many people the leaderboard names. */\nconst TOP_USERS = 10\n\n/**\n * How long a Stripe answer to \"when was the add-on bought\" is kept.\n *\n * The date does not move once the item exists, and the card is opened far\n * more often than an add-on is bought, so the only cost of a stale entry is\n * a card that says \"since —\" for up to this long after a purchase. The\n * alternative — a Stripe round trip on every render — is the thing the brief\n * forbade.\n */\nconst ADDON_SINCE_TTL_MS = 15 * 60 * 1000\n\ninterface AddonSince {\n since: string | null\n sinceSource: StaffOrgAiAddon['sinceSource']\n at: number\n}\n\nconst addonSinceCache = new Map<string, AddonSince>()\n\n/** Test seam: drop every cached Stripe answer. */\nexport function resetAddonSinceCache(): void {\n addonSinceCache.clear()\n}\n\n/**\n * The instant the add-on's subscription item was created, from Stripe.\n *\n * The org document carries the add-on as a quantity (`seatAddons.aiAddon`)\n * and nothing about WHEN — the webhook mirrors items, not their dates. Stripe\n * has it as `created` on the subscription item, found by the price id the\n * add-on sells at (`addonKindFromPriceId`), so a rename or a price change\n * does not lose it.\n */\nasync function addonSinceFor(orgId: string): Promise<AddonSince> {\n const cached = addonSinceCache.get(orgId)\n if (cached && Date.now() - cached.at < ADDON_SINCE_TTL_MS) return cached\n const answer = await readAddonSince(orgId)\n addonSinceCache.set(orgId, answer)\n return answer\n}\n\nasync function readAddonSince(orgId: string): Promise<AddonSince> {\n const at = Date.now()\n const stripeKey = process.env.STRIPE_SECRET_KEY\n if (!stripeKey) return { since: null, sinceSource: 'unavailable', at }\n try {\n const customerId = (await readOrgBilling(orgId)).stripeCustomerId\n if (!customerId) return { since: null, sinceSource: 'no-subscription', at }\n const response = await fetch(\n `https://api.stripe.com/v1/subscriptions?customer=${encodeURIComponent(\n String(customerId),\n )}&status=all&limit=10`,\n { headers: { Authorization: `Bearer ${stripeKey}` } },\n )\n const payload = await response.json().catch(() => null)\n if (!response.ok) {\n console.error('[ai/admin/org] Stripe subscription lookup failed', {\n orgId,\n status: response.status,\n detail: payload?.error?.message ?? null,\n })\n return { since: null, sinceSource: 'unavailable', at }\n }\n const subscriptions = (payload?.data ?? []) as Array<{\n status?: string\n items?: { data?: Array<{ created?: number; price?: { id?: string } }> }\n }>\n // The LIVE subscription's item, when one exists: an add-on on a canceled\n // subscription is not an add-on the org has, and `hasAiAddon` already\n // says so — the date beside \"off\" would only confuse.\n const live = subscriptions.filter((subscription) =>\n isLiveSubscriptionStatus(subscription.status),\n )\n for (const subscription of live.length ? live : subscriptions) {\n for (const item of subscription.items?.data ?? []) {\n if (addonKindFromPriceId(item.price?.id) !== 'aiAddon') continue\n const created = Number(item.created)\n if (Number.isFinite(created) && created > 0) {\n return {\n since: new Date(created * 1000).toISOString(),\n sinceSource: 'stripe',\n at,\n }\n }\n }\n }\n return { since: null, sinceSource: 'stripe', at }\n } catch (error) {\n console.error('[ai/admin/org] Stripe subscription lookup threw', error)\n return { since: null, sinceSource: 'unavailable', at }\n }\n}\n\nconst toIso = (value: unknown): string | null => {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted.toISOString()\n if (value instanceof Date) return value.toISOString()\n return null\n}\n\n/**\n * The month's generation jobs, summarized (AGL-2904's collection).\n *\n * Reads `orgs/{orgId}/aiJobs` by `createdAt` from the first instant of the\n * month, newest first, up to `JOBS_SCAN`. An org with no such collection\n * answers an empty snapshot, which is the honest \"no generation jobs yet\";\n * a read that FAILED answers `null`, which the card renders as a failure\n * rather than as an idle org. The field names are the ones `AiJob` declares\n * in `ai-jobs.types.ts`: `kind`, `status`, `creditsReserved`, `creditsSpent`,\n * `createdAt`, `createdBy`.\n */\nexport async function readOrgAiJobsSummary(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiJobs | null> {\n try {\n const monthStart = new Date(`${month}-01T00:00:00.000Z`)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('aiJobs')\n .where('createdAt', '>=', monthStart)\n .orderBy('createdAt', 'desc')\n .limit(JOBS_SCAN + 1)\n .get()\n const truncated = snapshot.docs.length > JOBS_SCAN\n const docs = truncated ? snapshot.docs.slice(0, JOBS_SCAN) : snapshot.docs\n const counts = emptyJobCounts()\n const recent: StaffOrgAiJob[] = []\n for (const doc of docs) {\n const status = String(doc.get('status') ?? '')\n if ((STAFF_ORG_AI_JOB_STATUSES as readonly string[]).includes(status)) {\n counts[status as keyof typeof counts] += 1\n }\n if (recent.length < JOBS_RECENT) {\n recent.push({\n id: doc.id,\n kind: String(doc.get('kind') ?? ''),\n status,\n creditsReserved: Number(doc.get('creditsReserved') ?? 0) || 0,\n creditsSpent: Number(doc.get('creditsSpent') ?? 0) || 0,\n createdAt: toIso(doc.get('createdAt')),\n createdBy:\n typeof doc.get('createdBy') === 'string'\n ? String(doc.get('createdBy'))\n : null,\n })\n }\n }\n return { counts, recent, truncated }\n } catch (error) {\n console.error('[ai/admin/org] jobs read failed', error)\n return null\n }\n}\n\n/**\n * The month's per-user rollup, dearest first (AGL-2928).\n *\n * `readOrgAiUsageByUser` is the one reader of\n * `orgs/{orgId}/aiUsageByUser/{uid}/months/{month}` — the customer's Usage\n * table reads through it too — joined to the roster for names and carrying\n * each person's share of the org's measured spend. The card keeps the\n * dearest `TOP_USERS`.\n */\nasync function readOrgAiTopUsers(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiUser[]> {\n try {\n const usage = await readOrgAiUsageByUser(firestore, orgId, month)\n return usage.rows.slice(0, TOP_USERS).map((row) => ({\n uid: row.uid,\n name: row.name,\n credits: row.credits,\n estCostUsd: row.estCostUsd,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }))\n } catch (error) {\n console.error('[ai/admin/org] per-user read failed', error)\n return []\n }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const {\n method,\n query,\n headers: rawHeaders,\n } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken)\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const orgId = String(query.orgId ?? '')\n if (!orgId) return Response.json({ error: 'Missing orgId' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const now = new Date()\n const month = assistUsageMonth(now)\n const db = firebaseAdmin.app().firestore()\n const orgRef = db.collection('orgs').doc(orgId)\n\n const [orgSnap, billingSnap, monthSnap, rollupSnap] = await Promise.all([\n orgRef.get(),\n orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get(),\n orgRef.collection('assistUsage').doc(month).get(),\n orgRef.collection('usage').orderBy('month', 'desc').limit(1).get(),\n ])\n if (!orgSnap.exists) {\n return Response.json({ error: 'No such organization' }, { status: 404 })\n }\n // Org doc FIRST so a stale inline `subscription` loses to the billing\n // mirror — the same merge the margin route and the org detail read make.\n const org = {\n ...(orgSnap.data() ?? {}),\n ...(billingSnap.exists ? billingSnap.data() : {}),\n } as Record<string, unknown>\n const monthDoc = monthSnap.exists ? (monthSnap.data() ?? {}) : null\n\n const [jobs, users, since] = await Promise.all([\n readOrgAiJobsSummary(db, orgId, month),\n readOrgAiTopUsers(db, orgId, month),\n addonSinceFor(orgId),\n ])\n\n const pool = composeStaffOrgAiPool(org as never, monthDoc, now)\n // The overage line is an invoice, so it is priced off what the month\n // DREW; everything below it is our bill, so it reads what the month\n // COST (AGL-3015).\n const overage = composeStaffOrgAiOverage(org as never, pool.billedUsd)\n const addon = composeStaffOrgAiAddon(org as never, {\n since: since.since,\n sinceSource: since.sinceSource,\n })\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n const rollupDoc = rollupSnap.docs[0]\n // The whole-org contribution margin, on the same row the margin\n // utilization page builds — with THIS month's live assist spend in\n // place of the figure frozen onto the rollup, as that route does.\n const contributionRow = rollupDoc\n ? orgMarginRow({\n orgId,\n name: (org['name'] as string | undefined) ?? null,\n org: org as never,\n month: String(rollupDoc.get('month') ?? rollupDoc.id),\n rollup: {\n ...orgCogsInputFrom(rollupDoc.data()),\n assistCostUsd: pool.providerUsd,\n },\n })\n : null\n const margin = composeStaffOrgAiMargin({\n org: org as never,\n pool,\n overage,\n addon,\n thresholdUsd,\n multiple: assistMarginMultiple(pool.providerUsd, thresholdUsd),\n contribution: {\n month: contributionRow?.month ?? null,\n marginPct: contributionRow?.marginPct ?? null,\n rating: contributionRow?.rating ?? null,\n },\n })\n\n const body: StaffOrgAiResponse = {\n month,\n addon,\n pool,\n overage,\n refusals: assistRefusalCounts(monthDoc?.['refusals']),\n jobs,\n users,\n margin,\n tokens: composeStaffOrgAiTokens(monthDoc),\n }\n return Response.json(body, { status: 200 })\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/org]', error)\n return Response.json({ error: 'AI lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["isLiveSubscriptionStatus","ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","orgCogsInputFrom","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readOrgBilling","assistUsageMonth","assistRefusalCounts","readOrgAiUsageByUser","invalidIdTokenResponse","addonKindFromPriceId","orgMarginRow","assistCogsAlertThresholdUsd","assistMarginMultiple","composeStaffOrgAiAddon","composeStaffOrgAiMargin","composeStaffOrgAiOverage","composeStaffOrgAiPool","composeStaffOrgAiTokens","emptyJobCounts","STAFF_ORG_AI_JOB_STATUSES","JOBS_SCAN","JOBS_RECENT","TOP_USERS","ADDON_SINCE_TTL_MS","addonSinceCache","Map","resetAddonSinceCache","clear","addonSinceFor","orgId","cached","get","Date","now","at","answer","readAddonSince","set","stripeKey","process","env","STRIPE_SECRET_KEY","since","sinceSource","customerId","stripeCustomerId","response","fetch","encodeURIComponent","String","headers","Authorization","payload","json","catch","ok","console","error","status","detail","message","subscriptions","data","live","filter","subscription","length","item","items","price","id","created","Number","isFinite","toISOString","toIso","value","converted","toDate","readOrgAiJobsSummary","firestore","month","monthStart","snapshot","collection","doc","where","orderBy","limit","truncated","docs","slice","counts","recent","includes","push","kind","creditsReserved","creditsSpent","createdAt","createdBy","readOrgAiTopUsers","usage","rows","map","row","uid","name","credits","estCostUsd","share","requests","refusals","byKind","byHost","handler","request","query","method","rawHeaders","Response","authorization","idToken","startsWith","undefined","orgSnap","monthSnap","org","rollupDoc","decoded","app","auth","verifyIdToken","email_verified","db","orgRef","billingSnap","rollupSnap","Promise","all","exists","monthDoc","jobs","users","pool","overage","billedUsd","addon","thresholdUsd","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","contributionRow","rollup","assistCostUsd","providerUsd","margin","multiple","contribution","marginPct","rating","body","tokens","unauthenticated","GET"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-org.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n isLiveSubscriptionStatus,\n ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n orgCogsInputFrom,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n readOrgBilling,\n} from '@aglyn/tenant-data-admin'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { readOrgAiUsageByUser } from '../usage/ai-usage-by-user'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { addonKindFromPriceId } from '@aglyn/tenant-data-admin/server/billing-addons'\nimport { orgMarginRow } from '@aglyn/aglyn/app-utils/margin-utilization'\nimport {\n assistCogsAlertThresholdUsd,\n assistMarginMultiple,\n} from '../usage/assist-spend-guards'\nimport {\n freeAccountUsageRef,\n freeAssistAccount,\n} from '../usage/assist-free-taste'\nimport {\n composeStaffAiAccountAllowance,\n composeStaffOrgAiAddon,\n composeStaffOrgAiMargin,\n composeStaffOrgAiOverage,\n composeStaffOrgAiPool,\n composeStaffOrgAiTokens,\n emptyJobCounts,\n STAFF_ORG_AI_JOB_STATUSES,\n type StaffOrgAiAddon,\n type StaffOrgAiJob,\n type StaffOrgAiJobs,\n type StaffOrgAiResponse,\n type StaffOrgAiUser,\n} from '../usage/staff-org-ai'\n\n/**\n * THE STAFF AI CARD'S BACKING READ (AGL-2930).\n *\n * One request composes everything the card on the staff org page shows: the\n * add-on and when it was bought, the credit pool and its parts, the month's\n * overage and the org's own ceilings, the refusal counter, the generation\n * jobs, the per-user rollup and the margin. Staff-gated exactly as\n * `/api/admin/org-usage` is, and read-only — every access below is a `get()`.\n *\n * ## The reads, stated\n *\n * The org document and its billing mirror; this month's `assistUsage`\n * document; the newest usage rollup (for the contribution margin, the same\n * read the margin route makes); a bounded page of `aiJobs`; the per-user\n * rollup for the month; and, when the add-on is on, ONE Stripe subscription\n * lookup that is cached in-process — see `addonSinceFor`.\n *\n * ## No audit row\n *\n * The card loads every time the staff org page opens, so an `adminAudit` row\n * per load recorded page views rather than decisions and buried the org's\n * real admin actions under them. The route only reads.\n */\n\n/** Generation jobs read per request; the counts are a floor past it. */\nconst JOBS_SCAN = 200\n\n/** How many jobs the card lists. */\nconst JOBS_RECENT = 10\n\n/** How many people the leaderboard names. */\nconst TOP_USERS = 10\n\n/**\n * How long a Stripe answer to \"when was the add-on bought\" is kept.\n *\n * The date does not move once the item exists, and the card is opened far\n * more often than an add-on is bought, so the only cost of a stale entry is\n * a card that says \"since —\" for up to this long after a purchase. The\n * alternative — a Stripe round trip on every render — is the thing the brief\n * forbade.\n */\nconst ADDON_SINCE_TTL_MS = 15 * 60 * 1000\n\ninterface AddonSince {\n since: string | null\n sinceSource: StaffOrgAiAddon['sinceSource']\n at: number\n}\n\nconst addonSinceCache = new Map<string, AddonSince>()\n\n/** Test seam: drop every cached Stripe answer. */\nexport function resetAddonSinceCache(): void {\n addonSinceCache.clear()\n}\n\n/**\n * The instant the add-on's subscription item was created, from Stripe.\n *\n * The org document carries the add-on as a quantity (`seatAddons.aiAddon`)\n * and nothing about WHEN — the webhook mirrors items, not their dates. Stripe\n * has it as `created` on the subscription item, found by the price id the\n * add-on sells at (`addonKindFromPriceId`), so a rename or a price change\n * does not lose it.\n */\nasync function addonSinceFor(orgId: string): Promise<AddonSince> {\n const cached = addonSinceCache.get(orgId)\n if (cached && Date.now() - cached.at < ADDON_SINCE_TTL_MS) return cached\n const answer = await readAddonSince(orgId)\n addonSinceCache.set(orgId, answer)\n return answer\n}\n\nasync function readAddonSince(orgId: string): Promise<AddonSince> {\n const at = Date.now()\n const stripeKey = process.env.STRIPE_SECRET_KEY\n if (!stripeKey) return { since: null, sinceSource: 'unavailable', at }\n try {\n const customerId = (await readOrgBilling(orgId)).stripeCustomerId\n if (!customerId) return { since: null, sinceSource: 'no-subscription', at }\n const response = await fetch(\n `https://api.stripe.com/v1/subscriptions?customer=${encodeURIComponent(\n String(customerId),\n )}&status=all&limit=10`,\n { headers: { Authorization: `Bearer ${stripeKey}` } },\n )\n const payload = await response.json().catch(() => null)\n if (!response.ok) {\n console.error('[ai/admin/org] Stripe subscription lookup failed', {\n orgId,\n status: response.status,\n detail: payload?.error?.message ?? null,\n })\n return { since: null, sinceSource: 'unavailable', at }\n }\n const subscriptions = (payload?.data ?? []) as Array<{\n status?: string\n items?: { data?: Array<{ created?: number; price?: { id?: string } }> }\n }>\n // The LIVE subscription's item, when one exists: an add-on on a canceled\n // subscription is not an add-on the org has, and `hasAiAddon` already\n // says so — the date beside \"off\" would only confuse.\n const live = subscriptions.filter((subscription) =>\n isLiveSubscriptionStatus(subscription.status),\n )\n for (const subscription of live.length ? live : subscriptions) {\n for (const item of subscription.items?.data ?? []) {\n if (addonKindFromPriceId(item.price?.id) !== 'aiAddon') continue\n const created = Number(item.created)\n if (Number.isFinite(created) && created > 0) {\n return {\n since: new Date(created * 1000).toISOString(),\n sinceSource: 'stripe',\n at,\n }\n }\n }\n }\n return { since: null, sinceSource: 'stripe', at }\n } catch (error) {\n console.error('[ai/admin/org] Stripe subscription lookup threw', error)\n return { since: null, sinceSource: 'unavailable', at }\n }\n}\n\nconst toIso = (value: unknown): string | null => {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted.toISOString()\n if (value instanceof Date) return value.toISOString()\n return null\n}\n\n/**\n * The month's generation jobs, summarized (AGL-2904's collection).\n *\n * Reads `orgs/{orgId}/aiJobs` by `createdAt` from the first instant of the\n * month, newest first, up to `JOBS_SCAN`. An org with no such collection\n * answers an empty snapshot, which is the honest \"no generation jobs yet\";\n * a read that FAILED answers `null`, which the card renders as a failure\n * rather than as an idle org. The field names are the ones `AiJob` declares\n * in `ai-jobs.types.ts`: `kind`, `status`, `creditsReserved`, `creditsSpent`,\n * `createdAt`, `createdBy`.\n */\nexport async function readOrgAiJobsSummary(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiJobs | null> {\n try {\n const monthStart = new Date(`${month}-01T00:00:00.000Z`)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('aiJobs')\n .where('createdAt', '>=', monthStart)\n .orderBy('createdAt', 'desc')\n .limit(JOBS_SCAN + 1)\n .get()\n const truncated = snapshot.docs.length > JOBS_SCAN\n const docs = truncated ? snapshot.docs.slice(0, JOBS_SCAN) : snapshot.docs\n const counts = emptyJobCounts()\n const recent: StaffOrgAiJob[] = []\n for (const doc of docs) {\n const status = String(doc.get('status') ?? '')\n if ((STAFF_ORG_AI_JOB_STATUSES as readonly string[]).includes(status)) {\n counts[status as keyof typeof counts] += 1\n }\n if (recent.length < JOBS_RECENT) {\n recent.push({\n id: doc.id,\n kind: String(doc.get('kind') ?? ''),\n status,\n creditsReserved: Number(doc.get('creditsReserved') ?? 0) || 0,\n creditsSpent: Number(doc.get('creditsSpent') ?? 0) || 0,\n createdAt: toIso(doc.get('createdAt')),\n createdBy:\n typeof doc.get('createdBy') === 'string'\n ? String(doc.get('createdBy'))\n : null,\n })\n }\n }\n return { counts, recent, truncated }\n } catch (error) {\n console.error('[ai/admin/org] jobs read failed', error)\n return null\n }\n}\n\n/**\n * The month's per-user rollup, dearest first (AGL-2928).\n *\n * `readOrgAiUsageByUser` is the one reader of\n * `orgs/{orgId}/aiUsageByUser/{uid}/months/{month}` — the customer's Usage\n * table reads through it too — joined to the roster for names and carrying\n * each person's share of the org's measured spend. The card keeps the\n * dearest `TOP_USERS`.\n */\nasync function readOrgAiTopUsers(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiUser[]> {\n try {\n const usage = await readOrgAiUsageByUser(firestore, orgId, month)\n return usage.rows.slice(0, TOP_USERS).map((row) => ({\n uid: row.uid,\n name: row.name,\n credits: row.credits,\n estCostUsd: row.estCostUsd,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }))\n } catch (error) {\n console.error('[ai/admin/org] per-user read failed', error)\n return []\n }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const {\n method,\n query,\n headers: rawHeaders,\n } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken)\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const orgId = String(query.orgId ?? '')\n if (!orgId) return Response.json({ error: 'Missing orgId' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const now = new Date()\n const month = assistUsageMonth(now)\n const db = firebaseAdmin.app().firestore()\n const orgRef = db.collection('orgs').doc(orgId)\n\n const [orgSnap, billingSnap, monthSnap, rollupSnap] = await Promise.all([\n orgRef.get(),\n orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get(),\n orgRef.collection('assistUsage').doc(month).get(),\n orgRef.collection('usage').orderBy('month', 'desc').limit(1).get(),\n ])\n if (!orgSnap.exists) {\n return Response.json({ error: 'No such organization' }, { status: 404 })\n }\n // Org doc FIRST so a stale inline `subscription` loses to the billing\n // mirror — the same merge the margin route and the org detail read make.\n const org = {\n ...(orgSnap.data() ?? {}),\n ...(billingSnap.exists ? billingSnap.data() : {}),\n } as Record<string, unknown>\n const monthDoc = monthSnap.exists ? (monthSnap.data() ?? {}) : null\n\n // A Free workspace also draws on its OWNER's account allowance\n // (AGL-2925), which is what staff give credits back to beside the band\n // (AGL-3595). One more document, read only for a Free workspace.\n const free = freeAssistAccount(org as never)\n const [jobs, users, since, accountSnap] = await Promise.all([\n readOrgAiJobsSummary(db, orgId, month),\n readOrgAiTopUsers(db, orgId, month),\n addonSinceFor(orgId),\n free?.accountUid\n ? freeAccountUsageRef(db, free.accountUid, month).get()\n : Promise.resolve(null),\n ])\n\n const pool = composeStaffOrgAiPool(org as never, monthDoc, now)\n // The overage line is an invoice, so it is priced off what the month\n // DREW; everything below it is our bill, so it reads what the month\n // COST (AGL-3015).\n const overage = composeStaffOrgAiOverage(org as never, pool.billedUsd)\n const addon = composeStaffOrgAiAddon(org as never, {\n since: since.since,\n sinceSource: since.sinceSource,\n })\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n const rollupDoc = rollupSnap.docs[0]\n // The whole-org contribution margin, on the same row the margin\n // utilization page builds — with THIS month's live assist spend in\n // place of the figure frozen onto the rollup, as that route does.\n const contributionRow = rollupDoc\n ? orgMarginRow({\n orgId,\n name: (org['name'] as string | undefined) ?? null,\n org: org as never,\n month: String(rollupDoc.get('month') ?? rollupDoc.id),\n rollup: {\n ...orgCogsInputFrom(rollupDoc.data()),\n assistCostUsd: pool.providerUsd,\n },\n })\n : null\n const margin = composeStaffOrgAiMargin({\n org: org as never,\n pool,\n overage,\n addon,\n thresholdUsd,\n multiple: assistMarginMultiple(pool.providerUsd, thresholdUsd),\n contribution: {\n month: contributionRow?.month ?? null,\n marginPct: contributionRow?.marginPct ?? null,\n rating: contributionRow?.rating ?? null,\n },\n })\n\n const body: StaffOrgAiResponse = {\n month,\n addon,\n pool,\n overage,\n refusals: assistRefusalCounts(monthDoc?.['refusals']),\n jobs,\n users,\n margin,\n tokens: composeStaffOrgAiTokens(monthDoc),\n account:\n free?.accountUid && accountSnap\n ? composeStaffAiAccountAllowance(\n free.accountUid,\n month,\n accountSnap.exists ? (accountSnap.data() ?? null) : null,\n )\n : null,\n }\n return Response.json(body, { status: 200 })\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/org]', error)\n return Response.json({ error: 'AI lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["isLiveSubscriptionStatus","ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","orgCogsInputFrom","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readOrgBilling","assistUsageMonth","assistRefusalCounts","readOrgAiUsageByUser","invalidIdTokenResponse","addonKindFromPriceId","orgMarginRow","assistCogsAlertThresholdUsd","assistMarginMultiple","freeAccountUsageRef","freeAssistAccount","composeStaffAiAccountAllowance","composeStaffOrgAiAddon","composeStaffOrgAiMargin","composeStaffOrgAiOverage","composeStaffOrgAiPool","composeStaffOrgAiTokens","emptyJobCounts","STAFF_ORG_AI_JOB_STATUSES","JOBS_SCAN","JOBS_RECENT","TOP_USERS","ADDON_SINCE_TTL_MS","addonSinceCache","Map","resetAddonSinceCache","clear","addonSinceFor","orgId","cached","get","Date","now","at","answer","readAddonSince","set","stripeKey","process","env","STRIPE_SECRET_KEY","since","sinceSource","customerId","stripeCustomerId","response","fetch","encodeURIComponent","String","headers","Authorization","payload","json","catch","ok","console","error","status","detail","message","subscriptions","data","live","filter","subscription","length","item","items","price","id","created","Number","isFinite","toISOString","toIso","value","converted","toDate","readOrgAiJobsSummary","firestore","month","monthStart","snapshot","collection","doc","where","orderBy","limit","truncated","docs","slice","counts","recent","includes","push","kind","creditsReserved","creditsSpent","createdAt","createdBy","readOrgAiTopUsers","usage","rows","map","row","uid","name","credits","estCostUsd","share","requests","refusals","byKind","byHost","handler","request","query","method","rawHeaders","Response","authorization","idToken","startsWith","undefined","orgSnap","monthSnap","org","rollupDoc","accountSnap","decoded","app","auth","verifyIdToken","email_verified","db","orgRef","billingSnap","rollupSnap","Promise","all","exists","monthDoc","free","jobs","users","accountUid","resolve","pool","overage","billedUsd","addon","thresholdUsd","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","contributionRow","rollup","assistCostUsd","providerUsd","margin","multiple","contribution","marginPct","rating","body","tokens","account","unauthenticated","GET"],"mappings":"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"}
|
|
@@ -21,6 +21,7 @@ import { hasAiAddon, resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-en
|
|
|
21
21
|
import { readPlatformFreeSpend } from "../usage/assist-free-taste.js";
|
|
22
22
|
import { assistRefusalCounts } from "../usage/assist-refusals.js";
|
|
23
23
|
import { assistUsageDay, assistUsageMonth } from "../usage/assist-usage.js";
|
|
24
|
+
import { ASSIST_RETURNED_USD_FIELD, assistSpendAfterReturnsUsd } from "../usage/assist-credit-returns.js";
|
|
24
25
|
import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
25
26
|
import { assistSignalRow, mineAssistSignals, rankAssistSpend } from "../usage/assist-signal-mining.js";
|
|
26
27
|
/**
|
|
@@ -118,7 +119,8 @@ async function rankAssistSpendForMonth(firestore, limit, month) {
|
|
|
118
119
|
orgId,
|
|
119
120
|
plan: 'free',
|
|
120
121
|
aiAddon: false,
|
|
121
|
-
credits
|
|
122
|
+
// Net of credits given back (AGL-3595); the provider dollars are not.
|
|
123
|
+
credits: assistCreditsFromUsd(assistSpendAfterReturnsUsd(billedUsd, doc.get(ASSIST_RETURNED_USD_FIELD))),
|
|
122
124
|
providerCostUsd: assistProviderCostUsd(billedUsd, doc.get(ASSIST_PROVIDER_COST_FIELD)),
|
|
123
125
|
refusals: assistRefusalCounts(doc.get('refusals'))
|
|
124
126
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-signals.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport {\n ASSIST_PROVIDER_COST_FIELD,\n assistCreditsFromUsd,\n assistProviderCostUsd,\n} from '../usage/assist-credits'\nimport {\n hasAiAddon,\n resolveEffectivePlan,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { readPlatformFreeSpend } from '../usage/assist-free-taste'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { assistUsageDay, assistUsageMonth } from '../usage/assist-usage'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n assistSignalRow,\n mineAssistSignals,\n rankAssistSpend,\n type AssistSpendRow,\n} from '../usage/assist-signal-mining'\n\n/**\n * The Assist docs-gap and cost mining view (AGL-1860, AGL-2252), served at\n * `/api/ai/admin/signals` for the plugin's Assist signal staff page.\n * Staff-gated, read-only, and the only reader `assistSignals` has.\n *\n * ## Why a route and not a client query\n *\n * `assistSignals` is absent from `firebase-firestore.rules` deliberately: the\n * org block matches its subcollections BY NAME and carries no wildcard, so an\n * unlisted name is default-deny for every client. That is the property that\n * lets the collection hold cross-tenant analytics at all. Reading it therefore\n * goes through the Admin SDK behind the staff claim, exactly as every staff\n * route does.\n *\n * ## The collection group, and the index it does not need\n *\n * One `collectionGroup('assistSignals')` read gathers every org's signals in a\n * single query; the owning org is `ref.parent.parent.id`, since the document\n * carries no `orgId` field (it carries no identifiers at all — see AGL-1972).\n * The query has no `where` and no `orderBy`, so it needs no composite index,\n * and adding one later would mean declaring a `COLLECTION_GROUP`-scoped\n * override and deploying it by hand. `/api/admin/overview` reads org billing\n * the same way and for the same reason.\n *\n * ## Truncation is reported, never hidden\n *\n * The read is bounded — an unbounded fleet-wide get is how a staff page\n * becomes an outage — but a ranking cut short looks exactly like a complete\n * one, so the ceiling is fetched PLUS ONE and the extra row sets `truncated`.\n * AGL-2220 is the standing example of the alternative: a sweep that stopped at\n * 500 orgs and reported nothing about the rest, for as long as nobody counted.\n */\n\n/**\n * Rows read in one pass. Each signal is a handful of scalars, so this is a\n * few megabytes at the ceiling — large enough to be worth ranking, small\n * enough that the honest answer to \"is it all of them\" is usually yes.\n */\nconst SCAN_CEILING = 20000\n\n/**\n * How much of an answer the prose panel shows.\n *\n * Enough to judge whether the answer was wrong, short enough that twenty-five\n * of them are still a page somebody reads.\n */\nconst ANSWER_PREVIEW_CHARS = 600\n\n/**\n * Month documents read in one pass for the spend leaderboard (AGL-2930).\n *\n * `assistUsage` holds one document per org per month, so this is orgs times\n * months — a fraction of the signal count, and the same PLUS-ONE truncation\n * probe the signals read uses. Read as a collection group with NO `where`\n * and NO `orderBy`, for the reason the module note gives: a filter on\n * `month` would need a COLLECTION_GROUP-scoped index deployed by hand, and\n * filtering the month in memory costs nothing at this size.\n */\nconst SPEND_SCAN_CEILING = 20000\n\nexport interface AssistSpendLeaderboard {\n month: string\n rows: AssistSpendRow[]\n /** How many workspaces had a document for the month — the ranked count. */\n ranked: number\n /** True when the scan hit its ceiling, so the ranking is over a sample. */\n truncated: boolean\n /**\n * True when the month documents could not be read at all. Reported rather\n * than rendered as an empty table: \"no workspace spent anything\" and \"the\n * read failed\" are opposite findings, and the rest of the board — read\n * from a different collection — is still good.\n */\n failed: boolean\n}\n\n/**\n * The month's per-org spend, refusals and plan, dearest first.\n *\n * Plan and add-on come from the org and billing documents of the ranked\n * rows only — bounded by `limit`, never by the fleet — merged org-doc-first\n * exactly as every other staff reader merges them.\n */\nasync function readAssistSpendLeaderboard(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n try {\n return await rankAssistSpendForMonth(firestore, limit, month)\n } catch (error) {\n console.error('[ai/admin/signals] spend leaderboard read failed', error)\n return { month, rows: [], ranked: 0, truncated: false, failed: true }\n }\n}\n\nasync function rankAssistSpendForMonth(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n const snapshot = await firestore\n .collectionGroup('assistUsage')\n .limit(SPEND_SCAN_CEILING + 1)\n .get()\n const truncated = snapshot.size > SPEND_SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SPEND_SCAN_CEILING) : snapshot.docs\n const unranked: AssistSpendRow[] = []\n for (const doc of docs) {\n // The id IS the month; the field is the same value and is what an\n // older document may lack.\n if (doc.id !== month && doc.get('month') !== month) continue\n const orgId = doc.ref.parent.parent?.id\n if (!orgId) continue\n // Two figures off one document (AGL-3015): the credits a workspace drew\n // come from what it was BILLED, and the dollars staff rank workspaces by\n // come from what it COST US. Ranking on the billed figure would sort the\n // fleet by our own markup wherever workspaces differ in model mix.\n const billed = Number(doc.get('estCostUsd') ?? 0)\n const billedUsd = Number.isFinite(billed) && billed > 0 ? billed : 0\n unranked.push({\n orgId,\n plan: 'free',\n aiAddon: false,\n credits: assistCreditsFromUsd(billedUsd),\n providerCostUsd: assistProviderCostUsd(\n billedUsd,\n doc.get(ASSIST_PROVIDER_COST_FIELD),\n ),\n refusals: assistRefusalCounts(doc.get('refusals')),\n })\n }\n const { rows, ranked } = rankAssistSpend(unranked, limit)\n if (rows.length) {\n const orgRefs = rows.map((row) => firestore.collection('orgs').doc(row.orgId))\n const [orgSnaps, billingSnaps] = await Promise.all([\n firestore.getAll(...orgRefs),\n firestore.getAll(\n ...orgRefs.map((ref) =>\n ref.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID),\n ),\n ),\n ])\n rows.forEach((row, index) => {\n const orgData = orgSnaps[index]?.exists ? (orgSnaps[index].data() ?? {}) : {}\n const org = {\n ...orgData,\n ...(billingSnaps[index]?.exists ? billingSnaps[index].data() : {}),\n }\n row.plan = resolveEffectivePlan(org as never)\n row.aiAddon = hasAiAddon(org as never)\n const label = (orgData['name'] ?? orgData['slug'] ?? '') as string\n row.orgLabel = label || null\n })\n }\n return { month, rows, ranked, truncated, failed: false }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const requested = Number(query.limit ?? 25)\n const limit =\n Number.isFinite(requested) && requested > 0\n ? Math.min(Math.floor(requested), 100)\n : 25\n\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collectionGroup('assistSignals')\n // Ceiling PLUS ONE: the extra document is what makes truncation\n // detectable rather than assumed.\n .limit(SCAN_CEILING + 1)\n .get()\n\n const truncated = snapshot.size > SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SCAN_CEILING) : snapshot.docs\n const rows = docs.map((doc) =>\n assistSignalRow(\n doc.ref.parent.parent?.id ?? '(unknown)',\n // The signal id IS the exchange id (AGL-2314) — one id, two halves,\n // minted together by `recordAssistExchange` precisely so the join is\n // possible. Until now nothing carried it this far.\n doc.id,\n doc.data(),\n ),\n )\n\n const report = mineAssistSignals(rows, { truncated, limit })\n\n // This month's spend, per workspace (AGL-2930) — the board's only\n // month-scoped panel, read off the month documents rather than mined\n // from the all-time signals above.\n const spend = await readAssistSpendLeaderboard(\n firebaseAdmin.app().firestore(),\n limit,\n assistUsageMonth(),\n )\n\n /*==========================================\n * THE VERBATIM HALF (AGL-2314).\n *\n * `assistExchanges` stores the question a customer typed and the answer\n * we gave, for 180 days, and `assist-usage.ts` calls it \"the data loop's\n * corpus\". NOTHING read it — one write, no reader, no collection group.\n * So we retained customers' words, and committed publicly in the privacy\n * policy to retaining them, for zero product value.\n *\n * Read here rather than in the miner because the miner is pure: it does\n * the arithmetic, the route does the reading.\n *\n * ## Only the turns that FAILED\n *\n * The shortlist is thumbs-down and ungrounded turns only, capped by the\n * same `limit` as every other panel. That is a privacy decision as much\n * as a cost one: the counts already answer \"how often\", and the words are\n * fetched only where the counts CANNOT say what went wrong. Pulling every\n * exchange would be surveillance with a dashboard on it.\n *\n * `uid` is deliberately not projected. The corpus question is what people\n * asked, never who asked it — and the exchange is the only document that\n * still carries an identifier at all.\n *\n * ## An expired exchange is an ANSWER\n *\n * The 180-day TTL means a signal can outlive its prose, and it is meant\n * to. A missing document therefore reports `expired: true` rather than\n * being dropped: a shortlist silently shorter than the failure count it\n * came from would read as \"these are all the failures\", which is the\n * AGL-2220 shape.\n *=========================================*/\n const firestore = firebaseAdmin.app().firestore()\n const exchanges = report.proseCandidates.length\n ? await firestore.getAll(\n ...report.proseCandidates.map((candidate) =>\n firestore\n .collection('orgs')\n .doc(candidate.orgId)\n .collection('assistExchanges')\n .doc(candidate.exchangeId),\n ),\n )\n : []\n const prose = report.proseCandidates.map((candidate, index) => {\n const snapshot = exchanges[index]\n const data = (snapshot?.exists ? snapshot.data() : null) ?? {}\n return {\n ...candidate,\n expired: !snapshot?.exists,\n question: typeof data['question'] === 'string' ? data['question'] : null,\n // Trimmed, not omitted: judging whether an answer was wrong needs to\n // see it, and a whole answer per row would make the panel unreadable.\n answer:\n typeof data['answer'] === 'string'\n ? data['answer'].slice(0, ANSWER_PREVIEW_CHARS)\n : null,\n answerTruncated:\n typeof data['answer'] === 'string' &&\n data['answer'].length > ANSWER_PREVIEW_CHARS,\n }\n })\n\n /*==========================================\n * THE WORKSPACE, BY NAME.\n *\n * A staff reader recognizes a customer by name, never by document id,\n * and the cost board's own column header says \"Workspace\" over a column\n * of generated ids. `/api/admin/overview` fixed the same shape by\n * building a label map from a snapshot it already held; there is no such\n * snapshot here — this route reads `assistSignals` and nothing else — so\n * the labels are fetched, with the same `name` → `slug` → id precedence\n * so one workspace cannot appear under two different labels on two\n * screens.\n *\n * Bounded by what is actually RENDERED, not by the fleet. Both lists the\n * ids come from are already sliced to `limit` (25 by default, 100 at the\n * ceiling), so this is one `getAll` over at most a couple of hundred\n * documents — and it reads the orgs the reader can see rather than\n * scanning a collection to name a handful of rows.\n *\n * An org that has been deleted keeps its id rather than going blank: an\n * unfamiliar id is still a lead a staff member can paste into the org\n * search, and an empty cell is not.\n *=========================================*/\n const orgIds = [\n ...new Set(\n [\n ...report.orgs.map((row) => row.orgId),\n ...report.proseCandidates.map((row) => row.orgId),\n ].filter((orgId) => orgId && orgId !== '(unknown)'),\n ),\n ]\n const orgLabelById = new Map<string, string>()\n if (orgIds.length) {\n const orgDocs = await firestore.getAll(\n ...orgIds.map((orgId) => firestore.collection('orgs').doc(orgId)),\n )\n for (const doc of orgDocs) {\n if (!doc.exists) continue\n const data = doc.data() ?? {}\n const label = (data['name'] ?? data['slug'] ?? '') as string\n if (label) orgLabelById.set(doc.id, label)\n }\n }\n const orgLabel = (orgId: string): string =>\n orgLabelById.get(orgId) ?? orgId\n\n // Today's free-tier spend against the platform ceiling (AGL-2925): one\n // document read, and the only place staff can see the taste switching\n // itself off before the email arrives. Best-effort — a readout that\n // cannot be read must not take the rest of the board down with it.\n const freeSpend = await readPlatformFreeSpend(\n firestore,\n assistUsageDay(),\n ).catch((error) => {\n console.error('[ai/admin/signals] free spend readout failed', error)\n return undefined\n })\n\n return Response.json(\n {\n ...report,\n freeSpend,\n orgs: report.orgs.map((row) => ({\n ...row,\n orgLabel: orgLabel(row.orgId),\n })),\n prose: prose.map((row) => ({ ...row, orgLabel: orgLabel(row.orgId) })),\n spend,\n ceiling: SCAN_CEILING,\n },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/signals]', error)\n return Response.json({ error: 'Assist signal lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","ASSIST_PROVIDER_COST_FIELD","assistCreditsFromUsd","assistProviderCostUsd","hasAiAddon","resolveEffectivePlan","readPlatformFreeSpend","assistRefusalCounts","assistUsageDay","assistUsageMonth","invalidIdTokenResponse","assistSignalRow","mineAssistSignals","rankAssistSpend","SCAN_CEILING","ANSWER_PREVIEW_CHARS","SPEND_SCAN_CEILING","readAssistSpendLeaderboard","firestore","limit","month","rankAssistSpendForMonth","error","console","rows","ranked","truncated","failed","snapshot","collectionGroup","get","size","docs","slice","unranked","doc","id","orgId","ref","parent","billed","Number","billedUsd","isFinite","push","plan","aiAddon","credits","providerCostUsd","refusals","length","orgRefs","map","row","collection","orgSnaps","billingSnaps","Promise","all","getAll","forEach","index","orgData","exists","data","org","label","orgLabel","handler","request","headers","method","query","rawHeaders","Response","json","status","authorization","idToken","startsWith","undefined","decoded","app","auth","verifyIdToken","email_verified","requested","Math","min","floor","report","spend","exchanges","proseCandidates","candidate","exchangeId","prose","expired","question","answer","answerTruncated","orgIds","Set","orgs","filter","orgLabelById","Map","orgDocs","set","freeSpend","catch","ceiling","unauthenticated","GET"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-signals.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport {\n ASSIST_PROVIDER_COST_FIELD,\n assistCreditsFromUsd,\n assistProviderCostUsd,\n} from '../usage/assist-credits'\nimport {\n hasAiAddon,\n resolveEffectivePlan,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { readPlatformFreeSpend } from '../usage/assist-free-taste'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { assistUsageDay, assistUsageMonth } from '../usage/assist-usage'\nimport {\n ASSIST_RETURNED_USD_FIELD,\n assistSpendAfterReturnsUsd,\n} from '../usage/assist-credit-returns'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n assistSignalRow,\n mineAssistSignals,\n rankAssistSpend,\n type AssistSpendRow,\n} from '../usage/assist-signal-mining'\n\n/**\n * The Assist docs-gap and cost mining view (AGL-1860, AGL-2252), served at\n * `/api/ai/admin/signals` for the plugin's Assist signal staff page.\n * Staff-gated, read-only, and the only reader `assistSignals` has.\n *\n * ## Why a route and not a client query\n *\n * `assistSignals` is absent from `firebase-firestore.rules` deliberately: the\n * org block matches its subcollections BY NAME and carries no wildcard, so an\n * unlisted name is default-deny for every client. That is the property that\n * lets the collection hold cross-tenant analytics at all. Reading it therefore\n * goes through the Admin SDK behind the staff claim, exactly as every staff\n * route does.\n *\n * ## The collection group, and the index it does not need\n *\n * One `collectionGroup('assistSignals')` read gathers every org's signals in a\n * single query; the owning org is `ref.parent.parent.id`, since the document\n * carries no `orgId` field (it carries no identifiers at all — see AGL-1972).\n * The query has no `where` and no `orderBy`, so it needs no composite index,\n * and adding one later would mean declaring a `COLLECTION_GROUP`-scoped\n * override and deploying it by hand. `/api/admin/overview` reads org billing\n * the same way and for the same reason.\n *\n * ## Truncation is reported, never hidden\n *\n * The read is bounded — an unbounded fleet-wide get is how a staff page\n * becomes an outage — but a ranking cut short looks exactly like a complete\n * one, so the ceiling is fetched PLUS ONE and the extra row sets `truncated`.\n * AGL-2220 is the standing example of the alternative: a sweep that stopped at\n * 500 orgs and reported nothing about the rest, for as long as nobody counted.\n */\n\n/**\n * Rows read in one pass. Each signal is a handful of scalars, so this is a\n * few megabytes at the ceiling — large enough to be worth ranking, small\n * enough that the honest answer to \"is it all of them\" is usually yes.\n */\nconst SCAN_CEILING = 20000\n\n/**\n * How much of an answer the prose panel shows.\n *\n * Enough to judge whether the answer was wrong, short enough that twenty-five\n * of them are still a page somebody reads.\n */\nconst ANSWER_PREVIEW_CHARS = 600\n\n/**\n * Month documents read in one pass for the spend leaderboard (AGL-2930).\n *\n * `assistUsage` holds one document per org per month, so this is orgs times\n * months — a fraction of the signal count, and the same PLUS-ONE truncation\n * probe the signals read uses. Read as a collection group with NO `where`\n * and NO `orderBy`, for the reason the module note gives: a filter on\n * `month` would need a COLLECTION_GROUP-scoped index deployed by hand, and\n * filtering the month in memory costs nothing at this size.\n */\nconst SPEND_SCAN_CEILING = 20000\n\nexport interface AssistSpendLeaderboard {\n month: string\n rows: AssistSpendRow[]\n /** How many workspaces had a document for the month — the ranked count. */\n ranked: number\n /** True when the scan hit its ceiling, so the ranking is over a sample. */\n truncated: boolean\n /**\n * True when the month documents could not be read at all. Reported rather\n * than rendered as an empty table: \"no workspace spent anything\" and \"the\n * read failed\" are opposite findings, and the rest of the board — read\n * from a different collection — is still good.\n */\n failed: boolean\n}\n\n/**\n * The month's per-org spend, refusals and plan, dearest first.\n *\n * Plan and add-on come from the org and billing documents of the ranked\n * rows only — bounded by `limit`, never by the fleet — merged org-doc-first\n * exactly as every other staff reader merges them.\n */\nasync function readAssistSpendLeaderboard(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n try {\n return await rankAssistSpendForMonth(firestore, limit, month)\n } catch (error) {\n console.error('[ai/admin/signals] spend leaderboard read failed', error)\n return { month, rows: [], ranked: 0, truncated: false, failed: true }\n }\n}\n\nasync function rankAssistSpendForMonth(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n const snapshot = await firestore\n .collectionGroup('assistUsage')\n .limit(SPEND_SCAN_CEILING + 1)\n .get()\n const truncated = snapshot.size > SPEND_SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SPEND_SCAN_CEILING) : snapshot.docs\n const unranked: AssistSpendRow[] = []\n for (const doc of docs) {\n // The id IS the month; the field is the same value and is what an\n // older document may lack.\n if (doc.id !== month && doc.get('month') !== month) continue\n const orgId = doc.ref.parent.parent?.id\n if (!orgId) continue\n // Two figures off one document (AGL-3015): the credits a workspace drew\n // come from what it was BILLED, and the dollars staff rank workspaces by\n // come from what it COST US. Ranking on the billed figure would sort the\n // fleet by our own markup wherever workspaces differ in model mix.\n const billed = Number(doc.get('estCostUsd') ?? 0)\n const billedUsd = Number.isFinite(billed) && billed > 0 ? billed : 0\n unranked.push({\n orgId,\n plan: 'free',\n aiAddon: false,\n // Net of credits given back (AGL-3595); the provider dollars are not.\n credits: assistCreditsFromUsd(\n assistSpendAfterReturnsUsd(billedUsd, doc.get(ASSIST_RETURNED_USD_FIELD)),\n ),\n providerCostUsd: assistProviderCostUsd(\n billedUsd,\n doc.get(ASSIST_PROVIDER_COST_FIELD),\n ),\n refusals: assistRefusalCounts(doc.get('refusals')),\n })\n }\n const { rows, ranked } = rankAssistSpend(unranked, limit)\n if (rows.length) {\n const orgRefs = rows.map((row) => firestore.collection('orgs').doc(row.orgId))\n const [orgSnaps, billingSnaps] = await Promise.all([\n firestore.getAll(...orgRefs),\n firestore.getAll(\n ...orgRefs.map((ref) =>\n ref.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID),\n ),\n ),\n ])\n rows.forEach((row, index) => {\n const orgData = orgSnaps[index]?.exists ? (orgSnaps[index].data() ?? {}) : {}\n const org = {\n ...orgData,\n ...(billingSnaps[index]?.exists ? billingSnaps[index].data() : {}),\n }\n row.plan = resolveEffectivePlan(org as never)\n row.aiAddon = hasAiAddon(org as never)\n const label = (orgData['name'] ?? orgData['slug'] ?? '') as string\n row.orgLabel = label || null\n })\n }\n return { month, rows, ranked, truncated, failed: false }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const requested = Number(query.limit ?? 25)\n const limit =\n Number.isFinite(requested) && requested > 0\n ? Math.min(Math.floor(requested), 100)\n : 25\n\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collectionGroup('assistSignals')\n // Ceiling PLUS ONE: the extra document is what makes truncation\n // detectable rather than assumed.\n .limit(SCAN_CEILING + 1)\n .get()\n\n const truncated = snapshot.size > SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SCAN_CEILING) : snapshot.docs\n const rows = docs.map((doc) =>\n assistSignalRow(\n doc.ref.parent.parent?.id ?? '(unknown)',\n // The signal id IS the exchange id (AGL-2314) — one id, two halves,\n // minted together by `recordAssistExchange` precisely so the join is\n // possible. Until now nothing carried it this far.\n doc.id,\n doc.data(),\n ),\n )\n\n const report = mineAssistSignals(rows, { truncated, limit })\n\n // This month's spend, per workspace (AGL-2930) — the board's only\n // month-scoped panel, read off the month documents rather than mined\n // from the all-time signals above.\n const spend = await readAssistSpendLeaderboard(\n firebaseAdmin.app().firestore(),\n limit,\n assistUsageMonth(),\n )\n\n /*==========================================\n * THE VERBATIM HALF (AGL-2314).\n *\n * `assistExchanges` stores the question a customer typed and the answer\n * we gave, for 180 days, and `assist-usage.ts` calls it \"the data loop's\n * corpus\". NOTHING read it — one write, no reader, no collection group.\n * So we retained customers' words, and committed publicly in the privacy\n * policy to retaining them, for zero product value.\n *\n * Read here rather than in the miner because the miner is pure: it does\n * the arithmetic, the route does the reading.\n *\n * ## Only the turns that FAILED\n *\n * The shortlist is thumbs-down and ungrounded turns only, capped by the\n * same `limit` as every other panel. That is a privacy decision as much\n * as a cost one: the counts already answer \"how often\", and the words are\n * fetched only where the counts CANNOT say what went wrong. Pulling every\n * exchange would be surveillance with a dashboard on it.\n *\n * `uid` is deliberately not projected. The corpus question is what people\n * asked, never who asked it — and the exchange is the only document that\n * still carries an identifier at all.\n *\n * ## An expired exchange is an ANSWER\n *\n * The 180-day TTL means a signal can outlive its prose, and it is meant\n * to. A missing document therefore reports `expired: true` rather than\n * being dropped: a shortlist silently shorter than the failure count it\n * came from would read as \"these are all the failures\", which is the\n * AGL-2220 shape.\n *=========================================*/\n const firestore = firebaseAdmin.app().firestore()\n const exchanges = report.proseCandidates.length\n ? await firestore.getAll(\n ...report.proseCandidates.map((candidate) =>\n firestore\n .collection('orgs')\n .doc(candidate.orgId)\n .collection('assistExchanges')\n .doc(candidate.exchangeId),\n ),\n )\n : []\n const prose = report.proseCandidates.map((candidate, index) => {\n const snapshot = exchanges[index]\n const data = (snapshot?.exists ? snapshot.data() : null) ?? {}\n return {\n ...candidate,\n expired: !snapshot?.exists,\n question: typeof data['question'] === 'string' ? data['question'] : null,\n // Trimmed, not omitted: judging whether an answer was wrong needs to\n // see it, and a whole answer per row would make the panel unreadable.\n answer:\n typeof data['answer'] === 'string'\n ? data['answer'].slice(0, ANSWER_PREVIEW_CHARS)\n : null,\n answerTruncated:\n typeof data['answer'] === 'string' &&\n data['answer'].length > ANSWER_PREVIEW_CHARS,\n }\n })\n\n /*==========================================\n * THE WORKSPACE, BY NAME.\n *\n * A staff reader recognizes a customer by name, never by document id,\n * and the cost board's own column header says \"Workspace\" over a column\n * of generated ids. `/api/admin/overview` fixed the same shape by\n * building a label map from a snapshot it already held; there is no such\n * snapshot here — this route reads `assistSignals` and nothing else — so\n * the labels are fetched, with the same `name` → `slug` → id precedence\n * so one workspace cannot appear under two different labels on two\n * screens.\n *\n * Bounded by what is actually RENDERED, not by the fleet. Both lists the\n * ids come from are already sliced to `limit` (25 by default, 100 at the\n * ceiling), so this is one `getAll` over at most a couple of hundred\n * documents — and it reads the orgs the reader can see rather than\n * scanning a collection to name a handful of rows.\n *\n * An org that has been deleted keeps its id rather than going blank: an\n * unfamiliar id is still a lead a staff member can paste into the org\n * search, and an empty cell is not.\n *=========================================*/\n const orgIds = [\n ...new Set(\n [\n ...report.orgs.map((row) => row.orgId),\n ...report.proseCandidates.map((row) => row.orgId),\n ].filter((orgId) => orgId && orgId !== '(unknown)'),\n ),\n ]\n const orgLabelById = new Map<string, string>()\n if (orgIds.length) {\n const orgDocs = await firestore.getAll(\n ...orgIds.map((orgId) => firestore.collection('orgs').doc(orgId)),\n )\n for (const doc of orgDocs) {\n if (!doc.exists) continue\n const data = doc.data() ?? {}\n const label = (data['name'] ?? data['slug'] ?? '') as string\n if (label) orgLabelById.set(doc.id, label)\n }\n }\n const orgLabel = (orgId: string): string =>\n orgLabelById.get(orgId) ?? orgId\n\n // Today's free-tier spend against the platform ceiling (AGL-2925): one\n // document read, and the only place staff can see the taste switching\n // itself off before the email arrives. Best-effort — a readout that\n // cannot be read must not take the rest of the board down with it.\n const freeSpend = await readPlatformFreeSpend(\n firestore,\n assistUsageDay(),\n ).catch((error) => {\n console.error('[ai/admin/signals] free spend readout failed', error)\n return undefined\n })\n\n return Response.json(\n {\n ...report,\n freeSpend,\n orgs: report.orgs.map((row) => ({\n ...row,\n orgLabel: orgLabel(row.orgId),\n })),\n prose: prose.map((row) => ({ ...row, orgLabel: orgLabel(row.orgId) })),\n spend,\n ceiling: SCAN_CEILING,\n },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/signals]', error)\n return Response.json({ error: 'Assist signal lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","ASSIST_PROVIDER_COST_FIELD","assistCreditsFromUsd","assistProviderCostUsd","hasAiAddon","resolveEffectivePlan","readPlatformFreeSpend","assistRefusalCounts","assistUsageDay","assistUsageMonth","ASSIST_RETURNED_USD_FIELD","assistSpendAfterReturnsUsd","invalidIdTokenResponse","assistSignalRow","mineAssistSignals","rankAssistSpend","SCAN_CEILING","ANSWER_PREVIEW_CHARS","SPEND_SCAN_CEILING","readAssistSpendLeaderboard","firestore","limit","month","rankAssistSpendForMonth","error","console","rows","ranked","truncated","failed","snapshot","collectionGroup","get","size","docs","slice","unranked","doc","id","orgId","ref","parent","billed","Number","billedUsd","isFinite","push","plan","aiAddon","credits","providerCostUsd","refusals","length","orgRefs","map","row","collection","orgSnaps","billingSnaps","Promise","all","getAll","forEach","index","orgData","exists","data","org","label","orgLabel","handler","request","headers","method","query","rawHeaders","Response","json","status","authorization","idToken","startsWith","undefined","decoded","app","auth","verifyIdToken","email_verified","requested","Math","min","floor","report","spend","exchanges","proseCandidates","candidate","exchangeId","prose","expired","question","answer","answerTruncated","orgIds","Set","orgs","filter","orgLabelById","Map","orgDocs","set","freeSpend","catch","ceiling","unauthenticated","GET"],"mappings":"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"}
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
*/ import { pluginRequestFromWeb } from "@aglyn/aglyn/server";
|
|
17
17
|
import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
|
|
18
18
|
import { readUserAiUsageMonths } from "../usage/ai-usage-by-user.js";
|
|
19
|
+
import { freeAccountUsageRef } from "../usage/assist-free-taste.js";
|
|
20
|
+
import { assistUsageMonth } from "../usage/assist-usage.js";
|
|
21
|
+
import { composeStaffAiAccountAllowance } from "../usage/staff-org-ai.js";
|
|
19
22
|
import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
20
23
|
/**
|
|
21
24
|
* ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user
|
|
@@ -56,6 +59,7 @@ async function handler(request) {
|
|
|
56
59
|
status: 400
|
|
57
60
|
});
|
|
58
61
|
try {
|
|
62
|
+
var _allowanceSnap_data;
|
|
59
63
|
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
60
64
|
if (!decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
61
65
|
return emailUnverifiedResponse();
|
|
@@ -86,10 +90,17 @@ async function handler(request) {
|
|
|
86
90
|
}
|
|
87
91
|
// Newest month first, dearest workspace first within it.
|
|
88
92
|
rows.sort((a, b)=>b.month.localeCompare(a.month) || b.credits - a.credits);
|
|
93
|
+
// The account's own Free allowance this month (AGL-2925), which every
|
|
94
|
+
// free workspace this person owns draws on and which staff can give
|
|
95
|
+
// credits back to from this page (AGL-3595).
|
|
96
|
+
const month = assistUsageMonth();
|
|
97
|
+
const allowanceSnap = await freeAccountUsageRef(firestore, uid, month).get();
|
|
98
|
+
const allowance = composeStaffAiAccountAllowance(uid, month, allowanceSnap.exists ? (_allowanceSnap_data = allowanceSnap.data()) != null ? _allowanceSnap_data : null : null);
|
|
89
99
|
return Response.json({
|
|
90
100
|
uid,
|
|
91
101
|
rows,
|
|
92
|
-
truncated: reverse.size >= MAX_ORGS
|
|
102
|
+
truncated: reverse.size >= MAX_ORGS,
|
|
103
|
+
allowance
|
|
93
104
|
}, {
|
|
94
105
|
status: 200
|
|
95
106
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-user.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { readUserAiUsageMonths } from '../usage/ai-usage-by-user'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user\n * page's card, beside `admin/users/detail`.\n *\n * Walks the account's reverse index of memberships, the same read the detail\n * route makes, and lists each workspace's months for this uid newest first.\n * A membership walk rather than a collection-group query, so the card works\n * the moment it ships and needs no index deployed first; a workspace the\n * person has left is not listed, and its months expire on the retention\n * window.\n *\n * Read only, and not audited: the card loads with every open of the staff\n * user page, the same reason the org card writes no row.\n */\n\n/** The most workspaces one account is walked across. */\nconst MAX_ORGS = 50\n\nexport interface StaffUserAiUsageRow {\n orgId: string\n orgName: string | null\n slug: string | null\n month: string\n credits: number\n requests: number\n refusals: number\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const uid = String(query.uid ?? '').trim()\n if (!uid) return Response.json({ error: 'Missing uid' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const reverse = await firestore\n .collection('users')\n .doc(uid)\n .collection('orgs')\n .limit(MAX_ORGS)\n .get()\n\n const rows: StaffUserAiUsageRow[] = []\n for (const entry of reverse.docs) {\n const months = await readUserAiUsageMonths(firestore, entry.id, uid)\n for (const month of months) {\n rows.push({\n orgId: entry.id,\n orgName: typeof entry.get('orgName') === 'string' ? String(entry.get('orgName')) : null,\n slug: typeof entry.get('slug') === 'string' ? String(entry.get('slug')) : null,\n month: month.month,\n credits: month.credits,\n requests: month.requests,\n refusals: month.refusals,\n })\n }\n }\n // Newest month first, dearest workspace first within it.\n rows.sort(\n (a, b) => b.month.localeCompare(a.month) || b.credits - a.credits,\n )\n return Response.json(\n { uid, rows, truncated: reverse.size >= MAX_ORGS },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/user]', error)\n return Response.json({ error: 'AI usage lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readUserAiUsageMonths","invalidIdTokenResponse","MAX_ORGS","handler","request","headers","query","method","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","uid","String","trim","decoded","app","auth","verifyIdToken","email_verified","firestore","reverse","collection","doc","limit","get","rows","entry","docs","months","id","month","push","orgId","orgName","slug","credits","requests","refusals","sort","a","b","localeCompare","truncated","size","unauthenticated","console","GET"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,sBAAqB;AAC1D,SACEC,uBAAuB,EACvBC,aAAa,EACbC,sBAAsB,QACjB,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,+BAA2B;AACjE,SAASC,sBAAsB,QAAQ,mDAAkD;AAEzF;;;;;;;;;;;;;CAaC,GAED,sDAAsD,GACtD,MAAMC,WAAW;AAYjB,eAAeC,QAAQC,OAAgB;QAMfC,wBAKHC;IAVnB,MAAM,EAAEC,MAAM,EAAED,KAAK,EAAED,SAASG,UAAU,EAAE,GAAG,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-user.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { readUserAiUsageMonths } from '../usage/ai-usage-by-user'\nimport { freeAccountUsageRef } from '../usage/assist-free-taste'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { composeStaffAiAccountAllowance } from '../usage/staff-org-ai'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user\n * page's card, beside `admin/users/detail`.\n *\n * Walks the account's reverse index of memberships, the same read the detail\n * route makes, and lists each workspace's months for this uid newest first.\n * A membership walk rather than a collection-group query, so the card works\n * the moment it ships and needs no index deployed first; a workspace the\n * person has left is not listed, and its months expire on the retention\n * window.\n *\n * Read only, and not audited: the card loads with every open of the staff\n * user page, the same reason the org card writes no row.\n */\n\n/** The most workspaces one account is walked across. */\nconst MAX_ORGS = 50\n\nexport interface StaffUserAiUsageRow {\n orgId: string\n orgName: string | null\n slug: string | null\n month: string\n credits: number\n requests: number\n refusals: number\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const uid = String(query.uid ?? '').trim()\n if (!uid) return Response.json({ error: 'Missing uid' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const reverse = await firestore\n .collection('users')\n .doc(uid)\n .collection('orgs')\n .limit(MAX_ORGS)\n .get()\n\n const rows: StaffUserAiUsageRow[] = []\n for (const entry of reverse.docs) {\n const months = await readUserAiUsageMonths(firestore, entry.id, uid)\n for (const month of months) {\n rows.push({\n orgId: entry.id,\n orgName: typeof entry.get('orgName') === 'string' ? String(entry.get('orgName')) : null,\n slug: typeof entry.get('slug') === 'string' ? String(entry.get('slug')) : null,\n month: month.month,\n credits: month.credits,\n requests: month.requests,\n refusals: month.refusals,\n })\n }\n }\n // Newest month first, dearest workspace first within it.\n rows.sort(\n (a, b) => b.month.localeCompare(a.month) || b.credits - a.credits,\n )\n // The account's own Free allowance this month (AGL-2925), which every\n // free workspace this person owns draws on and which staff can give\n // credits back to from this page (AGL-3595).\n const month = assistUsageMonth()\n const allowanceSnap = await freeAccountUsageRef(firestore, uid, month).get()\n const allowance = composeStaffAiAccountAllowance(\n uid,\n month,\n allowanceSnap.exists ? (allowanceSnap.data() ?? null) : null,\n )\n return Response.json(\n { uid, rows, truncated: reverse.size >= MAX_ORGS, allowance },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/user]', error)\n return Response.json({ error: 'AI usage lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readUserAiUsageMonths","freeAccountUsageRef","assistUsageMonth","composeStaffAiAccountAllowance","invalidIdTokenResponse","MAX_ORGS","handler","request","headers","query","method","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","uid","String","trim","allowanceSnap","decoded","app","auth","verifyIdToken","email_verified","firestore","reverse","collection","doc","limit","get","rows","entry","docs","months","id","month","push","orgId","orgName","slug","credits","requests","refusals","sort","a","b","localeCompare","allowance","exists","data","truncated","size","unauthenticated","console","GET"],"mappings":"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"}
|
|
@@ -30,6 +30,7 @@ import { AI_MODEL_RESTRICTION_PLANS } from "../providers/model-choice.js";
|
|
|
30
30
|
import { resolveAiProvider } from "../providers/routing.js";
|
|
31
31
|
import { AI_HOST_CREDITS_FIELD, applyAiAllotmentWrites, listAiAllotments, seedAiHostMonthCredits } from "../usage/ai-allotments.js";
|
|
32
32
|
import { userAiUsageMonthRef } from "../usage/ai-usage-by-user.js";
|
|
33
|
+
import { ASSIST_RETURNED_USD_FIELD, assistSpendAfterReturnsUsd } from "../usage/assist-credit-returns.js";
|
|
33
34
|
import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
34
35
|
// lockdown-423: exempt — a spend control, the posture of ai/billing/overage
|
|
35
36
|
// beside it: a billing-locked workspace must still be able to LOWER what its
|
|
@@ -130,7 +131,7 @@ const memberName = (member)=>{
|
|
|
130
131
|
}, 0);
|
|
131
132
|
}
|
|
132
133
|
async function handleGet(request) {
|
|
133
|
-
var _params_hostId, _params_uid, _params_orgId, _ref, _ref1, _orgSnapshot_data
|
|
134
|
+
var _params_hostId, _params_uid, _params_orgId, _ref, _ref1, _orgSnapshot_data;
|
|
134
135
|
const { query, headers } = await pluginRequestFromWeb(request);
|
|
135
136
|
const params = query != null ? query : {};
|
|
136
137
|
const hostId = String((_params_hostId = params['hostId']) != null ? _params_hostId : '').trim();
|
|
@@ -224,7 +225,7 @@ async function handleGet(request) {
|
|
|
224
225
|
orgId,
|
|
225
226
|
month,
|
|
226
227
|
pool: {
|
|
227
|
-
used: assistCreditsFromUsd(
|
|
228
|
+
used: assistCreditsFromUsd(assistSpendAfterReturnsUsd(orgMonth.get('estCostUsd'), orgMonth.get(ASSIST_RETURNED_USD_FIELD))),
|
|
228
229
|
limit: resolveAssistCreditBudget(org)
|
|
229
230
|
},
|
|
230
231
|
allotments: rows,
|