@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.
- package/package.json +10 -10
- package/src/lib/components/inbox-attribution-zone.d.ts +6 -0
- package/src/lib/components/inbox-attribution-zone.js.map +1 -1
- package/src/lib/components/inbox-console-page.js +18 -76
- package/src/lib/components/inbox-console-page.js.map +1 -1
- package/src/lib/components/inbox-glance-card.component.d.ts +1 -1
- package/src/lib/components/inbox-glance-card.component.js +9 -3
- package/src/lib/components/inbox-glance-card.component.js.map +1 -1
- package/src/lib/components/submission-reply.component.js +11 -2
- package/src/lib/components/submission-reply.component.js.map +1 -1
- package/src/lib/components/submissions-card.component.js +86 -22
- package/src/lib/components/submissions-card.component.js.map +1 -1
- package/src/lib/components/visitor-door-notices.component.d.ts +54 -0
- package/src/lib/components/visitor-door-notices.component.js +123 -0
- package/src/lib/components/visitor-door-notices.component.js.map +1 -0
- package/src/lib/model/submission-links.d.ts +70 -0
- package/src/lib/model/submission-links.js +74 -0
- package/src/lib/model/submission-links.js.map +1 -0
- package/src/lib/server.js +53 -20
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-form-submissions.d.ts +25 -0
- package/src/lib/testing/stand-in-form-submissions.js +103 -0
- package/src/lib/testing/stand-in-form-submissions.js.map +1 -0
- package/src/lib/testing/stand-in-person-records.d.ts +35 -0
- package/src/lib/testing/stand-in-person-records.js +67 -0
- 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
|
|
96
|
-
//
|
|
97
|
-
import {
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
440
|
-
* `false` by exactly one path in the product and it writes
|
|
441
|
-
* is the read that makes `declined` mean something at enrollment time,
|
|
442
|
-
* absent
|
|
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
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
* may be the one that became an
|
|
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
|
-
|
|
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
|
|
509
|
+
const data = withRetainedRefusals(found ? _extends({}, found.data) : null, refusalsOf([
|
|
477
510
|
...retained.values()
|
|
478
511
|
][0]));
|
|
479
512
|
return {
|