@aglyn/plugins-inbox 1.0.0-beta.221 → 1.0.0-beta.223

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.
Files changed (26) hide show
  1. package/package.json +10 -10
  2. package/src/lib/components/inbox-attribution-zone.d.ts +6 -0
  3. package/src/lib/components/inbox-attribution-zone.js.map +1 -1
  4. package/src/lib/components/inbox-console-page.js +18 -76
  5. package/src/lib/components/inbox-console-page.js.map +1 -1
  6. package/src/lib/components/inbox-glance-card.component.d.ts +1 -1
  7. package/src/lib/components/inbox-glance-card.component.js +9 -3
  8. package/src/lib/components/inbox-glance-card.component.js.map +1 -1
  9. package/src/lib/components/submission-reply.component.js +11 -2
  10. package/src/lib/components/submission-reply.component.js.map +1 -1
  11. package/src/lib/components/submissions-card.component.js +86 -22
  12. package/src/lib/components/submissions-card.component.js.map +1 -1
  13. package/src/lib/components/visitor-door-notices.component.d.ts +54 -0
  14. package/src/lib/components/visitor-door-notices.component.js +123 -0
  15. package/src/lib/components/visitor-door-notices.component.js.map +1 -0
  16. package/src/lib/model/submission-links.d.ts +70 -0
  17. package/src/lib/model/submission-links.js +74 -0
  18. package/src/lib/model/submission-links.js.map +1 -0
  19. package/src/lib/server.js +53 -20
  20. package/src/lib/server.js.map +1 -1
  21. package/src/lib/testing/stand-in-form-submissions.d.ts +25 -0
  22. package/src/lib/testing/stand-in-form-submissions.js +103 -0
  23. package/src/lib/testing/stand-in-form-submissions.js.map +1 -0
  24. package/src/lib/testing/stand-in-person-records.d.ts +35 -0
  25. package/src/lib/testing/stand-in-person-records.js +67 -0
  26. package/src/lib/testing/stand-in-person-records.js.map +1 -0
