@aglyn/plugins-inbox 1.0.0-beta.218 → 1.0.0-beta.219
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/submissions-card.component.js +4 -3
- package/src/lib/components/submissions-card.component.js.map +1 -1
- package/src/lib/model/inbox-record-routes.d.ts +24 -0
- package/src/lib/model/inbox-record-routes.js +60 -0
- package/src/lib/model/inbox-record-routes.js.map +1 -0
- package/src/lib/model/submission-presenter.d.ts +10 -1
- package/src/lib/model/submission-presenter.js +15 -0
- package/src/lib/model/submission-presenter.js.map +1 -1
- package/src/lib/plugin.js +2 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server.js +20 -2
- package/src/lib/server.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-inbox",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.219",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
35
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
36
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.219",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.219",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.219",
|
|
31
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.219",
|
|
32
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.219",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.219",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.219",
|
|
35
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.219",
|
|
36
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.219",
|
|
37
37
|
"@swc/helpers": "0.5.23"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
19
|
-
import { FORMS_MAX_PER_HOST,
|
|
19
|
+
import { FORMS_MAX_PER_HOST, pluginDocsHelp } from "@aglyn/aglyn";
|
|
20
|
+
import { INBOX_SUBMISSION_PARAM } from "../model/inbox-record-routes.js";
|
|
20
21
|
// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry
|
|
21
22
|
// point the tenant's loader dynamically imports to activate the marketing
|
|
22
23
|
// plugin's SITE half, so a console card named there ships to every published
|
|
@@ -567,7 +568,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
567
568
|
submissions.length === 0 && !hasMoreSubmissions && !filtering ? /*#__PURE__*/ _jsx(Typography, {
|
|
568
569
|
variant: "body2",
|
|
569
570
|
color: "text.secondary",
|
|
570
|
-
children: scoped ? 'No submissions carry this form’s id yet. Messages this ' + 'form’s design collected before it became a form entity are ' + 'in the Inbox, filed under the name they were sent with.' : hostId == null ? 'No form submissions on any site yet.' : 'No form submissions yet. Add a Contact Form element to a ' + '
|
|
571
|
+
children: scoped ? 'No submissions carry this form’s id yet. Messages this ' + 'form’s design collected before it became a form entity are ' + 'in the Inbox, filed under the name they were sent with.' : hostId == null ? 'No form submissions on any site yet.' : 'No form submissions yet. Add a Contact Form element to a ' + 'page — visitor messages arrive here.'
|
|
571
572
|
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
572
573
|
children: [
|
|
573
574
|
/*#__PURE__*/ _jsx(ListFilterChips, {
|
|
@@ -639,7 +640,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
639
640
|
color: "text.secondary",
|
|
640
641
|
children: [
|
|
641
642
|
`Received ${(_ref4 = reader == null ? void 0 : (_reader_createdAt = reader.createdAt) == null ? void 0 : (_reader_createdAt_toDate = _reader_createdAt.toDate) == null ? void 0 : _reader_createdAt_toDate.call(_reader_createdAt).toLocaleString()) != null ? _ref4 : ''}` + // The site it was sent to, where the list spans every site.
|
|
642
|
-
(hostId == null && readerSite ? ` · ${orgSiteName(orgMount, readerSite)}` : '') + ((reader == null ? void 0 : reader.screenId) ? ` ·
|
|
643
|
+
(hostId == null && readerSite ? ` · ${orgSiteName(orgMount, readerSite)}` : '') + ((reader == null ? void 0 : reader.screenId) ? ` · page ${reader.screenId}` : ''),
|
|
643
644
|
(reader == null ? void 0 : reader.path) ? ` · ${String(reader.path)}` : ''
|
|
644
645
|
]
|
|
645
646
|
}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/submissions-card.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'use client'\n\nimport {\n FORMS_MAX_PER_HOST,\n INBOX_SUBMISSION_PARAM,\n pluginDocsHelp,\n type ConsolePluginOrgMount,\n} from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, for the reason above: the barrel\n// is the plugin's site entry point.\nimport {\n pluginRecordByEmailHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiDeleteOutline,\n mdiEmailOpenOutline,\n mdiEmailOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport {\n type ListFilterOption,\n listFilterGridColumns,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type { ListQueryDeclaration } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n usePluginApiPost,\n} from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n Avatar,\n Box,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Divider,\n Stack,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n collectionGroup,\n deleteDoc,\n doc,\n getDoc,\n limit,\n orderBy,\n query,\n updateDoc,\n} from 'firebase/firestore'\nimport { useSearchParams } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport {\n relativeTime,\n routingChips,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\nimport {\n FORM_SCOPED_SUBMISSION_LIST_QUERY,\n ORG_SUBMISSION_LIST_QUERY,\n SUBMISSION_FILTER_HEADERS,\n SUBMISSION_LIST_QUERY,\n SUBMISSION_READ_OPTIONS,\n formSubmissionBase,\n orgSubmissionBase,\n} from '../constants/list-queries'\nimport { orgSiteName } from './inbox-org-sites'\nimport SubmissionListAssignment from './submission-list-assignment.component'\nimport SubmissionReply from './submission-reply.component'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n\n/**\n * The forms plugin's recount route (AGL-3330), by address: this plugin\n * shows the submissions a form counts and may not import the plugin that\n * keeps the counts.\n */\nconst FORM_STATS_API_PATH = '/api/forms/stats'\n/** Read, Form and Site are picked, so the panel shows selects. */\nconst SUBMISSION_SELECT_FIELDS = ['read', 'formId', 'hostId']\nconst SUBMISSION_HIDDEN_COLUMNS = { read: false, formId: false }\n\n/** Every field of a message, as one line — what the Message column draws. */\nconst messageTextOf = (submission: any): string =>\n Object.entries(submission?.fields ?? {})\n .map(([key, value]) => `${key}: ${value}`)\n .join(' · ')\n\n/**\n * The Submissions section of the Inbox (AGL-77/104/109 → AGL-395): the form\n * messages a site collected, newest first, and the reader that opens one.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n */\nexport interface SubmissionsCardProps {\n /**\n * The site whose submissions are listed, or `null` on the organization's\n * Inbox, where the card lists every site's at once (AGL-3303) and each row\n * is acted on as the site it was sent to.\n */\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n /**\n * Narrow this card to ONE form, permanently.\n *\n * What the forms plugin's detail surface renders instead of a second\n * submissions table. The reader has already chosen the subject by being on\n * that page, so scoping here does three things a copy would have had to\n * re-derive: the form picker is not rendered (there is nothing to pick),\n * the site's `forms` collection is not read at all (the picker was its only\n * consumer), and the empty state names the form rather than the site.\n *\n * Everything else — the paged walk, the reader dialog, read/unread,\n * delete, reply, list assignment, attribution — is the same code answering\n * a narrower query. That is the reason this is a prop rather than a second\n * component: a per-form table written separately would be a second reader\n * to keep in step, and it would have been the one to reintroduce the\n * unordered `limit()` this card's own comment exists to warn about.\n */\n formId?: string\n}\n\nexport function SubmissionsCard({\n hostId,\n formId,\n orgMount,\n}: SubmissionsCardProps) {\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const postPluginApi = usePluginApiPost()\n const { confirm } = useConfirmationContext()\n /** Scoped to one form: the subject is fixed and nothing may widen it. */\n const scoped = Boolean(formId)\n /** Every site of this organization at once, when no site is named. */\n const orgId = hostId == null ? (orgMount?.orgId ?? null) : null\n /*\n * The site a row lives on, which is where every act on it is addressed:\n * this card's own site, or — across every site — the `hostId` the submit\n * route stamps on the row. That stamp is frozen against a client update and\n * written from the row's own path, so it names the document's real parent;\n * the rules check the host role on that site either way.\n */\n const siteOf = useCallback(\n (submission: any): string | null =>\n hostId ??\n (typeof submission?.hostId === 'string' && submission.hostId\n ? submission.hostId\n : null),\n [hostId],\n )\n /*\n * Where the sender's CONTACT is (AGL-2612). A submission that carried an\n * address updated a contact in the CRM at stage Lead; the row links to\n * it by that address, and the Contacts list — which holds the id nothing\n * here does — opens the record. The same hub path the Members & leads\n * rows use to open a lead.\n */\n const routeContext = useRecordRouteContext()\n\n /*\n * The site's forms, for the Submissions filter.\n *\n * `FORMS_MAX_PER_HOST` is a read WINDOW, not a cap on the collection — how\n * many forms a site may hold is `formsPerHost`, enforced at the create in\n * `/api/hosts/resources`, and a staff-set per-org override can raise it\n * past this window. So the window can be smaller than the catalog, and one\n * more document than fits is read on purpose: a filter that quietly listed\n * the first N would report \"this form does not exist\" and \"this form is\n * past the window\" as the same empty answer.\n *\n * Ordered by `__name__`. `displayName` would be the nicer order and is the\n * wrong instrument: `orderBy` on a data field DROPS every document missing\n * it, invisibly, so a form saved without a name would vanish from its own\n * filter. The list is sorted for display below, where a missing name is\n * merely ugly.\n */\n const { data: formDocs } = useFirestoreCollection<any>(\n () =>\n // Forms are one site's catalog. Across every site there is no single\n // catalog to filter by, and picking a site is how a reader gets one.\n scoped || !hostId\n ? null\n : query(\n collection(firestore, 'hosts', hostId, 'forms'),\n orderBy('__name__'),\n limit(FORMS_MAX_PER_HOST + 1),\n ),\n [firestore, hostId, scoped],\n { idField: '$id' },\n )\n /** More forms exist than the window shows; the filter has to say so. */\n const formsTruncated = (formDocs?.length ?? 0) > FORMS_MAX_PER_HOST\n const forms = useMemo(\n () =>\n [...(formDocs ?? [])]\n .slice(0, FORMS_MAX_PER_HOST)\n .sort((left: any, right: any) =>\n String(left.displayName ?? left.$id).localeCompare(\n String(right.displayName ?? right.$id),\n ),\n ),\n [formDocs],\n )\n /** Form is offered where the site's catalog is read. */\n const offersForm = !scoped && Boolean(hostId) && forms.length > 0\n /*\n * WHAT THIS CARD'S LIST CAN BE ASKED (AGL-3321) — every clause and the\n * search on the query, see `SUBMISSION_LIST_QUERY`:\n *\n * - a site's Inbox: From, Read and — where the site has forms — Form;\n * - a card scoped to one form: that form is the list's own scope, and\n * Form has nothing left to pick, so it is not offered. The scope wins\n * over any clause: it is the base, and no clause can reach it;\n * - every site's, on the organization's Inbox: From, Read and Site,\n * under the `orgId` clause the rules require of the group read.\n */\n const declaration = useMemo<ListQueryDeclaration>(() => {\n if (scoped) return FORM_SCOPED_SUBMISSION_LIST_QUERY\n if (hostId == null) return ORG_SUBMISSION_LIST_QUERY\n return offersForm\n ? SUBMISSION_LIST_QUERY\n : {\n ...SUBMISSION_LIST_QUERY,\n fields: SUBMISSION_LIST_QUERY.fields.filter((field) => field.column !== 'formId'),\n }\n }, [scoped, hostId, offersForm])\n const base = useMemo(\n () =>\n formId ? formSubmissionBase(formId) : hostId == null && orgId ? orgSubmissionBase(orgId) : [],\n [formId, hostId, orgId],\n )\n const gridFilter = useListGridFilter({ selectFields: SUBMISSION_SELECT_FIELDS })\n\n /*\n * The inbox WALKS its submissions instead of sampling them (AGL-2501,\n * AGL-2292), and every filter and search word is a predicate on the walk\n * (AGL-3321), so a page is a page of the matches.\n *\n * `limit(200)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred\n * of the site's messages, which the client sort then arranged newest-first\n * so the result looked like a feed. A site past two hundred submissions\n * could not reach the rest, and the messages missing left no gap: the row\n * dates on screen simply skipped, which reads as a quiet week rather than\n * as an unreachable inbox.\n *\n * `createdAt` is safe to order on, checked against the writer rather than\n * assumed: `apps/tenant/app/api/forms/submit/route.ts` is the only path\n * that creates one and stamps `createdAt: serverTimestamp()` on every add,\n * the v1 API only ever reads and deletes, and `formSubmissions` is absent\n * from `IMPORTABLE_FIELDS`, so no restore path can make one without it.\n *\n * EVERY SITE'S SUBMISSIONS IN ONE QUERY on the organization's Inbox\n * (AGL-3303), not a listener per site: the collection group, newest first.\n * The `orgId` clause is not a filter this card could drop. The rules admit\n * a group read only when it is narrowed to an org the reader spans, so an\n * unfiltered group query is refused outright. Every submission carries\n * `orgId`: the submit route stamps it, and the rows written before it did\n * were stamped once (AGL-3303). A row without one would be in no\n * organization's list, though its own site's Inbox lists it either way.\n */\n const {\n rows: submissions,\n hasMore: hasMoreSubmissions,\n page: submissionPage,\n setPage: setSubmissionPage,\n pageSize: submissionPageSize,\n setPageSize: setSubmissionPageSize,\n plan,\n } = useListQuery<any>({\n collection: hostId\n ? collection(firestore, 'hosts', hostId, 'formSubmissions')\n : orgId\n ? collectionGroup(firestore, 'formSubmissions')\n : null,\n declaration,\n request: { clauses: gridFilter.clauses, search: gridFilter.searchWords, base },\n deps: [firestore, hostId, orgId],\n idField: '$id',\n })\n const submissionOptions = useMemo<Readonly<Record<string, readonly ListFilterOption[]>>>(\n () => ({\n read: SUBMISSION_READ_OPTIONS,\n ...(offersForm\n ? {\n formId: forms.map((form: any) => ({\n value: String(form.$id),\n label: String(form.displayName || form.$id),\n })),\n }\n : {}),\n ...(hostId == null\n ? {\n hostId: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {}),\n }),\n [offersForm, forms, hostId, orgMount],\n )\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: declaration.fields,\n headers: SUBMISSION_FILTER_HEADERS,\n options: submissionOptions,\n }),\n [plan.refused, declaration, submissionOptions],\n )\n const searching = gridFilter.searchWords.some((word) => word.trim())\n const filtering = gridFilter.clauses.length > 0 || searching\n /** Narrowed by nothing but a Form clause. */\n const onlyForm =\n !searching &&\n gridFilter.clauses.length > 0 &&\n gridFilter.clauses.every((clause) => clause.field === 'formId')\n\n // Mail reader (AGL-104): opening a submission shows the full message and\n // marks it read.\n const [reader, setReader] = useState<any | null>(null)\n const handleOpenReader = useCallback(\n (submission: any) => () => {\n setReader(submission)\n const site = siteOf(submission)\n if (!submission.read && site) {\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: true },\n )\n }\n },\n [firestore, siteOf],\n )\n\n const handleToggleRead = useCallback(\n (submission: any) => () => {\n const site = siteOf(submission)\n if (!site) return\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: !submission.read },\n )\n },\n [firestore, siteOf],\n )\n\n /*\n * A submission named in the URL opens in the reader on arrival (AGL-2622).\n *\n * A contact's timeline names the submission that captured the person, and\n * the address it links to is this tab with `?submission={id}`. The\n * document is read once, by id, rather than looked for in the paged\n * window: the window is the newest page of a walk that may not reach a\n * submission from months back, and one document read is what the URL\n * asked for. Opening it marks it read, as a click on the row would. A\n * submission that is gone says so, because a reader that silently stays\n * shut reads as the link having done nothing.\n *\n * Once per id: the ref keeps a re-render — and the same URL re-settling —\n * from reopening a reader the person has since closed. The landing read\n * is judged against that ref rather than an effect cleanup: a listener\n * settling while the read is in flight re-renders this card, and a\n * cleanup keyed on any dep would cancel the answer to the question the\n * URL just asked.\n */\n const searchParams = useSearchParams()\n const seededSubmissionId = searchParams?.get(INBOX_SUBMISSION_PARAM) ?? null\n const seededOpened = useRef<string | null>(null)\n useEffect(() => {\n // A submission link names one site's Inbox; the org's has no site to read\n // the id under.\n if (!hostId) return\n if (!seededSubmissionId || seededOpened.current === seededSubmissionId) return\n seededOpened.current = seededSubmissionId\n void getDoc(doc(firestore, 'hosts', hostId, 'formSubmissions', seededSubmissionId))\n .then((snapshot) => {\n if (seededOpened.current !== snapshot.id) return\n if (!snapshot.exists()) {\n enqueueSnackbar('That submission is no longer in the Inbox.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n handleOpenReader({ ...snapshot.data(), $id: snapshot.id })()\n })\n .catch(() => undefined)\n }, [seededSubmissionId, firestore, hostId, handleOpenReader, enqueueSnackbar])\n\n const handleDelete = useCallback(\n (submission: any) => async () => {\n const site = siteOf(submission)\n if (!site) return\n const confirmed = await confirm({\n title: 'Delete this submission?',\n description: 'The submission is removed permanently.',\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await deleteDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n )\n enqueueSnackbar('Submission deleted', {\n variant: 'success',\n persist: false,\n })\n /*\n * The form counted this submission when it arrived (AGL-3330), and an\n * increment cannot see a delete: the forms plugin recounts the form\n * from the rows that are left. Best effort — the delete has happened\n * either way, and the recount script catches a follow-up that failed.\n */\n if (typeof submission.formId === 'string' && submission.formId) {\n void postPluginApi(FORM_STATS_API_PATH, { hostId: site, formIds: [submission.formId] })\n }\n },\n [confirm, firestore, siteOf, enqueueSnackbar, postPluginApi],\n )\n\n /*\n * One row per submission, newest first: who sent it, what it said and how\n * long ago. The row opens the reader, which also marks it read.\n */\n const submissionActions = (submission: any): RowActionsMenuItem[] => {\n const senderEmail = submissionSender(submission.fields).email\n // Where the sender's contact is read, asked of whichever plugin keeps\n // people; with none loaded there is no such page, and no menu item.\n const contactsHref = routeContext\n ? pluginRecordListHref('contact', routeContext)\n : null\n return [\n ...(routeContext && contactsHref\n ? [\n {\n key: 'crm',\n label: 'Open contact in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n ...(senderEmail\n ? {\n href:\n pluginRecordByEmailHref(\n 'contact',\n routeContext,\n senderEmail,\n ) ?? contactsHref,\n }\n : {\n disabled: true,\n disabledReason:\n 'This submission carried no email address, so no contact was updated.',\n }),\n },\n ]\n : []),\n {\n key: 'read',\n label: submission.read ? 'Mark unread' : 'Mark read',\n icon: (\n <MdiIcon\n path={submission.read ? mdiEmailOutline.path : mdiEmailOpenOutline.path}\n size={0.8}\n />\n ),\n onClick: handleToggleRead(submission),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: handleDelete(submission),\n },\n ]\n }\n const submissionColumns: GridColDef[] = [\n {\n /*\n The mockup's list is people, not forms (AGL-2168): an initials avatar,\n the sender, and the form name beneath it. The unread DOT replaces the\n `New` chip — the row is already bold, and a chip that says \"New\" beside\n bold text is the same fact twice.\n */\n field: 'from',\n headerName: 'From',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, submission) => submissionSender(submission.fields).label,\n renderCell: ({ row: submission }) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n {!submission.read ? (\n <Box\n aria-label=\"Unread\"\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: 'primary.main',\n flexShrink: 0,\n }}\n />\n ) : (\n <Box sx={{ width: 8, flexShrink: 0 }} />\n )}\n <Avatar\n sx={{\n width: 28,\n height: 28,\n fontSize: 13,\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {sender.label}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {submission.formName ?? 'Form'}\n </Typography>\n </Stack>\n </Stack>\n )\n },\n },\n /*\n Which site it was sent to, on the organization's Inbox only — under a\n site every row is that site's, and a column saying so would be noise.\n */\n ...(hostId == null\n ? [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 1,\n minWidth: 140,\n // Sorted and drawn by name; the filter matches the id.\n valueGetter: (_value: unknown, submission: any) =>\n orgSiteName(orgMount, submission.hostId),\n renderCell: ({ row: submission }: { row: any }) =>\n orgSiteName(orgMount, submission.hostId),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'message',\n headerName: 'Message',\n flex: 2,\n minWidth: 260,\n valueGetter: (_value, submission) => messageTextOf(submission),\n renderCell: ({ value }) => (\n <Typography variant=\"body2\" noWrap>\n {value}\n </Typography>\n ),\n },\n {\n /*\n Relative, as the mockup shows it — an inbox is scanned for recency and a\n locale timestamp makes the reader do the subtraction. The absolute time\n stays on the detail dialog, where it is the fact you actually want.\n */\n field: 'createdAt',\n headerName: 'Received',\n width: 130,\n valueGetter: (_value, submission) =>\n submission.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: submission }) => (\n <Tooltip title={submission.createdAt?.toDate?.().toLocaleString() ?? ''}>\n <span>{relativeTime(submission.createdAt?.toDate?.().getTime())}</span>\n </Tooltip>\n ),\n },\n /*\n READ, as the panel picks it — never drawn: the unread dot and the bold\n row already say it. A boolean field has no hidden column of its own (the\n grid's boolean operators are not the select's), so it is declared here,\n kept hidden and out of Manage columns.\n */\n {\n field: 'read',\n headerName: 'Read',\n hideable: false,\n valueGetter: (_value, submission) => String(Boolean(submission.read)),\n },\n /*\n One overflow menu, as the Members & leads rows have: two inline buttons\n had no room for a third, and the contact link is the one a reader\n reaches for after reading. Present but disabled for a submission with no\n address, with the reason — a row that simply lacked the item would read\n as the contact not existing.\n */\n listActionsColumn(\n (submission) => (\n <ListRowActions\n label={String(submissionSender(submission.fields).email ?? submission.$id)}\n items={submissionActions(submission)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n /** The site the open submission was sent to, which its reader acts as. */\n const readerSite = reader ? siteOf(reader) : null\n\n return (\n <>\n <CardDisplay\n header={scoped ? 'Submissions to this form' : 'Form Submissions'}\n help={pluginDocsHelp(\n 'forms',\n hostId == null\n ? {\n anchor: '#every-sites-inbox-at-once',\n excerpt:\n 'Every site’s form messages in one list, newest first, ' +\n 'each under the site it was sent to.',\n }\n : {\n anchor: '#the-inbox',\n excerpt:\n 'Messages your forms collected, newest first, showing who ' +\n 'sent each one and where it was routed.',\n },\n )}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {/*\n * The Inbox stays the site-wide answer to \"who is waiting for a\n * reply\" — that question does not decompose by form. The panel's\n * Form filter narrows it on request; it does not turn the page into\n * a per-form view. It is offered only when the site HAS forms, and\n * never on a card already scoped to one.\n */}\n {offersForm && formsTruncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\" component=\"p\" sx={{ mb: 1 }}>\n {`The Form filter offers the first ${FORMS_MAX_PER_HOST.toLocaleString()} ` +\n 'forms. Narrow by form is incomplete; with no Form filter the ' +\n 'list still covers every submission.'}\n </Typography>\n ) : null}\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: submissions.length ? 2 : 1 }}\n >\n {'Every site’s submissions, newest first. Choose a site to ' +\n 'narrow them by form.'}\n </Typography>\n ) : null}\n {submissions.length === 0 && !hasMoreSubmissions && !filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {scoped\n ? 'No submissions carry this form’s id yet. Messages this ' +\n 'form’s design collected before it became a form entity are ' +\n 'in the Inbox, filed under the name they were sent with.'\n : hostId == null\n ? 'No form submissions on any site yet.'\n : 'No form submissions yet. Add a Contact Form element to a ' +\n 'screen — visitor messages arrive here.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={declaration.fields}\n headers={SUBMISSION_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={submissionOptions}\n />\n <ListQueryNotices refused={refusals} notices={plan.notices} />\n <ListTable\n aria-label={scoped ? 'Submissions to this form' : 'Form submissions'}\n rows={submissions}\n columns={listFilterGridColumns(\n submissionColumns,\n declaration.fields,\n submissionOptions,\n SUBMISSION_FILTER_HEADERS,\n )}\n initialState={{ columns: { columnVisibilityModel: SUBMISSION_HIDDEN_COLUMNS } }}\n noRowsLabel={\n onlyForm\n ? 'No submissions for this form yet. Submissions sent before ' +\n 'the form was created are listed with no Form filter.'\n : 'No submissions match these filters'\n }\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, submission) => handleOpenReader(submission)()}\n // An unread submission reads bold across its row, which is why\n // the list needs no `New` chip.\n getRowClassName={({ row }) => (row.read ? '' : 'submission-unread')}\n sx={{\n '& .submission-unread .MuiDataGrid-cell, & .submission-unread .MuiTypography-root':\n { fontWeight: 'fontWeightBold' },\n }}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search go to the query; the grid neither\n // filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnSorting\n />\n <ListPagination\n page={submissionPage}\n pageSize={submissionPageSize}\n rowCount={submissions.length}\n hasMore={hasMoreSubmissions}\n onPageChange={setSubmissionPage}\n onPageSizeChange={setSubmissionPageSize}\n />\n </>\n )}\n </CardDisplay>\n <Dialog\n open={Boolean(reader)}\n onClose={() => setReader(null)}\n maxWidth=\"sm\"\n fullWidth\n >\n <DialogTitle>{reader?.formName ?? 'Form submission'}</DialogTitle>\n <DialogContent>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Received ${reader?.createdAt?.toDate?.().toLocaleString() ?? ''}` +\n // The site it was sent to, where the list spans every site.\n (hostId == null && readerSite\n ? ` · ${orgSiteName(orgMount, readerSite)}`\n : '') +\n (reader?.screenId ? ` · screen ${reader.screenId}` : '')}\n {/*\n THE PAGE THE FORM WAS ON. Stored by the submit route since the\n form existed and rendered by nothing, which is also the field\n the marketing console's landing-page grouping joins on — a\n reader who wants to check one row against that grouping has to\n be able to see the row's own page.\n */}\n {reader?.path ? ` · ${String(reader.path)}` : ''}\n </Typography>\n <Divider sx={{ my: 1.5 }} />\n <Stack spacing={1.5}>\n {Object.entries(reader?.fields ?? {}).map(([key, value]) => (\n <Stack key={key} spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {key}\n </Typography>\n <Typography\n variant=\"body2\"\n sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n >\n {String(value)}\n </Typography>\n </Stack>\n ))}\n </Stack>\n {/*\n What happened to this submission (AGL-2168). The mockup puts\n these under the fields: `Saved to Inbox` and `Added to \"Leads\"\n dataset`. The second is stamped by the submit route only when a\n record was really appended — a form bound to a deleted dataset,\n or one whose record quota is full, shows no chip rather than a\n chip for a row that does not exist. Both are failures the route\n already swallows silently, and a chip that lied about them\n would be worse than the silence.\n */}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ mt: 2, flexWrap: 'wrap', rowGap: 1 }}\n >\n {routingChips(reader?.routing).map((chip) => (\n <Chip\n key={chip.label}\n size=\"small\"\n label={chip.label}\n color={chip.color}\n variant=\"outlined\"\n />\n ))}\n </Stack>\n {/*\n WHERE THIS SUBMISSION CAME FROM.\n\n One keyed document read — the attribution's id is `form:{id}` and\n the submission's id is that `{id}` — paid when a merchant opens a\n submission rather than once per row of the list. A submission that\n was credited to nobody renders the sentence saying so; it never\n renders a campaign with a zero beside it.\n */}\n {reader?.$id && readerSite ? (\n <Box sx={{ mt: 2 }}>\n <InboxRecordAttributionZone\n hostId={readerSite}\n recordKind=\"form\"\n recordId={String(reader.$id)}\n />\n </Box>\n ) : null}\n {/*\n Answering the person is the act the Inbox exists for, and it is\n inside the reader rather than on the row because a reply written\n without the message in front of you is the reply that answers the\n wrong question. Mounted with the dialog, so its reads — the site\n name and the replies already sent — are paid when a merchant opens\n a submission and not once per visit to this page.\n */}\n {reader && readerSite ? (\n <SubmissionReply hostId={readerSite} submission={reader} />\n ) : null}\n {/*\n Enrolling the sender in a marketing list — a SEPARATE act from\n answering them, and a separate card, because the person asked to\n be answered and did not ask to be marketed to. Its reads are paid\n only when a merchant presses its own button, so opening a\n submission to read it costs nothing extra.\n */}\n {reader && readerSite ? (\n <SubmissionListAssignment hostId={readerSite} submission={reader} />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button\n color=\"error\"\n onClick={async () => {\n const target = reader\n setReader(null)\n if (target) await handleDelete(target)()\n }}\n >\n {'Delete'}\n </Button>\n <Button\n onClick={() => {\n if (reader) void handleToggleRead({ ...reader, read: true })()\n setReader(null)\n }}\n >\n {'Mark unread'}\n </Button>\n <Button variant=\"contained\" onClick={() => setReader(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nSubmissionsCard.displayName = 'SubmissionsCard'\n\nexport default SubmissionsCard\n"],"names":["FORMS_MAX_PER_HOST","INBOX_SUBMISSION_PARAM","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordByEmailHref","pluginRecordListHref","mdiAccountArrowRight","mdiDeleteOutline","mdiEmailOpenOutline","mdiEmailOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","useFirestore","useFirestoreCollection","usePluginApiPost","useListQuery","Avatar","Box","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Divider","Stack","Tooltip","Typography","collection","collectionGroup","deleteDoc","doc","getDoc","limit","orderBy","query","updateDoc","useSearchParams","useCallback","useEffect","useMemo","useRef","useState","relativeTime","routingChips","senderHue","submissionSender","FORM_SCOPED_SUBMISSION_LIST_QUERY","ORG_SUBMISSION_LIST_QUERY","SUBMISSION_FILTER_HEADERS","SUBMISSION_LIST_QUERY","SUBMISSION_READ_OPTIONS","formSubmissionBase","orgSubmissionBase","orgSiteName","SubmissionListAssignment","SubmissionReply","useRecordRouteContext","FORM_STATS_API_PATH","SUBMISSION_SELECT_FIELDS","SUBMISSION_HIDDEN_COLUMNS","read","formId","messageTextOf","submission","Object","entries","fields","map","key","value","join","SubmissionsCard","hostId","orgMount","reader","firestore","enqueueSnackbar","postPluginApi","confirm","scoped","Boolean","orgId","siteOf","routeContext","data","formDocs","idField","formsTruncated","length","forms","slice","sort","left","right","String","displayName","$id","localeCompare","offersForm","declaration","filter","field","column","base","gridFilter","selectFields","rows","submissions","hasMore","hasMoreSubmissions","page","submissionPage","setPage","setSubmissionPage","pageSize","submissionPageSize","setPageSize","setSubmissionPageSize","plan","request","clauses","search","searchWords","deps","submissionOptions","form","label","hosts","host","id","name","refusals","refused","headers","options","searching","some","word","trim","filtering","onlyForm","every","clause","setReader","handleOpenReader","site","handleToggleRead","searchParams","seededSubmissionId","get","seededOpened","current","then","snapshot","exists","variant","persist","catch","undefined","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","formIds","submissionActions","senderEmail","email","contactsHref","icon","path","size","href","disabled","disabledReason","onClick","destructive","submissionColumns","headerName","flex","minWidth","valueGetter","_value","renderCell","row","sender","hue","direction","spacing","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","fontSize","initials","noWrap","formName","createdAt","toDate","getTime","toLocaleString","span","hideable","items","readerSite","header","help","anchor","excerpt","contentGutterX","contentGutterY","contentBordered","component","mb","onChange","setClauses","notices","columns","initialState","columnVisibilityModel","noRowsLabel","rowHeight","onOpen","_id","getRowClassName","fontWeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnSorting","rowCount","onPageChange","onPageSizeChange","open","onClose","maxWidth","fullWidth","screenId","my","whiteSpace","wordBreak","mt","flexWrap","rowGap","routing","chip","recordKind","recordId","target"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/submissions-card.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'use client'\n\nimport {\n FORMS_MAX_PER_HOST,\n pluginDocsHelp,\n type ConsolePluginOrgMount,\n} from '@aglyn/aglyn'\nimport { INBOX_SUBMISSION_PARAM } from '../model/inbox-record-routes'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, for the reason above: the barrel\n// is the plugin's site entry point.\nimport {\n pluginRecordByEmailHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiDeleteOutline,\n mdiEmailOpenOutline,\n mdiEmailOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport {\n type ListFilterOption,\n listFilterGridColumns,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type { ListQueryDeclaration } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n usePluginApiPost,\n} from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n Avatar,\n Box,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Divider,\n Stack,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n collectionGroup,\n deleteDoc,\n doc,\n getDoc,\n limit,\n orderBy,\n query,\n updateDoc,\n} from 'firebase/firestore'\nimport { useSearchParams } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport {\n relativeTime,\n routingChips,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\nimport {\n FORM_SCOPED_SUBMISSION_LIST_QUERY,\n ORG_SUBMISSION_LIST_QUERY,\n SUBMISSION_FILTER_HEADERS,\n SUBMISSION_LIST_QUERY,\n SUBMISSION_READ_OPTIONS,\n formSubmissionBase,\n orgSubmissionBase,\n} from '../constants/list-queries'\nimport { orgSiteName } from './inbox-org-sites'\nimport SubmissionListAssignment from './submission-list-assignment.component'\nimport SubmissionReply from './submission-reply.component'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n\n/**\n * The forms plugin's recount route (AGL-3330), by address: this plugin\n * shows the submissions a form counts and may not import the plugin that\n * keeps the counts.\n */\nconst FORM_STATS_API_PATH = '/api/forms/stats'\n/** Read, Form and Site are picked, so the panel shows selects. */\nconst SUBMISSION_SELECT_FIELDS = ['read', 'formId', 'hostId']\nconst SUBMISSION_HIDDEN_COLUMNS = { read: false, formId: false }\n\n/** Every field of a message, as one line — what the Message column draws. */\nconst messageTextOf = (submission: any): string =>\n Object.entries(submission?.fields ?? {})\n .map(([key, value]) => `${key}: ${value}`)\n .join(' · ')\n\n/**\n * The Submissions section of the Inbox (AGL-77/104/109 → AGL-395): the form\n * messages a site collected, newest first, and the reader that opens one.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n */\nexport interface SubmissionsCardProps {\n /**\n * The site whose submissions are listed, or `null` on the organization's\n * Inbox, where the card lists every site's at once (AGL-3303) and each row\n * is acted on as the site it was sent to.\n */\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n /**\n * Narrow this card to ONE form, permanently.\n *\n * What the forms plugin's detail surface renders instead of a second\n * submissions table. The reader has already chosen the subject by being on\n * that page, so scoping here does three things a copy would have had to\n * re-derive: the form picker is not rendered (there is nothing to pick),\n * the site's `forms` collection is not read at all (the picker was its only\n * consumer), and the empty state names the form rather than the site.\n *\n * Everything else — the paged walk, the reader dialog, read/unread,\n * delete, reply, list assignment, attribution — is the same code answering\n * a narrower query. That is the reason this is a prop rather than a second\n * component: a per-form table written separately would be a second reader\n * to keep in step, and it would have been the one to reintroduce the\n * unordered `limit()` this card's own comment exists to warn about.\n */\n formId?: string\n}\n\nexport function SubmissionsCard({\n hostId,\n formId,\n orgMount,\n}: SubmissionsCardProps) {\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const postPluginApi = usePluginApiPost()\n const { confirm } = useConfirmationContext()\n /** Scoped to one form: the subject is fixed and nothing may widen it. */\n const scoped = Boolean(formId)\n /** Every site of this organization at once, when no site is named. */\n const orgId = hostId == null ? (orgMount?.orgId ?? null) : null\n /*\n * The site a row lives on, which is where every act on it is addressed:\n * this card's own site, or — across every site — the `hostId` the submit\n * route stamps on the row. That stamp is frozen against a client update and\n * written from the row's own path, so it names the document's real parent;\n * the rules check the host role on that site either way.\n */\n const siteOf = useCallback(\n (submission: any): string | null =>\n hostId ??\n (typeof submission?.hostId === 'string' && submission.hostId\n ? submission.hostId\n : null),\n [hostId],\n )\n /*\n * Where the sender's CONTACT is (AGL-2612). A submission that carried an\n * address updated a contact in the CRM at stage Lead; the row links to\n * it by that address, and the Contacts list — which holds the id nothing\n * here does — opens the record. The same hub path the Members & leads\n * rows use to open a lead.\n */\n const routeContext = useRecordRouteContext()\n\n /*\n * The site's forms, for the Submissions filter.\n *\n * `FORMS_MAX_PER_HOST` is a read WINDOW, not a cap on the collection — how\n * many forms a site may hold is `formsPerHost`, enforced at the create in\n * `/api/hosts/resources`, and a staff-set per-org override can raise it\n * past this window. So the window can be smaller than the catalog, and one\n * more document than fits is read on purpose: a filter that quietly listed\n * the first N would report \"this form does not exist\" and \"this form is\n * past the window\" as the same empty answer.\n *\n * Ordered by `__name__`. `displayName` would be the nicer order and is the\n * wrong instrument: `orderBy` on a data field DROPS every document missing\n * it, invisibly, so a form saved without a name would vanish from its own\n * filter. The list is sorted for display below, where a missing name is\n * merely ugly.\n */\n const { data: formDocs } = useFirestoreCollection<any>(\n () =>\n // Forms are one site's catalog. Across every site there is no single\n // catalog to filter by, and picking a site is how a reader gets one.\n scoped || !hostId\n ? null\n : query(\n collection(firestore, 'hosts', hostId, 'forms'),\n orderBy('__name__'),\n limit(FORMS_MAX_PER_HOST + 1),\n ),\n [firestore, hostId, scoped],\n { idField: '$id' },\n )\n /** More forms exist than the window shows; the filter has to say so. */\n const formsTruncated = (formDocs?.length ?? 0) > FORMS_MAX_PER_HOST\n const forms = useMemo(\n () =>\n [...(formDocs ?? [])]\n .slice(0, FORMS_MAX_PER_HOST)\n .sort((left: any, right: any) =>\n String(left.displayName ?? left.$id).localeCompare(\n String(right.displayName ?? right.$id),\n ),\n ),\n [formDocs],\n )\n /** Form is offered where the site's catalog is read. */\n const offersForm = !scoped && Boolean(hostId) && forms.length > 0\n /*\n * WHAT THIS CARD'S LIST CAN BE ASKED (AGL-3321) — every clause and the\n * search on the query, see `SUBMISSION_LIST_QUERY`:\n *\n * - a site's Inbox: From, Read and — where the site has forms — Form;\n * - a card scoped to one form: that form is the list's own scope, and\n * Form has nothing left to pick, so it is not offered. The scope wins\n * over any clause: it is the base, and no clause can reach it;\n * - every site's, on the organization's Inbox: From, Read and Site,\n * under the `orgId` clause the rules require of the group read.\n */\n const declaration = useMemo<ListQueryDeclaration>(() => {\n if (scoped) return FORM_SCOPED_SUBMISSION_LIST_QUERY\n if (hostId == null) return ORG_SUBMISSION_LIST_QUERY\n return offersForm\n ? SUBMISSION_LIST_QUERY\n : {\n ...SUBMISSION_LIST_QUERY,\n fields: SUBMISSION_LIST_QUERY.fields.filter((field) => field.column !== 'formId'),\n }\n }, [scoped, hostId, offersForm])\n const base = useMemo(\n () =>\n formId ? formSubmissionBase(formId) : hostId == null && orgId ? orgSubmissionBase(orgId) : [],\n [formId, hostId, orgId],\n )\n const gridFilter = useListGridFilter({ selectFields: SUBMISSION_SELECT_FIELDS })\n\n /*\n * The inbox WALKS its submissions instead of sampling them (AGL-2501,\n * AGL-2292), and every filter and search word is a predicate on the walk\n * (AGL-3321), so a page is a page of the matches.\n *\n * `limit(200)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred\n * of the site's messages, which the client sort then arranged newest-first\n * so the result looked like a feed. A site past two hundred submissions\n * could not reach the rest, and the messages missing left no gap: the row\n * dates on screen simply skipped, which reads as a quiet week rather than\n * as an unreachable inbox.\n *\n * `createdAt` is safe to order on, checked against the writer rather than\n * assumed: `apps/tenant/app/api/forms/submit/route.ts` is the only path\n * that creates one and stamps `createdAt: serverTimestamp()` on every add,\n * the v1 API only ever reads and deletes, and `formSubmissions` is absent\n * from `IMPORTABLE_FIELDS`, so no restore path can make one without it.\n *\n * EVERY SITE'S SUBMISSIONS IN ONE QUERY on the organization's Inbox\n * (AGL-3303), not a listener per site: the collection group, newest first.\n * The `orgId` clause is not a filter this card could drop. The rules admit\n * a group read only when it is narrowed to an org the reader spans, so an\n * unfiltered group query is refused outright. Every submission carries\n * `orgId`: the submit route stamps it, and the rows written before it did\n * were stamped once (AGL-3303). A row without one would be in no\n * organization's list, though its own site's Inbox lists it either way.\n */\n const {\n rows: submissions,\n hasMore: hasMoreSubmissions,\n page: submissionPage,\n setPage: setSubmissionPage,\n pageSize: submissionPageSize,\n setPageSize: setSubmissionPageSize,\n plan,\n } = useListQuery<any>({\n collection: hostId\n ? collection(firestore, 'hosts', hostId, 'formSubmissions')\n : orgId\n ? collectionGroup(firestore, 'formSubmissions')\n : null,\n declaration,\n request: { clauses: gridFilter.clauses, search: gridFilter.searchWords, base },\n deps: [firestore, hostId, orgId],\n idField: '$id',\n })\n const submissionOptions = useMemo<Readonly<Record<string, readonly ListFilterOption[]>>>(\n () => ({\n read: SUBMISSION_READ_OPTIONS,\n ...(offersForm\n ? {\n formId: forms.map((form: any) => ({\n value: String(form.$id),\n label: String(form.displayName || form.$id),\n })),\n }\n : {}),\n ...(hostId == null\n ? {\n hostId: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {}),\n }),\n [offersForm, forms, hostId, orgMount],\n )\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: declaration.fields,\n headers: SUBMISSION_FILTER_HEADERS,\n options: submissionOptions,\n }),\n [plan.refused, declaration, submissionOptions],\n )\n const searching = gridFilter.searchWords.some((word) => word.trim())\n const filtering = gridFilter.clauses.length > 0 || searching\n /** Narrowed by nothing but a Form clause. */\n const onlyForm =\n !searching &&\n gridFilter.clauses.length > 0 &&\n gridFilter.clauses.every((clause) => clause.field === 'formId')\n\n // Mail reader (AGL-104): opening a submission shows the full message and\n // marks it read.\n const [reader, setReader] = useState<any | null>(null)\n const handleOpenReader = useCallback(\n (submission: any) => () => {\n setReader(submission)\n const site = siteOf(submission)\n if (!submission.read && site) {\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: true },\n )\n }\n },\n [firestore, siteOf],\n )\n\n const handleToggleRead = useCallback(\n (submission: any) => () => {\n const site = siteOf(submission)\n if (!site) return\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: !submission.read },\n )\n },\n [firestore, siteOf],\n )\n\n /*\n * A submission named in the URL opens in the reader on arrival (AGL-2622).\n *\n * A contact's timeline names the submission that captured the person, and\n * the address it links to is this tab with `?submission={id}`. The\n * document is read once, by id, rather than looked for in the paged\n * window: the window is the newest page of a walk that may not reach a\n * submission from months back, and one document read is what the URL\n * asked for. Opening it marks it read, as a click on the row would. A\n * submission that is gone says so, because a reader that silently stays\n * shut reads as the link having done nothing.\n *\n * Once per id: the ref keeps a re-render — and the same URL re-settling —\n * from reopening a reader the person has since closed. The landing read\n * is judged against that ref rather than an effect cleanup: a listener\n * settling while the read is in flight re-renders this card, and a\n * cleanup keyed on any dep would cancel the answer to the question the\n * URL just asked.\n */\n const searchParams = useSearchParams()\n const seededSubmissionId = searchParams?.get(INBOX_SUBMISSION_PARAM) ?? null\n const seededOpened = useRef<string | null>(null)\n useEffect(() => {\n // A submission link names one site's Inbox; the org's has no site to read\n // the id under.\n if (!hostId) return\n if (!seededSubmissionId || seededOpened.current === seededSubmissionId) return\n seededOpened.current = seededSubmissionId\n void getDoc(doc(firestore, 'hosts', hostId, 'formSubmissions', seededSubmissionId))\n .then((snapshot) => {\n if (seededOpened.current !== snapshot.id) return\n if (!snapshot.exists()) {\n enqueueSnackbar('That submission is no longer in the Inbox.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n handleOpenReader({ ...snapshot.data(), $id: snapshot.id })()\n })\n .catch(() => undefined)\n }, [seededSubmissionId, firestore, hostId, handleOpenReader, enqueueSnackbar])\n\n const handleDelete = useCallback(\n (submission: any) => async () => {\n const site = siteOf(submission)\n if (!site) return\n const confirmed = await confirm({\n title: 'Delete this submission?',\n description: 'The submission is removed permanently.',\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await deleteDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n )\n enqueueSnackbar('Submission deleted', {\n variant: 'success',\n persist: false,\n })\n /*\n * The form counted this submission when it arrived (AGL-3330), and an\n * increment cannot see a delete: the forms plugin recounts the form\n * from the rows that are left. Best effort — the delete has happened\n * either way, and the recount script catches a follow-up that failed.\n */\n if (typeof submission.formId === 'string' && submission.formId) {\n void postPluginApi(FORM_STATS_API_PATH, { hostId: site, formIds: [submission.formId] })\n }\n },\n [confirm, firestore, siteOf, enqueueSnackbar, postPluginApi],\n )\n\n /*\n * One row per submission, newest first: who sent it, what it said and how\n * long ago. The row opens the reader, which also marks it read.\n */\n const submissionActions = (submission: any): RowActionsMenuItem[] => {\n const senderEmail = submissionSender(submission.fields).email\n // Where the sender's contact is read, asked of whichever plugin keeps\n // people; with none loaded there is no such page, and no menu item.\n const contactsHref = routeContext\n ? pluginRecordListHref('contact', routeContext)\n : null\n return [\n ...(routeContext && contactsHref\n ? [\n {\n key: 'crm',\n label: 'Open contact in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n ...(senderEmail\n ? {\n href:\n pluginRecordByEmailHref(\n 'contact',\n routeContext,\n senderEmail,\n ) ?? contactsHref,\n }\n : {\n disabled: true,\n disabledReason:\n 'This submission carried no email address, so no contact was updated.',\n }),\n },\n ]\n : []),\n {\n key: 'read',\n label: submission.read ? 'Mark unread' : 'Mark read',\n icon: (\n <MdiIcon\n path={submission.read ? mdiEmailOutline.path : mdiEmailOpenOutline.path}\n size={0.8}\n />\n ),\n onClick: handleToggleRead(submission),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: handleDelete(submission),\n },\n ]\n }\n const submissionColumns: GridColDef[] = [\n {\n /*\n The mockup's list is people, not forms (AGL-2168): an initials avatar,\n the sender, and the form name beneath it. The unread DOT replaces the\n `New` chip — the row is already bold, and a chip that says \"New\" beside\n bold text is the same fact twice.\n */\n field: 'from',\n headerName: 'From',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, submission) => submissionSender(submission.fields).label,\n renderCell: ({ row: submission }) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n {!submission.read ? (\n <Box\n aria-label=\"Unread\"\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: 'primary.main',\n flexShrink: 0,\n }}\n />\n ) : (\n <Box sx={{ width: 8, flexShrink: 0 }} />\n )}\n <Avatar\n sx={{\n width: 28,\n height: 28,\n fontSize: 13,\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {sender.label}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {submission.formName ?? 'Form'}\n </Typography>\n </Stack>\n </Stack>\n )\n },\n },\n /*\n Which site it was sent to, on the organization's Inbox only — under a\n site every row is that site's, and a column saying so would be noise.\n */\n ...(hostId == null\n ? [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 1,\n minWidth: 140,\n // Sorted and drawn by name; the filter matches the id.\n valueGetter: (_value: unknown, submission: any) =>\n orgSiteName(orgMount, submission.hostId),\n renderCell: ({ row: submission }: { row: any }) =>\n orgSiteName(orgMount, submission.hostId),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'message',\n headerName: 'Message',\n flex: 2,\n minWidth: 260,\n valueGetter: (_value, submission) => messageTextOf(submission),\n renderCell: ({ value }) => (\n <Typography variant=\"body2\" noWrap>\n {value}\n </Typography>\n ),\n },\n {\n /*\n Relative, as the mockup shows it — an inbox is scanned for recency and a\n locale timestamp makes the reader do the subtraction. The absolute time\n stays on the detail dialog, where it is the fact you actually want.\n */\n field: 'createdAt',\n headerName: 'Received',\n width: 130,\n valueGetter: (_value, submission) =>\n submission.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: submission }) => (\n <Tooltip title={submission.createdAt?.toDate?.().toLocaleString() ?? ''}>\n <span>{relativeTime(submission.createdAt?.toDate?.().getTime())}</span>\n </Tooltip>\n ),\n },\n /*\n READ, as the panel picks it — never drawn: the unread dot and the bold\n row already say it. A boolean field has no hidden column of its own (the\n grid's boolean operators are not the select's), so it is declared here,\n kept hidden and out of Manage columns.\n */\n {\n field: 'read',\n headerName: 'Read',\n hideable: false,\n valueGetter: (_value, submission) => String(Boolean(submission.read)),\n },\n /*\n One overflow menu, as the Members & leads rows have: two inline buttons\n had no room for a third, and the contact link is the one a reader\n reaches for after reading. Present but disabled for a submission with no\n address, with the reason — a row that simply lacked the item would read\n as the contact not existing.\n */\n listActionsColumn(\n (submission) => (\n <ListRowActions\n label={String(submissionSender(submission.fields).email ?? submission.$id)}\n items={submissionActions(submission)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n /** The site the open submission was sent to, which its reader acts as. */\n const readerSite = reader ? siteOf(reader) : null\n\n return (\n <>\n <CardDisplay\n header={scoped ? 'Submissions to this form' : 'Form Submissions'}\n help={pluginDocsHelp(\n 'forms',\n hostId == null\n ? {\n anchor: '#every-sites-inbox-at-once',\n excerpt:\n 'Every site’s form messages in one list, newest first, ' +\n 'each under the site it was sent to.',\n }\n : {\n anchor: '#the-inbox',\n excerpt:\n 'Messages your forms collected, newest first, showing who ' +\n 'sent each one and where it was routed.',\n },\n )}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {/*\n * The Inbox stays the site-wide answer to \"who is waiting for a\n * reply\" — that question does not decompose by form. The panel's\n * Form filter narrows it on request; it does not turn the page into\n * a per-form view. It is offered only when the site HAS forms, and\n * never on a card already scoped to one.\n */}\n {offersForm && formsTruncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\" component=\"p\" sx={{ mb: 1 }}>\n {`The Form filter offers the first ${FORMS_MAX_PER_HOST.toLocaleString()} ` +\n 'forms. Narrow by form is incomplete; with no Form filter the ' +\n 'list still covers every submission.'}\n </Typography>\n ) : null}\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: submissions.length ? 2 : 1 }}\n >\n {'Every site’s submissions, newest first. Choose a site to ' +\n 'narrow them by form.'}\n </Typography>\n ) : null}\n {submissions.length === 0 && !hasMoreSubmissions && !filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {scoped\n ? 'No submissions carry this form’s id yet. Messages this ' +\n 'form’s design collected before it became a form entity are ' +\n 'in the Inbox, filed under the name they were sent with.'\n : hostId == null\n ? 'No form submissions on any site yet.'\n : 'No form submissions yet. Add a Contact Form element to a ' +\n 'page — visitor messages arrive here.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={declaration.fields}\n headers={SUBMISSION_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={submissionOptions}\n />\n <ListQueryNotices refused={refusals} notices={plan.notices} />\n <ListTable\n aria-label={scoped ? 'Submissions to this form' : 'Form submissions'}\n rows={submissions}\n columns={listFilterGridColumns(\n submissionColumns,\n declaration.fields,\n submissionOptions,\n SUBMISSION_FILTER_HEADERS,\n )}\n initialState={{ columns: { columnVisibilityModel: SUBMISSION_HIDDEN_COLUMNS } }}\n noRowsLabel={\n onlyForm\n ? 'No submissions for this form yet. Submissions sent before ' +\n 'the form was created are listed with no Form filter.'\n : 'No submissions match these filters'\n }\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, submission) => handleOpenReader(submission)()}\n // An unread submission reads bold across its row, which is why\n // the list needs no `New` chip.\n getRowClassName={({ row }) => (row.read ? '' : 'submission-unread')}\n sx={{\n '& .submission-unread .MuiDataGrid-cell, & .submission-unread .MuiTypography-root':\n { fontWeight: 'fontWeightBold' },\n }}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search go to the query; the grid neither\n // filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnSorting\n />\n <ListPagination\n page={submissionPage}\n pageSize={submissionPageSize}\n rowCount={submissions.length}\n hasMore={hasMoreSubmissions}\n onPageChange={setSubmissionPage}\n onPageSizeChange={setSubmissionPageSize}\n />\n </>\n )}\n </CardDisplay>\n <Dialog\n open={Boolean(reader)}\n onClose={() => setReader(null)}\n maxWidth=\"sm\"\n fullWidth\n >\n <DialogTitle>{reader?.formName ?? 'Form submission'}</DialogTitle>\n <DialogContent>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Received ${reader?.createdAt?.toDate?.().toLocaleString() ?? ''}` +\n // The site it was sent to, where the list spans every site.\n (hostId == null && readerSite\n ? ` · ${orgSiteName(orgMount, readerSite)}`\n : '') +\n (reader?.screenId ? ` · page ${reader.screenId}` : '')}\n {/*\n THE PAGE THE FORM WAS ON. Stored by the submit route since the\n form existed and rendered by nothing, which is also the field\n the marketing console's landing-page grouping joins on — a\n reader who wants to check one row against that grouping has to\n be able to see the row's own page.\n */}\n {reader?.path ? ` · ${String(reader.path)}` : ''}\n </Typography>\n <Divider sx={{ my: 1.5 }} />\n <Stack spacing={1.5}>\n {Object.entries(reader?.fields ?? {}).map(([key, value]) => (\n <Stack key={key} spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {key}\n </Typography>\n <Typography\n variant=\"body2\"\n sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n >\n {String(value)}\n </Typography>\n </Stack>\n ))}\n </Stack>\n {/*\n What happened to this submission (AGL-2168). The mockup puts\n these under the fields: `Saved to Inbox` and `Added to \"Leads\"\n dataset`. The second is stamped by the submit route only when a\n record was really appended — a form bound to a deleted dataset,\n or one whose record quota is full, shows no chip rather than a\n chip for a row that does not exist. Both are failures the route\n already swallows silently, and a chip that lied about them\n would be worse than the silence.\n */}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ mt: 2, flexWrap: 'wrap', rowGap: 1 }}\n >\n {routingChips(reader?.routing).map((chip) => (\n <Chip\n key={chip.label}\n size=\"small\"\n label={chip.label}\n color={chip.color}\n variant=\"outlined\"\n />\n ))}\n </Stack>\n {/*\n WHERE THIS SUBMISSION CAME FROM.\n\n One keyed document read — the attribution's id is `form:{id}` and\n the submission's id is that `{id}` — paid when a merchant opens a\n submission rather than once per row of the list. A submission that\n was credited to nobody renders the sentence saying so; it never\n renders a campaign with a zero beside it.\n */}\n {reader?.$id && readerSite ? (\n <Box sx={{ mt: 2 }}>\n <InboxRecordAttributionZone\n hostId={readerSite}\n recordKind=\"form\"\n recordId={String(reader.$id)}\n />\n </Box>\n ) : null}\n {/*\n Answering the person is the act the Inbox exists for, and it is\n inside the reader rather than on the row because a reply written\n without the message in front of you is the reply that answers the\n wrong question. Mounted with the dialog, so its reads — the site\n name and the replies already sent — are paid when a merchant opens\n a submission and not once per visit to this page.\n */}\n {reader && readerSite ? (\n <SubmissionReply hostId={readerSite} submission={reader} />\n ) : null}\n {/*\n Enrolling the sender in a marketing list — a SEPARATE act from\n answering them, and a separate card, because the person asked to\n be answered and did not ask to be marketed to. Its reads are paid\n only when a merchant presses its own button, so opening a\n submission to read it costs nothing extra.\n */}\n {reader && readerSite ? (\n <SubmissionListAssignment hostId={readerSite} submission={reader} />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button\n color=\"error\"\n onClick={async () => {\n const target = reader\n setReader(null)\n if (target) await handleDelete(target)()\n }}\n >\n {'Delete'}\n </Button>\n <Button\n onClick={() => {\n if (reader) void handleToggleRead({ ...reader, read: true })()\n setReader(null)\n }}\n >\n {'Mark unread'}\n </Button>\n <Button variant=\"contained\" onClick={() => setReader(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nSubmissionsCard.displayName = 'SubmissionsCard'\n\nexport default SubmissionsCard\n"],"names":["FORMS_MAX_PER_HOST","pluginDocsHelp","INBOX_SUBMISSION_PARAM","InboxRecordAttributionZone","pluginRecordByEmailHref","pluginRecordListHref","mdiAccountArrowRight","mdiDeleteOutline","mdiEmailOpenOutline","mdiEmailOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","useFirestore","useFirestoreCollection","usePluginApiPost","useListQuery","Avatar","Box","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Divider","Stack","Tooltip","Typography","collection","collectionGroup","deleteDoc","doc","getDoc","limit","orderBy","query","updateDoc","useSearchParams","useCallback","useEffect","useMemo","useRef","useState","relativeTime","routingChips","senderHue","submissionSender","FORM_SCOPED_SUBMISSION_LIST_QUERY","ORG_SUBMISSION_LIST_QUERY","SUBMISSION_FILTER_HEADERS","SUBMISSION_LIST_QUERY","SUBMISSION_READ_OPTIONS","formSubmissionBase","orgSubmissionBase","orgSiteName","SubmissionListAssignment","SubmissionReply","useRecordRouteContext","FORM_STATS_API_PATH","SUBMISSION_SELECT_FIELDS","SUBMISSION_HIDDEN_COLUMNS","read","formId","messageTextOf","submission","Object","entries","fields","map","key","value","join","SubmissionsCard","hostId","orgMount","reader","firestore","enqueueSnackbar","postPluginApi","confirm","scoped","Boolean","orgId","siteOf","routeContext","data","formDocs","idField","formsTruncated","length","forms","slice","sort","left","right","String","displayName","$id","localeCompare","offersForm","declaration","filter","field","column","base","gridFilter","selectFields","rows","submissions","hasMore","hasMoreSubmissions","page","submissionPage","setPage","setSubmissionPage","pageSize","submissionPageSize","setPageSize","setSubmissionPageSize","plan","request","clauses","search","searchWords","deps","submissionOptions","form","label","hosts","host","id","name","refusals","refused","headers","options","searching","some","word","trim","filtering","onlyForm","every","clause","setReader","handleOpenReader","site","handleToggleRead","searchParams","seededSubmissionId","get","seededOpened","current","then","snapshot","exists","variant","persist","catch","undefined","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","formIds","submissionActions","senderEmail","email","contactsHref","icon","path","size","href","disabled","disabledReason","onClick","destructive","submissionColumns","headerName","flex","minWidth","valueGetter","_value","renderCell","row","sender","hue","direction","spacing","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","fontSize","initials","noWrap","formName","createdAt","toDate","getTime","toLocaleString","span","hideable","items","readerSite","header","help","anchor","excerpt","contentGutterX","contentGutterY","contentBordered","component","mb","onChange","setClauses","notices","columns","initialState","columnVisibilityModel","noRowsLabel","rowHeight","onOpen","_id","getRowClassName","fontWeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnSorting","rowCount","onPageChange","onPageSizeChange","open","onClose","maxWidth","fullWidth","screenId","my","whiteSpace","wordBreak","mt","flexWrap","rowGap","routing","chip","recordKind","recordId","target"],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** The query key the Submissions section opens one submission's reader by. */
|
|
18
|
+
export declare const INBOX_SUBMISSION_PARAM = "submission";
|
|
19
|
+
/**
|
|
20
|
+
* Called from the console registrar. The owner is named rather than read off
|
|
21
|
+
* the loader's marker, so the route registers the same way under a spec that
|
|
22
|
+
* calls the registrar directly.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerInboxRecordRoutes(): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { buildRoute, Route } from "@aglyn/aglyn/app-utils/console-routes";
|
|
17
|
+
import { registerPluginRecordRoute } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
18
|
+
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
19
|
+
/**
|
|
20
|
+
* Where a form submission is read, published for every other surface
|
|
21
|
+
* (AGL-3080).
|
|
22
|
+
*
|
|
23
|
+
* A contact's timeline opens the submission that captured the person. It asks
|
|
24
|
+
* the record-route registry for `formSubmission` rather than spelling this
|
|
25
|
+
* plugin's console path, and gets `null` (text instead of a link) where this
|
|
26
|
+
* plugin is not loaded.
|
|
27
|
+
*
|
|
28
|
+
* A submission has no page of its own: it opens in the reader over the
|
|
29
|
+
* Submissions section, which reads {@link INBOX_SUBMISSION_PARAM} on arrival.
|
|
30
|
+
* The section is at both levels; opening one submission needs the site whose
|
|
31
|
+
* form took it, so only a site addresses a record.
|
|
32
|
+
*/ /** The nav slug the shell resolves the Inbox by, at either level. */ const INBOX_SLUG = 'inbox';
|
|
33
|
+
/** The query key the Submissions section opens one submission's reader by. */ export const INBOX_SUBMISSION_PARAM = 'submission';
|
|
34
|
+
function submissions({ orgSlug, host }) {
|
|
35
|
+
const hub = host ? buildRoute(Route.HOST_PLUGIN, {
|
|
36
|
+
orgSlug,
|
|
37
|
+
host,
|
|
38
|
+
pluginSlug: INBOX_SLUG
|
|
39
|
+
}) : buildRoute(Route.ORG_PLUGIN, {
|
|
40
|
+
orgSlug,
|
|
41
|
+
pluginSlug: INBOX_SLUG
|
|
42
|
+
});
|
|
43
|
+
return `${hub}/submissions`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Called from the console registrar. The owner is named rather than read off
|
|
47
|
+
* the loader's marker, so the route registers the same way under a spec that
|
|
48
|
+
* calls the registrar directly.
|
|
49
|
+
*/ export function registerInboxRecordRoutes() {
|
|
50
|
+
registerPluginRecordRoute('formSubmission', {
|
|
51
|
+
list: submissions,
|
|
52
|
+
record: (context, id)=>context.host ? `${submissions(context)}?${new URLSearchParams({
|
|
53
|
+
[INBOX_SUBMISSION_PARAM]: id
|
|
54
|
+
}).toString()}` : null
|
|
55
|
+
}, {
|
|
56
|
+
pluginId: BUNDLE_ID
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=inbox-record-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/model/inbox-record-routes.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 { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n registerPluginRecordRoute,\n type PluginRecordRouteContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * Where a form submission is read, published for every other surface\n * (AGL-3080).\n *\n * A contact's timeline opens the submission that captured the person. It asks\n * the record-route registry for `formSubmission` rather than spelling this\n * plugin's console path, and gets `null` (text instead of a link) where this\n * plugin is not loaded.\n *\n * A submission has no page of its own: it opens in the reader over the\n * Submissions section, which reads {@link INBOX_SUBMISSION_PARAM} on arrival.\n * The section is at both levels; opening one submission needs the site whose\n * form took it, so only a site addresses a record.\n */\n\n/** The nav slug the shell resolves the Inbox by, at either level. */\nconst INBOX_SLUG = 'inbox'\n\n/** The query key the Submissions section opens one submission's reader by. */\nexport const INBOX_SUBMISSION_PARAM = 'submission'\n\nfunction submissions({ orgSlug, host }: PluginRecordRouteContext): string {\n const hub = host\n ? buildRoute(Route.HOST_PLUGIN, { orgSlug, host, pluginSlug: INBOX_SLUG })\n : buildRoute(Route.ORG_PLUGIN, { orgSlug, pluginSlug: INBOX_SLUG })\n return `${hub}/submissions`\n}\n\n/**\n * Called from the console registrar. The owner is named rather than read off\n * the loader's marker, so the route registers the same way under a spec that\n * calls the registrar directly.\n */\nexport function registerInboxRecordRoutes(): void {\n registerPluginRecordRoute(\n 'formSubmission',\n {\n list: submissions,\n record: (context, id) =>\n context.host\n ? `${submissions(context)}?${new URLSearchParams({ [INBOX_SUBMISSION_PARAM]: id }).toString()}`\n : null,\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["buildRoute","Route","registerPluginRecordRoute","BUNDLE_ID","INBOX_SLUG","INBOX_SUBMISSION_PARAM","submissions","orgSlug","host","hub","HOST_PLUGIN","pluginSlug","ORG_PLUGIN","registerInboxRecordRoutes","list","record","context","id","URLSearchParams","toString","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,KAAK,QAAQ,wCAAuC;AACzE,SACEC,yBAAyB,QAEpB,mDAAkD;AACzD,SAASC,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;;;;CAaC,GAED,mEAAmE,GACnE,MAAMC,aAAa;AAEnB,4EAA4E,GAC5E,OAAO,MAAMC,yBAAyB,aAAY;AAElD,SAASC,YAAY,EAAEC,OAAO,EAAEC,IAAI,EAA4B;IAC9D,MAAMC,MAAMD,OACRR,WAAWC,MAAMS,WAAW,EAAE;QAAEH;QAASC;QAAMG,YAAYP;IAAW,KACtEJ,WAAWC,MAAMW,UAAU,EAAE;QAAEL;QAASI,YAAYP;IAAW;IACnE,OAAO,GAAGK,IAAI,YAAY,CAAC;AAC7B;AAEA;;;;CAIC,GACD,OAAO,SAASI;IACdX,0BACE,kBACA;QACEY,MAAMR;QACNS,QAAQ,CAACC,SAASC,KAChBD,QAAQR,IAAI,GACR,GAAGF,YAAYU,SAAS,CAAC,EAAE,IAAIE,gBAAgB;gBAAE,CAACb,uBAAuB,EAAEY;YAAG,GAAGE,QAAQ,IAAI,GAC7F;IACR,GACA;QAAEC,UAAUjB;IAAU;AAE1B"}
|
|
@@ -69,10 +69,19 @@ export interface SubmissionRouting {
|
|
|
69
69
|
name?: string;
|
|
70
70
|
recordId?: string;
|
|
71
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* The dataset the form is bound to, when the record was refused because a
|
|
74
|
+
* value failed the dataset's model (AGL-2773): field id → the reason.
|
|
75
|
+
*/
|
|
76
|
+
datasetRefused?: {
|
|
77
|
+
id?: string;
|
|
78
|
+
name?: string;
|
|
79
|
+
errors?: Record<string, string>;
|
|
80
|
+
};
|
|
72
81
|
}
|
|
73
82
|
export interface RoutingChip {
|
|
74
83
|
label: string;
|
|
75
|
-
color: 'success' | 'info' | 'default';
|
|
84
|
+
color: 'success' | 'info' | 'warning' | 'default';
|
|
76
85
|
}
|
|
77
86
|
/**
|
|
78
87
|
* The status chips the mockup's detail pane carries under the fields:
|
|
@@ -110,6 +110,21 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
110
110
|
color: 'info'
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
|
+
/*
|
|
114
|
+
* The record the submission did NOT become, and why (AGL-2773). A form
|
|
115
|
+
* bound to a dataset whose model refuses one of the submitted values keeps
|
|
116
|
+
* the submission and writes no row; without this the owner sees a form
|
|
117
|
+
* that "stopped saving" and no reason for it.
|
|
118
|
+
*/ const refused = routing == null ? void 0 : routing.datasetRefused;
|
|
119
|
+
if (refused && !(dataset == null ? void 0 : dataset.recordId)) {
|
|
120
|
+
var _refused_errors;
|
|
121
|
+
const reasons = Object.values((_refused_errors = refused.errors) != null ? _refused_errors : {}).filter((reason)=>typeof reason === 'string' && reason);
|
|
122
|
+
const where = refused.name ? `“${refused.name}” dataset` : 'the dataset';
|
|
123
|
+
chips.push({
|
|
124
|
+
label: reasons.length ? `Not added to ${where}: ${reasons.join('; ')}` : `Not added to ${where}`,
|
|
125
|
+
color: 'warning'
|
|
126
|
+
});
|
|
127
|
+
}
|
|
113
128
|
return chips;
|
|
114
129
|
}
|
|
115
130
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/model/submission-presenter.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 { messageSender } from '@aglyn/aglyn/app-utils/message-search'\n\n/**\n * How the Inbox renders one submission (AGL-2168).\n *\n * `/product/forms`'s hero mockup IS the Inbox, and it shows every row as a\n * person: a coloured initials avatar, `Priya Nair`, the form name, and\n * `2m`. The console rendered no sender at all — the row's one content cell\n * was every field concatenated as `name: Priya Nair · email: … · message:\n * …`, so the name was in there somewhere, at the mercy of whatever order\n * the author happened to define the fields in.\n */\n\nexport interface SubmissionSender {\n /** Best available display name — a name, else an email, else a fallback. */\n label: string\n /** The email, when one was submitted. */\n email?: string\n /** One or two letters for the avatar. */\n initials: string\n}\n\n/**\n * Who sent it.\n *\n * A form is author-defined, so there is no guaranteed name field — the\n * lookup is by convention and falls back to the email, then to the form's\n * own name. It never returns an empty string: an avatar with no letters in\n * it is worse than a generic one.\n */\nexport function submissionSender(\n fields: Record<string, unknown> | undefined,\n fallback = 'Someone',\n): SubmissionSender {\n // The platform's one convention for who wrote in — the one the list's\n // From filter and search are stamped from (AGL-3321), so the name a row\n // draws is the name it is found by.\n const { name, email } = messageSender(fields)\n const label = name ?? email ?? fallback\n return { label, ...(email ? { email } : {}), initials: initialsOf(label) }\n}\n\n/**\n * Up to two initials. An email falls back to its local part, so\n * `priya@lumen.co` gives `P` rather than a `@`.\n */\nexport function initialsOf(label: string): string {\n const trimmed = String(label ?? '').trim()\n const source = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed\n const words = source\n .split(/[\\s._-]+/)\n .map((word) => word.trim())\n .filter(Boolean)\n const letters = words\n .slice(0, 2)\n .map((word) => word[0])\n .join('')\n return (letters || source[0] || '?').toUpperCase()\n}\n\n/**\n * A deterministic hue for the avatar, so one sender keeps one colour\n * across sessions and machines. The mockup's avatars are coloured, and a\n * random palette index would make the same person a different colour on\n * every render.\n */\nexport function senderHue(label: string): number {\n let hash = 0\n for (let index = 0; index < label.length; index += 1) {\n hash = (hash * 31 + label.charCodeAt(index)) % 360\n }\n return hash\n}\n\n/**\n * `2m`, `18m`, `1h`, `3d` — the relative times the mockup's list shows.\n *\n * The console rendered `toLocaleString()`, which is the right answer to a\n * different question: an inbox is scanned for recency, and \"8/18/2026,\n * 2:04:31 PM\" makes the reader do the subtraction. The absolute time stays\n * on the detail pane, where it is the fact you actually want.\n */\nexport function relativeTime(\n atMs: number | undefined,\n nowMs: number = Date.now(),\n): string {\n if (!atMs || !Number.isFinite(atMs)) return '--'\n const seconds = Math.round((nowMs - atMs) / 1000)\n // A clock skew between the browser and the server can put a fresh\n // submission a few seconds in the future. \"in 4 seconds\" on an inbox row\n // is a bug report waiting to happen; \"now\" is simply true.\n if (seconds < 60) return 'now'\n const minutes = Math.floor(seconds / 60)\n if (minutes < 60) return `${minutes}m`\n const hours = Math.floor(minutes / 60)\n if (hours < 24) return `${hours}h`\n const days = Math.floor(hours / 24)\n if (days < 7) return `${days}d`\n const weeks = Math.floor(days / 7)\n if (weeks < 5) return `${weeks}w`\n return `${Math.floor(days / 30)}mo`\n}\n\nexport interface SubmissionRouting {\n /** The dataset a record was appended to, when one was. */\n dataset?: { id?: string; name?: string; recordId?: string }\n}\n\nexport interface RoutingChip {\n label: string\n color: 'success' | 'info' | 'default'\n}\n\n/**\n * The status chips the mockup's detail pane carries under the fields:\n * `Saved to Inbox` (green) and `Added to \"Leads\" dataset` (blue).\n *\n * The first is unconditional and free — every submission that exists is in\n * the Inbox, which is exactly why the mockup shows it: it is the\n * reassurance, not a status.\n *\n * The second reports what the submit route actually DID. It is stamped on\n * the document only when a record was really appended, so a form bound to\n * a dataset that was deleted, or whose record quota was full, shows no chip\n * rather than a chip for a row that does not exist — the two failure modes\n * the route already swallows silently.\n */\nexport function routingChips(routing: SubmissionRouting | undefined): RoutingChip[] {\n const chips: RoutingChip[] = [{ label: 'Saved to Inbox', color: 'success' }]\n const dataset = routing?.dataset\n if (dataset?.recordId) {\n chips.push({\n label: dataset.name\n ? `Added to “${dataset.name}” dataset`\n : 'Added to a dataset',\n color: 'info',\n })\n }\n return chips\n}\n"],"names":["messageSender","submissionSender","fields","fallback","name","email","label","initials","initialsOf","trimmed","String","trim","source","includes","split","words","map","word","filter","Boolean","letters","slice","join","toUpperCase","senderHue","hash","index","length","charCodeAt","relativeTime","atMs","nowMs","Date","now","Number","isFinite","seconds","Math","round","minutes","floor","hours","days","weeks","routingChips","routing","chips","color","dataset","recordId","push"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,wCAAuC;AAsBrE;;;;;;;CAOC,GACD,OAAO,SAASC,iBACdC,MAA2C,EAC3CC,WAAW,SAAS;QAMNC;IAJd,sEAAsE;IACtE,wEAAwE;IACxE,oCAAoC;IACpC,MAAM,EAAEA,IAAI,EAAEC,KAAK,EAAE,GAAGL,cAAcE;IACtC,MAAMI,SAAQF,OAAAA,eAAAA,OAAQC,iBAARD,OAAiBD;IAC/B,OAAO;QAAEG;OAAWD,QAAQ;QAAEA;IAAM,IAAI,CAAC;QAAIE,UAAUC,WAAWF;;AACpE;AAEA;;;CAGC,GACD,OAAO,SAASE,WAAWF,KAAa;IACtC,MAAMG,UAAUC,OAAOJ,gBAAAA,QAAS,IAAIK,IAAI;IACxC,MAAMC,SAASH,QAAQI,QAAQ,CAAC,OAAOJ,QAAQK,KAAK,CAAC,IAAI,CAAC,EAAE,GAAGL;IAC/D,MAAMM,QAAQH,OACXE,KAAK,CAAC,YACNE,GAAG,CAAC,CAACC,OAASA,KAAKN,IAAI,IACvBO,MAAM,CAACC;IACV,MAAMC,UAAUL,MACbM,KAAK,CAAC,GAAG,GACTL,GAAG,CAAC,CAACC,OAASA,IAAI,CAAC,EAAE,EACrBK,IAAI,CAAC;IACR,OAAO,AAACF,CAAAA,WAAWR,MAAM,CAAC,EAAE,IAAI,GAAE,EAAGW,WAAW;AAClD;AAEA;;;;;CAKC,GACD,OAAO,SAASC,UAAUlB,KAAa;IACrC,IAAImB,OAAO;IACX,IAAK,IAAIC,QAAQ,GAAGA,QAAQpB,MAAMqB,MAAM,EAAED,SAAS,EAAG;QACpDD,OAAO,AAACA,CAAAA,OAAO,KAAKnB,MAAMsB,UAAU,CAACF,MAAK,IAAK;IACjD;IACA,OAAOD;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASI,aACdC,IAAwB,EACxBC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,IAAI,CAACH,QAAQ,CAACI,OAAOC,QAAQ,CAACL,OAAO,OAAO;IAC5C,MAAMM,UAAUC,KAAKC,KAAK,CAAC,AAACP,CAAAA,QAAQD,IAAG,IAAK;IAC5C,kEAAkE;IAClE,yEAAyE;IACzE,2DAA2D;IAC3D,IAAIM,UAAU,IAAI,OAAO;IACzB,MAAMG,UAAUF,KAAKG,KAAK,CAACJ,UAAU;IACrC,IAAIG,UAAU,IAAI,OAAO,GAAGA,QAAQ,CAAC,CAAC;IACtC,MAAME,QAAQJ,KAAKG,KAAK,CAACD,UAAU;IACnC,IAAIE,QAAQ,IAAI,OAAO,GAAGA,MAAM,CAAC,CAAC;IAClC,MAAMC,OAAOL,KAAKG,KAAK,CAACC,QAAQ;IAChC,IAAIC,OAAO,GAAG,OAAO,GAAGA,KAAK,CAAC,CAAC;IAC/B,MAAMC,QAAQN,KAAKG,KAAK,CAACE,OAAO;IAChC,IAAIC,QAAQ,GAAG,OAAO,GAAGA,MAAM,CAAC,CAAC;IACjC,OAAO,GAAGN,KAAKG,KAAK,CAACE,OAAO,IAAI,EAAE,CAAC;AACrC;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/model/submission-presenter.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 { messageSender } from '@aglyn/aglyn/app-utils/message-search'\n\n/**\n * How the Inbox renders one submission (AGL-2168).\n *\n * `/product/forms`'s hero mockup IS the Inbox, and it shows every row as a\n * person: a coloured initials avatar, `Priya Nair`, the form name, and\n * `2m`. The console rendered no sender at all — the row's one content cell\n * was every field concatenated as `name: Priya Nair · email: … · message:\n * …`, so the name was in there somewhere, at the mercy of whatever order\n * the author happened to define the fields in.\n */\n\nexport interface SubmissionSender {\n /** Best available display name — a name, else an email, else a fallback. */\n label: string\n /** The email, when one was submitted. */\n email?: string\n /** One or two letters for the avatar. */\n initials: string\n}\n\n/**\n * Who sent it.\n *\n * A form is author-defined, so there is no guaranteed name field — the\n * lookup is by convention and falls back to the email, then to the form's\n * own name. It never returns an empty string: an avatar with no letters in\n * it is worse than a generic one.\n */\nexport function submissionSender(\n fields: Record<string, unknown> | undefined,\n fallback = 'Someone',\n): SubmissionSender {\n // The platform's one convention for who wrote in — the one the list's\n // From filter and search are stamped from (AGL-3321), so the name a row\n // draws is the name it is found by.\n const { name, email } = messageSender(fields)\n const label = name ?? email ?? fallback\n return { label, ...(email ? { email } : {}), initials: initialsOf(label) }\n}\n\n/**\n * Up to two initials. An email falls back to its local part, so\n * `priya@lumen.co` gives `P` rather than a `@`.\n */\nexport function initialsOf(label: string): string {\n const trimmed = String(label ?? '').trim()\n const source = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed\n const words = source\n .split(/[\\s._-]+/)\n .map((word) => word.trim())\n .filter(Boolean)\n const letters = words\n .slice(0, 2)\n .map((word) => word[0])\n .join('')\n return (letters || source[0] || '?').toUpperCase()\n}\n\n/**\n * A deterministic hue for the avatar, so one sender keeps one colour\n * across sessions and machines. The mockup's avatars are coloured, and a\n * random palette index would make the same person a different colour on\n * every render.\n */\nexport function senderHue(label: string): number {\n let hash = 0\n for (let index = 0; index < label.length; index += 1) {\n hash = (hash * 31 + label.charCodeAt(index)) % 360\n }\n return hash\n}\n\n/**\n * `2m`, `18m`, `1h`, `3d` — the relative times the mockup's list shows.\n *\n * The console rendered `toLocaleString()`, which is the right answer to a\n * different question: an inbox is scanned for recency, and \"8/18/2026,\n * 2:04:31 PM\" makes the reader do the subtraction. The absolute time stays\n * on the detail pane, where it is the fact you actually want.\n */\nexport function relativeTime(\n atMs: number | undefined,\n nowMs: number = Date.now(),\n): string {\n if (!atMs || !Number.isFinite(atMs)) return '--'\n const seconds = Math.round((nowMs - atMs) / 1000)\n // A clock skew between the browser and the server can put a fresh\n // submission a few seconds in the future. \"in 4 seconds\" on an inbox row\n // is a bug report waiting to happen; \"now\" is simply true.\n if (seconds < 60) return 'now'\n const minutes = Math.floor(seconds / 60)\n if (minutes < 60) return `${minutes}m`\n const hours = Math.floor(minutes / 60)\n if (hours < 24) return `${hours}h`\n const days = Math.floor(hours / 24)\n if (days < 7) return `${days}d`\n const weeks = Math.floor(days / 7)\n if (weeks < 5) return `${weeks}w`\n return `${Math.floor(days / 30)}mo`\n}\n\nexport interface SubmissionRouting {\n /** The dataset a record was appended to, when one was. */\n dataset?: { id?: string; name?: string; recordId?: string }\n /**\n * The dataset the form is bound to, when the record was refused because a\n * value failed the dataset's model (AGL-2773): field id → the reason.\n */\n datasetRefused?: {\n id?: string\n name?: string\n errors?: Record<string, string>\n }\n}\n\nexport interface RoutingChip {\n label: string\n color: 'success' | 'info' | 'warning' | 'default'\n}\n\n/**\n * The status chips the mockup's detail pane carries under the fields:\n * `Saved to Inbox` (green) and `Added to \"Leads\" dataset` (blue).\n *\n * The first is unconditional and free — every submission that exists is in\n * the Inbox, which is exactly why the mockup shows it: it is the\n * reassurance, not a status.\n *\n * The second reports what the submit route actually DID. It is stamped on\n * the document only when a record was really appended, so a form bound to\n * a dataset that was deleted, or whose record quota was full, shows no chip\n * rather than a chip for a row that does not exist — the two failure modes\n * the route already swallows silently.\n */\nexport function routingChips(routing: SubmissionRouting | undefined): RoutingChip[] {\n const chips: RoutingChip[] = [{ label: 'Saved to Inbox', color: 'success' }]\n const dataset = routing?.dataset\n if (dataset?.recordId) {\n chips.push({\n label: dataset.name\n ? `Added to “${dataset.name}” dataset`\n : 'Added to a dataset',\n color: 'info',\n })\n }\n /*\n * The record the submission did NOT become, and why (AGL-2773). A form\n * bound to a dataset whose model refuses one of the submitted values keeps\n * the submission and writes no row; without this the owner sees a form\n * that \"stopped saving\" and no reason for it.\n */\n const refused = routing?.datasetRefused\n if (refused && !dataset?.recordId) {\n const reasons = Object.values(refused.errors ?? {}).filter(\n (reason) => typeof reason === 'string' && reason,\n )\n const where = refused.name ? `“${refused.name}” dataset` : 'the dataset'\n chips.push({\n label: reasons.length\n ? `Not added to ${where}: ${reasons.join('; ')}`\n : `Not added to ${where}`,\n color: 'warning',\n })\n }\n return chips\n}\n"],"names":["messageSender","submissionSender","fields","fallback","name","email","label","initials","initialsOf","trimmed","String","trim","source","includes","split","words","map","word","filter","Boolean","letters","slice","join","toUpperCase","senderHue","hash","index","length","charCodeAt","relativeTime","atMs","nowMs","Date","now","Number","isFinite","seconds","Math","round","minutes","floor","hours","days","weeks","routingChips","routing","chips","color","dataset","recordId","push","refused","datasetRefused","reasons","Object","values","errors","reason","where"],"mappings":"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"}
|
package/src/lib/plugin.js
CHANGED
|
@@ -20,6 +20,7 @@ import { INBOX_CONSOLE_SECTIONS, INBOX_ORG_CONSOLE_SECTIONS } from "./components
|
|
|
20
20
|
import { registerPluginZone } from "@aglyn/aglyn/plugin-manager/plugin-zones";
|
|
21
21
|
import { INBOX_CAMPAIGNS_ZONE, INBOX_RECORD_ATTRIBUTION_ZONE } from "./components/inbox-attribution-zone.js";
|
|
22
22
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
23
|
+
import { registerInboxRecordRoutes } from "./model/inbox-record-routes.js";
|
|
23
24
|
/** Code-split: the Inbox console page only loads when opened. */ const InboxConsolePage = lazy(()=>import("./components/inbox-console-page.js"));
|
|
24
25
|
/**
|
|
25
26
|
* Code-split: the submissions table loads when a form page asks for it. The
|
|
@@ -35,6 +36,7 @@ import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
|
35
36
|
* section and the attribution shown inside a submission are zones this
|
|
36
37
|
* plugin hosts; the plugin that owns campaigns draws in them.
|
|
37
38
|
*/ export function registerInboxConsole() {
|
|
39
|
+
registerInboxRecordRoutes();
|
|
38
40
|
registerPluginZone({
|
|
39
41
|
zone: INBOX_RECORD_ATTRIBUTION_ZONE,
|
|
40
42
|
label: 'Where a lead or a submission came from',
|
package/src/lib/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/inbox/src/lib/plugin.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 * as Aglyn from '@aglyn/aglyn'\nimport { mdiInboxArrowDown } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n INBOX_CONSOLE_SECTIONS,\n INBOX_ORG_CONSOLE_SECTIONS,\n} from './components/inbox-console-sections'\nimport { registerPluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport {\n INBOX_CAMPAIGNS_ZONE,\n INBOX_RECORD_ATTRIBUTION_ZONE,\n} from './components/inbox-attribution-zone'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/** Code-split: the Inbox console page only loads when opened. */\nconst InboxConsolePage = lazy(() => import('./components/inbox-console-page'))\n\n/**\n * Code-split: the submissions table loads when a form page asks for it. The\n * zone hands `hostId` and `formId`, which is what the table's own props are.\n */\nconst FormSubmissionsReader = lazy(\n () => import('./components/submissions-card.component'),\n)\n\n/** Dashboard glance card, loaded only where the shell renders the slot. */\nconst InboxGlanceCard = lazy(\n () => import('./components/inbox-glance-card.component'),\n)\n\n/**\n * Inbox feature plugin (AGL-395). Console-only — form submissions, site\n * members/leads and campaigns live in Firestore and have no canvas element,\n * so there is no UI bundle. The console half declares the Inbox nav + its\n * three sections through the ConsoleExtension registry (always-on), once\n * under each site and once for the organization (AGL-3303). The Campaigns\n * section and the attribution shown inside a submission are zones this\n * plugin hosts; the plugin that owns campaigns draws in them.\n */\nexport function registerInboxConsole(): void {\n registerPluginZone(\n {\n zone: INBOX_RECORD_ATTRIBUTION_ZONE,\n label: 'Where a lead or a submission came from',\n surface: 'console',\n description:\n 'In a lead’s “Where this came from” dialog and under an open submission. A widget here says which campaign or link brought it; it is handed the site, what the record is and its id, and it writes nothing.',\n },\n // Named, because a spec calls this registrar without the loader.\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: INBOX_CAMPAIGNS_ZONE,\n label: 'The Inbox’s Campaigns section',\n surface: 'console',\n description:\n 'The body of the Inbox’s Campaigns tab. A widget here lists the site’s campaigns; it is handed the site and nothing else — or, on the organization’s Inbox, no site (`hostId: null`) and the organization with its sites (`orgMount`), and lists every site’s.',\n },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Inbox',\n // The host dashboard's inbox glance. A form submission is the one thing\n // on a site that is waiting for a REPLY, and until this it was two\n // clicks from the page an owner opens first.\n widgets: [\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.hostDashboard,\n widgetId: 'inbox-glance',\n title: 'Inbox',\n Component: InboxGlanceCard,\n },\n // One form's submissions, on the form's own page. The forms plugin\n // hosts the zone and hands it the site and the form; the reader is this\n // plugin's table, narrowed to that form, so the form page gets the same\n // ordered, paged walk the Inbox uses without importing it.\n {\n slot: 'formSubmissions',\n widgetId: 'inbox-form-submissions',\n title: 'Submissions',\n Component: FormSubmissionsReader,\n },\n ],\n navItems: [\n {\n label: 'Inbox',\n href: '/inbox',\n // Sections as ROUTES (AGL-2501): each is a real URL the shell\n // resolves and gates, so the page mounts the one being read.\n sections: INBOX_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-inbox',\n icon: { path: mdiInboxArrowDown.path },\n header: {\n title: 'Inbox',\n icon: { path: mdiInboxArrowDown.path },\n docsTopic: 'forms',\n },\n Component: InboxConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Inbox, at `/[orgSlug]/inbox` (AGL-3303): every\n * site's submissions in one list, the organization's leads, and every\n * campaign, with a site filter that narrows the page to one site's own\n * view. The page is the same component; handed no site, it draws the\n * org sections.\n *\n * It carries the SITE tab's id on purpose, as the org CRM and Marketing\n * tabs do: `release_inbox` names `nav-tab-inbox`, so one flag holds both\n * levels and the org Inbox cannot ship while the site's is switched off.\n */\n orgNavItems: [\n {\n label: 'Inbox',\n href: '/inbox',\n sections: INBOX_ORG_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-inbox',\n icon: { path: mdiInboxArrowDown.path },\n header: {\n title: 'Inbox',\n icon: { path: mdiInboxArrowDown.path },\n docsTopic: 'forms',\n },\n Component: InboxConsolePage,\n },\n ],\n })\n}\n"],"names":["Aglyn","mdiInboxArrowDown","lazy","INBOX_CONSOLE_SECTIONS","INBOX_ORG_CONSOLE_SECTIONS","registerPluginZone","INBOX_CAMPAIGNS_ZONE","INBOX_RECORD_ATTRIBUTION_ZONE","BUNDLE_ID","InboxConsolePage","FormSubmissionsReader","InboxGlanceCard","registerInboxConsole","zone","label","surface","description","pluginId","registerConsoleExtension","displayName","widgets","slot","CONSOLE_WIDGET_SLOTS","hostDashboard","widgetId","title","Component","navItems","href","sections","navTabId","icon","path","header","docsTopic","orgNavItems"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,iBAAiB,QAAQ,yBAAwB;AAC1D,SAASC,IAAI,QAAQ,QAAO;AAC5B,SACEC,sBAAsB,EACtBC,0BAA0B,QACrB,yCAAqC;AAC5C,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SACEC,oBAAoB,EACpBC,6BAA6B,QACxB,yCAAqC;AAC5C,SAASC,SAAS,QAAQ,+BAA2B;
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/inbox/src/lib/plugin.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 * as Aglyn from '@aglyn/aglyn'\nimport { mdiInboxArrowDown } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n INBOX_CONSOLE_SECTIONS,\n INBOX_ORG_CONSOLE_SECTIONS,\n} from './components/inbox-console-sections'\nimport { registerPluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport {\n INBOX_CAMPAIGNS_ZONE,\n INBOX_RECORD_ATTRIBUTION_ZONE,\n} from './components/inbox-attribution-zone'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerInboxRecordRoutes } from './model/inbox-record-routes'\n\n/** Code-split: the Inbox console page only loads when opened. */\nconst InboxConsolePage = lazy(() => import('./components/inbox-console-page'))\n\n/**\n * Code-split: the submissions table loads when a form page asks for it. The\n * zone hands `hostId` and `formId`, which is what the table's own props are.\n */\nconst FormSubmissionsReader = lazy(\n () => import('./components/submissions-card.component'),\n)\n\n/** Dashboard glance card, loaded only where the shell renders the slot. */\nconst InboxGlanceCard = lazy(\n () => import('./components/inbox-glance-card.component'),\n)\n\n/**\n * Inbox feature plugin (AGL-395). Console-only — form submissions, site\n * members/leads and campaigns live in Firestore and have no canvas element,\n * so there is no UI bundle. The console half declares the Inbox nav + its\n * three sections through the ConsoleExtension registry (always-on), once\n * under each site and once for the organization (AGL-3303). The Campaigns\n * section and the attribution shown inside a submission are zones this\n * plugin hosts; the plugin that owns campaigns draws in them.\n */\nexport function registerInboxConsole(): void {\n registerInboxRecordRoutes()\n registerPluginZone(\n {\n zone: INBOX_RECORD_ATTRIBUTION_ZONE,\n label: 'Where a lead or a submission came from',\n surface: 'console',\n description:\n 'In a lead’s “Where this came from” dialog and under an open submission. A widget here says which campaign or link brought it; it is handed the site, what the record is and its id, and it writes nothing.',\n },\n // Named, because a spec calls this registrar without the loader.\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: INBOX_CAMPAIGNS_ZONE,\n label: 'The Inbox’s Campaigns section',\n surface: 'console',\n description:\n 'The body of the Inbox’s Campaigns tab. A widget here lists the site’s campaigns; it is handed the site and nothing else — or, on the organization’s Inbox, no site (`hostId: null`) and the organization with its sites (`orgMount`), and lists every site’s.',\n },\n { pluginId: BUNDLE_ID },\n )\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Inbox',\n // The host dashboard's inbox glance. A form submission is the one thing\n // on a site that is waiting for a REPLY, and until this it was two\n // clicks from the page an owner opens first.\n widgets: [\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.hostDashboard,\n widgetId: 'inbox-glance',\n title: 'Inbox',\n Component: InboxGlanceCard,\n },\n // One form's submissions, on the form's own page. The forms plugin\n // hosts the zone and hands it the site and the form; the reader is this\n // plugin's table, narrowed to that form, so the form page gets the same\n // ordered, paged walk the Inbox uses without importing it.\n {\n slot: 'formSubmissions',\n widgetId: 'inbox-form-submissions',\n title: 'Submissions',\n Component: FormSubmissionsReader,\n },\n ],\n navItems: [\n {\n label: 'Inbox',\n href: '/inbox',\n // Sections as ROUTES (AGL-2501): each is a real URL the shell\n // resolves and gates, so the page mounts the one being read.\n sections: INBOX_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-inbox',\n icon: { path: mdiInboxArrowDown.path },\n header: {\n title: 'Inbox',\n icon: { path: mdiInboxArrowDown.path },\n docsTopic: 'forms',\n },\n Component: InboxConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Inbox, at `/[orgSlug]/inbox` (AGL-3303): every\n * site's submissions in one list, the organization's leads, and every\n * campaign, with a site filter that narrows the page to one site's own\n * view. The page is the same component; handed no site, it draws the\n * org sections.\n *\n * It carries the SITE tab's id on purpose, as the org CRM and Marketing\n * tabs do: `release_inbox` names `nav-tab-inbox`, so one flag holds both\n * levels and the org Inbox cannot ship while the site's is switched off.\n */\n orgNavItems: [\n {\n label: 'Inbox',\n href: '/inbox',\n sections: INBOX_ORG_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-inbox',\n icon: { path: mdiInboxArrowDown.path },\n header: {\n title: 'Inbox',\n icon: { path: mdiInboxArrowDown.path },\n docsTopic: 'forms',\n },\n Component: InboxConsolePage,\n },\n ],\n })\n}\n"],"names":["Aglyn","mdiInboxArrowDown","lazy","INBOX_CONSOLE_SECTIONS","INBOX_ORG_CONSOLE_SECTIONS","registerPluginZone","INBOX_CAMPAIGNS_ZONE","INBOX_RECORD_ATTRIBUTION_ZONE","BUNDLE_ID","registerInboxRecordRoutes","InboxConsolePage","FormSubmissionsReader","InboxGlanceCard","registerInboxConsole","zone","label","surface","description","pluginId","registerConsoleExtension","displayName","widgets","slot","CONSOLE_WIDGET_SLOTS","hostDashboard","widgetId","title","Component","navItems","href","sections","navTabId","icon","path","header","docsTopic","orgNavItems"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,iBAAiB,QAAQ,yBAAwB;AAC1D,SAASC,IAAI,QAAQ,QAAO;AAC5B,SACEC,sBAAsB,EACtBC,0BAA0B,QACrB,yCAAqC;AAC5C,SAASC,kBAAkB,QAAQ,2CAA0C;AAC7E,SACEC,oBAAoB,EACpBC,6BAA6B,QACxB,yCAAqC;AAC5C,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,yBAAyB,QAAQ,iCAA6B;AAEvE,+DAA+D,GAC/D,MAAMC,mBAAmBR,KAAK,IAAM,MAAM,CAAC;AAE3C;;;CAGC,GACD,MAAMS,wBAAwBT,KAC5B,IAAM,MAAM,CAAC;AAGf,yEAAyE,GACzE,MAAMU,kBAAkBV,KACtB,IAAM,MAAM,CAAC;AAGf;;;;;;;;CAQC,GACD,OAAO,SAASW;IACdJ;IACAJ,mBACE;QACES,MAAMP;QACNQ,OAAO;QACPC,SAAS;QACTC,aACE;IACJ,GACA,iEAAiE;IACjE;QAAEC,UAAUV;IAAU;IAExBH,mBACE;QACES,MAAMR;QACNS,OAAO;QACPC,SAAS;QACTC,aACE;IACJ,GACA;QAAEC,UAAUV;IAAU;IAExBR,MAAMmB,wBAAwB,CAAC;QAC7BD,UAAUV;QACVY,aAAa;QACb,wEAAwE;QACxE,mEAAmE;QACnE,6CAA6C;QAC7CC,SAAS;YACP;gBACEC,MAAMtB,MAAMuB,oBAAoB,CAACC,aAAa;gBAC9CC,UAAU;gBACVC,OAAO;gBACPC,WAAWf;YACb;YACA,mEAAmE;YACnE,wEAAwE;YACxE,wEAAwE;YACxE,2DAA2D;YAC3D;gBACEU,MAAM;gBACNG,UAAU;gBACVC,OAAO;gBACPC,WAAWhB;YACb;SACD;QACDiB,UAAU;YACR;gBACEb,OAAO;gBACPc,MAAM;gBACN,8DAA8D;gBAC9D,6DAA6D;gBAC7DC,UAAU3B;gBACV4B,UAAU;gBACVC,MAAM;oBAAEC,MAAMhC,kBAAkBgC,IAAI;gBAAC;gBACrCC,QAAQ;oBACNR,OAAO;oBACPM,MAAM;wBAAEC,MAAMhC,kBAAkBgC,IAAI;oBAAC;oBACrCE,WAAW;gBACb;gBACAR,WAAWjB;YACb;SACD;QACD;;;;;;;;;;KAUC,GACD0B,aAAa;YACX;gBACErB,OAAO;gBACPc,MAAM;gBACNC,UAAU1B;gBACV2B,UAAU;gBACVC,MAAM;oBAAEC,MAAMhC,kBAAkBgC,IAAI;gBAAC;gBACrCC,QAAQ;oBACNR,OAAO;oBACPM,MAAM;wBAAEC,MAAMhC,kBAAkBgC,IAAI;oBAAC;oBACrCE,WAAW;gBACb;gBACAR,WAAWjB;YACb;SACD;IACH;AACF"}
|
package/src/lib/server.js
CHANGED
|
@@ -95,6 +95,10 @@ import { emailSuppressionKey, enrollListMember, firebaseAdmin, getOrgForHost, ho
|
|
|
95
95
|
// The leaf, not the barrel: this plugin's specs substitute the barrel
|
|
96
96
|
// wholesale, and the lookup must reach the real index logic under them.
|
|
97
97
|
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
98
|
+
// And for the refusal a deleted contact left behind (AGL-3338): the real store
|
|
99
|
+
// and the real merge, under whatever the specs put in the barrels' place.
|
|
100
|
+
import { readRetainedRefusals } from "@aglyn/tenant-data-admin/server/retained-refusals";
|
|
101
|
+
import { refusalsOf, withRetainedRefusals } from "@aglyn/aglyn/app-utils/retained-refusals";
|
|
98
102
|
// The leaf for the same reason: which sites a group's opt-outs live on is the
|
|
99
103
|
// rule, and a spec's barrel double must not be able to stand in for it.
|
|
100
104
|
import { consentGroupOptOutHosts } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
@@ -448,11 +452,25 @@ import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, composeReplyBody, replyRecipient } f
|
|
|
448
452
|
* (AGL-3320): a pass-through carries them onto the membership as the record
|
|
449
453
|
* holds them, rather than re-recording the opt-in over the group as it
|
|
450
454
|
* stands today.
|
|
455
|
+
*
|
|
456
|
+
* A refusal outlives the contact it was written on (AGL-3338): what the
|
|
457
|
+
* org's retained store kept for this address is laid over the contact, or
|
|
458
|
+
* over no contact at all, before either is read — so a person whose record
|
|
459
|
+
* was deleted still reads `declined`, and one who signed up again since reads
|
|
460
|
+
* the newer grant. A retained read that fails falls to `declined` with the
|
|
461
|
+
* contact read.
|
|
451
462
|
*/ async function storedConsentForAddress(hostId, group, email) {
|
|
452
463
|
try {
|
|
453
464
|
const contacts = await orgDataCollectionForHost(hostId, 'contacts');
|
|
454
|
-
const found = await
|
|
455
|
-
|
|
465
|
+
const [found, retained] = await Promise.all([
|
|
466
|
+
findContactByEmail(contacts, email),
|
|
467
|
+
readRetainedRefusals(contacts, [
|
|
468
|
+
email
|
|
469
|
+
])
|
|
470
|
+
]);
|
|
471
|
+
const data = withRetainedRefusals(found ? found.data() : null, refusalsOf([
|
|
472
|
+
...retained.values()
|
|
473
|
+
][0]));
|
|
456
474
|
return {
|
|
457
475
|
stored: readMarketingBasis(data, group),
|
|
458
476
|
grants: grantEntriesAsRecorded(data, group)
|
package/src/lib/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/inbox/src/lib/server.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\n/**\n * The Inbox's two acts on the person who sent a submission: answer them, and\n * put them on a marketing list.\n *\n * `POST inbox/reply` sends one message. `POST inbox/assign-list` enrolls, and\n * `POST inbox/list-options` tells the merchant what they may do before they\n * do it.\n *\n * ## The two acts are kept apart everywhere\n *\n * A reply is TRANSACTIONAL — the person asked to be contacted by submitting\n * the form, and answering them is the transaction they started. A list is\n * MARKETING — a standing invitation to mail them about things they never\n * asked about. So they are separate routes, separate records and separate\n * cards on screen, and neither is a side effect of the other: replying\n * enrolls nobody and writes no consent, and enrolling sends nothing and\n * meters nothing. Folding either into the other would make a merchant's\n * ordinary act of answering a customer into an act with consequences they\n * did not choose. The enrollment rule itself is the framework's\n * `list-assignment-policy`, shared with the Emails console's audience card so\n * that both surfaces refuse the same people for the same stated reasons.\n *\n * ## The boundary this feature sits on, stated because the UI must say it too\n *\n * The Inbox is not a mailbox. Nothing in the INBOX receives mail: no route\n * of its own, no MX record pointed at a submission, no parser. A submission\n * arrived as an HTTP POST, not as a message, so there is no `Message-ID` to\n * thread against and a reply is always the FIRST message in its conversation.\n * (The platform's one receiving door is the CRM's capture address —\n * `POST /api/crm/inbound` in the console app, AGL-2657 — which files a\n * forwarded or copied message on a contact's timeline. It threads nothing\n * here: a submission is not mail, and the Inbox's statement stands.)\n *\n * That decides two things:\n *\n * - **No `In-Reply-To` or `References` header is sent.** There is nothing to\n * put in them. Inventing an identifier would produce headers that reference\n * a message no mail server has ever seen, which threads nothing and makes\n * some filters treat the message as forged.\n * - **`Reply-To` is the sender's own console account address**, so when the\n * recipient answers, the answer reaches a human in a real mailbox. It does\n * not come back here, and the composer says so. A reply that went to the\n * platform's unmonitored address would be a message the merchant never sees\n * and the customer believes was received.\n *\n * The thread the merchant sees is ours, stored under the submission, and it\n * holds what we sent — never what came back, because nothing comes back.\n *\n * ## Sending identity\n *\n * The `From:` address is whatever THIS SITE sends as — `hostSendingIdentity`,\n * the same resolution a campaign gets — so a site with a domain of its own\n * replies on it, and a site without one replies on the shared pool, which is\n * correct for a reply because a reply is transactional.\n *\n * The display name varies separately, through `resolveBrandingProfile`, which\n * is entitlement-gated: a merchant without white-label replies under the\n * platform's name even on their own domain.\n *\n * `Reply-To` stays load-bearing either way, and not as a stopgap. Nothing\n * here receives mail, so the answer has to be routed to a mailbox somebody\n * actually reads — and `Reply-To` is the one header that may name an address\n * on a domain this platform has never verified.\n *\n * ## Consent and suppression\n *\n * A reply is transactional: the recipient asked to be contacted by submitting\n * the form, so no marketing-consent record is required and none is read. The\n * suppression lists still apply — both of them — and this is the first send\n * path in the product to consult BOTH on one address:\n *\n * - the platform list, through `isEmailSuppressed`, which fails closed;\n * - this site's list, under `hosts/{hostId}/suppressions`.\n *\n * Both are keyed with `emailSuppressionKey`, one derivation, so the two reads\n * cannot disagree about which document to look for.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n assignmentReadout,\n grantEntriesAsRecorded,\n isOrgWideMember,\n readMarketingBasis,\n registerPluginApiRoute,\n resolveBrandingProfile,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailSuppressionKey,\n enrollListMember,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, not the barrel: this plugin's specs substitute the barrel\n// wholesale, and the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\n// The leaf for the same reason: which sites a group's opt-outs live on is the\n// rule, and a spec's barrel double must not be able to stand in for it.\nimport { consentGroupOptOutHosts } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n REPLY_BODY_MAX,\n REPLY_SUBJECT_MAX,\n composeReplyBody,\n replyRecipient,\n type ReplyRefusal,\n} from './model/reply-policy'\n\n/** Where a reply is stored, under the submission it answers. */\nexport const REPLIES_SUBCOLLECTION = 'replies'\n\n/** `context` on the send: the log label and the Resend attribution tag. */\nexport const REPLY_CONTEXT = 'inbox-reply'\n\n/** What a refusal says to the merchant. One line, naming the cause. */\nconst REFUSAL_MESSAGES: Record<ReplyRefusal, string> = {\n 'no-address': 'This submission has no email field, so there is nobody to reply to.',\n 'unroutable-address':\n 'The email address on this submission is not a valid address.',\n 'suppressed-platform':\n 'This address bounced or reported a message as spam, so it cannot be mailed.',\n 'suppressed-host':\n 'This address unsubscribed from this site, so it cannot be mailed.',\n}\n\n/**\n * Is this address suppressed, on either list?\n *\n * Order matters only for the reason reported, not the outcome. The platform\n * list is read first because it is the one that carries a bounce learned\n * anywhere — including on a send that named no site — and that is the more\n * useful thing to tell a merchant who is about to retype the message.\n *\n * Named for the ADDRESS rather than for either act, because both use it: a\n * reply must not go to a dead or complaining mailbox, and a list enrollment\n * must not put one on a standing audience. A second copy for the second act\n * would be two answers to \"may this address be mailed\".\n *\n * \"This site\" is its consent group: the site list is read on every site of\n * `group`, because somebody who unsubscribed from one site of a declared\n * group unsubscribed from the sender this site is. A group of one is the one\n * read it always was.\n */\nexport async function addressSuppression(\n hostId: string,\n email: string,\n firestore?: unknown,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<ReplyRefusal | null> {\n const hostIds =\n group && group.hostId === hostId ? consentGroupOptOutHosts(group) : [hostId]\n const key = emailSuppressionKey(email)\n // `emailSuppressionKey` returns null for an address it cannot key, and\n // `isEmailSuppressed` already answers `true` for that case. Treating it as\n // suppressed here keeps the two halves agreeing: an address we cannot key\n // is an address we cannot prove is safe to mail.\n if (!key) return 'suppressed-platform'\n if (await isEmailSuppressed(email, firestore)) return 'suppressed-platform'\n const db = (firestore ?? firebaseAdmin.app().firestore()) as any\n const docs = await Promise.all(\n hostIds.map((id) =>\n db.collection('hosts').doc(id).collection('suppressions').doc(key).get(),\n ),\n )\n return docs.some((doc) => doc.exists) ? 'suppressed-host' : null\n}\n\n/**\n * Reply to one submission.\n *\n * Body: `{ hostId, submissionId, subject, message }`. The recipient is\n * deliberately absent — it is read off the stored submission, so this route\n * cannot be pointed at an address of the caller's choosing.\n */\nexport const inboxReplyHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n const subject = String(req.body?.subject ?? '')\n .trim()\n .slice(0, REPLY_SUBJECT_MAX)\n const message = String(req.body?.message ?? '')\n .trim()\n .slice(0, REPLY_BODY_MAX)\n if (!hostId || !submissionId) {\n return res.status(400).json({ error: 'Missing hostId or submissionId' })\n }\n if (!subject || !message) {\n return res.status(400).json({ error: 'Missing subject or message' })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n // The address the reply goes back to, and the only thing that makes this\n // send worth making. A console account with no address on it would put\n // the customer's answer nowhere, so it refuses rather than sending a\n // message that invites a reply into a void.\n const replyTo = String(decoded.email ?? '')\n if (!replyTo) {\n return res.status(400).json({\n error:\n 'Your account has no email address, so a reply would have nowhere to come back to.',\n })\n }\n\n const submissionRef = hostRef\n .collection('formSubmissions')\n .doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return res.status(404).json({ error: 'Unknown submission' })\n }\n\n const fields = (submission.get('fields') ?? {}) as Record<string, unknown>\n const recipient = replyRecipient(fields)\n if ('refusal' in recipient) {\n return res\n .status(422)\n .json({ error: REFUSAL_MESSAGES[recipient.refusal], reason: recipient.refusal })\n }\n\n // The org once, for the sender's consent group here and its brand below.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const suppressed = await addressSuppression(\n hostId,\n recipient.email,\n undefined,\n await consentGroupForSite(\n hostId,\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n ),\n )\n if (suppressed) {\n return res\n .status(409)\n .json({ error: REFUSAL_MESSAGES[suppressed], reason: suppressed })\n }\n\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? '',\n )\n const branding = resolveBrandingProfile(owner?.org as never)\n\n // No `html` is passed on purpose. `sendEmail` synthesizes the HTML part\n // from `text`, which is the single place that guarantee is enforced; a\n // hand-built one here would be a second place it can be forgotten.\n // No `priority` either: absent, it resolves to transactional, which the\n // platform governor may never refuse. That is the correct class — a reply\n // is a person answering a person, and it cannot be retried by a sweep.\n const result = await sendEmail({\n to: recipient.email,\n subject,\n text: composeReplyBody({ message, fields, siteName }),\n replyTo,\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: REPLY_CONTEXT,\n })\n\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reply could not be sent.',\n reason: (result as { reason?: string }).reason ?? 'unknown',\n })\n }\n\n // Metered as transactional, so it enters the cost meter and never the\n // campaign meter a plan limit can refuse.\n void meterHostEmail(hostId, 1, 'transactional')\n\n const sentAtMs = Date.now()\n const replyRef = await submissionRef\n .collection(REPLIES_SUBCOLLECTION)\n .add({\n to: recipient.email,\n subject,\n // The merchant's own words, not the composed wire body. The quote and\n // the attribution line are rendered from the submission every time, so\n // storing them would be storing a copy of a document one field away.\n message,\n replyTo,\n fromName: branding.fromName,\n sentByUid: decoded.uid,\n providerMessageId: (result as { id?: string | null }).id ?? null,\n sentAtMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n // A replied submission is a handled one, so it stops being unread in the\n // same write that records the reply. `repliedAtMs` is what the row reads;\n // the count of replies is a subcollection read the list must not make.\n await submissionRef.set(\n { read: true, repliedAtMs: sentAtMs },\n { merge: true },\n )\n\n return res.status(200).json({\n sent: true,\n replyId: replyRef.id,\n to: recipient.email,\n replyTo,\n sentAtMs,\n })\n } catch (error) {\n console.error('[inbox] reply failed', error)\n return res.status(500).json({ error: 'The reply could not be sent.' })\n }\n}\n\n/** Where an assignment is recorded, under the submission that occasioned it. */\nexport const LIST_ASSIGNMENTS_SUBCOLLECTION = 'listAssignments'\n\n/** The `source` stamped on a member enrolled from the Inbox. */\nexport const ASSIGNMENT_SOURCE = 'inbox'\n\n/**\n * How many lists the picker offers.\n *\n * A ceiling on the READ, so a merchant with an unusual number of lists costs\n * one bounded query rather than a scan, and the response says when it is a\n * floor rather than presenting a slice as the whole set.\n */\nexport const LIST_OPTIONS_LIMIT = 100\n\n/** Everything both list-assignment handlers need, or the refusal to send. */\ntype AssignmentContext =\n | {\n ok: true\n uid: string\n orgId: string\n firestore: FirebaseFirestore.Firestore\n submissionRef: FirebaseFirestore.DocumentReference\n email: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, about which submission, and for which person.\n *\n * ## Two gates, not one\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the security rules\n * put both behind `isOrgWideMember()` — an editor invited to ONE site is an\n * org member with `allHosts: false`, and gating an org-wide write on the host\n * role alone would let a single-site collaborator enroll people into an\n * audience every other site in the org can mail. The Admin SDK evaluates no\n * rules, so this route is the enforcement rather than an echo of it.\n *\n * ## The address is read off the submission\n *\n * Through `replyRecipient`, the same resolver the reply and the Inbox row use.\n * A `to` in the request body would let a site editor enroll an address of\n * their choosing into a marketing audience, which is the same defect the\n * reply handler refuses for the same reason.\n */\nasync function resolveAssignmentContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<AssignmentContext> {\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n if (!hostId || !submissionId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or submissionId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String((await getOrgForHost(hostId).catch(() => null))?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so adding ' +\n 'someone to one needs organization-wide access rather than access ' +\n 'to this site.',\n },\n }\n }\n\n const submissionRef = hostRef.collection('formSubmissions').doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown submission' } }\n }\n const recipient = replyRecipient(\n (submission.get('fields') ?? {}) as Record<string, unknown>,\n )\n if ('refusal' in recipient) {\n return {\n ok: false,\n status: 422,\n body: {\n error: ASSIGNMENT_REFUSAL_MESSAGES[recipient.refusal],\n reason: recipient.refusal,\n },\n }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n orgId,\n firestore,\n submissionRef,\n email: recipient.email,\n }\n}\n\n/**\n * The person's own consent facts, read off the org contact for this address.\n *\n * The CRM record is where a refusal lives: `marketingConsent` is written\n * `false` by exactly one path in the product and it writes a contact. So this\n * is the read that makes `declined` mean something at enrollment time, and an\n * absent contact is honestly `unrecorded` rather than a reason to guess.\n *\n * Read UNSCOPED, deliberately. `scopedToHost` narrows an org collection to\n * what one site may see, and a refusal filtered out by that narrowing is a\n * refusal this route would then step over — the failure mode is mailing\n * somebody who said no. It is safe here because the caller has already been\n * proved an org-wide member, which is the tier the rules grant the whole\n * org's contacts to.\n *\n * Through the org's address index (AGL-2633), for the same failure mode\n * from the other side: a refusal recorded on a record that was later\n * merged into another is the survivor's refusal, and the sender's address\n * may be the one that became an alternate.\n *\n * The person's grants come back beside the verdict, off the same document\n * (AGL-3320): a pass-through carries them onto the membership as the record\n * holds them, rather than re-recording the opt-in over the group as it\n * stands today.\n */\nasync function storedConsentForAddress(\n hostId: string,\n group: ConsentGroup,\n email: string,\n): Promise<{\n stored: MarketingConsentRecord\n grants: Record<string, Record<string, unknown>>\n}> {\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const found = await findContactByEmail(contacts, email)\n const data = found ? (found.data() as Record<string, unknown>) : null\n return {\n stored: readMarketingBasis(data, group),\n grants: grantEntriesAsRecorded(data, group),\n }\n } catch (error) {\n console.error('[inbox] consent lookup failed', error)\n /*\n * FAILS TO `declined`, which reads oddly until you name the alternative.\n * A throwing read cannot say the person consented and cannot say they\n * refused; the question is which way the unknown should fall. `unrecorded`\n * would leave the attestation control on screen and let the merchant add\n * somebody whose stored refusal this route simply failed to see, and no\n * later surface would ever revisit it. A refusal costs a retry.\n */\n return {\n stored: {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody, because nobody asserted this: it is what a\n // failed read falls back to, not a refusal anyone recorded. Claiming\n // `'person'` here would put a refusal in the audit trail that the\n // person never made.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n },\n grants: {},\n }\n }\n}\n\n/**\n * `POST inbox/list-options` — what the merchant may do with this sender.\n *\n * Reads only. It exists because the answer needs three things the browser\n * cannot have: the org's lists (rules put them behind org-wide membership,\n * which the acting console session may not hold), the person's consent record\n * (an org contact, same gate), and both suppression lists. Computing any of\n * it client-side would be a second copy of the rule, on the surface whose\n * whole job is to tell the merchant the truth about what is about to happen.\n *\n * Reached by an explicit expansion in the reader, never on mount: it is three\n * reads and a bounded query, and paying them once per opened submission would\n * charge every merchant who never touches lists.\n */\nexport const inboxListOptionsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppression = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n const { stored } = await storedConsentForAddress(\n hostId,\n group,\n context.email,\n )\n const readout = assignmentReadout({ stored, suppression })\n\n const listsRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n /*\n * Ordered by document id, which is the one key every list has.\n * `limit()` alone answers in that order anyway but does not SAY so, and a\n * page of results whose order is an implementation detail is the shape\n * that turned an audience into a random sample. Ordering by `name` would\n * be worse than either: `orderBy` drops documents missing the field, so a\n * list created without a name would vanish from its own picker.\n */\n const snapshot = await listsRef\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(LIST_OPTIONS_LIMIT + 1)\n .get()\n const lists = snapshot.docs\n .slice(0, LIST_OPTIONS_LIMIT)\n .map((doc: FirebaseFirestore.QueryDocumentSnapshot) => ({\n id: doc.id,\n name: String(doc.get('name') ?? doc.id),\n }))\n\n return res.status(200).json({\n to: context.email,\n lists,\n listsTruncated: snapshot.docs.length > LIST_OPTIONS_LIMIT,\n basis: stored.basis,\n basisAtMs: stored.basisAtMs,\n ...readout,\n })\n } catch (error) {\n console.error('[inbox] list options failed', error)\n return res.status(500).json({ error: 'The lists could not be read.' })\n }\n}\n\n/**\n * `POST inbox/assign-list` — put this sender on a marketing list.\n *\n * Body: `{ hostId, submissionId, listId, attestConsent? }`. `attestConsent` is\n * the merchant STATING that they have this person's permission; it is not a\n * way to name a basis, because the pass-through basis is derived server-side\n * from the person's own record.\n *\n * ## What this act is, and what it is not\n *\n * It is marketing enrollment: the only consumer of a list is a campaign. It\n * is therefore kept entirely separate from the reply — different route,\n * different card in the UI, different record — and replying enrolls nobody.\n * The reverse holds too: enrolling somebody sends them nothing, meters\n * nothing, and is not a promise that they are still mailable when a campaign\n * eventually runs. Suppression is consulted again at send time, in\n * `filterSendableForHost`, because an address can be suppressed the day after\n * it is enrolled and an enrollment-time check that licensed every later send\n * would be a check that passes once and pays out forever.\n */\nexport const inboxAssignListHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listId = String(req.body?.listId ?? '')\n if (!listId) return res.status(400).json({ error: 'Missing listId' })\n const attested = req.body?.attestConsent === true\n\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n const refuse = (status: number, reason: AssignmentRefusal) =>\n res\n .status(status)\n .json({ error: ASSIGNMENT_REFUSAL_MESSAGES[reason], reason })\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppressed = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n if (suppressed) return refuse(409, suppressed)\n\n const listRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown list' })\n }\n\n const { stored, grants } = await storedConsentForAddress(\n hostId,\n group,\n context.email,\n )\n const nowMs = Date.now()\n const decision = assignmentBasis({\n stored,\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n // 409 for a refusal that no answer can change, 422 for the one the\n // merchant can answer by asserting a basis. The distinction is what\n // lets the UI offer the attestation control on exactly one of them.\n return refuse(decision.refusal === 'declined' ? 409 : 422, decision.refusal)\n }\n\n const enrollment = await enrollListMember({\n listRef,\n group,\n email: context.email,\n source: ASSIGNMENT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own rows\n // away when a person stops matching, and a decision somebody made by\n // hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in' ? { grantEntries: grants } : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal that the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds the\n * row, so it is the backstop for every enrollment route; reaching it\n * here means the two records disagree, and the refusal wins.\n */\n return refuse(409, enrollment.refusal === 'declined' ? 'declined' : 'no-address')\n }\n\n /*\n * The attestation trail, beside the reply record and for the same reason:\n * a support question about a marketing list starts with \"who put this\n * person on it, and on what basis\". The member document carries the basis\n * so the send-time join can read it; this carries the same facts where\n * the act happened, which is the only place that survives the person\n * later being removed from the list.\n */\n await context.submissionRef\n .collection(LIST_ASSIGNMENTS_SUBCOLLECTION)\n .add({\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n basisAtMs: decision.atMs,\n assertedByUid: decision.byUid,\n addedByUid: context.uid,\n addedAtMs: nowMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n enrolled: true,\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n })\n } catch (error) {\n console.error('[inbox] list assignment failed', error)\n return res.status(500).json({ error: 'The list assignment failed.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`. Each is reached by a person pressing a button in\n * a browser, so the visitor limiter's per-(site, IP) budget is far above any\n * real use of them and is the right ceiling for surfaces that put mail on the\n * wire or a person into a marketing audience.\n */\nexport function registerInboxConsoleApi(): void {\n registerPluginApiRoute('inbox/reply', inboxReplyHandler)\n registerPluginApiRoute('inbox/list-options', inboxListOptionsHandler)\n registerPluginApiRoute('inbox/assign-list', inboxAssignListHandler)\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","assignmentReadout","grantEntriesAsRecorded","isOrgWideMember","readMarketingBasis","registerPluginApiRoute","resolveBrandingProfile","emailSuppressionKey","enrollListMember","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","resolveOrgMembership","findContactByEmail","consentGroupOptOutHosts","sendEmail","FieldValue","REPLY_BODY_MAX","REPLY_SUBJECT_MAX","composeReplyBody","replyRecipient","REPLIES_SUBCOLLECTION","REPLY_CONTEXT","REFUSAL_MESSAGES","addressSuppression","hostId","email","firestore","group","hostIds","key","db","app","docs","Promise","all","map","id","collection","doc","get","some","exists","inboxReplyHandler","req","res","method","status","json","error","String","body","submissionId","subject","trim","slice","message","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","decoded","submission","auth","verifyIdToken","hostRef","memberRole","uid","replyTo","submissionRef","fields","recipient","refusal","reason","owner","catch","suppressed","org","siteName","branding","result","to","text","fromName","sendingIdentity","audience","context","sent","sentAtMs","Date","now","replyRef","add","sentByUid","providerMessageId","createdAt","serverTimestamp","set","read","repliedAtMs","merge","replyId","console","LIST_ASSIGNMENTS_SUBCOLLECTION","ASSIGNMENT_SOURCE","LIST_OPTIONS_LIMIT","resolveAssignmentContext","ok","orgId","membership","member","orgWideWriter","role","orgSuspended","storedConsentForAddress","contacts","found","data","stored","grants","basis","assertedBy","source","basisAtMs","capturedAtMs","inboxListOptionsHandler","suppression","readout","listsRef","snapshot","orderBy","FieldPath","documentId","limit","lists","name","listsTruncated","inboxAssignListHandler","listId","attested","attestConsent","listSnapshot","refuse","listRef","nowMs","decision","actingUid","enrollment","via","consent","grantEntries","enrolled","listName","memberId","atMs","assertedByUid","byUid","addedByUid","addedAtMs","registerInboxConsoleApi"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/inbox/src/lib/server.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\n/**\n * The Inbox's two acts on the person who sent a submission: answer them, and\n * put them on a marketing list.\n *\n * `POST inbox/reply` sends one message. `POST inbox/assign-list` enrolls, and\n * `POST inbox/list-options` tells the merchant what they may do before they\n * do it.\n *\n * ## The two acts are kept apart everywhere\n *\n * A reply is TRANSACTIONAL — the person asked to be contacted by submitting\n * the form, and answering them is the transaction they started. A list is\n * MARKETING — a standing invitation to mail them about things they never\n * asked about. So they are separate routes, separate records and separate\n * cards on screen, and neither is a side effect of the other: replying\n * enrolls nobody and writes no consent, and enrolling sends nothing and\n * meters nothing. Folding either into the other would make a merchant's\n * ordinary act of answering a customer into an act with consequences they\n * did not choose. The enrollment rule itself is the framework's\n * `list-assignment-policy`, shared with the Emails console's audience card so\n * that both surfaces refuse the same people for the same stated reasons.\n *\n * ## The boundary this feature sits on, stated because the UI must say it too\n *\n * The Inbox is not a mailbox. Nothing in the INBOX receives mail: no route\n * of its own, no MX record pointed at a submission, no parser. A submission\n * arrived as an HTTP POST, not as a message, so there is no `Message-ID` to\n * thread against and a reply is always the FIRST message in its conversation.\n * (The platform's one receiving door is the CRM's capture address —\n * `POST /api/crm/inbound` in the console app, AGL-2657 — which files a\n * forwarded or copied message on a contact's timeline. It threads nothing\n * here: a submission is not mail, and the Inbox's statement stands.)\n *\n * That decides two things:\n *\n * - **No `In-Reply-To` or `References` header is sent.** There is nothing to\n * put in them. Inventing an identifier would produce headers that reference\n * a message no mail server has ever seen, which threads nothing and makes\n * some filters treat the message as forged.\n * - **`Reply-To` is the sender's own console account address**, so when the\n * recipient answers, the answer reaches a human in a real mailbox. It does\n * not come back here, and the composer says so. A reply that went to the\n * platform's unmonitored address would be a message the merchant never sees\n * and the customer believes was received.\n *\n * The thread the merchant sees is ours, stored under the submission, and it\n * holds what we sent — never what came back, because nothing comes back.\n *\n * ## Sending identity\n *\n * The `From:` address is whatever THIS SITE sends as — `hostSendingIdentity`,\n * the same resolution a campaign gets — so a site with a domain of its own\n * replies on it, and a site without one replies on the shared pool, which is\n * correct for a reply because a reply is transactional.\n *\n * The display name varies separately, through `resolveBrandingProfile`, which\n * is entitlement-gated: a merchant without white-label replies under the\n * platform's name even on their own domain.\n *\n * `Reply-To` stays load-bearing either way, and not as a stopgap. Nothing\n * here receives mail, so the answer has to be routed to a mailbox somebody\n * actually reads — and `Reply-To` is the one header that may name an address\n * on a domain this platform has never verified.\n *\n * ## Consent and suppression\n *\n * A reply is transactional: the recipient asked to be contacted by submitting\n * the form, so no marketing-consent record is required and none is read. The\n * suppression lists still apply — both of them — and this is the first send\n * path in the product to consult BOTH on one address:\n *\n * - the platform list, through `isEmailSuppressed`, which fails closed;\n * - this site's list, under `hosts/{hostId}/suppressions`.\n *\n * Both are keyed with `emailSuppressionKey`, one derivation, so the two reads\n * cannot disagree about which document to look for.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n assignmentReadout,\n grantEntriesAsRecorded,\n isOrgWideMember,\n readMarketingBasis,\n registerPluginApiRoute,\n resolveBrandingProfile,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailSuppressionKey,\n enrollListMember,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, not the barrel: this plugin's specs substitute the barrel\n// wholesale, and the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\n// And for the refusal a deleted contact left behind (AGL-3338): the real store\n// and the real merge, under whatever the specs put in the barrels' place.\nimport { readRetainedRefusals } from '@aglyn/tenant-data-admin/server/retained-refusals'\nimport { refusalsOf, withRetainedRefusals } from '@aglyn/aglyn/app-utils/retained-refusals'\n// The leaf for the same reason: which sites a group's opt-outs live on is the\n// rule, and a spec's barrel double must not be able to stand in for it.\nimport { consentGroupOptOutHosts } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n REPLY_BODY_MAX,\n REPLY_SUBJECT_MAX,\n composeReplyBody,\n replyRecipient,\n type ReplyRefusal,\n} from './model/reply-policy'\n\n/** Where a reply is stored, under the submission it answers. */\nexport const REPLIES_SUBCOLLECTION = 'replies'\n\n/** `context` on the send: the log label and the Resend attribution tag. */\nexport const REPLY_CONTEXT = 'inbox-reply'\n\n/** What a refusal says to the merchant. One line, naming the cause. */\nconst REFUSAL_MESSAGES: Record<ReplyRefusal, string> = {\n 'no-address': 'This submission has no email field, so there is nobody to reply to.',\n 'unroutable-address':\n 'The email address on this submission is not a valid address.',\n 'suppressed-platform':\n 'This address bounced or reported a message as spam, so it cannot be mailed.',\n 'suppressed-host':\n 'This address unsubscribed from this site, so it cannot be mailed.',\n}\n\n/**\n * Is this address suppressed, on either list?\n *\n * Order matters only for the reason reported, not the outcome. The platform\n * list is read first because it is the one that carries a bounce learned\n * anywhere — including on a send that named no site — and that is the more\n * useful thing to tell a merchant who is about to retype the message.\n *\n * Named for the ADDRESS rather than for either act, because both use it: a\n * reply must not go to a dead or complaining mailbox, and a list enrollment\n * must not put one on a standing audience. A second copy for the second act\n * would be two answers to \"may this address be mailed\".\n *\n * \"This site\" is its consent group: the site list is read on every site of\n * `group`, because somebody who unsubscribed from one site of a declared\n * group unsubscribed from the sender this site is. A group of one is the one\n * read it always was.\n */\nexport async function addressSuppression(\n hostId: string,\n email: string,\n firestore?: unknown,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<ReplyRefusal | null> {\n const hostIds =\n group && group.hostId === hostId ? consentGroupOptOutHosts(group) : [hostId]\n const key = emailSuppressionKey(email)\n // `emailSuppressionKey` returns null for an address it cannot key, and\n // `isEmailSuppressed` already answers `true` for that case. Treating it as\n // suppressed here keeps the two halves agreeing: an address we cannot key\n // is an address we cannot prove is safe to mail.\n if (!key) return 'suppressed-platform'\n if (await isEmailSuppressed(email, firestore)) return 'suppressed-platform'\n const db = (firestore ?? firebaseAdmin.app().firestore()) as any\n const docs = await Promise.all(\n hostIds.map((id) =>\n db.collection('hosts').doc(id).collection('suppressions').doc(key).get(),\n ),\n )\n return docs.some((doc) => doc.exists) ? 'suppressed-host' : null\n}\n\n/**\n * Reply to one submission.\n *\n * Body: `{ hostId, submissionId, subject, message }`. The recipient is\n * deliberately absent — it is read off the stored submission, so this route\n * cannot be pointed at an address of the caller's choosing.\n */\nexport const inboxReplyHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n const subject = String(req.body?.subject ?? '')\n .trim()\n .slice(0, REPLY_SUBJECT_MAX)\n const message = String(req.body?.message ?? '')\n .trim()\n .slice(0, REPLY_BODY_MAX)\n if (!hostId || !submissionId) {\n return res.status(400).json({ error: 'Missing hostId or submissionId' })\n }\n if (!subject || !message) {\n return res.status(400).json({ error: 'Missing subject or message' })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n // The address the reply goes back to, and the only thing that makes this\n // send worth making. A console account with no address on it would put\n // the customer's answer nowhere, so it refuses rather than sending a\n // message that invites a reply into a void.\n const replyTo = String(decoded.email ?? '')\n if (!replyTo) {\n return res.status(400).json({\n error:\n 'Your account has no email address, so a reply would have nowhere to come back to.',\n })\n }\n\n const submissionRef = hostRef\n .collection('formSubmissions')\n .doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return res.status(404).json({ error: 'Unknown submission' })\n }\n\n const fields = (submission.get('fields') ?? {}) as Record<string, unknown>\n const recipient = replyRecipient(fields)\n if ('refusal' in recipient) {\n return res\n .status(422)\n .json({ error: REFUSAL_MESSAGES[recipient.refusal], reason: recipient.refusal })\n }\n\n // The org once, for the sender's consent group here and its brand below.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const suppressed = await addressSuppression(\n hostId,\n recipient.email,\n undefined,\n await consentGroupForSite(\n hostId,\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n ),\n )\n if (suppressed) {\n return res\n .status(409)\n .json({ error: REFUSAL_MESSAGES[suppressed], reason: suppressed })\n }\n\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? '',\n )\n const branding = resolveBrandingProfile(owner?.org as never)\n\n // No `html` is passed on purpose. `sendEmail` synthesizes the HTML part\n // from `text`, which is the single place that guarantee is enforced; a\n // hand-built one here would be a second place it can be forgotten.\n // No `priority` either: absent, it resolves to transactional, which the\n // platform governor may never refuse. That is the correct class — a reply\n // is a person answering a person, and it cannot be retried by a sweep.\n const result = await sendEmail({\n to: recipient.email,\n subject,\n text: composeReplyBody({ message, fields, siteName }),\n replyTo,\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: REPLY_CONTEXT,\n })\n\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reply could not be sent.',\n reason: (result as { reason?: string }).reason ?? 'unknown',\n })\n }\n\n // Metered as transactional, so it enters the cost meter and never the\n // campaign meter a plan limit can refuse.\n void meterHostEmail(hostId, 1, 'transactional')\n\n const sentAtMs = Date.now()\n const replyRef = await submissionRef\n .collection(REPLIES_SUBCOLLECTION)\n .add({\n to: recipient.email,\n subject,\n // The merchant's own words, not the composed wire body. The quote and\n // the attribution line are rendered from the submission every time, so\n // storing them would be storing a copy of a document one field away.\n message,\n replyTo,\n fromName: branding.fromName,\n sentByUid: decoded.uid,\n providerMessageId: (result as { id?: string | null }).id ?? null,\n sentAtMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n // A replied submission is a handled one, so it stops being unread in the\n // same write that records the reply. `repliedAtMs` is what the row reads;\n // the count of replies is a subcollection read the list must not make.\n await submissionRef.set(\n { read: true, repliedAtMs: sentAtMs },\n { merge: true },\n )\n\n return res.status(200).json({\n sent: true,\n replyId: replyRef.id,\n to: recipient.email,\n replyTo,\n sentAtMs,\n })\n } catch (error) {\n console.error('[inbox] reply failed', error)\n return res.status(500).json({ error: 'The reply could not be sent.' })\n }\n}\n\n/** Where an assignment is recorded, under the submission that occasioned it. */\nexport const LIST_ASSIGNMENTS_SUBCOLLECTION = 'listAssignments'\n\n/** The `source` stamped on a member enrolled from the Inbox. */\nexport const ASSIGNMENT_SOURCE = 'inbox'\n\n/**\n * How many lists the picker offers.\n *\n * A ceiling on the READ, so a merchant with an unusual number of lists costs\n * one bounded query rather than a scan, and the response says when it is a\n * floor rather than presenting a slice as the whole set.\n */\nexport const LIST_OPTIONS_LIMIT = 100\n\n/** Everything both list-assignment handlers need, or the refusal to send. */\ntype AssignmentContext =\n | {\n ok: true\n uid: string\n orgId: string\n firestore: FirebaseFirestore.Firestore\n submissionRef: FirebaseFirestore.DocumentReference\n email: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, about which submission, and for which person.\n *\n * ## Two gates, not one\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the security rules\n * put both behind `isOrgWideMember()` — an editor invited to ONE site is an\n * org member with `allHosts: false`, and gating an org-wide write on the host\n * role alone would let a single-site collaborator enroll people into an\n * audience every other site in the org can mail. The Admin SDK evaluates no\n * rules, so this route is the enforcement rather than an echo of it.\n *\n * ## The address is read off the submission\n *\n * Through `replyRecipient`, the same resolver the reply and the Inbox row use.\n * A `to` in the request body would let a site editor enroll an address of\n * their choosing into a marketing audience, which is the same defect the\n * reply handler refuses for the same reason.\n */\nasync function resolveAssignmentContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<AssignmentContext> {\n const hostId = String(req.body?.hostId ?? '')\n const submissionId = String(req.body?.submissionId ?? '')\n if (!hostId || !submissionId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or submissionId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String((await getOrgForHost(hostId).catch(() => null))?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so adding ' +\n 'someone to one needs organization-wide access rather than access ' +\n 'to this site.',\n },\n }\n }\n\n const submissionRef = hostRef.collection('formSubmissions').doc(submissionId)\n const submission = await submissionRef.get()\n if (!submission.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown submission' } }\n }\n const recipient = replyRecipient(\n (submission.get('fields') ?? {}) as Record<string, unknown>,\n )\n if ('refusal' in recipient) {\n return {\n ok: false,\n status: 422,\n body: {\n error: ASSIGNMENT_REFUSAL_MESSAGES[recipient.refusal],\n reason: recipient.refusal,\n },\n }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n orgId,\n firestore,\n submissionRef,\n email: recipient.email,\n }\n}\n\n/**\n * The person's own consent facts, read off the org contact for this address.\n *\n * The CRM record is where a refusal lives: `marketingConsent` is written\n * `false` by exactly one path in the product and it writes a contact. So this\n * is the read that makes `declined` mean something at enrollment time, and an\n * absent contact is honestly `unrecorded` rather than a reason to guess.\n *\n * Read UNSCOPED, deliberately. `scopedToHost` narrows an org collection to\n * what one site may see, and a refusal filtered out by that narrowing is a\n * refusal this route would then step over — the failure mode is mailing\n * somebody who said no. It is safe here because the caller has already been\n * proved an org-wide member, which is the tier the rules grant the whole\n * org's contacts to.\n *\n * Through the org's address index (AGL-2633), for the same failure mode\n * from the other side: a refusal recorded on a record that was later\n * merged into another is the survivor's refusal, and the sender's address\n * may be the one that became an alternate.\n *\n * The person's grants come back beside the verdict, off the same document\n * (AGL-3320): a pass-through carries them onto the membership as the record\n * holds them, rather than re-recording the opt-in over the group as it\n * stands today.\n *\n * A refusal outlives the contact it was written on (AGL-3338): what the\n * org's retained store kept for this address is laid over the contact, or\n * over no contact at all, before either is read — so a person whose record\n * was deleted still reads `declined`, and one who signed up again since reads\n * the newer grant. A retained read that fails falls to `declined` with the\n * contact read.\n */\nasync function storedConsentForAddress(\n hostId: string,\n group: ConsentGroup,\n email: string,\n): Promise<{\n stored: MarketingConsentRecord\n grants: Record<string, Record<string, unknown>>\n}> {\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const [found, retained] = await Promise.all([\n findContactByEmail(contacts, email),\n readRetainedRefusals(contacts, [email]),\n ])\n const data = withRetainedRefusals(\n found ? (found.data() as Record<string, unknown>) : null,\n refusalsOf([...retained.values()][0]),\n )\n return {\n stored: readMarketingBasis(data, group),\n grants: grantEntriesAsRecorded(data, group),\n }\n } catch (error) {\n console.error('[inbox] consent lookup failed', error)\n /*\n * FAILS TO `declined`, which reads oddly until you name the alternative.\n * A throwing read cannot say the person consented and cannot say they\n * refused; the question is which way the unknown should fall. `unrecorded`\n * would leave the attestation control on screen and let the merchant add\n * somebody whose stored refusal this route simply failed to see, and no\n * later surface would ever revisit it. A refusal costs a retry.\n */\n return {\n stored: {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody, because nobody asserted this: it is what a\n // failed read falls back to, not a refusal anyone recorded. Claiming\n // `'person'` here would put a refusal in the audit trail that the\n // person never made.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n },\n grants: {},\n }\n }\n}\n\n/**\n * `POST inbox/list-options` — what the merchant may do with this sender.\n *\n * Reads only. It exists because the answer needs three things the browser\n * cannot have: the org's lists (rules put them behind org-wide membership,\n * which the acting console session may not hold), the person's consent record\n * (an org contact, same gate), and both suppression lists. Computing any of\n * it client-side would be a second copy of the rule, on the surface whose\n * whole job is to tell the merchant the truth about what is about to happen.\n *\n * Reached by an explicit expansion in the reader, never on mount: it is three\n * reads and a bounded query, and paying them once per opened submission would\n * charge every merchant who never touches lists.\n */\nexport const inboxListOptionsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppression = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n const { stored } = await storedConsentForAddress(\n hostId,\n group,\n context.email,\n )\n const readout = assignmentReadout({ stored, suppression })\n\n const listsRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n /*\n * Ordered by document id, which is the one key every list has.\n * `limit()` alone answers in that order anyway but does not SAY so, and a\n * page of results whose order is an implementation detail is the shape\n * that turned an audience into a random sample. Ordering by `name` would\n * be worse than either: `orderBy` drops documents missing the field, so a\n * list created without a name would vanish from its own picker.\n */\n const snapshot = await listsRef\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(LIST_OPTIONS_LIMIT + 1)\n .get()\n const lists = snapshot.docs\n .slice(0, LIST_OPTIONS_LIMIT)\n .map((doc: FirebaseFirestore.QueryDocumentSnapshot) => ({\n id: doc.id,\n name: String(doc.get('name') ?? doc.id),\n }))\n\n return res.status(200).json({\n to: context.email,\n lists,\n listsTruncated: snapshot.docs.length > LIST_OPTIONS_LIMIT,\n basis: stored.basis,\n basisAtMs: stored.basisAtMs,\n ...readout,\n })\n } catch (error) {\n console.error('[inbox] list options failed', error)\n return res.status(500).json({ error: 'The lists could not be read.' })\n }\n}\n\n/**\n * `POST inbox/assign-list` — put this sender on a marketing list.\n *\n * Body: `{ hostId, submissionId, listId, attestConsent? }`. `attestConsent` is\n * the merchant STATING that they have this person's permission; it is not a\n * way to name a basis, because the pass-through basis is derived server-side\n * from the person's own record.\n *\n * ## What this act is, and what it is not\n *\n * It is marketing enrollment: the only consumer of a list is a campaign. It\n * is therefore kept entirely separate from the reply — different route,\n * different card in the UI, different record — and replying enrolls nobody.\n * The reverse holds too: enrolling somebody sends them nothing, meters\n * nothing, and is not a promise that they are still mailable when a campaign\n * eventually runs. Suppression is consulted again at send time, in\n * `filterSendableForHost`, because an address can be suppressed the day after\n * it is enrolled and an enrollment-time check that licensed every later send\n * would be a check that passes once and pays out forever.\n */\nexport const inboxAssignListHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listId = String(req.body?.listId ?? '')\n if (!listId) return res.status(400).json({ error: 'Missing listId' })\n const attested = req.body?.attestConsent === true\n\n try {\n const context = await resolveAssignmentContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const hostId = String(req.body?.hostId ?? '')\n\n const refuse = (status: number, reason: AssignmentRefusal) =>\n res\n .status(status)\n .json({ error: ASSIGNMENT_REFUSAL_MESSAGES[reason], reason })\n\n // The controller this enrollment is made for — the declared group of\n // sites that are one sender, or this site alone — and the suppression\n // lists read across it, as the send will read them.\n const group = await consentGroupForSite(hostId)\n const suppressed = await addressSuppression(\n hostId,\n context.email,\n undefined,\n group,\n )\n if (suppressed) return refuse(409, suppressed)\n\n const listRef = context.firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown list' })\n }\n\n const { stored, grants } = await storedConsentForAddress(\n hostId,\n group,\n context.email,\n )\n const nowMs = Date.now()\n const decision = assignmentBasis({\n stored,\n attested,\n actingUid: context.uid,\n nowMs,\n })\n if ('refusal' in decision) {\n // 409 for a refusal that no answer can change, 422 for the one the\n // merchant can answer by asserting a basis. The distinction is what\n // lets the UI offer the attestation control on exactly one of them.\n return refuse(decision.refusal === 'declined' ? 409 : 422, decision.refusal)\n }\n\n const enrollment = await enrollListMember({\n listRef,\n group,\n email: context.email,\n source: ASSIGNMENT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own rows\n // away when a person stops matching, and a decision somebody made by\n // hand is not a rule match that lapsed.\n via: 'manual',\n consent: decision,\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in' ? { grantEntries: grants } : {}),\n })\n if (enrollment.enrolled === false) {\n /*\n * The membership itself records a refusal that the CRM record did not.\n * `enrollListMember` is the only writer of the collection and holds the\n * row, so it is the backstop for every enrollment route; reaching it\n * here means the two records disagree, and the refusal wins.\n */\n return refuse(409, enrollment.refusal === 'declined' ? 'declined' : 'no-address')\n }\n\n /*\n * The attestation trail, beside the reply record and for the same reason:\n * a support question about a marketing list starts with \"who put this\n * person on it, and on what basis\". The member document carries the basis\n * so the send-time join can read it; this carries the same facts where\n * the act happened, which is the only place that survives the person\n * later being removed from the list.\n */\n await context.submissionRef\n .collection(LIST_ASSIGNMENTS_SUBCOLLECTION)\n .add({\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n basisAtMs: decision.atMs,\n assertedByUid: decision.byUid,\n addedByUid: context.uid,\n addedAtMs: nowMs,\n createdAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n enrolled: true,\n to: context.email,\n listId,\n listName: String(listSnapshot.get('name') ?? listId),\n memberId: enrollment.memberId,\n basis: decision.basis,\n })\n } catch (error) {\n console.error('[inbox] list assignment failed', error)\n return res.status(500).json({ error: 'The list assignment failed.' })\n }\n}\n\n/**\n * Console API registration.\n *\n * None of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts`. Each is reached by a person pressing a button in\n * a browser, so the visitor limiter's per-(site, IP) budget is far above any\n * real use of them and is the right ceiling for surfaces that put mail on the\n * wire or a person into a marketing audience.\n */\nexport function registerInboxConsoleApi(): void {\n registerPluginApiRoute('inbox/reply', inboxReplyHandler)\n registerPluginApiRoute('inbox/list-options', inboxListOptionsHandler)\n registerPluginApiRoute('inbox/assign-list', inboxAssignListHandler)\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","assignmentReadout","grantEntriesAsRecorded","isOrgWideMember","readMarketingBasis","registerPluginApiRoute","resolveBrandingProfile","emailSuppressionKey","enrollListMember","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","resolveOrgMembership","findContactByEmail","readRetainedRefusals","refusalsOf","withRetainedRefusals","consentGroupOptOutHosts","sendEmail","FieldValue","REPLY_BODY_MAX","REPLY_SUBJECT_MAX","composeReplyBody","replyRecipient","REPLIES_SUBCOLLECTION","REPLY_CONTEXT","REFUSAL_MESSAGES","addressSuppression","hostId","email","firestore","group","hostIds","key","db","app","docs","Promise","all","map","id","collection","doc","get","some","exists","inboxReplyHandler","req","res","method","status","json","error","String","body","submissionId","subject","trim","slice","message","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","decoded","submission","auth","verifyIdToken","hostRef","memberRole","uid","replyTo","submissionRef","fields","recipient","refusal","reason","owner","catch","suppressed","org","siteName","branding","result","to","text","fromName","sendingIdentity","audience","context","sent","sentAtMs","Date","now","replyRef","add","sentByUid","providerMessageId","createdAt","serverTimestamp","set","read","repliedAtMs","merge","replyId","console","LIST_ASSIGNMENTS_SUBCOLLECTION","ASSIGNMENT_SOURCE","LIST_OPTIONS_LIMIT","resolveAssignmentContext","ok","orgId","membership","member","orgWideWriter","role","orgSuspended","storedConsentForAddress","contacts","found","retained","data","values","stored","grants","basis","assertedBy","source","basisAtMs","capturedAtMs","inboxListOptionsHandler","suppression","readout","listsRef","snapshot","orderBy","FieldPath","documentId","limit","lists","name","listsTruncated","inboxAssignListHandler","listId","attested","attestConsent","listSnapshot","refuse","listRef","nowMs","decision","actingUid","enrollment","via","consent","grantEntries","enrolled","listName","memberId","atMs","assertedByUid","byUid","addedByUid","addedAtMs","registerInboxConsoleApi"],"mappings":"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"}
|