@aglyn/tenant-feature-instance 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +3 -0
- package/jest.config.d.ts +36 -0
- package/package.json +45 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/duplicate-resource-dialog.d.ts +77 -0
- package/src/lib/components/duplicate-resource-dialog.js +217 -0
- package/src/lib/components/duplicate-resource-dialog.js.map +1 -0
- package/src/lib/constants/firebase-config.d.ts +68 -0
- package/src/lib/constants/firebase-config.js +155 -0
- package/src/lib/constants/firebase-config.js.map +1 -0
- package/src/lib/constants/firebase-emulator-hosts.d.ts +60 -0
- package/src/lib/constants/firebase-emulator-hosts.js +81 -0
- package/src/lib/constants/firebase-emulator-hosts.js.map +1 -0
- package/src/lib/constants/mock-data.d.ts +24 -0
- package/src/lib/constants/mock-data.js +49 -0
- package/src/lib/constants/mock-data.js.map +1 -0
- package/src/lib/constants/site-paths.d.ts +47 -0
- package/src/lib/constants/site-paths.js +66 -0
- package/src/lib/constants/site-paths.js.map +1 -0
- package/src/lib/hooks/firebase/auth-persistence.d.ts +74 -0
- package/src/lib/hooks/firebase/auth-persistence.js +117 -0
- package/src/lib/hooks/firebase/auth-persistence.js.map +1 -0
- package/src/lib/hooks/firebase/firebase-services.d.ts +172 -0
- package/src/lib/hooks/firebase/firebase-services.js +522 -0
- package/src/lib/hooks/firebase/firebase-services.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-cache.d.ts +125 -0
- package/src/lib/hooks/firebase/firestore-cache.js +152 -0
- package/src/lib/hooks/firebase/firestore-cache.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.d.ts +169 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js +316 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js.map +1 -0
- package/src/lib/hooks/firestore-denial-reporter.d.ts +192 -0
- package/src/lib/hooks/firestore-denial-reporter.js +293 -0
- package/src/lib/hooks/firestore-denial-reporter.js.map +1 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.d.ts +62 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js +91 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.d.ts +80 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js +89 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-seed-write.d.ts +125 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js +193 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -0
- package/src/lib/hooks/helpers/listen-options.d.ts +91 -0
- package/src/lib/hooks/helpers/listen-options.js +92 -0
- package/src/lib/hooks/helpers/listen-options.js.map +1 -0
- package/src/lib/hooks/helpers/use-doc.d.ts +38 -0
- package/src/lib/hooks/helpers/use-doc.js +213 -0
- package/src/lib/hooks/helpers/use-doc.js.map +1 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.d.ts +28 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js +110 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js.map +1 -0
- package/src/lib/hooks/host-collection-queries.d.ts +125 -0
- package/src/lib/hooks/host-collection-queries.js +126 -0
- package/src/lib/hooks/host-collection-queries.js.map +1 -0
- package/src/lib/hooks/list-filter-constraints.d.ts +64 -0
- package/src/lib/hooks/list-filter-constraints.js +288 -0
- package/src/lib/hooks/list-filter-constraints.js.map +1 -0
- package/src/lib/hooks/sorted-collection-window.d.ts +207 -0
- package/src/lib/hooks/sorted-collection-window.js +150 -0
- package/src/lib/hooks/sorted-collection-window.js.map +1 -0
- package/src/lib/hooks/use-component-version.d.ts +43 -0
- package/src/lib/hooks/use-component-version.js +73 -0
- package/src/lib/hooks/use-component-version.js.map +1 -0
- package/src/lib/hooks/use-component.d.ts +39 -0
- package/src/lib/hooks/use-component.js +55 -0
- package/src/lib/hooks/use-component.js.map +1 -0
- package/src/lib/hooks/use-console-host-route.d.ts +24 -0
- package/src/lib/hooks/use-console-host-route.js +77 -0
- package/src/lib/hooks/use-console-host-route.js.map +1 -0
- package/src/lib/hooks/use-duplicate-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-duplicate-resource-api.js +61 -0
- package/src/lib/hooks/use-duplicate-resource-api.js.map +1 -0
- package/src/lib/hooks/use-firestore-collection.d.ts +62 -0
- package/src/lib/hooks/use-firestore-collection.js +307 -0
- package/src/lib/hooks/use-firestore-collection.js.map +1 -0
- package/src/lib/hooks/use-firestore-doc.d.ts +42 -0
- package/src/lib/hooks/use-firestore-doc.js +192 -0
- package/src/lib/hooks/use-firestore-doc.js.map +1 -0
- package/src/lib/hooks/use-form-version.d.ts +44 -0
- package/src/lib/hooks/use-form-version.js +73 -0
- package/src/lib/hooks/use-form-version.js.map +1 -0
- package/src/lib/hooks/use-host-activity-logger.d.ts +35 -0
- package/src/lib/hooks/use-host-activity-logger.js +96 -0
- package/src/lib/hooks/use-host-activity-logger.js.map +1 -0
- package/src/lib/hooks/use-host-campaigns.d.ts +53 -0
- package/src/lib/hooks/use-host-campaigns.js +93 -0
- package/src/lib/hooks/use-host-campaigns.js.map +1 -0
- package/src/lib/hooks/use-host-org-id.d.ts +96 -0
- package/src/lib/hooks/use-host-org-id.js +154 -0
- package/src/lib/hooks/use-host-org-id.js.map +1 -0
- package/src/lib/hooks/use-host-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-host-resource-api.js +60 -0
- package/src/lib/hooks/use-host-resource-api.js.map +1 -0
- package/src/lib/hooks/use-host-template.d.ts +44 -0
- package/src/lib/hooks/use-host-template.js +82 -0
- package/src/lib/hooks/use-host-template.js.map +1 -0
- package/src/lib/hooks/use-host-version-api.d.ts +59 -0
- package/src/lib/hooks/use-host-version-api.js +69 -0
- package/src/lib/hooks/use-host-version-api.js.map +1 -0
- package/src/lib/hooks/use-host.d.ts +101 -0
- package/src/lib/hooks/use-host.js +45 -0
- package/src/lib/hooks/use-host.js.map +1 -0
- package/src/lib/hooks/use-layout-version.d.ts +33 -0
- package/src/lib/hooks/use-layout-version.js +64 -0
- package/src/lib/hooks/use-layout-version.js.map +1 -0
- package/src/lib/hooks/use-layout.d.ts +41 -0
- package/src/lib/hooks/use-layout.js +49 -0
- package/src/lib/hooks/use-layout.js.map +1 -0
- package/src/lib/hooks/use-live-artifact-count.d.ts +50 -0
- package/src/lib/hooks/use-live-artifact-count.js +102 -0
- package/src/lib/hooks/use-live-artifact-count.js.map +1 -0
- package/src/lib/hooks/use-org-member-options.d.ts +57 -0
- package/src/lib/hooks/use-org-member-options.js +112 -0
- package/src/lib/hooks/use-org-member-options.js.map +1 -0
- package/src/lib/hooks/use-org-plan.d.ts +46 -0
- package/src/lib/hooks/use-org-plan.js +86 -0
- package/src/lib/hooks/use-org-plan.js.map +1 -0
- package/src/lib/hooks/use-paged-collection.d.ts +95 -0
- package/src/lib/hooks/use-paged-collection.js +130 -0
- package/src/lib/hooks/use-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-plugin-config.d.ts +38 -0
- package/src/lib/hooks/use-plugin-config.js +120 -0
- package/src/lib/hooks/use-plugin-config.js.map +1 -0
- package/src/lib/hooks/use-scope-tokens.d.ts +28 -0
- package/src/lib/hooks/use-scope-tokens.js +100 -0
- package/src/lib/hooks/use-scope-tokens.js.map +1 -0
- package/src/lib/hooks/use-screen-version.d.ts +33 -0
- package/src/lib/hooks/use-screen-version.js +65 -0
- package/src/lib/hooks/use-screen-version.js.map +1 -0
- package/src/lib/hooks/use-screen.d.ts +68 -0
- package/src/lib/hooks/use-screen.js +50 -0
- package/src/lib/hooks/use-screen.js.map +1 -0
- package/src/lib/hooks/use-sending-identity-api.d.ts +197 -0
- package/src/lib/hooks/use-sending-identity-api.js +95 -0
- package/src/lib/hooks/use-sending-identity-api.js.map +1 -0
- package/src/lib/hooks/use-sorted-paged-collection.d.ts +48 -0
- package/src/lib/hooks/use-sorted-paged-collection.js +213 -0
- package/src/lib/hooks/use-sorted-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-switcher-collection.d.ts +105 -0
- package/src/lib/hooks/use-switcher-collection.js +275 -0
- package/src/lib/hooks/use-switcher-collection.js.map +1 -0
- package/src/lib/hooks/use-user-name.d.ts +51 -0
- package/src/lib/hooks/use-user-name.js +90 -0
- package/src/lib/hooks/use-user-name.js.map +1 -0
- package/src/lib/hooks/use-user-photo.d.ts +50 -0
- package/src/lib/hooks/use-user-photo.js +87 -0
- package/src/lib/hooks/use-user-photo.js.map +1 -0
- package/src/lib/tenant.d.ts +61 -0
- package/src/lib/tenant.js +62 -0
- package/src/lib/tenant.js.map +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
*/ 'use client';
|
|
17
|
+
import { Timestamp } from "@aglyn/shared-util-timestamp";
|
|
18
|
+
import { collection, getCountFromServer, query, where } from "firebase/firestore";
|
|
19
|
+
import { useEffect, useState } from "react";
|
|
20
|
+
import { useFirestore } from "./firebase/firebase-services.js";
|
|
21
|
+
/**
|
|
22
|
+
* Every timestamp is greater than this one, and no `null` is.
|
|
23
|
+
*
|
|
24
|
+
* Firestore orders values by TYPE before value, and `null` sorts below every
|
|
25
|
+
* number, string and timestamp — so an inequality against the epoch selects
|
|
26
|
+
* exactly the documents whose `deletedAt` holds a real timestamp. Documents
|
|
27
|
+
* with no `deletedAt` field are excluded from an inequality outright, which
|
|
28
|
+
* is the other half of the same answer.
|
|
29
|
+
*
|
|
30
|
+
* Built where it is used rather than at module scope. This module is reached
|
|
31
|
+
* through the library's barrel, so a `Timestamp` call up here runs for every
|
|
32
|
+
* consumer of every hook in it — including the many that stand a stub in for
|
|
33
|
+
* `@aglyn/shared-util-timestamp` and never touch this one.
|
|
34
|
+
*/ const beforeEveryTombstone = ()=>Timestamp.fromMillis(0);
|
|
35
|
+
/**
|
|
36
|
+
* How many artifacts of a kind a site actually holds — a server aggregate,
|
|
37
|
+
* not the length of a page (AGL-1716, the AGL-1706 shape).
|
|
38
|
+
*
|
|
39
|
+
* A list that pages holds ten rows. Handing that ten to a quota readout is a
|
|
40
|
+
* confident wrong number in the flattering direction: "10/10 layouts on your
|
|
41
|
+
* plan" on a site with sixty of them reads as room to spare, right up until
|
|
42
|
+
* `/api/hosts/resources` refuses the create. The list and the count are
|
|
43
|
+
* different questions and are asked separately — one aggregate per mount,
|
|
44
|
+
* re-asked when `epoch` moves.
|
|
45
|
+
*
|
|
46
|
+
* ## Why two aggregates rather than one
|
|
47
|
+
*
|
|
48
|
+
* Deleting an artifact stamps `deletedAt` and leaves the document in place,
|
|
49
|
+
* so a published tenant page keeps grafting until its next revalidate. The
|
|
50
|
+
* enforcing route counts the LIVE ones — `/api/hosts/resources` reads the
|
|
51
|
+
* collection with a `deletedAt` projection and keeps the entries where it is
|
|
52
|
+
* `== null` — so a console count over the whole collection would quote a cap
|
|
53
|
+
* usage the server does not enforce, and would disagree with the list beside
|
|
54
|
+
* it row for row.
|
|
55
|
+
*
|
|
56
|
+
* Firestore cannot express "field is absent" as a predicate, and the two live
|
|
57
|
+
* shapes are not one value: an artifact created through the resources route
|
|
58
|
+
* carries no `deletedAt` at all, while one installed from the marketplace
|
|
59
|
+
* carries an explicit `deletedAt: null` (`install.ts` writes it). So
|
|
60
|
+
* `where('deletedAt', '==', null)` would return the marketplace copies alone.
|
|
61
|
+
* Counting the TOMBSTONES instead is exact, because there is only one shape
|
|
62
|
+
* of tombstone — a `Timestamp` — and subtracting them from the total leaves
|
|
63
|
+
* precisely the documents the server calls live.
|
|
64
|
+
*
|
|
65
|
+
* Both aggregates bill one document read each at these collection sizes.
|
|
66
|
+
*
|
|
67
|
+
* @returns the live count, or `null` while it is pending or if it was
|
|
68
|
+
* refused. A caller must fall back to something that can only UNDERSTATE the
|
|
69
|
+
* truth, never to `0`: an entitlement check answers from whatever it is
|
|
70
|
+
* handed, and zero used is the one wrong answer that opens a gate.
|
|
71
|
+
*/ export function useLiveArtifactCount(hostId, artifact, epoch = 0) {
|
|
72
|
+
const firestore = useFirestore();
|
|
73
|
+
const [count, setCount] = useState(null);
|
|
74
|
+
useEffect(()=>{
|
|
75
|
+
let active = true;
|
|
76
|
+
const artifacts = collection(firestore, 'hosts', hostId, artifact);
|
|
77
|
+
void Promise.all([
|
|
78
|
+
getCountFromServer(artifacts),
|
|
79
|
+
getCountFromServer(query(artifacts, where('deletedAt', '>', beforeEveryTombstone())))
|
|
80
|
+
]).then(([total, tombstones])=>{
|
|
81
|
+
if (!active) return;
|
|
82
|
+
// Never below zero: the two aggregates are separate round-trips and a
|
|
83
|
+
// delete landing between them can make the second the larger read.
|
|
84
|
+
setCount(Math.max(0, total.data().count - tombstones.data().count));
|
|
85
|
+
}).catch(()=>{
|
|
86
|
+
// The caller's fallback stands. Deliberately not 0 — see the return
|
|
87
|
+
// contract above.
|
|
88
|
+
});
|
|
89
|
+
return ()=>{
|
|
90
|
+
active = false;
|
|
91
|
+
};
|
|
92
|
+
}, [
|
|
93
|
+
firestore,
|
|
94
|
+
hostId,
|
|
95
|
+
artifact,
|
|
96
|
+
epoch
|
|
97
|
+
]);
|
|
98
|
+
return count;
|
|
99
|
+
}
|
|
100
|
+
export default useLiveArtifactCount;
|
|
101
|
+
|
|
102
|
+
//# sourceMappingURL=use-live-artifact-count.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-live-artifact-count.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'use client'\n\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { collection, getCountFromServer, query, where } from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport { useFirestore } from './firebase/firebase-services'\n\n/**\n * The per-site artifact collections that render as a console list.\n *\n * `forms` is one of them: it is created through the same resources route,\n * keyed by the same generated uid, and listed by the same table. Its cap is\n * enforced over the WHOLE collection rather than over live documents — a form\n * is deleted outright, and its submissions keep their `formId` either way —\n * so the aggregate below matches what `/api/hosts/resources` counts, with the\n * tombstone subtraction a no-op because nothing writes `deletedAt` on a form.\n */\nexport type HostArtifactCollection =\n | 'screens'\n | 'layouts'\n | 'components'\n | 'templates'\n | 'forms'\n\n/**\n * Every timestamp is greater than this one, and no `null` is.\n *\n * Firestore orders values by TYPE before value, and `null` sorts below every\n * number, string and timestamp — so an inequality against the epoch selects\n * exactly the documents whose `deletedAt` holds a real timestamp. Documents\n * with no `deletedAt` field are excluded from an inequality outright, which\n * is the other half of the same answer.\n *\n * Built where it is used rather than at module scope. This module is reached\n * through the library's barrel, so a `Timestamp` call up here runs for every\n * consumer of every hook in it — including the many that stand a stub in for\n * `@aglyn/shared-util-timestamp` and never touch this one.\n */\nconst beforeEveryTombstone = () => Timestamp.fromMillis(0)\n\n/**\n * How many artifacts of a kind a site actually holds — a server aggregate,\n * not the length of a page (AGL-1716, the AGL-1706 shape).\n *\n * A list that pages holds ten rows. Handing that ten to a quota readout is a\n * confident wrong number in the flattering direction: \"10/10 layouts on your\n * plan\" on a site with sixty of them reads as room to spare, right up until\n * `/api/hosts/resources` refuses the create. The list and the count are\n * different questions and are asked separately — one aggregate per mount,\n * re-asked when `epoch` moves.\n *\n * ## Why two aggregates rather than one\n *\n * Deleting an artifact stamps `deletedAt` and leaves the document in place,\n * so a published tenant page keeps grafting until its next revalidate. The\n * enforcing route counts the LIVE ones — `/api/hosts/resources` reads the\n * collection with a `deletedAt` projection and keeps the entries where it is\n * `== null` — so a console count over the whole collection would quote a cap\n * usage the server does not enforce, and would disagree with the list beside\n * it row for row.\n *\n * Firestore cannot express \"field is absent\" as a predicate, and the two live\n * shapes are not one value: an artifact created through the resources route\n * carries no `deletedAt` at all, while one installed from the marketplace\n * carries an explicit `deletedAt: null` (`install.ts` writes it). So\n * `where('deletedAt', '==', null)` would return the marketplace copies alone.\n * Counting the TOMBSTONES instead is exact, because there is only one shape\n * of tombstone — a `Timestamp` — and subtracting them from the total leaves\n * precisely the documents the server calls live.\n *\n * Both aggregates bill one document read each at these collection sizes.\n *\n * @returns the live count, or `null` while it is pending or if it was\n * refused. A caller must fall back to something that can only UNDERSTATE the\n * truth, never to `0`: an entitlement check answers from whatever it is\n * handed, and zero used is the one wrong answer that opens a gate.\n */\nexport function useLiveArtifactCount(\n hostId: string,\n artifact: HostArtifactCollection,\n epoch = 0,\n): number | null {\n const firestore = useFirestore()\n const [count, setCount] = useState<number | null>(null)\n\n useEffect(() => {\n let active = true\n const artifacts = collection(firestore, 'hosts', hostId, artifact)\n void Promise.all([\n getCountFromServer(artifacts),\n getCountFromServer(\n query(artifacts, where('deletedAt', '>', beforeEveryTombstone())),\n ),\n ])\n .then(([total, tombstones]) => {\n if (!active) return\n // Never below zero: the two aggregates are separate round-trips and a\n // delete landing between them can make the second the larger read.\n setCount(\n Math.max(0, total.data().count - tombstones.data().count),\n )\n })\n .catch(() => {\n // The caller's fallback stands. Deliberately not 0 — see the return\n // contract above.\n })\n return () => {\n active = false\n }\n }, [firestore, hostId, artifact, epoch])\n\n return count\n}\n\nexport default useLiveArtifactCount\n"],"names":["Timestamp","collection","getCountFromServer","query","where","useEffect","useState","useFirestore","beforeEveryTombstone","fromMillis","useLiveArtifactCount","hostId","artifact","epoch","firestore","count","setCount","active","artifacts","Promise","all","then","total","tombstones","Math","max","data","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAASA,SAAS,QAAQ,+BAA8B;AACxD,SAASC,UAAU,EAAEC,kBAAkB,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AACjF,SAASC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAC3C,SAASC,YAAY,QAAQ,kCAA8B;AAmB3D;;;;;;;;;;;;;CAaC,GACD,MAAMC,uBAAuB,IAAMR,UAAUS,UAAU,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GACD,OAAO,SAASC,qBACdC,MAAc,EACdC,QAAgC,EAChCC,QAAQ,CAAC;IAET,MAAMC,YAAYP;IAClB,MAAM,CAACQ,OAAOC,SAAS,GAAGV,SAAwB;IAElDD,UAAU;QACR,IAAIY,SAAS;QACb,MAAMC,YAAYjB,WAAWa,WAAW,SAASH,QAAQC;QACzD,KAAKO,QAAQC,GAAG,CAAC;YACflB,mBAAmBgB;YACnBhB,mBACEC,MAAMe,WAAWd,MAAM,aAAa,KAAKI;SAE5C,EACEa,IAAI,CAAC,CAAC,CAACC,OAAOC,WAAW;YACxB,IAAI,CAACN,QAAQ;YACb,sEAAsE;YACtE,mEAAmE;YACnED,SACEQ,KAAKC,GAAG,CAAC,GAAGH,MAAMI,IAAI,GAAGX,KAAK,GAAGQ,WAAWG,IAAI,GAAGX,KAAK;QAE5D,GACCY,KAAK,CAAC;QACL,oEAAoE;QACpE,kBAAkB;QACpB;QACF,OAAO;YACLV,SAAS;QACX;IACF,GAAG;QAACH;QAAWH;QAAQC;QAAUC;KAAM;IAEvC,OAAOE;AACT;AAEA,eAAeL,qBAAoB"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AglynOrgMember } from '@aglyn/aglyn';
|
|
2
|
+
/** One team member, as much of them as a picker needs. */
|
|
3
|
+
export interface OrgMemberOption {
|
|
4
|
+
/** The account uid — what a record's `ownerUid` stores. */
|
|
5
|
+
uid: string;
|
|
6
|
+
/** The name a colleague recognizes: display name, else address, else uid. */
|
|
7
|
+
label: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface OrgMemberOptions {
|
|
11
|
+
options: OrgMemberOption[];
|
|
12
|
+
/** The read has answered, one way or the other — false while settling or disabled. */
|
|
13
|
+
ready: boolean;
|
|
14
|
+
/** Why the roster is empty, when it is empty because the route refused. */
|
|
15
|
+
error: string | null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The roster as picker options, sorted by how they read.
|
|
19
|
+
*
|
|
20
|
+
* Exported on its own so the mapping has one definition: a bulk "set owner"
|
|
21
|
+
* on the contacts list and an audience rule's owner filter must show the
|
|
22
|
+
* same names for the same people, or a merchant assigning contacts to "Ada"
|
|
23
|
+
* on one screen would be filtering for "ada@example.com" on the other.
|
|
24
|
+
*/
|
|
25
|
+
export declare function orgMemberOptions(members: ReadonlyArray<Partial<AglynOrgMember> & {
|
|
26
|
+
$id: string;
|
|
27
|
+
}>): OrgMemberOption[];
|
|
28
|
+
/**
|
|
29
|
+
* THE ORG'S TEAM, for an owner picker on some record's page (AGL-2603).
|
|
30
|
+
*
|
|
31
|
+
* ## Through the members route, not the collection
|
|
32
|
+
*
|
|
33
|
+
* `orgs/{orgId}/members` is readable by an org-wide member and by nobody
|
|
34
|
+
* else: the rules evaluate a list against the QUERY, so a collaborator scoped
|
|
35
|
+
* to one site reading the roster gets `permission-denied`, not a shorter
|
|
36
|
+
* roster. `GET /api/orgs/members?orgId=` re-derives membership with the Admin
|
|
37
|
+
* SDK and answers any member of the org, which is who assigns an owner.
|
|
38
|
+
*
|
|
39
|
+
* ## It is OFF unless a caller asks
|
|
40
|
+
*
|
|
41
|
+
* `enabled` defaults to false, the way `useHostCampaigns` does and for the
|
|
42
|
+
* same reason: the picker this feeds sits on surfaces a reader opens for
|
|
43
|
+
* something else, and a roster fetched on every arrival at the contacts list
|
|
44
|
+
* is a request for a control nobody has opened. Callers turn it on when the
|
|
45
|
+
* owner control is actually on screen.
|
|
46
|
+
*
|
|
47
|
+
* ## A refusal is reported, never rendered as an empty team
|
|
48
|
+
*
|
|
49
|
+
* An org with no members is impossible — it has an owner — so an empty list
|
|
50
|
+
* with no error would always be a lie. `authorizedFetch` refuses to send a
|
|
51
|
+
* request it cannot authorize and answers a 401 carrying the reason; that
|
|
52
|
+
* reason, or the route's own, is what `error` holds.
|
|
53
|
+
*/
|
|
54
|
+
export declare function useOrgMemberOptions(orgId: string | undefined, options?: {
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
}): OrgMemberOptions;
|
|
57
|
+
export default useOrgMemberOptions;
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
*/ 'use client';
|
|
17
|
+
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
18
|
+
import { useEffect, useMemo, useState } from "react";
|
|
19
|
+
import { useUser } from "./firebase/firebase-services.js";
|
|
20
|
+
/**
|
|
21
|
+
* The roster as picker options, sorted by how they read.
|
|
22
|
+
*
|
|
23
|
+
* Exported on its own so the mapping has one definition: a bulk "set owner"
|
|
24
|
+
* on the contacts list and an audience rule's owner filter must show the
|
|
25
|
+
* same names for the same people, or a merchant assigning contacts to "Ada"
|
|
26
|
+
* on one screen would be filtering for "ada@example.com" on the other.
|
|
27
|
+
*/ export function orgMemberOptions(members) {
|
|
28
|
+
return members.map((member)=>({
|
|
29
|
+
uid: String(member.$id),
|
|
30
|
+
label: String(member.displayName || member.email || member.$id),
|
|
31
|
+
email: member.email ? String(member.email) : undefined
|
|
32
|
+
})).sort((a, b)=>a.label.localeCompare(b.label));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* THE ORG'S TEAM, for an owner picker on some record's page (AGL-2603).
|
|
36
|
+
*
|
|
37
|
+
* ## Through the members route, not the collection
|
|
38
|
+
*
|
|
39
|
+
* `orgs/{orgId}/members` is readable by an org-wide member and by nobody
|
|
40
|
+
* else: the rules evaluate a list against the QUERY, so a collaborator scoped
|
|
41
|
+
* to one site reading the roster gets `permission-denied`, not a shorter
|
|
42
|
+
* roster. `GET /api/orgs/members?orgId=` re-derives membership with the Admin
|
|
43
|
+
* SDK and answers any member of the org, which is who assigns an owner.
|
|
44
|
+
*
|
|
45
|
+
* ## It is OFF unless a caller asks
|
|
46
|
+
*
|
|
47
|
+
* `enabled` defaults to false, the way `useHostCampaigns` does and for the
|
|
48
|
+
* same reason: the picker this feeds sits on surfaces a reader opens for
|
|
49
|
+
* something else, and a roster fetched on every arrival at the contacts list
|
|
50
|
+
* is a request for a control nobody has opened. Callers turn it on when the
|
|
51
|
+
* owner control is actually on screen.
|
|
52
|
+
*
|
|
53
|
+
* ## A refusal is reported, never rendered as an empty team
|
|
54
|
+
*
|
|
55
|
+
* An org with no members is impossible — it has an owner — so an empty list
|
|
56
|
+
* with no error would always be a lie. `authorizedFetch` refuses to send a
|
|
57
|
+
* request it cannot authorize and answers a 401 carrying the reason; that
|
|
58
|
+
* reason, or the route's own, is what `error` holds.
|
|
59
|
+
*/ export function useOrgMemberOptions(orgId, options) {
|
|
60
|
+
var _ref;
|
|
61
|
+
const enabled = (_ref = options == null ? void 0 : options.enabled) != null ? _ref : false;
|
|
62
|
+
const { data: user } = useUser();
|
|
63
|
+
const [state, setState] = useState({
|
|
64
|
+
orgId: null,
|
|
65
|
+
members: [],
|
|
66
|
+
error: null
|
|
67
|
+
});
|
|
68
|
+
useEffect(()=>{
|
|
69
|
+
if (!enabled || !orgId) return;
|
|
70
|
+
let active = true;
|
|
71
|
+
void (async ()=>{
|
|
72
|
+
let members = [];
|
|
73
|
+
let error = null;
|
|
74
|
+
try {
|
|
75
|
+
const response = await authorizedFetch(user, `/api/orgs/members?orgId=${encodeURIComponent(orgId)}`);
|
|
76
|
+
const payload = await response.json().catch(()=>({}));
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
var _ref;
|
|
79
|
+
error = String((_ref = payload == null ? void 0 : payload.error) != null ? _ref : 'The team could not be read.');
|
|
80
|
+
} else {
|
|
81
|
+
members = orgMemberOptions(Array.isArray(payload == null ? void 0 : payload.members) ? payload.members : []);
|
|
82
|
+
}
|
|
83
|
+
} catch (unused) {
|
|
84
|
+
error = 'The team could not be read.';
|
|
85
|
+
}
|
|
86
|
+
if (active) setState({
|
|
87
|
+
orgId,
|
|
88
|
+
members,
|
|
89
|
+
error
|
|
90
|
+
});
|
|
91
|
+
})();
|
|
92
|
+
return ()=>{
|
|
93
|
+
active = false;
|
|
94
|
+
};
|
|
95
|
+
}, [
|
|
96
|
+
enabled,
|
|
97
|
+
orgId,
|
|
98
|
+
user
|
|
99
|
+
]);
|
|
100
|
+
return useMemo(()=>({
|
|
101
|
+
options: state.orgId === orgId ? state.members : [],
|
|
102
|
+
ready: enabled && Boolean(orgId) && state.orgId === orgId,
|
|
103
|
+
error: state.orgId === orgId ? state.error : null
|
|
104
|
+
}), [
|
|
105
|
+
state,
|
|
106
|
+
orgId,
|
|
107
|
+
enabled
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
export default useOrgMemberOptions;
|
|
111
|
+
|
|
112
|
+
//# sourceMappingURL=use-org-member-options.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-org-member-options.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'use client'\n\nimport type { AglynOrgMember } from '@aglyn/aglyn'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useEffect, useMemo, useState } from 'react'\nimport { useUser } from './firebase/firebase-services'\n\n/** One team member, as much of them as a picker needs. */\nexport interface OrgMemberOption {\n /** The account uid — what a record's `ownerUid` stores. */\n uid: string\n /** The name a colleague recognizes: display name, else address, else uid. */\n label: string\n email?: string\n}\n\nexport interface OrgMemberOptions {\n options: OrgMemberOption[]\n /** The read has answered, one way or the other — false while settling or disabled. */\n ready: boolean\n /** Why the roster is empty, when it is empty because the route refused. */\n error: string | null\n}\n\n/**\n * The roster as picker options, sorted by how they read.\n *\n * Exported on its own so the mapping has one definition: a bulk \"set owner\"\n * on the contacts list and an audience rule's owner filter must show the\n * same names for the same people, or a merchant assigning contacts to \"Ada\"\n * on one screen would be filtering for \"ada@example.com\" on the other.\n */\nexport function orgMemberOptions(\n members: ReadonlyArray<Partial<AglynOrgMember> & { $id: string }>,\n): OrgMemberOption[] {\n return members\n .map((member) => ({\n uid: String(member.$id),\n label: String(member.displayName || member.email || member.$id),\n email: member.email ? String(member.email) : undefined,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * THE ORG'S TEAM, for an owner picker on some record's page (AGL-2603).\n *\n * ## Through the members route, not the collection\n *\n * `orgs/{orgId}/members` is readable by an org-wide member and by nobody\n * else: the rules evaluate a list against the QUERY, so a collaborator scoped\n * to one site reading the roster gets `permission-denied`, not a shorter\n * roster. `GET /api/orgs/members?orgId=` re-derives membership with the Admin\n * SDK and answers any member of the org, which is who assigns an owner.\n *\n * ## It is OFF unless a caller asks\n *\n * `enabled` defaults to false, the way `useHostCampaigns` does and for the\n * same reason: the picker this feeds sits on surfaces a reader opens for\n * something else, and a roster fetched on every arrival at the contacts list\n * is a request for a control nobody has opened. Callers turn it on when the\n * owner control is actually on screen.\n *\n * ## A refusal is reported, never rendered as an empty team\n *\n * An org with no members is impossible — it has an owner — so an empty list\n * with no error would always be a lie. `authorizedFetch` refuses to send a\n * request it cannot authorize and answers a 401 carrying the reason; that\n * reason, or the route's own, is what `error` holds.\n */\nexport function useOrgMemberOptions(\n orgId: string | undefined,\n options?: { enabled?: boolean },\n): OrgMemberOptions {\n const enabled = options?.enabled ?? false\n const { data: user } = useUser()\n const [state, setState] = useState<{\n orgId: string | null\n members: OrgMemberOption[]\n error: string | null\n }>({ orgId: null, members: [], error: null })\n\n useEffect(() => {\n if (!enabled || !orgId) return\n let active = true\n void (async () => {\n let members: OrgMemberOption[] = []\n let error: string | null = null\n try {\n const response = await authorizedFetch(\n user,\n `/api/orgs/members?orgId=${encodeURIComponent(orgId)}`,\n )\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n error = String(payload?.error ?? 'The team could not be read.')\n } else {\n members = orgMemberOptions(\n Array.isArray(payload?.members) ? payload.members : [],\n )\n }\n } catch {\n error = 'The team could not be read.'\n }\n if (active) setState({ orgId, members, error })\n })()\n return () => {\n active = false\n }\n }, [enabled, orgId, user])\n\n return useMemo(\n () => ({\n options: state.orgId === orgId ? state.members : [],\n ready: enabled && Boolean(orgId) && state.orgId === orgId,\n error: state.orgId === orgId ? state.error : null,\n }),\n [state, orgId, enabled],\n )\n}\n\nexport default useOrgMemberOptions\n"],"names":["authorizedFetch","useEffect","useMemo","useState","useUser","orgMemberOptions","members","map","member","uid","String","$id","label","displayName","email","undefined","sort","a","b","localeCompare","useOrgMemberOptions","orgId","options","enabled","data","user","state","setState","error","active","response","encodeURIComponent","payload","json","catch","ok","Array","isArray","ready","Boolean"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAGA,SAASA,eAAe,QAAQ,2CAA0C;AAC1E,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACpD,SAASC,OAAO,QAAQ,kCAA8B;AAmBtD;;;;;;;CAOC,GACD,OAAO,SAASC,iBACdC,OAAiE;IAEjE,OAAOA,QACJC,GAAG,CAAC,CAACC,SAAY,CAAA;YAChBC,KAAKC,OAAOF,OAAOG,GAAG;YACtBC,OAAOF,OAAOF,OAAOK,WAAW,IAAIL,OAAOM,KAAK,IAAIN,OAAOG,GAAG;YAC9DG,OAAON,OAAOM,KAAK,GAAGJ,OAAOF,OAAOM,KAAK,IAAIC;QAC/C,CAAA,GACCC,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEL,KAAK,CAACO,aAAa,CAACD,EAAEN,KAAK;AACjD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASQ,oBACdC,KAAyB,EACzBC,OAA+B;;IAE/B,MAAMC,kBAAUD,2BAAAA,QAASC,OAAO,mBAAI;IACpC,MAAM,EAAEC,MAAMC,IAAI,EAAE,GAAGrB;IACvB,MAAM,CAACsB,OAAOC,SAAS,GAAGxB,SAIvB;QAAEkB,OAAO;QAAMf,SAAS,EAAE;QAAEsB,OAAO;IAAK;IAE3C3B,UAAU;QACR,IAAI,CAACsB,WAAW,CAACF,OAAO;QACxB,IAAIQ,SAAS;QACb,KAAK,AAAC,CAAA;YACJ,IAAIvB,UAA6B,EAAE;YACnC,IAAIsB,QAAuB;YAC3B,IAAI;gBACF,MAAME,WAAW,MAAM9B,gBACrByB,MACA,CAAC,wBAAwB,EAAEM,mBAAmBV,QAAQ;gBAExD,MAAMW,UAAU,MAAMF,SAASG,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;gBACpD,IAAI,CAACJ,SAASK,EAAE,EAAE;;oBAChBP,QAAQlB,eAAOsB,2BAAAA,QAASJ,KAAK,mBAAI;gBACnC,OAAO;oBACLtB,UAAUD,iBACR+B,MAAMC,OAAO,CAACL,2BAAAA,QAAS1B,OAAO,IAAI0B,QAAQ1B,OAAO,GAAG,EAAE;gBAE1D;YACF,EAAE,eAAM;gBACNsB,QAAQ;YACV;YACA,IAAIC,QAAQF,SAAS;gBAAEN;gBAAOf;gBAASsB;YAAM;QAC/C,CAAA;QACA,OAAO;YACLC,SAAS;QACX;IACF,GAAG;QAACN;QAASF;QAAOI;KAAK;IAEzB,OAAOvB,QACL,IAAO,CAAA;YACLoB,SAASI,MAAML,KAAK,KAAKA,QAAQK,MAAMpB,OAAO,GAAG,EAAE;YACnDgC,OAAOf,WAAWgB,QAAQlB,UAAUK,MAAML,KAAK,KAAKA;YACpDO,OAAOF,MAAML,KAAK,KAAKA,QAAQK,MAAME,KAAK,GAAG;QAC/C,CAAA,GACA;QAACF;QAAOL;QAAOE;KAAQ;AAE3B;AAEA,eAAeH,oBAAmB"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface OrgPlanState {
|
|
2
|
+
/** The owning org's doc, once it has arrived. */
|
|
3
|
+
org: Record<string, unknown> | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Whether `org` is trustworthy. FALSE means the answer is still in
|
|
6
|
+
* flight — hold rather than resolve entitlements, because an absent org
|
|
7
|
+
* is indistinguishable from the free tier to every `Aglyn.checkQuota` /
|
|
8
|
+
* `checkEntitlement` call.
|
|
9
|
+
*/
|
|
10
|
+
ready: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A host's owning org doc, for plan quotas and entitlements, with an honest
|
|
14
|
+
* `ready` flag (AGL-1064).
|
|
15
|
+
*
|
|
16
|
+
* This existed as a copy-pasted pair in five commerce console cards:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* const orgId = useHostOrgId(hostId)
|
|
20
|
+
* const { data: org } = useFirestoreDoc(
|
|
21
|
+
* () => doc(firestore, 'orgs', orgId ?? '-pending-'), [firestore, orgId])
|
|
22
|
+
* const quota = Aglyn.checkQuota(org, 'posRegisters', registers.length)
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* `useHostOrgId` returns null while the `hostIndex` lookup is in flight, so
|
|
26
|
+
* `org` is undefined for the first render or two of every mount — and
|
|
27
|
+
* `checkQuota(undefined, …)` does not mean "unknown", it resolves the FREE
|
|
28
|
+
* tier (`posRegisters: 0`, `productsPerHost: 0`, `inventoryLocations: 1`).
|
|
29
|
+
* A paying customer clicking Add inside that window was told their plan does
|
|
30
|
+
* not include what they bought.
|
|
31
|
+
*
|
|
32
|
+
* Same defect shape as {@link useOrgDataScope} (AGL-1061) and
|
|
33
|
+
* `useScopeTokens` (AGL-1047): a hook handing out a resolved-looking value
|
|
34
|
+
* before it has resolved. Callers gate their controls on `ready` rather than
|
|
35
|
+
* letting an absent org answer a billing question.
|
|
36
|
+
*
|
|
37
|
+
* The `'-pending-'` sentinel goes with it. Addressing `orgs/-pending-` was a
|
|
38
|
+
* guaranteed-denied read on every mount; this holds the ref at null instead,
|
|
39
|
+
* so no query is issued until there is a real org to ask about.
|
|
40
|
+
*
|
|
41
|
+
* Not to be confused with the console's `useOrgPlans` (plural), which reads
|
|
42
|
+
* the plan of every org the user belongs to for the switcher. This one is
|
|
43
|
+
* the single owning org of a HOST.
|
|
44
|
+
*/
|
|
45
|
+
export declare function useOrgPlan(hostId: string | undefined): OrgPlanState;
|
|
46
|
+
export default useOrgPlan;
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
*/ 'use client';
|
|
17
|
+
import { doc } from "firebase/firestore";
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
import { useFirestore } from "./firebase/firebase-services.js";
|
|
20
|
+
import { useFirestoreDoc } from "./use-firestore-doc.js";
|
|
21
|
+
import { useHostOrgIdState } from "./use-host-org-id.js";
|
|
22
|
+
/**
|
|
23
|
+
* A host's owning org doc, for plan quotas and entitlements, with an honest
|
|
24
|
+
* `ready` flag (AGL-1064).
|
|
25
|
+
*
|
|
26
|
+
* This existed as a copy-pasted pair in five commerce console cards:
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* const orgId = useHostOrgId(hostId)
|
|
30
|
+
* const { data: org } = useFirestoreDoc(
|
|
31
|
+
* () => doc(firestore, 'orgs', orgId ?? '-pending-'), [firestore, orgId])
|
|
32
|
+
* const quota = Aglyn.checkQuota(org, 'posRegisters', registers.length)
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* `useHostOrgId` returns null while the `hostIndex` lookup is in flight, so
|
|
36
|
+
* `org` is undefined for the first render or two of every mount — and
|
|
37
|
+
* `checkQuota(undefined, …)` does not mean "unknown", it resolves the FREE
|
|
38
|
+
* tier (`posRegisters: 0`, `productsPerHost: 0`, `inventoryLocations: 1`).
|
|
39
|
+
* A paying customer clicking Add inside that window was told their plan does
|
|
40
|
+
* not include what they bought.
|
|
41
|
+
*
|
|
42
|
+
* Same defect shape as {@link useOrgDataScope} (AGL-1061) and
|
|
43
|
+
* `useScopeTokens` (AGL-1047): a hook handing out a resolved-looking value
|
|
44
|
+
* before it has resolved. Callers gate their controls on `ready` rather than
|
|
45
|
+
* letting an absent org answer a billing question.
|
|
46
|
+
*
|
|
47
|
+
* The `'-pending-'` sentinel goes with it. Addressing `orgs/-pending-` was a
|
|
48
|
+
* guaranteed-denied read on every mount; this holds the ref at null instead,
|
|
49
|
+
* so no query is issued until there is a real org to ask about.
|
|
50
|
+
*
|
|
51
|
+
* Not to be confused with the console's `useOrgPlans` (plural), which reads
|
|
52
|
+
* the plan of every org the user belongs to for the switcher. This one is
|
|
53
|
+
* the single owning org of a HOST.
|
|
54
|
+
*/ export function useOrgPlan(hostId) {
|
|
55
|
+
const firestore = useFirestore();
|
|
56
|
+
const { orgId, loaded } = useHostOrgIdState(hostId);
|
|
57
|
+
const { data: org, status } = useFirestoreDoc(()=>orgId ? doc(firestore, 'orgs', orgId) : null, [
|
|
58
|
+
firestore,
|
|
59
|
+
orgId
|
|
60
|
+
]);
|
|
61
|
+
return useMemo(()=>({
|
|
62
|
+
org,
|
|
63
|
+
// A host with no owning org is SETTLED, not pending — there is no
|
|
64
|
+
// org doc coming, and holding forever would leave the card
|
|
65
|
+
// permanently disabled. `status` only moves off 'loading' once a
|
|
66
|
+
// ref exists, so it cannot answer that case.
|
|
67
|
+
//
|
|
68
|
+
// `'success'` and not `!== 'loading'` (AGL-1066). A refused listen can
|
|
69
|
+
// now reach `'error'`, and `'error'` satisfies `!== 'loading'` while
|
|
70
|
+
// leaving `org` undefined — so this hook would have handed out
|
|
71
|
+
// `ready: true` with no org, and `checkQuota(undefined, …)` does not
|
|
72
|
+
// mean "unknown", it resolves the FREE tier. That is the exact fault
|
|
73
|
+
// this hook was written to close, arriving through a different door:
|
|
74
|
+
// a paying org rendered as Free for as long as its session stayed
|
|
75
|
+
// stale. Pending is the right answer to a question we cannot read.
|
|
76
|
+
ready: loaded && (!orgId || status === 'success')
|
|
77
|
+
}), [
|
|
78
|
+
org,
|
|
79
|
+
loaded,
|
|
80
|
+
orgId,
|
|
81
|
+
status
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
export default useOrgPlan;
|
|
85
|
+
|
|
86
|
+
//# sourceMappingURL=use-org-plan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-org-plan.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'use client'\n\nimport { doc } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from './firebase/firebase-services'\nimport { useFirestoreDoc } from './use-firestore-doc'\nimport { useHostOrgIdState } from './use-host-org-id'\n\nexport interface OrgPlanState {\n /** The owning org's doc, once it has arrived. */\n org: Record<string, unknown> | undefined\n /**\n * Whether `org` is trustworthy. FALSE means the answer is still in\n * flight — hold rather than resolve entitlements, because an absent org\n * is indistinguishable from the free tier to every `Aglyn.checkQuota` /\n * `checkEntitlement` call.\n */\n ready: boolean\n}\n\n/**\n * A host's owning org doc, for plan quotas and entitlements, with an honest\n * `ready` flag (AGL-1064).\n *\n * This existed as a copy-pasted pair in five commerce console cards:\n *\n * ```ts\n * const orgId = useHostOrgId(hostId)\n * const { data: org } = useFirestoreDoc(\n * () => doc(firestore, 'orgs', orgId ?? '-pending-'), [firestore, orgId])\n * const quota = Aglyn.checkQuota(org, 'posRegisters', registers.length)\n * ```\n *\n * `useHostOrgId` returns null while the `hostIndex` lookup is in flight, so\n * `org` is undefined for the first render or two of every mount — and\n * `checkQuota(undefined, …)` does not mean \"unknown\", it resolves the FREE\n * tier (`posRegisters: 0`, `productsPerHost: 0`, `inventoryLocations: 1`).\n * A paying customer clicking Add inside that window was told their plan does\n * not include what they bought.\n *\n * Same defect shape as {@link useOrgDataScope} (AGL-1061) and\n * `useScopeTokens` (AGL-1047): a hook handing out a resolved-looking value\n * before it has resolved. Callers gate their controls on `ready` rather than\n * letting an absent org answer a billing question.\n *\n * The `'-pending-'` sentinel goes with it. Addressing `orgs/-pending-` was a\n * guaranteed-denied read on every mount; this holds the ref at null instead,\n * so no query is issued until there is a real org to ask about.\n *\n * Not to be confused with the console's `useOrgPlans` (plural), which reads\n * the plan of every org the user belongs to for the switcher. This one is\n * the single owning org of a HOST.\n */\nexport function useOrgPlan(hostId: string | undefined): OrgPlanState {\n const firestore = useFirestore()\n const { orgId, loaded } = useHostOrgIdState(hostId)\n const { data: org, status } = useFirestoreDoc<Record<string, unknown>>(\n () => (orgId ? doc(firestore, 'orgs', orgId) : null),\n [firestore, orgId],\n )\n return useMemo(\n () => ({\n org,\n // A host with no owning org is SETTLED, not pending — there is no\n // org doc coming, and holding forever would leave the card\n // permanently disabled. `status` only moves off 'loading' once a\n // ref exists, so it cannot answer that case.\n //\n // `'success'` and not `!== 'loading'` (AGL-1066). A refused listen can\n // now reach `'error'`, and `'error'` satisfies `!== 'loading'` while\n // leaving `org` undefined — so this hook would have handed out\n // `ready: true` with no org, and `checkQuota(undefined, …)` does not\n // mean \"unknown\", it resolves the FREE tier. That is the exact fault\n // this hook was written to close, arriving through a different door:\n // a paying org rendered as Free for as long as its session stayed\n // stale. Pending is the right answer to a question we cannot read.\n ready: loaded && (!orgId || status === 'success'),\n }),\n [org, loaded, orgId, status],\n )\n}\n\nexport default useOrgPlan\n"],"names":["doc","useMemo","useFirestore","useFirestoreDoc","useHostOrgIdState","useOrgPlan","hostId","firestore","orgId","loaded","data","org","status","ready"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAASA,GAAG,QAAQ,qBAAoB;AACxC,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,kCAA8B;AAC3D,SAASC,eAAe,QAAQ,yBAAqB;AACrD,SAASC,iBAAiB,QAAQ,uBAAmB;AAcrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GACD,OAAO,SAASC,WAAWC,MAA0B;IACnD,MAAMC,YAAYL;IAClB,MAAM,EAAEM,KAAK,EAAEC,MAAM,EAAE,GAAGL,kBAAkBE;IAC5C,MAAM,EAAEI,MAAMC,GAAG,EAAEC,MAAM,EAAE,GAAGT,gBAC5B,IAAOK,QAAQR,IAAIO,WAAW,QAAQC,SAAS,MAC/C;QAACD;QAAWC;KAAM;IAEpB,OAAOP,QACL,IAAO,CAAA;YACLU;YACA,kEAAkE;YAClE,2DAA2D;YAC3D,iEAAiE;YACjE,6CAA6C;YAC7C,EAAE;YACF,uEAAuE;YACvE,qEAAqE;YACrE,+DAA+D;YAC/D,qEAAqE;YACrE,qEAAqE;YACrE,qEAAqE;YACrE,kEAAkE;YAClE,mEAAmE;YACnEE,OAAOJ,UAAW,CAAA,CAACD,SAASI,WAAW,SAAQ;QACjD,CAAA,GACA;QAACD;QAAKF;QAAQD;QAAOI;KAAO;AAEhC;AAEA,eAAeP,WAAU"}
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
import type { DocumentData, Query } from 'firebase/firestore';
|
|
18
|
+
import { type DependencyList } from 'react';
|
|
19
|
+
import { type UseFirestoreCollectionOptions, type UseFirestoreCollectionResult } from './use-firestore-collection';
|
|
20
|
+
export interface UsePagedCollectionOptions extends UseFirestoreCollectionOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Rows per page. Defaults to the console-wide smallest option — a list
|
|
23
|
+
* that picks its own number is how five different ones appear, and on a
|
|
24
|
+
* query bounded by its page size the smallest page is the smallest bill.
|
|
25
|
+
*/
|
|
26
|
+
pageSize?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface UsePagedCollectionResult<T> extends UseFirestoreCollectionResult<T> {
|
|
29
|
+
/** The current page's rows. `data` holds the probe row; this never does. */
|
|
30
|
+
rows: T[];
|
|
31
|
+
/** A further page exists. A FACT from the probe row, not a guess. */
|
|
32
|
+
hasMore: boolean;
|
|
33
|
+
/** Zero-based, to match `ListPagination` and MUI. */
|
|
34
|
+
page: number;
|
|
35
|
+
setPage: (page: number) => void;
|
|
36
|
+
pageSize: number;
|
|
37
|
+
setPageSize: (pageSize: number) => void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A paged window over a live collection, instead of a big read sliced small.
|
|
41
|
+
*
|
|
42
|
+
* ## The shape this replaces
|
|
43
|
+
*
|
|
44
|
+
* A card would listen with `limit(300)` and render `rows.slice(0, 50)`. That
|
|
45
|
+
* is wrong twice over. Two hundred and fifty documents are read, billed and
|
|
46
|
+
* discarded on every mount — and the rows past the slice are not merely
|
|
47
|
+
* wasted, they are UNREACHABLE, because nothing renders them and no control
|
|
48
|
+
* asks for more. A merchant with sixty gift cards sees fifty and no
|
|
49
|
+
* indication that ten more exist; past three hundred, a card issued this
|
|
50
|
+
* morning cannot be reached at all.
|
|
51
|
+
*
|
|
52
|
+
* Here the window IS the query, and it is expressed in PAGES so the same
|
|
53
|
+
* `ListPagination` footer serves this and a cursor feed. The listener is
|
|
54
|
+
* widened to cover every page up to the one being read, so paging BACK costs
|
|
55
|
+
* nothing — the rows are already in the snapshot — and paging forward costs
|
|
56
|
+
* one page more.
|
|
57
|
+
*
|
|
58
|
+
* ## Why the window covers page 0..n rather than page n alone
|
|
59
|
+
*
|
|
60
|
+
* This is a live `onSnapshot`, not a fetch. Firestore cursors need a document
|
|
61
|
+
* snapshot to resume from, which a listener that has never read page n - 1
|
|
62
|
+
* does not have. Widening the limit is what makes forward and backward
|
|
63
|
+
* symmetrical without a second read path, and the cost is bounded by how deep
|
|
64
|
+
* the reader actually goes — with the shared default of ten, page three is
|
|
65
|
+
* thirty-one documents, and most readers never leave page one.
|
|
66
|
+
*
|
|
67
|
+
* ## Why over-fetch by one
|
|
68
|
+
*
|
|
69
|
+
* `hasMore` has to be a fact. Comparing `length === limit` is wrong exactly
|
|
70
|
+
* when the count is an even multiple of the page size — the case that offers
|
|
71
|
+
* a "next page" leading nowhere, or hides one that leads somewhere. The probe
|
|
72
|
+
* row answers it outright and is never rendered.
|
|
73
|
+
*
|
|
74
|
+
* ## Why the window resets on `deps`
|
|
75
|
+
*
|
|
76
|
+
* `deps` identify the SUBJECT being listened to. Without the reset, a card
|
|
77
|
+
* paged three deep on one site opens the next site three pages in — a read
|
|
78
|
+
* the reader never asked for, charged to whoever they switched to.
|
|
79
|
+
*
|
|
80
|
+
* `buildQuery` receives the limit to apply, so the caller keeps ownership of
|
|
81
|
+
* ordering and predicates:
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* usePagedCollection<Card>(
|
|
85
|
+
* (pageLimit) =>
|
|
86
|
+
* hostId
|
|
87
|
+
* ? query(collection(firestore, 'hosts', hostId, 'giftCards'),
|
|
88
|
+
* orderBy('createdAt', 'desc'), limit(pageLimit))
|
|
89
|
+
* : null,
|
|
90
|
+
* [firestore, hostId],
|
|
91
|
+
* { idField: '$id' },
|
|
92
|
+
* )
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export declare function usePagedCollection<T = DocumentData>(buildQuery: (pageLimit: number) => Query<DocumentData> | null | undefined, deps: DependencyList, options?: UsePagedCollectionOptions): UsePagedCollectionResult<T>;
|