@@ -0,0 +1,123 @@
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 { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
+ // By their own modules rather than the barrel: the Inbox's specs replace
19
+ // `@aglyn/aglyn` with a closed world, and these are small.
20
+ import { utcMonthKey } from "@aglyn/aglyn/app-utils/utc-month";
21
+ import { pluginVisitorDoors, visitorDoorCaughtNotice, visitorDoorPausedNotice } from "@aglyn/aglyn/plugin-manager/plugin-visitor-doors";
22
+ import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
23
+ import { Alert, AlertTitle, Typography } from "@mui/material";
24
+ import { doc } from "firebase/firestore";
25
+ import { Fragment } from "react";
26
+ /** The declared doors: compiled into the bundle, so the list never changes. */ const DOORS = pluginVisitorDoors();
27
+ /**
28
+ * Every declared public door's flood notices, for the site whose traffic
29
+ * lands here (AGL-1666, AGL-1836, AGL-3080).
30
+ *
31
+ * A door is declared by the plugin that keeps it (`visitorDoors`), which names
32
+ * the two site counters it keeps by month and the words; the Inbox names no
33
+ * door. Each counter is a client-unwritable document host admins can already
34
+ * read (AGL-1367), keyed by the SERVER's month through the shared
35
+ * `utcMonthKey` — a key derived differently here would read zero refusals on
36
+ * exactly the sites being refused.
37
+ *
38
+ * A hook, read by the page above its section guard, so the listens start with
39
+ * the page rather than a frame later with a section. Two listens per door, in
40
+ * the same order on every render: the list is compiled and never changes
41
+ * length, which is what makes a hook per entry safe here.
42
+ */ export function useVisitorDoorNotices(hostId) {
43
+ const firestore = useFirestore();
44
+ const notices = [];
45
+ for (const door of DOORS){
46
+ var _ref, _ref1;
47
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- a compiled, fixed-length list
48
+ const { data: refusedCounter } = useFirestoreDoc(()=>hostId ? doc(firestore, 'hosts', hostId, 'counters', door.refusedCounter) : null, [
49
+ firestore,
50
+ hostId,
51
+ door.refusedCounter
52
+ ]);
53
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- a compiled, fixed-length list
54
+ const { data: caughtCounter } = useFirestoreDoc(()=>hostId ? doc(firestore, 'hosts', hostId, 'counters', door.caughtCounter) : null, [
55
+ firestore,
56
+ hostId,
57
+ door.caughtCounter
58
+ ]);
59
+ notices.push({
60
+ door: door.door,
61
+ paused: visitorDoorPausedNotice(door, {
62
+ refused: Number((_ref = refusedCounter == null ? void 0 : refusedCounter[utcMonthKey()]) != null ? _ref : 0),
63
+ ceiling: Number(refusedCounter == null ? void 0 : refusedCounter['ceiling']) || undefined
64
+ }),
65
+ caught: visitorDoorCaughtNotice(door, {
66
+ caught: Number((_ref1 = caughtCounter == null ? void 0 : caughtCounter[utcMonthKey()]) != null ? _ref1 : 0)
67
+ })
68
+ });
69
+ }
70
+ return notices;
71
+ }
72
+ /**
73
+ * The notices themselves. The paused notice is a warning: until it, a
74
+ * refusal existed in two places a site owner cannot see — a counters
75
+ * document only Firestore's console renders, and one notification `system.`
76
+ * bucket-muting can suppress at write time. The catch count is info rather
77
+ * than warning on purpose: it reports protection WORKING — caught, dropped,
78
+ * never stored or billed. Each renders nothing below one, because a counter
79
+ * document persists from its first trip and a reassuring zero trains the
80
+ * reader to ignore the row.
81
+ */ export function VisitorDoorNotices({ notices }) {
82
+ return /*#__PURE__*/ _jsx(_Fragment, {
83
+ children: notices.map(({ door, paused, caught })=>/*#__PURE__*/ _jsxs(Fragment, {
84
+ children: [
85
+ paused ? /*#__PURE__*/ _jsxs(Alert, {
86
+ severity: "warning",
87
+ sx: {
88
+ mb: 2
89
+ },
90
+ children: [
91
+ /*#__PURE__*/ _jsx(AlertTitle, {
92
+ children: paused.title
93
+ }),
94
+ /*#__PURE__*/ _jsx(Typography, {
95
+ component: "div",
96
+ variant: "body2",
97
+ children: paused.message
98
+ }),
99
+ /*#__PURE__*/ _jsx(Typography, {
100
+ component: "div",
101
+ variant: "body2",
102
+ color: "text.secondary",
103
+ sx: {
104
+ mt: 0.5
105
+ },
106
+ children: paused.until
107
+ })
108
+ ]
109
+ }) : null,
110
+ caught ? /*#__PURE__*/ _jsx(Alert, {
111
+ severity: "info",
112
+ sx: {
113
+ mb: 2
114
+ },
115
+ children: caught
116
+ }) : null
117
+ ]
118
+ }, door))
119
+ });
120
+ }
121
+ export default VisitorDoorNotices;
122
+
123
+ //# sourceMappingURL=visitor-door-notices.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/visitor-door-notices.component.tsx"],"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\n'use client'\n\n// By their own modules rather than the barrel: the Inbox's specs replace\n// `@aglyn/aglyn` with a closed world, and these are small.\nimport { utcMonthKey } from '@aglyn/aglyn/app-utils/utc-month'\nimport {\n pluginVisitorDoors,\n visitorDoorCaughtNotice,\n visitorDoorPausedNotice,\n type VisitorDoorPausedNotice,\n} from '@aglyn/aglyn/plugin-manager/plugin-visitor-doors'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { Alert, AlertTitle, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { Fragment } from 'react'\n\n/** The declared doors: compiled into the bundle, so the list never changes. */\nconst DOORS = pluginVisitorDoors()\n\n/** One door's notices for a site: refusing now, and the month's catches. */\nexport interface VisitorDoorNoticeState {\n door: string\n paused: VisitorDoorPausedNotice | null\n caught: string | null\n}\n\n/**\n * Every declared public door's flood notices, for the site whose traffic\n * lands here (AGL-1666, AGL-1836, AGL-3080).\n *\n * A door is declared by the plugin that keeps it (`visitorDoors`), which names\n * the two site counters it keeps by month and the words; the Inbox names no\n * door. Each counter is a client-unwritable document host admins can already\n * read (AGL-1367), keyed by the SERVER's month through the shared\n * `utcMonthKey` — a key derived differently here would read zero refusals on\n * exactly the sites being refused.\n *\n * A hook, read by the page above its section guard, so the listens start with\n * the page rather than a frame later with a section. Two listens per door, in\n * the same order on every render: the list is compiled and never changes\n * length, which is what makes a hook per entry safe here.\n */\nexport function useVisitorDoorNotices(\n hostId: string | null | undefined,\n): VisitorDoorNoticeState[] {\n const firestore = useFirestore()\n const notices: VisitorDoorNoticeState[] = []\n for (const door of DOORS) {\n // eslint-disable-next-line react-hooks/rules-of-hooks -- a compiled, fixed-length list\n const { data: refusedCounter } = useFirestoreDoc<any>(\n () => (hostId ? doc(firestore, 'hosts', hostId, 'counters', door.refusedCounter) : null),\n [firestore, hostId, door.refusedCounter],\n )\n // eslint-disable-next-line react-hooks/rules-of-hooks -- a compiled, fixed-length list\n const { data: caughtCounter } = useFirestoreDoc<any>(\n () => (hostId ? doc(firestore, 'hosts', hostId, 'counters', door.caughtCounter) : null),\n [firestore, hostId, door.caughtCounter],\n )\n notices.push({\n door: door.door,\n paused: visitorDoorPausedNotice(door, {\n refused: Number(refusedCounter?.[utcMonthKey()] ?? 0),\n ceiling: Number(refusedCounter?.['ceiling']) || undefined,\n }),\n caught: visitorDoorCaughtNotice(door, {\n caught: Number(caughtCounter?.[utcMonthKey()] ?? 0),\n }),\n })\n }\n return notices\n}\n\n/**\n * The notices themselves. The paused notice is a warning: until it, a\n * refusal existed in two places a site owner cannot see — a counters\n * document only Firestore's console renders, and one notification `system.`\n * bucket-muting can suppress at write time. The catch count is info rather\n * than warning on purpose: it reports protection WORKING — caught, dropped,\n * never stored or billed. Each renders nothing below one, because a counter\n * document persists from its first trip and a reassuring zero trains the\n * reader to ignore the row.\n */\nexport function VisitorDoorNotices({\n notices,\n}: {\n notices: readonly VisitorDoorNoticeState[]\n}) {\n return (\n <>\n {notices.map(({ door, paused, caught }) => (\n <Fragment key={door}>\n {paused ? (\n <Alert severity=\"warning\" sx={{ mb: 2 }}>\n <AlertTitle>{paused.title}</AlertTitle>\n <Typography component=\"div\" variant=\"body2\">\n {paused.message}\n </Typography>\n <Typography\n component=\"div\"\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mt: 0.5 }}\n >\n {paused.until}\n </Typography>\n </Alert>\n ) : null}\n {caught ? (\n <Alert severity=\"info\" sx={{ mb: 2 }}>\n {caught}\n </Alert>\n ) : null}\n </Fragment>\n ))}\n </>\n )\n}\n\nexport default VisitorDoorNotices\n"],"names":["utcMonthKey","pluginVisitorDoors","visitorDoorCaughtNotice","visitorDoorPausedNotice","useFirestore","useFirestoreDoc","Alert","AlertTitle","Typography","doc","Fragment","DOORS","useVisitorDoorNotices","hostId","firestore","notices","door","data","refusedCounter","caughtCounter","push","paused","refused","Number","ceiling","undefined","caught","VisitorDoorNotices","map","severity","sx","mb","title","component","variant","message","color","mt","until"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;AAEA,yEAAyE;AACzE,2DAA2D;AAC3D,SAASA,WAAW,QAAQ,mCAAkC;AAC9D,SACEC,kBAAkB,EAClBC,uBAAuB,EACvBC,uBAAuB,QAElB,mDAAkD;AACzD,SAASC,YAAY,EAAEC,eAAe,QAAQ,iCAAgC;AAC9E,SAASC,KAAK,EAAEC,UAAU,EAAEC,UAAU,QAAQ,gBAAe;AAC7D,SAASC,GAAG,QAAQ,qBAAoB;AACxC,SAASC,QAAQ,QAAQ,QAAO;AAEhC,6EAA6E,GAC7E,MAAMC,QAAQV;AASd;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASW,sBACdC,MAAiC;IAEjC,MAAMC,YAAYV;IAClB,MAAMW,UAAoC,EAAE;IAC5C,KAAK,MAAMC,QAAQL,MAAO;;QACxB,uFAAuF;QACvF,MAAM,EAAEM,MAAMC,cAAc,EAAE,GAAGb,gBAC/B,IAAOQ,SAASJ,IAAIK,WAAW,SAASD,QAAQ,YAAYG,KAAKE,cAAc,IAAI,MACnF;YAACJ;YAAWD;YAAQG,KAAKE,cAAc;SAAC;QAE1C,uFAAuF;QACvF,MAAM,EAAED,MAAME,aAAa,EAAE,GAAGd,gBAC9B,IAAOQ,SAASJ,IAAIK,WAAW,SAASD,QAAQ,YAAYG,KAAKG,aAAa,IAAI,MAClF;YAACL;YAAWD;YAAQG,KAAKG,aAAa;SAAC;QAEzCJ,QAAQK,IAAI,CAAC;YACXJ,MAAMA,KAAKA,IAAI;YACfK,QAAQlB,wBAAwBa,MAAM;gBACpCM,SAASC,eAAOL,kCAAAA,cAAgB,CAAClB,cAAc,mBAAI;gBACnDwB,SAASD,OAAOL,kCAAAA,cAAgB,CAAC,UAAU,KAAKO;YAClD;YACAC,QAAQxB,wBAAwBc,MAAM;gBACpCU,QAAQH,gBAAOJ,iCAAAA,aAAe,CAACnB,cAAc,oBAAI;YACnD;QACF;IACF;IACA,OAAOe;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASY,mBAAmB,EACjCZ,OAAO,EAGR;IACC,qBACE;kBACGA,QAAQa,GAAG,CAAC,CAAC,EAAEZ,IAAI,EAAEK,MAAM,EAAEK,MAAM,EAAE,iBACpC,MAAChB;;oBACEW,uBACC,MAACf;wBAAMuB,UAAS;wBAAUC,IAAI;4BAAEC,IAAI;wBAAE;;0CACpC,KAACxB;0CAAYc,OAAOW,KAAK;;0CACzB,KAACxB;gCAAWyB,WAAU;gCAAMC,SAAQ;0CACjCb,OAAOc,OAAO;;0CAEjB,KAAC3B;gCACCyB,WAAU;gCACVC,SAAQ;gCACRE,OAAM;gCACNN,IAAI;oCAAEO,IAAI;gCAAI;0CAEbhB,OAAOiB,KAAK;;;yBAGf;oBACHZ,uBACC,KAACpB;wBAAMuB,UAAS;wBAAOC,IAAI;4BAAEC,IAAI;wBAAE;kCAChCL;yBAED;;eArBSV;;AA0BvB;AAEA,eAAeW,mBAAkB"}
@@ -0,0 +1,70 @@
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
+ /**
18
+ * WHAT AN OPEN SUBMISSION LINKS TO (AGL-3461).
19
+ *
20
+ * A submission names the form that took it, the page it was on and — since
21
+ * the submit route stamps it — the lead or contact the record system filed
22
+ * for its sender. Each is somebody else's record: the form is the Forms
23
+ * plugin's, the person the CRM's, and the page is the live site. So each
24
+ * link is asked of the owner (`hrefOf`, the record-route registry) and falls
25
+ * back to plain text when no plugin answers, rather than to a URL this
26
+ * plugin spelled for them.
27
+ */
28
+ /** The record the submit route stamps once the person is filed. */
29
+ export interface SubmissionCapturedRecord {
30
+ kind?: string;
31
+ id?: string;
32
+ }
33
+ /** The fields of a submission row this module reads. */
34
+ export interface SubmissionLinkSource {
35
+ formId?: unknown;
36
+ formName?: unknown;
37
+ path?: unknown;
38
+ campaignIds?: unknown;
39
+ pageCampaignIds?: unknown;
40
+ capturedRecord?: SubmissionCapturedRecord | null;
41
+ }
42
+ /** One line of the reader's "where it came in" block. */
43
+ export interface SubmissionLink {
44
+ key: 'form' | 'page' | 'record';
45
+ /** What the line is: `Form`, `Page`, `Lead`, `Contact`. */
46
+ label: string;
47
+ /** What the link reads. */
48
+ text: string;
49
+ /** Where it goes, or `null` to draw `text` plainly. */
50
+ href: string | null;
51
+ /** The live site, which opens beside the console rather than over it. */
52
+ external?: boolean;
53
+ }
54
+ /**
55
+ * The submission's form, page and filed person, in that order, each with the
56
+ * link its owner publishes. A line whose fact the row does not carry is
57
+ * left out rather than drawn empty.
58
+ */
59
+ export declare function submissionLinks(options: {
60
+ submission: SubmissionLinkSource;
61
+ /** The record-route registry's answer for one record, or `null`. */
62
+ hrefOf: (kind: string, id: string) => string | null;
63
+ /** The site's public origin, `https://…`, or `null` when unknown. */
64
+ siteOrigin: string | null;
65
+ }): SubmissionLink[];
66
+ /**
67
+ * The campaigns a submission is FILED under: its form's, then its page's,
68
+ * deduped — the two stamps the submit route writes when it arrives.
69
+ */
70
+ export declare function submissionFiledUnder(submission: SubmissionLinkSource | null | undefined): string[];
@@ -0,0 +1,74 @@
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
+ */ import { normalizeContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
17
+ /** The record kinds a captured person may be filed as. */ const PERSON_KINDS = {
18
+ lead: 'Lead',
19
+ contact: 'Contact'
20
+ };
21
+ /**
22
+ * The submission's form, page and filed person, in that order, each with the
23
+ * link its owner publishes. A line whose fact the row does not carry is
24
+ * left out rather than drawn empty.
25
+ */ export function submissionLinks(options) {
26
+ const { submission, hrefOf, siteOrigin } = options;
27
+ const links = [];
28
+ const formId = typeof submission.formId === 'string' ? submission.formId : '';
29
+ const formName = typeof submission.formName === 'string' && submission.formName.trim() ? submission.formName.trim() : 'Form';
30
+ if (formId || typeof submission.formName === 'string') {
31
+ links.push({
32
+ key: 'form',
33
+ label: 'Form',
34
+ text: formName,
35
+ href: formId ? hrefOf('form', formId) : null
36
+ });
37
+ }
38
+ const path = typeof submission.path === 'string' ? submission.path.trim() : '';
39
+ if (path) {
40
+ // Only a site-relative path is joined to the origin; anything else is a
41
+ // value somebody else wrote, and is shown rather than followed.
42
+ const relative = path.startsWith('/') && !path.startsWith('//');
43
+ links.push({
44
+ key: 'page',
45
+ label: 'Page',
46
+ text: path,
47
+ href: relative && siteOrigin ? `${siteOrigin}${path}` : null,
48
+ external: true
49
+ });
50
+ }
51
+ const captured = submission.capturedRecord;
52
+ const kind = typeof (captured == null ? void 0 : captured.kind) === 'string' ? captured.kind : '';
53
+ const id = typeof (captured == null ? void 0 : captured.id) === 'string' ? captured.id : '';
54
+ if (PERSON_KINDS[kind] && id) {
55
+ links.push({
56
+ key: 'record',
57
+ label: PERSON_KINDS[kind],
58
+ text: kind === 'lead' ? 'Open the lead' : 'Open the contact',
59
+ href: hrefOf(kind, id)
60
+ });
61
+ }
62
+ return links;
63
+ }
64
+ /**
65
+ * The campaigns a submission is FILED under: its form's, then its page's,
66
+ * deduped — the two stamps the submit route writes when it arrives.
67
+ */ export function submissionFiledUnder(submission) {
68
+ return normalizeContainerIds([
69
+ ...Array.isArray(submission == null ? void 0 : submission.campaignIds) ? submission.campaignIds : [],
70
+ ...Array.isArray(submission == null ? void 0 : submission.pageCampaignIds) ? submission.pageCampaignIds : []
71
+ ]);
72
+ }
73
+
74
+ //# sourceMappingURL=submission-links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/model/submission-links.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 { normalizeContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\n\n/**\n * WHAT AN OPEN SUBMISSION LINKS TO (AGL-3461).\n *\n * A submission names the form that took it, the page it was on and — since\n * the submit route stamps it — the lead or contact the record system filed\n * for its sender. Each is somebody else's record: the form is the Forms\n * plugin's, the person the CRM's, and the page is the live site. So each\n * link is asked of the owner (`hrefOf`, the record-route registry) and falls\n * back to plain text when no plugin answers, rather than to a URL this\n * plugin spelled for them.\n */\n\n/** The record the submit route stamps once the person is filed. */\nexport interface SubmissionCapturedRecord {\n kind?: string\n id?: string\n}\n\n/** The fields of a submission row this module reads. */\nexport interface SubmissionLinkSource {\n formId?: unknown\n formName?: unknown\n path?: unknown\n campaignIds?: unknown\n pageCampaignIds?: unknown\n capturedRecord?: SubmissionCapturedRecord | null\n}\n\n/** One line of the reader's \"where it came in\" block. */\nexport interface SubmissionLink {\n key: 'form' | 'page' | 'record'\n /** What the line is: `Form`, `Page`, `Lead`, `Contact`. */\n label: string\n /** What the link reads. */\n text: string\n /** Where it goes, or `null` to draw `text` plainly. */\n href: string | null\n /** The live site, which opens beside the console rather than over it. */\n external?: boolean\n}\n\n/** The record kinds a captured person may be filed as. */\nconst PERSON_KINDS: Record<string, string> = { lead: 'Lead', contact: 'Contact' }\n\n/**\n * The submission's form, page and filed person, in that order, each with the\n * link its owner publishes. A line whose fact the row does not carry is\n * left out rather than drawn empty.\n */\nexport function submissionLinks(options: {\n submission: SubmissionLinkSource\n /** The record-route registry's answer for one record, or `null`. */\n hrefOf: (kind: string, id: string) => string | null\n /** The site's public origin, `https://…`, or `null` when unknown. */\n siteOrigin: string | null\n}): SubmissionLink[] {\n const { submission, hrefOf, siteOrigin } = options\n const links: SubmissionLink[] = []\n const formId = typeof submission.formId === 'string' ? submission.formId : ''\n const formName =\n typeof submission.formName === 'string' && submission.formName.trim()\n ? submission.formName.trim()\n : 'Form'\n if (formId || typeof submission.formName === 'string') {\n links.push({\n key: 'form',\n label: 'Form',\n text: formName,\n href: formId ? hrefOf('form', formId) : null,\n })\n }\n const path = typeof submission.path === 'string' ? submission.path.trim() : ''\n if (path) {\n // Only a site-relative path is joined to the origin; anything else is a\n // value somebody else wrote, and is shown rather than followed.\n const relative = path.startsWith('/') && !path.startsWith('//')\n links.push({\n key: 'page',\n label: 'Page',\n text: path,\n href: relative && siteOrigin ? `${siteOrigin}${path}` : null,\n external: true,\n })\n }\n const captured = submission.capturedRecord\n const kind = typeof captured?.kind === 'string' ? captured.kind : ''\n const id = typeof captured?.id === 'string' ? captured.id : ''\n if (PERSON_KINDS[kind] && id) {\n links.push({\n key: 'record',\n label: PERSON_KINDS[kind],\n text: kind === 'lead' ? 'Open the lead' : 'Open the contact',\n href: hrefOf(kind, id),\n })\n }\n return links\n}\n\n/**\n * The campaigns a submission is FILED under: its form's, then its page's,\n * deduped — the two stamps the submit route writes when it arrives.\n */\nexport function submissionFiledUnder(submission: SubmissionLinkSource | null | undefined): string[] {\n return normalizeContainerIds([\n ...(Array.isArray(submission?.campaignIds) ? submission.campaignIds : []),\n ...(Array.isArray(submission?.pageCampaignIds) ? submission.pageCampaignIds : []),\n ])\n}\n"],"names":["normalizeContainerIds","PERSON_KINDS","lead","contact","submissionLinks","options","submission","hrefOf","siteOrigin","links","formId","formName","trim","push","key","label","text","href","path","relative","startsWith","external","captured","capturedRecord","kind","id","submissionFiledUnder","Array","isArray","campaignIds","pageCampaignIds"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAAQ,8CAA6C;AA2CnF,wDAAwD,GACxD,MAAMC,eAAuC;IAAEC,MAAM;IAAQC,SAAS;AAAU;AAEhF;;;;CAIC,GACD,OAAO,SAASC,gBAAgBC,OAM/B;IACC,MAAM,EAAEC,UAAU,EAAEC,MAAM,EAAEC,UAAU,EAAE,GAAGH;IAC3C,MAAMI,QAA0B,EAAE;IAClC,MAAMC,SAAS,OAAOJ,WAAWI,MAAM,KAAK,WAAWJ,WAAWI,MAAM,GAAG;IAC3E,MAAMC,WACJ,OAAOL,WAAWK,QAAQ,KAAK,YAAYL,WAAWK,QAAQ,CAACC,IAAI,KAC/DN,WAAWK,QAAQ,CAACC,IAAI,KACxB;IACN,IAAIF,UAAU,OAAOJ,WAAWK,QAAQ,KAAK,UAAU;QACrDF,MAAMI,IAAI,CAAC;YACTC,KAAK;YACLC,OAAO;YACPC,MAAML;YACNM,MAAMP,SAASH,OAAO,QAAQG,UAAU;QAC1C;IACF;IACA,MAAMQ,OAAO,OAAOZ,WAAWY,IAAI,KAAK,WAAWZ,WAAWY,IAAI,CAACN,IAAI,KAAK;IAC5E,IAAIM,MAAM;QACR,wEAAwE;QACxE,gEAAgE;QAChE,MAAMC,WAAWD,KAAKE,UAAU,CAAC,QAAQ,CAACF,KAAKE,UAAU,CAAC;QAC1DX,MAAMI,IAAI,CAAC;YACTC,KAAK;YACLC,OAAO;YACPC,MAAME;YACND,MAAME,YAAYX,aAAa,GAAGA,aAAaU,MAAM,GAAG;YACxDG,UAAU;QACZ;IACF;IACA,MAAMC,WAAWhB,WAAWiB,cAAc;IAC1C,MAAMC,OAAO,QAAOF,4BAAAA,SAAUE,IAAI,MAAK,WAAWF,SAASE,IAAI,GAAG;IAClE,MAAMC,KAAK,QAAOH,4BAAAA,SAAUG,EAAE,MAAK,WAAWH,SAASG,EAAE,GAAG;IAC5D,IAAIxB,YAAY,CAACuB,KAAK,IAAIC,IAAI;QAC5BhB,MAAMI,IAAI,CAAC;YACTC,KAAK;YACLC,OAAOd,YAAY,CAACuB,KAAK;YACzBR,MAAMQ,SAAS,SAAS,kBAAkB;YAC1CP,MAAMV,OAAOiB,MAAMC;QACrB;IACF;IACA,OAAOhB;AACT;AAEA;;;CAGC,GACD,OAAO,SAASiB,qBAAqBpB,UAAmD;IACtF,OAAON,sBAAsB;WACvB2B,MAAMC,OAAO,CAACtB,8BAAAA,WAAYuB,WAAW,IAAIvB,WAAWuB,WAAW,GAAG,EAAE;WACpEF,MAAMC,OAAO,CAACtB,8BAAAA,WAAYwB,eAAe,IAAIxB,WAAWwB,eAAe,GAAG,EAAE;KACjF;AACH"}
package/src/lib/server.js CHANGED
@@ -92,9 +92,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
92
92
  * cannot disagree about which document to look for.
93
93
  */ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, assignmentReadout, grantEntriesAsRecorded, isOrgWideMember, readMarketingBasis, registerPluginApiRoute, resolveBrandingProfile } from "@aglyn/aglyn/server";
94
94
  import { emailSuppressionKey, enrollListMember, firebaseAdmin, getOrgForHost, hostSendingIdentity, isEmailSuppressed, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
95
- // The leaf, not the barrel: this plugin's specs substitute the barrel
96
- // wholesale, and the lookup must reach the real index logic under them.
97
- import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
95
+ // The person behind the address, through the plugin that keeps people
96
+ // (AGL-3080): the Inbox never opens the record system's collections.
97
+ import { findPluginPerson } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
98
98
  // And for the refusal a deleted contact left behind (AGL-3338): the real store
99
99
  // and the real merge, under whatever the specs put in the barrels' place.
100
100
  import { readRetainedRefusals } from "@aglyn/tenant-data-admin/server/retained-refusals";
@@ -104,6 +104,10 @@ import { refusalsOf, withRetainedRefusals } from "@aglyn/aglyn/app-utils/retaine
104
104
  import { consentGroupOptOutHosts } from "@aglyn/aglyn/app-utils/consent-groups";
105
105
  import { messageFromName, sendEmail } from "@aglyn/shared-util-email";
106
106
  import { FieldValue } from "firebase-admin/firestore";
107
+ // The registries' own modules: a reply finds the submission it answers
108
+ // through the index the forms plugin publishes (AGL-3080).
109
+ import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
110
+ import { runPluginDeclarationsRepair } from "@aglyn/aglyn/plugin-manager/plugin-declarations-repair";
107
111
  import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } from "./model/reply-policy.js";
108
112
  /** Where a reply is stored, under the submission it answers. */ export const REPLIES_SUBCOLLECTION = 'replies';
109
113
  /** `context` on the send: the log label and the Resend attribution tag. */ export const REPLY_CONTEXT = 'inbox-reply';
@@ -205,9 +209,9 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
205
209
  error: 'Your account has no email address, so a reply would have nowhere to come back to.'
206
210
  });
