@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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type AiInventoryTheme, type AiSiteInventory } from '../model/ai-site-inventory';
|
|
17
|
+
import { type AiInventoryScreen, type AiInventoryTheme, type AiSiteInventory } from '../model/ai-site-inventory';
|
|
18
18
|
/**
|
|
19
19
|
* The reader behind the site inventory (AGL-2935): one site's components,
|
|
20
20
|
* layouts, templates, forms, datasets, content collections, screens and
|
|
@@ -46,6 +46,21 @@ export interface ReadSiteInventoryOptions {
|
|
|
46
46
|
}
|
|
47
47
|
/** The theme a site renders with, reduced to what a plan and an email can act on. */
|
|
48
48
|
export declare function aiInventoryTheme(host: Data | null): AiInventoryTheme | null;
|
|
49
|
+
/**
|
|
50
|
+
* The screen a site's starter home may be (AGL-3594, AGL-3408): the one
|
|
51
|
+
* `host.defaultHomeScreenId` names, while it still answers `/` in the routing
|
|
52
|
+
* map and is listed as a page. `null` on every site whose owner published a
|
|
53
|
+
* home page of their own, which clears the marker (AGL-3478), and on every
|
|
54
|
+
* site created before the marker existed, whose home page is the owner's.
|
|
55
|
+
*/
|
|
56
|
+
export declare function aiStarterHomeCandidate(host: Readonly<Record<string, unknown>> | null, screens: ReadonlyArray<Pick<AiInventoryScreen, 'id' | 'template'>>): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Whether the starter home is as the platform wrote it: its one version is
|
|
59
|
+
* the one it was provisioned and published with. A version saved in the
|
|
60
|
+
* editor is an edit, published or not, and an edited starter is the owner's
|
|
61
|
+
* page — a plan keeps it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function aiStarterHomeUntouched(publishedVersionId: string, versionIds: readonly string[]): boolean;
|
|
49
64
|
/**
|
|
50
65
|
* Read one site's inventory for a generation.
|
|
51
66
|
*
|
|
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
18
18
|
import { isFormArchived } from "@aglyn/aglyn/app-utils/forms";
|
|
19
19
|
import { describeTheme, resolveSiteTheme } from "@aglyn/aglyn/app-utils/site-theme";
|
|
20
20
|
import { REUSABLE_COMPONENT_KIND_EMAIL, reusableComponentKindOf } from "@aglyn/aglyn/app-utils/reusable-component-kind";
|
|
21
|
-
import { SCREEN_KIND_TEMPLATE } from "@aglyn/aglyn/app-utils/screen-route";
|
|
21
|
+
import { SCREEN_KIND_TEMPLATE, SCREEN_ROOT_PATH } from "@aglyn/aglyn/app-utils/screen-route";
|
|
22
22
|
import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
|
|
23
23
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
24
24
|
import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
@@ -79,6 +79,28 @@ async function readWindow(query, fields, cap, map) {
|
|
|
79
79
|
truncated: snapshot.docs.length > cap
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The screen a site's starter home may be (AGL-3594, AGL-3408): the one
|
|
84
|
+
* `host.defaultHomeScreenId` names, while it still answers `/` in the routing
|
|
85
|
+
* map and is listed as a page. `null` on every site whose owner published a
|
|
86
|
+
* home page of their own, which clears the marker (AGL-3478), and on every
|
|
87
|
+
* site created before the marker existed, whose home page is the owner's.
|
|
88
|
+
*/ export function aiStarterHomeCandidate(host, screens) {
|
|
89
|
+
const id = text(host == null ? void 0 : host['defaultHomeScreenId']);
|
|
90
|
+
if (!id) return null;
|
|
91
|
+
const routing = host == null ? void 0 : host['screens'];
|
|
92
|
+
const path = routing && typeof routing === 'object' ? routing[id] : undefined;
|
|
93
|
+
if (path !== SCREEN_ROOT_PATH) return null;
|
|
94
|
+
return screens.some((screen)=>screen.id === id && !screen.template) ? id : null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the starter home is as the platform wrote it: its one version is
|
|
98
|
+
* the one it was provisioned and published with. A version saved in the
|
|
99
|
+
* editor is an edit, published or not, and an edited starter is the owner's
|
|
100
|
+
* page — a plan keeps it.
|
|
101
|
+
*/ export function aiStarterHomeUntouched(publishedVersionId, versionIds) {
|
|
102
|
+
return Boolean(publishedVersionId) && versionIds.length === 1 && versionIds[0] === publishedVersionId;
|
|
103
|
+
}
|
|
82
104
|
/**
|
|
83
105
|
* The org's datasets shared with the site, through the `dataset` index the
|
|
84
106
|
* data plugin publishes (AGL-3080) rather than its collection: their names
|
|
@@ -130,6 +152,9 @@ async function readWindow(query, fields, cap, map) {
|
|
|
130
152
|
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
131
153
|
const cap = Math.min(AI_SITE_INVENTORY_MAX_PER_KIND, Math.max(1, Math.floor((_options_maxPerKind = options.maxPerKind) != null ? _options_maxPerKind : AI_SITE_INVENTORY_MAX_PER_KIND)));
|
|
132
154
|
const host = firestore.collection('hosts').doc(hostId);
|
|
155
|
+
// Each listed screen's published version, read in the same window, for the
|
|
156
|
+
// starter home's untouched test below.
|
|
157
|
+
const screenVersions = new Map();
|
|
133
158
|
const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] = await Promise.all([
|
|
134
159
|
readWindow(host.collection('components'), [
|
|
135
160
|
'displayName',
|
|
@@ -208,11 +233,13 @@ async function readWindow(query, fields, cap, map) {
|
|
|
208
233
|
'slug',
|
|
209
234
|
'layoutId',
|
|
210
235
|
'kind',
|
|
211
|
-
'deletedAt'
|
|
236
|
+
'deletedAt',
|
|
237
|
+
'versionId'
|
|
212
238
|
], cap, (id, data)=>{
|
|
213
239
|
const kind = text(data['kind']);
|
|
214
240
|
// An email design and an error body are not pages a plan links or duplicates.
|
|
215
241
|
if (data['deletedAt'] || kind && kind !== SCREEN_KIND_TEMPLATE) return null;
|
|
242
|
+
screenVersions.set(id, text(data['versionId']));
|
|
216
243
|
return {
|
|
217
244
|
id,
|
|
218
245
|
name: nameOf(data, id, 'displayName', 'slug'),
|
|
@@ -223,6 +250,19 @@ async function readWindow(query, fields, cap, map) {
|
|
|
223
250
|
}),
|
|
224
251
|
host.get()
|
|
225
252
|
]);
|
|
253
|
+
const hostData = hostDoc.exists ? (_hostDoc_data = hostDoc.data()) != null ? _hostDoc_data : {} : null;
|
|
254
|
+
const starter = aiStarterHomeCandidate(hostData, screens.rows);
|
|
255
|
+
if (starter) {
|
|
256
|
+
var _screenVersions_get;
|
|
257
|
+
// One more read, and only on a site that still carries the marker: the
|
|
258
|
+
// starter's versions, of which an untouched one has exactly the one it
|
|
259
|
+
// was provisioned with.
|
|
260
|
+
const versions = await host.collection('screens').doc(starter).collection('versions').select().limit(2).get();
|
|
261
|
+
if (aiStarterHomeUntouched((_screenVersions_get = screenVersions.get(starter)) != null ? _screenVersions_get : '', versions.docs.map((doc)=>doc.id))) {
|
|
262
|
+
const row = screens.rows.find((screen)=>screen.id === starter);
|
|
263
|
+
if (row) row.replaceable = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
226
266
|
const windows = {
|
|
227
267
|
components,
|
|
228
268
|
layouts,
|
|
@@ -241,7 +281,7 @@ async function readWindow(query, fields, cap, map) {
|
|
|
241
281
|
datasets: datasets.rows,
|
|
242
282
|
collections: collections.rows,
|
|
243
283
|
screens: screens.rows,
|
|
244
|
-
theme: aiInventoryTheme(
|
|
284
|
+
theme: aiInventoryTheme(hostData),
|
|
245
285
|
truncated: AI_INVENTORY_KINDS.filter((kind)=>windows[kind].truncated)
|
|
246
286
|
};
|
|
247
287
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.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 { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE } from '@aglyn/aglyn/app-utils/screen-route'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => {\n const fields = (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => ({ id: text(field?.['id']), name: text(field?.['name']) || text(field?.['id']) }))\n .filter((field) => field.name)\n return {\n id: record.id,\n name: record.name,\n fields: fields.map((field) => field.name),\n fieldIds: fields.map((field) => field.id || field.name),\n }\n }),\n truncated,\n }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readIndexedDatasets(orgId, hostId, cap),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","filter","fieldIds","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","components","layouts","templates","forms","collections","screens","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","Boolean","slug","layoutId","template","windows","exists"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.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 { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE, SCREEN_ROOT_PATH } from '@aglyn/aglyn/app-utils/screen-route'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * The screen a site's starter home may be (AGL-3594, AGL-3408): the one\n * `host.defaultHomeScreenId` names, while it still answers `/` in the routing\n * map and is listed as a page. `null` on every site whose owner published a\n * home page of their own, which clears the marker (AGL-3478), and on every\n * site created before the marker existed, whose home page is the owner's.\n */\nexport function aiStarterHomeCandidate(\n host: Readonly<Record<string, unknown>> | null,\n screens: ReadonlyArray<Pick<AiInventoryScreen, 'id' | 'template'>>,\n): string | null {\n const id = text(host?.['defaultHomeScreenId'])\n if (!id) return null\n const routing = host?.['screens']\n const path =\n routing && typeof routing === 'object' ? (routing as Record<string, unknown>)[id] : undefined\n if (path !== SCREEN_ROOT_PATH) return null\n return screens.some((screen) => screen.id === id && !screen.template) ? id : null\n}\n\n/**\n * Whether the starter home is as the platform wrote it: its one version is\n * the one it was provisioned and published with. A version saved in the\n * editor is an edit, published or not, and an edited starter is the owner's\n * page — a plan keeps it.\n */\nexport function aiStarterHomeUntouched(\n publishedVersionId: string,\n versionIds: readonly string[],\n): boolean {\n return Boolean(publishedVersionId) && versionIds.length === 1 && versionIds[0] === publishedVersionId\n}\n\n/**\n * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => {\n const fields = (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => ({ id: text(field?.['id']), name: text(field?.['name']) || text(field?.['id']) }))\n .filter((field) => field.name)\n return {\n id: record.id,\n name: record.name,\n fields: fields.map((field) => field.name),\n fieldIds: fields.map((field) => field.id || field.name),\n }\n }),\n truncated,\n }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n // Each listed screen's published version, read in the same window, for the\n // starter home's untouched test below.\n const screenVersions = new Map<string, string>()\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readIndexedDatasets(orgId, hostId, cap),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt', 'versionId'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n screenVersions.set(id, text(data['versionId']))\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const hostData = hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null\n const starter = aiStarterHomeCandidate(hostData, screens.rows)\n if (starter) {\n // One more read, and only on a site that still carries the marker: the\n // starter's versions, of which an untouched one has exactly the one it\n // was provisioned with.\n const versions = await host\n .collection('screens')\n .doc(starter)\n .collection('versions')\n .select()\n .limit(2)\n .get()\n if (aiStarterHomeUntouched(screenVersions.get(starter) ?? '', versions.docs.map((doc) => doc.id))) {\n const row = screens.rows.find((screen) => screen.id === starter)\n if (row) row.replaceable = true\n }\n }\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostData),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","SCREEN_ROOT_PATH","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","aiStarterHomeCandidate","screens","routing","undefined","some","screen","template","aiStarterHomeUntouched","publishedVersionId","versionIds","Boolean","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","filter","fieldIds","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","screenVersions","Map","components","layouts","templates","forms","collections","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","slug","set","layoutId","hostData","exists","starter","versions","find","replaceable","windows"],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** The staff roles that may give credits back. */
|
|
18
|
+
export declare const AI_CREDIT_RETURN_ROLES: readonly ["super", "billing"];
|
|
19
|
+
declare function handler(request: Request): Promise<Response>;
|
|
20
|
+
export declare const dynamic = "force-dynamic";
|
|
21
|
+
export { handler as POST };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { ORG_BILLING_DOC_ID, ORG_BILLING_SUBCOLLECTION, pluginRequestFromWeb } from "@aglyn/aglyn/server";
|
|
18
|
+
import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { setAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
20
|
+
import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
21
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
22
|
+
import { freeAssistAccount } from "../usage/assist-free-taste.js";
|
|
23
|
+
import { assistUsageMonth } from "../usage/assist-usage.js";
|
|
24
|
+
import { accountCreditMeter, isAssistCreditReturnKey, returnAssistCredits, workspaceCreditMeter } from "../usage/assist-credit-returns-write.js";
|
|
25
|
+
/**
|
|
26
|
+
* STAFF GIVE AI CREDITS BACK (AGL-3595) — compensation for a fault of ours.
|
|
27
|
+
*
|
|
28
|
+
* A planner bug refused a Free workspace's plan after it had spent 227 of
|
|
29
|
+
* its 300 monthly credits, and the person could not try again until the
|
|
30
|
+
* month rolled. This is the control that undoes that: staff return credits
|
|
31
|
+
* to the meter they were drawn from, with a reason, and the person's next
|
|
32
|
+
* request is admitted against the reduced figure.
|
|
33
|
+
*
|
|
34
|
+
* POST `{ orgId | uid, action, meter, credits?, reason, jobId?, idempotencyKey }`
|
|
35
|
+
*
|
|
36
|
+
* action `give` return `credits` to each named meter
|
|
37
|
+
* `reset` return everything each meter used this month
|
|
38
|
+
* meter `workspace` the workspace's band (needs `orgId`)
|
|
39
|
+
* `account` a Free owner's allowance (`orgId` of a Free
|
|
40
|
+
* workspace, or the account's `uid`)
|
|
41
|
+
* `both` the two together, for a Free workspace
|
|
42
|
+
*
|
|
43
|
+
* ## The gate
|
|
44
|
+
*
|
|
45
|
+
* The staff claim, and then the `super` or `billing` staff role — the bar
|
|
46
|
+
* `/api/admin/org-override` holds for a quota write, because a give-back is
|
|
47
|
+
* one: it hands out credits that are worth money. A missing role is
|
|
48
|
+
* `support`, and refused (AGL-495).
|
|
49
|
+
*
|
|
50
|
+
* ## What it writes
|
|
51
|
+
*
|
|
52
|
+
* An adjustment, never an edit: `returnAssistCredits` adds to the month's
|
|
53
|
+
* `returnedUsd` and keeps the act under `creditReturns.{key}`, and one
|
|
54
|
+
* `adminAudit` row commits in the same transaction. A repeated key writes
|
|
55
|
+
* nothing and answers `duplicate`, so a double-click returns once.
|
|
56
|
+
*/ /** The longest a staff reason may be, so a paste cannot fill the document. */ const REASON_MAX = 500;
|
|
57
|
+
/** The staff roles that may give credits back. */ export const AI_CREDIT_RETURN_ROLES = [
|
|
58
|
+
'super',
|
|
59
|
+
'billing'
|
|
60
|
+
];
|
|
61
|
+
const refuse = (error, status, code, extra = {})=>Response.json(_extends({
|
|
62
|
+
error
|
|
63
|
+
}, code ? {
|
|
64
|
+
code
|
|
65
|
+
} : {}, extra), {
|
|
66
|
+
status
|
|
67
|
+
});
|
|
68
|
+
async function handler(request) {
|
|
69
|
+
var _headers_authorization;
|
|
70
|
+
const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request);
|
|
71
|
+
const headers = rawHeaders;
|
|
72
|
+
if (method !== 'POST') return refuse('Method not allowed', 405);
|
|
73
|
+
const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
|
|
74
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
75
|
+
if (!idToken) return refuse('Unauthenticated', 401);
|
|
76
|
+
try {
|
|
77
|
+
var _decoded_staffRole, _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
|
|
78
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
79
|
+
if (!decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
80
|
+
return emailUnverifiedResponse();
|
|
81
|
+
}
|
|
82
|
+
if (!decoded['staff']) return refuse('Staff only', 403);
|
|
83
|
+
const role = String((_decoded_staffRole = decoded['staffRole']) != null ? _decoded_staffRole : 'support');
|
|
84
|
+
if (!AI_CREDIT_RETURN_ROLES.includes(role)) {
|
|
85
|
+
return refuse('Requires the billing or super staff role', 403, 'role');
|
|
86
|
+
}
|
|
87
|
+
const orgId = String((_ref = body == null ? void 0 : body.orgId) != null ? _ref : '').trim();
|
|
88
|
+
const uid = String((_ref1 = body == null ? void 0 : body.uid) != null ? _ref1 : '').trim();
|
|
89
|
+
const action = String((_ref2 = body == null ? void 0 : body.action) != null ? _ref2 : '');
|
|
90
|
+
const meter = String((_ref3 = body == null ? void 0 : body.meter) != null ? _ref3 : '');
|
|
91
|
+
if (!orgId && !uid) return refuse('Name an organization or an account', 400);
|
|
92
|
+
if (action !== 'give' && action !== 'reset') return refuse('Bad action', 400);
|
|
93
|
+
if (![
|
|
94
|
+
'workspace',
|
|
95
|
+
'account',
|
|
96
|
+
'both'
|
|
97
|
+
].includes(meter)) {
|
|
98
|
+
return refuse('Bad meter', 400);
|
|
99
|
+
}
|
|
100
|
+
// Refused before anything is read: the reason is the audit row's whole
|
|
101
|
+
// value, and a row written without one cannot be corrected later.
|
|
102
|
+
const reason = String((_ref4 = body == null ? void 0 : body.reason) != null ? _ref4 : '').trim().slice(0, REASON_MAX);
|
|
103
|
+
if (!reason) {
|
|
104
|
+
return refuse('Say why the credits are being given back', 400, 'reason_required');
|
|
105
|
+
}
|
|
106
|
+
const key = body == null ? void 0 : body.idempotencyKey;
|
|
107
|
+
if (!isAssistCreditReturnKey(key)) {
|
|
108
|
+
return refuse('Missing idempotency key', 400, 'idempotency_key');
|
|
109
|
+
}
|
|
110
|
+
const credits = Number(body == null ? void 0 : body.credits);
|
|
111
|
+
if (action === 'give' && !(Number.isInteger(credits) && credits > 0)) {
|
|
112
|
+
return refuse('Give back a positive whole number of credits', 400, 'invalid_credits');
|
|
113
|
+
}
|
|
114
|
+
const jobId = String((_ref5 = body == null ? void 0 : body.jobId) != null ? _ref5 : '').trim().slice(0, 200) || null;
|
|
115
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
116
|
+
const month = assistUsageMonth(new Date());
|
|
117
|
+
const targets = [];
|
|
118
|
+
let accountUid = null;
|
|
119
|
+
if (orgId) {
|
|
120
|
+
var _orgSnap_data;
|
|
121
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
122
|
+
const [orgSnap, billingSnap] = await Promise.all([
|
|
123
|
+
orgRef.get(),
|
|
124
|
+
orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get()
|
|
125
|
+
]);
|
|
126
|
+
if (!orgSnap.exists) return refuse('No such organization', 404);
|
|
127
|
+
// Org doc first, billing mirror over it — the merge the staff card
|
|
128
|
+
// reads, so a workspace whose subscription died resolves as Free here
|
|
129
|
+
// exactly as it does at the gate.
|
|
130
|
+
const org = _extends({}, (_orgSnap_data = orgSnap.data()) != null ? _orgSnap_data : {}, billingSnap.exists ? billingSnap.data() : {});
|
|
131
|
+
if (meter !== 'account') targets.push(workspaceCreditMeter(firestore, orgId, month));
|
|
132
|
+
if (meter !== 'workspace') {
|
|
133
|
+
var _ref6;
|
|
134
|
+
var _freeAssistAccount;
|
|
135
|
+
accountUid = (_ref6 = (_freeAssistAccount = freeAssistAccount(org)) == null ? void 0 : _freeAssistAccount.accountUid) != null ? _ref6 : null;
|
|
136
|
+
if (!accountUid) {
|
|
137
|
+
return refuse('Only a Free workspace with an owner draws on an account allowance', 409, 'no_account_allowance');
|
|
138
|
+
}
|
|
139
|
+
targets.push(accountCreditMeter(firestore, accountUid, month));
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// From the staff user page: the account's own allowance, nothing else.
|
|
143
|
+
if (meter !== 'account') {
|
|
144
|
+
return refuse('An account has only its allowance to give back to', 400);
|
|
145
|
+
}
|
|
146
|
+
accountUid = uid;
|
|
147
|
+
targets.push(accountCreditMeter(firestore, uid, month));
|
|
148
|
+
}
|
|
149
|
+
const outcome = await returnAssistCredits(firestore, {
|
|
150
|
+
month,
|
|
151
|
+
meters: targets,
|
|
152
|
+
credits: action === 'reset' ? 'all' : credits,
|
|
153
|
+
key,
|
|
154
|
+
reason,
|
|
155
|
+
actorUid: decoded.uid,
|
|
156
|
+
source: 'staff',
|
|
157
|
+
jobId
|
|
158
|
+
}, (tx, lines)=>{
|
|
159
|
+
var _ref;
|
|
160
|
+
var _returned_;
|
|
161
|
+
const returned = lines.filter((line)=>line.credits > 0);
|
|
162
|
+
setAdminAudit(tx, firestore, _extends({
|
|
163
|
+
actorUid: decoded.uid,
|
|
164
|
+
action: action === 'reset' ? 'ai.credits.reset' : 'ai.credits.giveBack',
|
|
165
|
+
target: (_ref = (_returned_ = returned[0]) == null ? void 0 : _returned_.path) != null ? _ref : targets[0].ref.path
|
|
166
|
+
}, accountUid ? {
|
|
167
|
+
subjectUid: accountUid
|
|
168
|
+
} : {}, {
|
|
169
|
+
note: `${reason} — ` + returned.map((line)=>`${line.credits} credits to the ${line.meter}`).join(', ') + (jobId ? ` (job ${jobId})` : ''),
|
|
170
|
+
after: {
|
|
171
|
+
orgId: orgId || null,
|
|
172
|
+
accountUid,
|
|
173
|
+
month,
|
|
174
|
+
jobId,
|
|
175
|
+
idempotencyKey: key,
|
|
176
|
+
meters: returned.map((line)=>({
|
|
177
|
+
meter: line.meter,
|
|
178
|
+
path: line.path,
|
|
179
|
+
credits: line.credits,
|
|
180
|
+
usedBefore: line.usedBefore
|
|
181
|
+
}))
|
|
182
|
+
},
|
|
183
|
+
at: FieldValue.serverTimestamp()
|
|
184
|
+
}));
|
|
185
|
+
});
|
|
186
|
+
if (outcome.status === 'over') {
|
|
187
|
+
return refuse('That is more than this month used: ' + outcome.lines.map((line)=>`${line.usedBefore} on the ${line.meter}`).join(', '), 409, 'over_return', {
|
|
188
|
+
lines: outcome.lines
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (outcome.status === 'nothing') {
|
|
192
|
+
return refuse('Nothing used this month to give back', 409, 'nothing_used', {
|
|
193
|
+
lines: outcome.lines
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return Response.json({
|
|
197
|
+
month,
|
|
198
|
+
duplicate: outcome.status === 'duplicate',
|
|
199
|
+
lines: outcome.lines
|
|
200
|
+
}, {
|
|
201
|
+
status: 200
|
|
202
|
+
});
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const unauthenticated = invalidIdTokenResponse(error);
|
|
205
|
+
if (unauthenticated) return unauthenticated;
|
|
206
|
+
console.error('[ai/admin/credits]', error);
|
|
207
|
+
return refuse('Giving credits back failed', 500);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export const dynamic = 'force-dynamic';
|
|
211
|
+
export { handler as POST };
|
|
212
|
+
|
|
213
|
+
//# sourceMappingURL=ai-admin-credits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-credits.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 { setAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { freeAssistAccount } from '../usage/assist-free-taste'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport {\n accountCreditMeter,\n isAssistCreditReturnKey,\n returnAssistCredits,\n workspaceCreditMeter,\n type AssistCreditMeterTarget,\n} from '../usage/assist-credit-returns-write'\n\n/**\n * STAFF GIVE AI CREDITS BACK (AGL-3595) — compensation for a fault of ours.\n *\n * A planner bug refused a Free workspace's plan after it had spent 227 of\n * its 300 monthly credits, and the person could not try again until the\n * month rolled. This is the control that undoes that: staff return credits\n * to the meter they were drawn from, with a reason, and the person's next\n * request is admitted against the reduced figure.\n *\n * POST `{ orgId | uid, action, meter, credits?, reason, jobId?, idempotencyKey }`\n *\n * action `give` return `credits` to each named meter\n * `reset` return everything each meter used this month\n * meter `workspace` the workspace's band (needs `orgId`)\n * `account` a Free owner's allowance (`orgId` of a Free\n * workspace, or the account's `uid`)\n * `both` the two together, for a Free workspace\n *\n * ## The gate\n *\n * The staff claim, and then the `super` or `billing` staff role — the bar\n * `/api/admin/org-override` holds for a quota write, because a give-back is\n * one: it hands out credits that are worth money. A missing role is\n * `support`, and refused (AGL-495).\n *\n * ## What it writes\n *\n * An adjustment, never an edit: `returnAssistCredits` adds to the month's\n * `returnedUsd` and keeps the act under `creditReturns.{key}`, and one\n * `adminAudit` row commits in the same transaction. A repeated key writes\n * nothing and answers `duplicate`, so a double-click returns once.\n */\n\n/** The longest a staff reason may be, so a paste cannot fill the document. */\nconst REASON_MAX = 500\n\n/** The staff roles that may give credits back. */\nexport const AI_CREDIT_RETURN_ROLES = ['super', 'billing'] as const\n\ntype Meter = 'workspace' | 'account' | 'both'\n\nconst refuse = (error: string, status: number, code?: string, extra = {}) =>\n Response.json({ error, ...(code ? { code } : {}), ...extra }, { status })\n\nasync function handler(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') return refuse('Method not allowed', 405)\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse('Unauthenticated', 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']) return refuse('Staff only', 403)\n const role = String(decoded['staffRole'] ?? 'support')\n if (!(AI_CREDIT_RETURN_ROLES as readonly string[]).includes(role)) {\n return refuse('Requires the billing or super staff role', 403, 'role')\n }\n\n const orgId = String(body?.orgId ?? '').trim()\n const uid = String(body?.uid ?? '').trim()\n const action = String(body?.action ?? '')\n const meter = String(body?.meter ?? '') as Meter\n if (!orgId && !uid) return refuse('Name an organization or an account', 400)\n if (action !== 'give' && action !== 'reset') return refuse('Bad action', 400)\n if (!['workspace', 'account', 'both'].includes(meter)) {\n return refuse('Bad meter', 400)\n }\n // Refused before anything is read: the reason is the audit row's whole\n // value, and a row written without one cannot be corrected later.\n const reason = String(body?.reason ?? '').trim().slice(0, REASON_MAX)\n if (!reason) {\n return refuse('Say why the credits are being given back', 400, 'reason_required')\n }\n const key = body?.idempotencyKey\n if (!isAssistCreditReturnKey(key)) {\n return refuse('Missing idempotency key', 400, 'idempotency_key')\n }\n const credits = Number(body?.credits)\n if (action === 'give' && !(Number.isInteger(credits) && credits > 0)) {\n return refuse('Give back a positive whole number of credits', 400, 'invalid_credits')\n }\n const jobId = String(body?.jobId ?? '').trim().slice(0, 200) || null\n\n const firestore = firebaseAdmin.app().firestore()\n const month = assistUsageMonth(new Date())\n const targets: AssistCreditMeterTarget[] = []\n let accountUid: string | null = null\n\n if (orgId) {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [orgSnap, billingSnap] = await Promise.all([\n orgRef.get(),\n orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get(),\n ])\n if (!orgSnap.exists) return refuse('No such organization', 404)\n // Org doc first, billing mirror over it — the merge the staff card\n // reads, so a workspace whose subscription died resolves as Free here\n // exactly as it does at the gate.\n const org = {\n ...(orgSnap.data() ?? {}),\n ...(billingSnap.exists ? billingSnap.data() : {}),\n }\n if (meter !== 'account') targets.push(workspaceCreditMeter(firestore, orgId, month))\n if (meter !== 'workspace') {\n accountUid = freeAssistAccount(org as never)?.accountUid ?? null\n if (!accountUid) {\n return refuse(\n 'Only a Free workspace with an owner draws on an account allowance',\n 409,\n 'no_account_allowance',\n )\n }\n targets.push(accountCreditMeter(firestore, accountUid, month))\n }\n } else {\n // From the staff user page: the account's own allowance, nothing else.\n if (meter !== 'account') {\n return refuse('An account has only its allowance to give back to', 400)\n }\n accountUid = uid\n targets.push(accountCreditMeter(firestore, uid, month))\n }\n\n const outcome = await returnAssistCredits(\n firestore,\n {\n month,\n meters: targets,\n credits: action === 'reset' ? 'all' : credits,\n key,\n reason,\n actorUid: decoded.uid,\n source: 'staff',\n jobId,\n },\n (tx, lines) => {\n const returned = lines.filter((line) => line.credits > 0)\n setAdminAudit(tx, firestore, {\n actorUid: decoded.uid,\n action: action === 'reset' ? 'ai.credits.reset' : 'ai.credits.giveBack',\n target: returned[0]?.path ?? targets[0]!.ref.path,\n ...(accountUid ? { subjectUid: accountUid } : {}),\n note:\n `${reason} — ` +\n returned.map((line) => `${line.credits} credits to the ${line.meter}`).join(', ') +\n (jobId ? ` (job ${jobId})` : ''),\n after: {\n orgId: orgId || null,\n accountUid,\n month,\n jobId,\n idempotencyKey: key,\n meters: returned.map((line) => ({\n meter: line.meter,\n path: line.path,\n credits: line.credits,\n usedBefore: line.usedBefore,\n })),\n },\n at: FieldValue.serverTimestamp(),\n })\n },\n )\n\n if (outcome.status === 'over') {\n return refuse(\n 'That is more than this month used: ' +\n outcome.lines.map((line) => `${line.usedBefore} on the ${line.meter}`).join(', '),\n 409,\n 'over_return',\n { lines: outcome.lines },\n )\n }\n if (outcome.status === 'nothing') {\n return refuse('Nothing used this month to give back', 409, 'nothing_used', {\n lines: outcome.lines,\n })\n }\n return Response.json(\n { month, duplicate: outcome.status === 'duplicate', lines: outcome.lines },\n { status: 200 },\n )\n } catch (error) {\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/credits]', error)\n return refuse('Giving credits back failed', 500)\n }\n}\n\nexport const dynamic = 'force-dynamic'\nexport { handler as POST }\n"],"names":["ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","setAdminAudit","invalidIdTokenResponse","FieldValue","freeAssistAccount","assistUsageMonth","accountCreditMeter","isAssistCreditReturnKey","returnAssistCredits","workspaceCreditMeter","REASON_MAX","AI_CREDIT_RETURN_ROLES","refuse","error","status","code","extra","Response","json","handler","request","headers","method","body","rawHeaders","authorization","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","email_verified","role","String","includes","orgId","trim","uid","action","meter","reason","key","idempotencyKey","credits","Number","isInteger","jobId","firestore","month","Date","targets","accountUid","orgSnap","orgRef","collection","doc","billingSnap","Promise","all","get","exists","org","data","push","outcome","meters","actorUid","source","tx","lines","returned","filter","line","target","path","ref","subjectUid","note","map","join","after","usedBefore","at","serverTimestamp","duplicate","unauthenticated","console","dynamic","POST"],"mappings":"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"}
|
|
@@ -23,7 +23,8 @@ import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token
|
|
|
23
23
|
import { addonKindFromPriceId } from "@aglyn/tenant-data-admin/server/billing-addons";
|
|
24
24
|
import { orgMarginRow } from "@aglyn/aglyn/app-utils/margin-utilization";
|
|
25
25
|
import { assistCogsAlertThresholdUsd, assistMarginMultiple } from "../usage/assist-spend-guards.js";
|
|
26
|
-
import {
|
|
26
|
+
import { freeAccountUsageRef, freeAssistAccount } from "../usage/assist-free-taste.js";
|
|
27
|
+
import { composeStaffAiAccountAllowance, composeStaffOrgAiAddon, composeStaffOrgAiMargin, composeStaffOrgAiOverage, composeStaffOrgAiPool, composeStaffOrgAiTokens, emptyJobCounts, STAFF_ORG_AI_JOB_STATUSES } from "../usage/staff-org-ai.js";
|
|
27
28
|
/**
|
|
28
29
|
* THE STAFF AI CARD'S BACKING READ (AGL-2930).
|
|
29
30
|
*
|
|
@@ -254,7 +255,7 @@ async function handler(request) {
|
|
|
254
255
|
status: 400
|
|
255
256
|
});
|
|
256
257
|
try {
|
|
257
|
-
var _orgSnap_data, _monthSnap_data, _org_name, _rollupDoc_get, _ref, _ref1, _ref2;
|
|
258
|
+
var _orgSnap_data, _monthSnap_data, _org_name, _rollupDoc_get, _ref, _ref1, _ref2, _accountSnap_data;
|
|
258
259
|
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
259
260
|
if (!decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
260
261
|
return emailUnverifiedResponse();
|
|
@@ -287,10 +288,15 @@ async function handler(request) {
|
|
|
287
288
|
// mirror — the same merge the margin route and the org detail read make.
|
|
288
289
|
const org = _extends({}, (_orgSnap_data = orgSnap.data()) != null ? _orgSnap_data : {}, billingSnap.exists ? billingSnap.data() : {});
|
|
289
290
|
const monthDoc = monthSnap.exists ? (_monthSnap_data = monthSnap.data()) != null ? _monthSnap_data : {} : null;
|
|
290
|
-
|
|
291
|
+
// A Free workspace also draws on its OWNER's account allowance
|
|
292
|
+
// (AGL-2925), which is what staff give credits back to beside the band
|
|
293
|
+
// (AGL-3595). One more document, read only for a Free workspace.
|
|
294
|
+
const free = freeAssistAccount(org);
|
|
295
|
+
const [jobs, users, since, accountSnap] = await Promise.all([
|
|
291
296
|
readOrgAiJobsSummary(db, orgId, month),
|
|
292
297
|
readOrgAiTopUsers(db, orgId, month),
|
|
293
|
-
addonSinceFor(orgId)
|
|
298
|
+
addonSinceFor(orgId),
|
|
299
|
+
(free == null ? void 0 : free.accountUid) ? freeAccountUsageRef(db, free.accountUid, month).get() : Promise.resolve(null)
|
|
294
300
|
]);
|
|
295
301
|
const pool = composeStaffOrgAiPool(org, monthDoc, now);
|
|
296
302
|
// The overage line is an invoice, so it is priced off what the month
|
|
@@ -337,7 +343,8 @@ async function handler(request) {
|
|
|
337
343
|
jobs,
|
|
338
344
|
users,
|
|
339
345
|
margin,
|
|
340
|
-
tokens: composeStaffOrgAiTokens(monthDoc)
|
|
346
|
+
tokens: composeStaffOrgAiTokens(monthDoc),
|
|
347
|
+
account: (free == null ? void 0 : free.accountUid) && accountSnap ? composeStaffAiAccountAllowance(free.accountUid, month, accountSnap.exists ? (_accountSnap_data = accountSnap.data()) != null ? _accountSnap_data : null : null) : null
|
|
341
348
|
};
|
|
342
349
|
return Response.json(body, {
|
|
343
350
|
status: 200
|