207
211
  }
208
- const submissionRef = hostRef.collection('formSubmissions').doc(submissionId);
209
- const submission = await submissionRef.get();
210
- if (!submission.exists) {
212
+ const submissionRef = await formSubmissionRef(hostId, submissionId);
213
+ const submission = await (submissionRef == null ? void 0 : submissionRef.get());
214
+ if (!submissionRef || !(submission == null ? void 0 : submission.exists)) {
211
215
  return res.status(404).json({
212
216
  error: 'Unknown submission'
213
217
  });
@@ -304,6 +308,28 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
304
308
  });
305
309
  }
306
310
  };
311
+ /**
312
+ * The submission a reply answers or an assignment acts on, or `null` (AGL-3080).
313
+ *
314
+ * Submissions are the forms plugin's records, kept where it keeps them, and
315
+ * this plugin is their reader: it finds one through the record index the
316
+ * forms plugin publishes for `formSubmission`, which hands back the one
317
+ * document — to mark it answered and to keep this plugin's own replies and
318
+ * assignments under it, as the forms plugin documents a reader may. A process
319
+ * whose boot failed runs the app's declarations step once and asks again; a
320
+ * build with no forms plugin keeps no submissions, which is `null`.
321
+ */ async function formSubmissionRef(hostId, submissionId) {
322
+ var _owner_index_ref;
323
+ let owner = pluginRecordIndex('formSubmission');
324
+ if (!owner && await runPluginDeclarationsRepair().catch(()=>false)) {
325
+ owner = pluginRecordIndex('formSubmission');
326
+ }
327
+ const ref = await (owner == null ? void 0 : (_owner_index_ref = owner.index.ref) == null ? void 0 : _owner_index_ref.call(owner.index, {
328
+ hostId,
329
+ id: submissionId
330
+ }));
331
+ return ref != null ? ref : null;
332
+ }
307
333
  /** Where an assignment is recorded, under the submission that occasioned it. */ export const LIST_ASSIGNMENTS_SUBCOLLECTION = 'listAssignments';
308
334
  /** The `source` stamped on a member enrolled from the Inbox. */ export const ASSIGNMENT_SOURCE = 'inbox';
309
335
  /**
@@ -402,9 +428,9 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
402
428
  }
403
429
  };
404
430
  }
405
- const submissionRef = hostRef.collection('formSubmissions').doc(submissionId);
406
- const submission = await submissionRef.get();
407
- if (!submission.exists) {
431
+ const submissionRef = await formSubmissionRef(hostId, submissionId);
432
+ const submission = await (submissionRef == null ? void 0 : submissionRef.get());
433
+ if (!submissionRef || !(submission == null ? void 0 : submission.exists)) {
408
434
  return {
409
435
  ok: false,
410
436
  status: 404,
@@ -434,12 +460,15 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
434
460
  };
435
461
  }
436
462
  /**
437
- * The person's own consent facts, read off the org contact for this address.
463
+ * The person's own consent facts, read off the person record the workspace
464
+ * keeps for this address — asked of the plugin that keeps people
465
+ * (`plugin-person-records`, AGL-3080), never read off its storage here.
438
466
  *
439
- * The CRM record is where a refusal lives: `marketingConsent` is written
440
- * `false` by exactly one path in the product and it writes a contact. So this
441
- * is the read that makes `declined` mean something at enrollment time, and an
442
- * absent contact is honestly `unrecorded` rather than a reason to guess.
467
+ * The person record is where a refusal lives: `marketingConsent` is written
468
+ * `false` by exactly one path in the product and it writes that record. So
469
+ * this is the read that makes `declined` mean something at enrollment time,
470
+ * and an absent person is honestly `unrecorded` rather than a reason to guess
471
+ * — as is a workspace with no record system, where nobody was ever asked.
443
472
  *
444
473
  * Read UNSCOPED, deliberately. `scopedToHost` narrows an org collection to
445
474
  * what one site may see, and a refusal filtered out by that narrowing is a
@@ -448,10 +477,11 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
448
477
  * proved an org-wide member, which is the tier the rules grant the whole
449
478
  * org's contacts to.
450
479
  *
451
- * Through the org's address index (AGL-2633), for the same failure mode
452
- * from the other side: a refusal recorded on a record that was later
453
- * merged into another is the survivor's refusal, and the sender's address
454
- * may be the one that became an alternate.
480
+ * By the owner's address lookup, which answers an alternate address a merge
481
+ * folded in (AGL-2633), for the same failure mode from the other side: a
482
+ * refusal recorded on a record that was later merged into another is the
483
+ * survivor's refusal, and the sender's address may be the one that became an
484
+ * alternate.
455
485
  *
456
486
  * The person's grants come back beside the verdict, off the same document
457
487
  * (AGL-3320): a pass-through carries them onto the membership as the record
@@ -468,12 +498,15 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
468
498
  try {
469
499
  const contacts = await orgDataCollectionForHost(hostId, 'contacts');
470
500
  const [found, retained] = await Promise.all([
471
- findContactByEmail(contacts, email),
501
+ findPluginPerson({
502
+ hostId,
503
+ email
504
+ }),
472
505
  readRetainedRefusals(contacts, [
473
506
  email
474
507
  ])
475
508
  ]);
476
- const data = withRetainedRefusals(found ? found.data() : null, refusalsOf([
509
+ const data = withRetainedRefusals(found ? _extends({}, found.data) : null, refusalsOf([
477
510
  ...retained.values()
478
511
  ][0]));
479
512
  return {