@aglyn/tenant-runtime 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +26 -0
- package/package.json +39 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +22 -0
- package/src/index.js.map +1 -0
- package/src/lib/apply-publish-schedule.d.ts +34 -0
- package/src/lib/apply-publish-schedule.js +285 -0
- package/src/lib/apply-publish-schedule.js.map +1 -0
- package/src/lib/assign-contact-owner.d.ts +162 -0
- package/src/lib/assign-contact-owner.js +322 -0
- package/src/lib/assign-contact-owner.js.map +1 -0
- package/src/lib/associate-company-by-domain.d.ts +70 -0
- package/src/lib/associate-company-by-domain.js +150 -0
- package/src/lib/associate-company-by-domain.js.map +1 -0
- package/src/lib/author-page-nodes.d.ts +36 -0
- package/src/lib/author-page-nodes.js +296 -0
- package/src/lib/author-page-nodes.js.map +1 -0
- package/src/lib/built-in-page-layout.d.ts +48 -0
- package/src/lib/built-in-page-layout.js +107 -0
- package/src/lib/built-in-page-layout.js.map +1 -0
- package/src/lib/capture-host-contact.d.ts +95 -0
- package/src/lib/capture-host-contact.js +137 -0
- package/src/lib/capture-host-contact.js.map +1 -0
- package/src/lib/collection-fallback-nodes.d.ts +90 -0
- package/src/lib/collection-fallback-nodes.js +602 -0
- package/src/lib/collection-fallback-nodes.js.map +1 -0
- package/src/lib/compose-author-page.d.ts +109 -0
- package/src/lib/compose-author-page.js +225 -0
- package/src/lib/compose-author-page.js.map +1 -0
- package/src/lib/compose-collection-page.d.ts +94 -0
- package/src/lib/compose-collection-page.js +256 -0
- package/src/lib/compose-collection-page.js.map +1 -0
- package/src/lib/compose-screen-nodes.d.ts +156 -0
- package/src/lib/compose-screen-nodes.js +547 -0
- package/src/lib/compose-screen-nodes.js.map +1 -0
- package/src/lib/compose-search-page.d.ts +39 -0
- package/src/lib/compose-search-page.js +57 -0
- package/src/lib/compose-search-page.js.map +1 -0
- package/src/lib/convert-host-lead.d.ts +189 -0
- package/src/lib/convert-host-lead.js +443 -0
- package/src/lib/convert-host-lead.js.map +1 -0
- package/src/lib/defer-lazy-panels.d.ts +58 -0
- package/src/lib/defer-lazy-panels.js +171 -0
- package/src/lib/defer-lazy-panels.js.map +1 -0
- package/src/lib/element-animation-assets.d.ts +62 -0
- package/src/lib/element-animation-assets.js +290 -0
- package/src/lib/element-animation-assets.js.map +1 -0
- package/src/lib/emit-host-event.d.ts +29 -0
- package/src/lib/emit-host-event.js +30 -0
- package/src/lib/emit-host-event.js.map +1 -0
- package/src/lib/enrich-gated-page.d.ts +67 -0
- package/src/lib/enrich-gated-page.js +134 -0
- package/src/lib/enrich-gated-page.js.map +1 -0
- package/src/lib/entry-link-routes.d.ts +61 -0
- package/src/lib/entry-link-routes.js +169 -0
- package/src/lib/entry-link-routes.js.map +1 -0
- package/src/lib/get-author-content.d.ts +119 -0
- package/src/lib/get-author-content.js +288 -0
- package/src/lib/get-author-content.js.map +1 -0
- package/src/lib/get-collection-content.d.ts +290 -0
- package/src/lib/get-collection-content.js +565 -0
- package/src/lib/get-collection-content.js.map +1 -0
- package/src/lib/get-components.d.ts +24 -0
- package/src/lib/get-components.js +102 -0
- package/src/lib/get-components.js.map +1 -0
- package/src/lib/get-datasets.d.ts +45 -0
- package/src/lib/get-datasets.js +169 -0
- package/src/lib/get-datasets.js.map +1 -0
- package/src/lib/get-forms.d.ts +24 -0
- package/src/lib/get-forms.js +116 -0
- package/src/lib/get-forms.js.map +1 -0
- package/src/lib/get-layout-version.d.ts +26 -0
- package/src/lib/get-layout-version.js +99 -0
- package/src/lib/get-layout-version.js.map +1 -0
- package/src/lib/get-media-asset-facts.d.ts +74 -0
- package/src/lib/get-media-asset-facts.js +116 -0
- package/src/lib/get-media-asset-facts.js.map +1 -0
- package/src/lib/get-plugin-installs.d.ts +29 -0
- package/src/lib/get-plugin-installs.js +94 -0
- package/src/lib/get-plugin-installs.js.map +1 -0
- package/src/lib/get-screen-version.d.ts +27 -0
- package/src/lib/get-screen-version.js +66 -0
- package/src/lib/get-screen-version.js.map +1 -0
- package/src/lib/get-screen.d.ts +39 -0
- package/src/lib/get-screen.js +126 -0
- package/src/lib/get-screen.js.map +1 -0
- package/src/lib/get-variables.d.ts +41 -0
- package/src/lib/get-variables.js +128 -0
- package/src/lib/get-variables.js.map +1 -0
- package/src/lib/get-video-asset-facts.d.ts +22 -0
- package/src/lib/get-video-asset-facts.js +22 -0
- package/src/lib/get-video-asset-facts.js.map +1 -0
- package/src/lib/host-event-listeners.d.ts +83 -0
- package/src/lib/host-event-listeners.js +91 -0
- package/src/lib/host-event-listeners.js.map +1 -0
- package/src/lib/org-permissions.d.ts +63 -0
- package/src/lib/org-permissions.js +187 -0
- package/src/lib/org-permissions.js.map +1 -0
- package/src/lib/repeat-record-pages.d.ts +48 -0
- package/src/lib/repeat-record-pages.js +64 -0
- package/src/lib/repeat-record-pages.js.map +1 -0
- package/src/lib/required-site-plugins.d.ts +114 -0
- package/src/lib/required-site-plugins.js +121 -0
- package/src/lib/required-site-plugins.js.map +1 -0
- package/src/lib/resolve-dataset.d.ts +40 -0
- package/src/lib/resolve-dataset.js +64 -0
- package/src/lib/resolve-dataset.js.map +1 -0
- package/src/lib/search-results-nodes.d.ts +48 -0
- package/src/lib/search-results-nodes.js +302 -0
- package/src/lib/search-results-nodes.js.map +1 -0
- package/src/lib/social-image-facts.d.ts +107 -0
- package/src/lib/social-image-facts.js +133 -0
- package/src/lib/social-image-facts.js.map +1 -0
- package/src/lib/stamp-form-dataset-bindings.d.ts +32 -0
- package/src/lib/stamp-form-dataset-bindings.js +59 -0
- package/src/lib/stamp-form-dataset-bindings.js.map +1 -0
- package/src/lib/template-screens.d.ts +227 -0
- package/src/lib/template-screens.js +364 -0
- package/src/lib/template-screens.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/apply-publish-schedule.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 type * as Aglyn from '@aglyn/aglyn/server'\nimport {\n checkEntitlement,\n composeScreenRoutePath,\n findScreenIdByRoutePath,\n SCREEN_KIND_EMAIL,\n} from '@aglyn/aglyn/server'\n// Deep import, like the Measurement Protocol sender's: `analytics-events.ts`\n// is deliberately DOM-free so both sides of the publish path can share the\n// one definition of `first_publish` (AGL-1588).\nimport { isFirstPublishedRoute } from '@aglyn/aglyn/app-utils/analytics-events'\nimport {\n firebaseAdmin,\n getOrgForHost,\n sendGa4SitePublished,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\n\n/** Parent chains deeper than `composeScreenRoutePath` accepts are invalid. */\nconst MAX_ANCESTOR_READS = 32\n\n/**\n * Why a due publish declined to make the screen reachable. Recorded on the\n * schedule as `skipped-unroutable` — see {@link resolveScheduledRoutePath}.\n */\ntype RouteRefusal = 'not-a-page' | 'no-path' | 'path-taken'\n\n/**\n * The routing-map path a due publish should register for a screen that has\n * no entry yet, or the reason it must not get one (AGL-1589).\n *\n * Reads the screen and walks its ancestor chain (one doc per level, bounded)\n * because the composed path is `parent segments + own slug` and the executor\n * is handed only the version pointer and the schedule. Runs at most once per\n * schedule that actually comes due, and never for the common case — a screen\n * whose route is already live is answered from the host document alone.\n *\n * The three refusals are all \"this cannot become an address\", and all three\n * are silent no-ops if left unrecorded, which is the bug this issue is about:\n *\n * - `not-a-page` — an email document (AGL-1383). It has no URL, the serve\n * path refuses to render it, and a routing entry would make it BILLABLE\n * against `screensPerHost` without making it reachable. `kind: 'template'`\n * is deliberately NOT refused: a collection entry template is routed on\n * purpose, which is how the compose pipeline picks it up (AGL-1400).\n * - `no-path` — the screen or an ancestor has no slug, so there is no address\n * to publish at.\n * - `path-taken` — another screen already holds the address. The interactive\n * path refuses this with a message (\"Another screen is already published at\n * …\"); registering it anyway would put two screens at one path and could\n * take a LIVE page off the site, which is a worse bug than the one being\n * fixed here.\n */\nasync function resolveScheduledRoutePath(options: {\n hostRef: FirebaseFirestore.DocumentReference\n screenId: string\n routing: Record<string, string>\n}): Promise<{ path: string } | { refused: RouteRefusal }> {\n const { hostRef, screenId, routing } = options\n const screens = hostRef.collection('screens')\n const screensById: Record<string, Aglyn.ScreenRouteNode | undefined> = {}\n\n let currentId: string | undefined = screenId\n for (let depth = 0; currentId && depth < MAX_ANCESTOR_READS; depth += 1) {\n if (screensById[currentId]) break // cycle — composeScreenRoutePath refuses\n const snapshot = await screens.doc(currentId).get()\n if (!snapshot.exists) break\n if (currentId === screenId) {\n if (\n snapshot.get('deletedAt') != null ||\n snapshot.get('kind') === SCREEN_KIND_EMAIL\n ) {\n return { refused: 'not-a-page' }\n }\n }\n screensById[currentId] = {\n slug: snapshot.get('slug'),\n parentId: snapshot.get('parentId'),\n }\n currentId = snapshot.get('parentId')\n }\n\n const path = composeScreenRoutePath(screenId, screensById)\n if (!path) return { refused: 'no-path' }\n const owner = findScreenIdByRoutePath(routing, path)\n if (owner && owner !== screenId) return { refused: 'path-taken' }\n return { path }\n}\n\n/**\n * Lazy scheduled-publishing executor (AGL-61): if the doc carries a pending\n * `publishSchedule` whose time has passed, flip the `versionId` pointer,\n * register the screen's routing entry when the route is not live yet\n * (AGL-1589), and mark the schedule applied. Runs during ISR revalidation,\n * so a schedule\n * takes effect on the first regeneration after its time (within the\n * revalidate window) — no dedicated cron needed. Returns the effective\n * versionId; fail-open on write errors (the pointer flips next revalidate).\n */\nexport async function applyDuePublishSchedule(options: {\n hostId: Aglyn.HostUid\n collectionName: 'screens' | 'layouts'\n docId: string\n parent: Pick<Aglyn.AglynScreen, 'versionId' | 'publishSchedule'>\n}): Promise<Aglyn.VersionUid | undefined> {\n const { hostId, collectionName, docId, parent } = options\n const schedule = parent.publishSchedule\n const publishAtMs = (schedule?.publishAt?.seconds ?? 0) * 1000\n if (schedule?.status !== 'pending' || publishAtMs > Date.now()) {\n return parent.versionId\n }\n\n // Plan gate (AGL-471): the console gates *writing* a schedule on the\n // scheduledPublishing entitlement, but this executor is the authority —\n // a schedule written directly to Firestore must not auto-publish on an\n // unentitled plan.\n const org = (await getOrgForHost(hostId))?.org ?? {}\n if (!checkEntitlement(org, 'scheduledPublishing')) {\n // Record the refusal instead of leaving it pending (AGL-1185).\n //\n // The refusal itself is correct and unchanged. What was wrong is that it\n // left no trace: the schedule stayed `pending` and permanently due, so the\n // moment the org upgraded to Business the next beat — every minute since\n // AGL-1159 — published it. Content scheduled on a plan that could not\n // honour it, and forgotten, would surface during an upgrade, which is\n // exactly when nobody is looking for it.\n //\n // Safe to write here because the dueness check above has already passed, so\n // this can only ever mark a schedule that came due and was declined. A\n // future-dated schedule is returned long before this point and is untouched.\n //\n // Terminal on purpose: upgrading does not resurrect it. A schedule whose\n // time passed while unentitled is stale by the time the plan changes, and\n // re-running it is the surprise this removes. Rescheduling is a deliberate\n // act, and the console can now say why the original never ran.\n //\n // Fail-open like every other write here — a failure leaves it pending,\n // which is today's behaviour, and the next beat retries.\n try {\n await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(collectionName)\n .doc(docId)\n .update({ 'publishSchedule.status': 'skipped-unentitled' })\n } catch (error) {\n console.error(error)\n }\n return parent.versionId\n }\n\n // Scheduled unpublish (AGL-113, screens only): drop the routing-map entry\n // so the path 404s on the next revalidate. This render still serves the\n // current version — the map is matched before this runs.\n if (schedule.action === 'unpublish') {\n if (collectionName === 'screens') {\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n await Promise.all([\n hostRef.update({\n [`screens.${docId}`]: FieldValue.delete(),\n }),\n hostRef.collection(collectionName).doc(docId).update({\n 'publishSchedule.status': 'applied',\n // Route no longer live — drop the published date to match the\n // interactive unpublish path (constants/screen-publishing.ts).\n publishedAt: FieldValue.delete(),\n }),\n ])\n } catch (error) {\n console.error(error)\n }\n }\n return parent.versionId\n }\n\n if (!schedule.versionId) return parent.versionId\n\n // Register the routing entry, not just the version pointer (AGL-1589).\n //\n // Until this, a due publish wrote `versionId` + `status: 'applied'` and\n // nothing else — never `hosts/{hostId}.screens.{screenId}`, the map the\n // tenant matches request paths against. A REPUBLISH was therefore correct\n // (the entry already existed, and only the version needed swapping) and a\n // FIRST publish silently was not: the schedule reported success, the\n // activity log said published, and the URL kept 404ing. Scheduled\n // publishing is a Business feature, so that was a paying customer's page.\n //\n // Mirrors `publishScreenRoute` (constants/screen-publishing.ts): the\n // composed path into the host's map, `publishedAt` stamped on the screen.\n // The stored `slug` is not rewritten — the path was composed FROM it, so\n // there is nothing to change.\n //\n // Screens only. A layout has no address of its own, exactly as the\n // unpublish branch above is screens-only.\n //\n // In practice the CRON BEAT is the only caller that reaches a first publish:\n // the lazy ISR path resolves a request path through the routing map before\n // it ever loads a screen, so an unrouted screen 404s without its schedule\n // being read. That is the same map this now writes — which is why the bug\n // could not heal itself on the next visit.\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const docRef = hostRef.collection(collectionName).doc(docId)\n\n let routePath: string | undefined\n // Decided from the routing map as read below — BEFORE the batch registers\n // this entry (AGL-1588). Left undefined outside the screens branch, where\n // no route is going live and no `site_published` is sent at all.\n let firstPublish: boolean | undefined\n if (collectionName === 'screens') {\n try {\n const routing = ((await hostRef.get()).get('screens') ?? {}) as Record<\n string,\n string\n >\n // An existing entry is the republish case: the route is already live,\n // this only swaps which version it serves, and there is nothing to\n // register and no activation to report.\n if (!routing[docId]) {\n // The same predicate the console's three publish surfaces use, so a\n // `first_publish` breakdown means one thing across all four senders.\n firstPublish = isFirstPublishedRoute(routing)\n const resolved = await resolveScheduledRoutePath({\n hostRef,\n screenId: docId,\n routing,\n })\n if ('refused' in resolved) {\n // Record the refusal rather than publishing a pointer at a page\n // nobody can reach — the AGL-1185 posture, for the same reason: an\n // unrecorded decline is indistinguishable from success, and leaving\n // it `pending` would re-attempt it on every beat forever. Terminal:\n // fixing the address is a deliberate act in the console, and\n // rescheduling is part of it.\n try {\n await docRef.update({\n 'publishSchedule.status': 'skipped-unroutable',\n })\n } catch (error) {\n console.error(error)\n }\n return parent.versionId\n }\n routePath = resolved.path\n }\n } catch (error) {\n // Fail-open like every other write here: leave the schedule pending and\n // let the next beat (or revalidate) retry. Publishing the pointer while\n // the routing question is unanswered is the failure being fixed.\n console.error(error)\n return parent.versionId\n }\n }\n\n try {\n const applied = {\n versionId: schedule.versionId,\n 'publishSchedule.status': 'applied',\n }\n if (routePath) {\n // ONE atomic commit, and the order matters more than the write count:\n // a status of `applied` with no routing entry is permanent (nothing\n // retries a terminal status), so the entry and the status must land\n // together or not at all. A failed commit leaves the schedule pending\n // and the next beat runs it again.\n const batch = firestore.batch()\n batch.update(hostRef, { [`screens.${docId}`]: routePath })\n batch.update(docRef, { ...applied, publishedAt: Timestamp.now() })\n await batch.commit()\n\n // A route just went live with no browser anywhere near it, which is\n // precisely what the client-side `site_published` in\n // `publishScreenRoute` cannot see (AGL-1562, AGL-1589). Awaited, not\n // floated: this render/beat is the only thing keeping the process\n // alive. It never throws, and it returns immediately when the\n // Measurement Protocol is not configured — the normal state on a\n // self-hosted deployment and in development.\n //\n // ⚠️ NOT the state of our own tenant app any more. This comment used to\n // say GA4_MEASUREMENT_ID / GA4_API_SECRET were unset on the\n // aglyn-tenant Vercel project; both landed on 2026-08-17 12:15 UTC and\n // the sender is LIVE on this path. The stale version of this note is\n // part of what made a 2026-08-19 smoke pass conclude every server-side\n // event was dead (AGL-2327). See docs/ANALYTICS.md.\n await sendGa4SitePublished({ hostId, firstPublish })\n } else {\n await docRef.update(applied)\n }\n } catch (error) {\n console.error(error)\n }\n // Serve the scheduled version for this render either way — the schedule\n // is due, and the write (or its retry next revalidate) makes it durable.\n return schedule.versionId\n}\n\nexport default applyDuePublishSchedule\n"],"names":["checkEntitlement","composeScreenRoutePath","findScreenIdByRoutePath","SCREEN_KIND_EMAIL","isFirstPublishedRoute","firebaseAdmin","getOrgForHost","sendGa4SitePublished","FieldValue","Timestamp","MAX_ANCESTOR_READS","resolveScheduledRoutePath","options","hostRef","screenId","routing","screens","collection","screensById","currentId","depth","snapshot","doc","get","exists","refused","slug","parentId","path","owner","applyDuePublishSchedule","schedule","hostId","collectionName","docId","parent","publishSchedule","publishAtMs","publishAt","seconds","status","Date","now","versionId","org","app","firestore","update","error","console","action","Promise","all","delete","publishedAt","docRef","routePath","firstPublish","resolved","applied","batch","commit"],"mappings":"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"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type ContactSource, type CrmAssignmentTarget } from '@aglyn/aglyn/server';
|
|
18
|
+
/**
|
|
19
|
+
* WHO A NEW RECORD BELONGS TO — the one place an owner is decided on the
|
|
20
|
+
* server (AGL-2618).
|
|
21
|
+
*
|
|
22
|
+
* Two entry points over one transaction. `assignOwnerForCapture` is the
|
|
23
|
+
* capture door's pass: it runs the org's assignment rules in order, falls
|
|
24
|
+
* back to the capturing site's default owner, and touches only a record
|
|
25
|
+
* that has NO owner — a door that named one (the console's drawer, an
|
|
26
|
+
* import column, a conversion with a picked owner) has expressed a person's
|
|
27
|
+
* choice. `reassignContactOwner` is the automation's: an author who put
|
|
28
|
+
* "assign an owner" on a stage change means to reassign, and the step hands
|
|
29
|
+
* this either a member or the rotation.
|
|
30
|
+
*
|
|
31
|
+
* ## One transaction, because the pointer is shared
|
|
32
|
+
*
|
|
33
|
+
* The round-robin pool's pointer lives on the org document, and two
|
|
34
|
+
* captures landing together must take two different members. Firestore
|
|
35
|
+
* transactions serialize on what they read, so the org document, the
|
|
36
|
+
* contact and the roster documents are all read inside the transaction and
|
|
37
|
+
* the pointer is advanced in the same commit that writes the owner: a
|
|
38
|
+
* concurrent capture that read the same pointer retries and reads the moved
|
|
39
|
+
* one. The alternative — read the pointer, decide, then write — is exactly
|
|
40
|
+
* the read-then-write shape that lets two captures pick the same person.
|
|
41
|
+
*
|
|
42
|
+
* ## The roster is checked, once per member per attempt
|
|
43
|
+
*
|
|
44
|
+
* A rule may name a member who has since left, and a pool may hold one. A
|
|
45
|
+
* rule naming nobody on the roster is passed over rather than stopping the
|
|
46
|
+
* pass — the next rule may well assign — and a pool member who is gone is
|
|
47
|
+
* skipped for the next in the order. `orgs/{orgId}/members/{uid}` is keyed
|
|
48
|
+
* by uid, so the check is one document read per candidate, and only the
|
|
49
|
+
* roster is consulted: a project-level Auth lookup would admit people who
|
|
50
|
+
* are not on this organization at all (AGL-1122).
|
|
51
|
+
*
|
|
52
|
+
* ## The lead follows the contact
|
|
53
|
+
*
|
|
54
|
+
* The capture doors that file a lead (`addHostLead`: a form routed to leads,
|
|
55
|
+
* a sign-up, a booking) key it by `personKey(email)`, so the lead's document
|
|
56
|
+
* is addressable from the capture alone. When it exists it is given the same
|
|
57
|
+
* owner in the same transaction; two doors write the lead AFTER the contact
|
|
58
|
+
* they `void`ed, so for those sources the mirror is retried a few times over
|
|
59
|
+
* the next seconds, detached, and the person's Leads list shows the owner
|
|
60
|
+
* as soon as the lead is there.
|
|
61
|
+
*
|
|
62
|
+
* ## The owner is told, unless they chose themselves
|
|
63
|
+
*
|
|
64
|
+
* One console notification per assignment, for the record the recipient
|
|
65
|
+
* will open: the lead when the site holds one for this person, the contact
|
|
66
|
+
* otherwise. Not when the actor IS the new owner — a rep who converts a
|
|
67
|
+
* lead into their own contact has not been handed anything.
|
|
68
|
+
*
|
|
69
|
+
* ## Never rejects
|
|
70
|
+
*
|
|
71
|
+
* The capture has already succeeded and a form submission must not fail
|
|
72
|
+
* because the CRM could not decide who follows up; a failure is logged and
|
|
73
|
+
* answered as `none`, and the record is exactly as assignable by hand as it
|
|
74
|
+
* was before this existed. The automation executor turns a `none` into the
|
|
75
|
+
* step's recorded error.
|
|
76
|
+
*/
|
|
77
|
+
export interface OwnerAssignmentInput {
|
|
78
|
+
hostId: string;
|
|
79
|
+
/** `orgs/{orgId}/contacts/{contactId}` — the record the owner is written on. */
|
|
80
|
+
contactId: string;
|
|
81
|
+
/** The contact's address, which keys the lead and names the person in the notification. */
|
|
82
|
+
email: string;
|
|
83
|
+
/**
|
|
84
|
+
* The signed-in person making the assignment, when there is one. Never
|
|
85
|
+
* notified about their own choice; absent for a capture and an automation.
|
|
86
|
+
*/
|
|
87
|
+
actorUid?: string | null;
|
|
88
|
+
}
|
|
89
|
+
export interface CaptureAssignmentInput extends OwnerAssignmentInput {
|
|
90
|
+
source: ContactSource;
|
|
91
|
+
/** The form the capture came through, for a rule conditioned on it. */
|
|
92
|
+
formId?: string | null;
|
|
93
|
+
/** The tags the capture carried; the contact's own are read from the facet. */
|
|
94
|
+
tags?: readonly string[];
|
|
95
|
+
}
|
|
96
|
+
export interface ReassignmentInput extends OwnerAssignmentInput {
|
|
97
|
+
assign: CrmAssignmentTarget;
|
|
98
|
+
}
|
|
99
|
+
/** Why an assignment assigned nobody — the automation reports these verbatim. */
|
|
100
|
+
export type OwnerAssignmentRefusal = 'no-org' | 'no-contact' | 'no-rule' | 'empty-pool' | 'not-a-member' | 'failed';
|
|
101
|
+
/** What decided an assignment: a rule, the site default, the step's member, or the rotation. */
|
|
102
|
+
export type OwnerAssignedBy = 'rule' | 'default' | 'member' | 'roundRobin';
|
|
103
|
+
export type OwnerAssignment = {
|
|
104
|
+
outcome: 'assigned';
|
|
105
|
+
ownerUid: string;
|
|
106
|
+
by: OwnerAssignedBy;
|
|
107
|
+
/** The rule that decided it, when one did. */
|
|
108
|
+
ruleId?: string;
|
|
109
|
+
/** The lead document existed and now carries the owner too. */
|
|
110
|
+
leadMirrored: boolean;
|
|
111
|
+
/** A notification was fanned out to the new owner. */
|
|
112
|
+
notified: boolean;
|
|
113
|
+
}
|
|
114
|
+
/** The record already had this owner (a capture), or the same one was named again (a reassignment). */
|
|
115
|
+
| {
|
|
116
|
+
outcome: 'unchanged';
|
|
117
|
+
ownerUid: string;
|
|
118
|
+
} | {
|
|
119
|
+
outcome: 'none';
|
|
120
|
+
reason: OwnerAssignmentRefusal;
|
|
121
|
+
};
|
|
122
|
+
/** The words the run history and the log give each refusal. */
|
|
123
|
+
export declare const OWNER_ASSIGNMENT_REFUSALS: Record<OwnerAssignmentRefusal, string>;
|
|
124
|
+
/** The record an assignment notification opens. */
|
|
125
|
+
export type AssignedRecord = {
|
|
126
|
+
kind: 'contact';
|
|
127
|
+
id: string;
|
|
128
|
+
} | {
|
|
129
|
+
kind: 'lead';
|
|
130
|
+
id: string;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Tells a member a record is now theirs — one console notification, typed
|
|
134
|
+
* by the record it opens — unless the member handed it to themselves.
|
|
135
|
+
* Shared by the pass above and by the routes where a person picks the
|
|
136
|
+
* owner outright (a lead conversion naming a colleague), so every server
|
|
137
|
+
* path that writes `ownerUid` says so in the same words. Never throws;
|
|
138
|
+
* the fan-out swallows its own failures.
|
|
139
|
+
*/
|
|
140
|
+
export declare function notifyRecordAssigned(input: {
|
|
141
|
+
hostId: string;
|
|
142
|
+
orgId: string;
|
|
143
|
+
ownerUid: string;
|
|
144
|
+
actorUid?: string | null;
|
|
145
|
+
record: AssignedRecord;
|
|
146
|
+
/** How the person reads in the message: their name, else their address. */
|
|
147
|
+
who: string;
|
|
148
|
+
}): Promise<boolean>;
|
|
149
|
+
/**
|
|
150
|
+
* The capture door's pass: the org's rules in order, then the capturing
|
|
151
|
+
* site's default owner, on a record that has no owner yet. See the module
|
|
152
|
+
* comment for the whole of it.
|
|
153
|
+
*/
|
|
154
|
+
export declare function assignOwnerForCapture(input: CaptureAssignmentInput): Promise<OwnerAssignment>;
|
|
155
|
+
/**
|
|
156
|
+
* The deliberate assignment — the `assignContactOwner` step, and a person
|
|
157
|
+
* handing a record to somebody: a member by uid (verified on the roster) or
|
|
158
|
+
* the next in the round-robin pool, overwriting whoever held it. Naming the
|
|
159
|
+
* owner the record already has changes nothing and tells nobody.
|
|
160
|
+
*/
|
|
161
|
+
export declare function reassignContactOwner(input: ReassignmentInput): Promise<OwnerAssignment>;
|
|
162
|
+
export default assignOwnerForCapture;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { assignmentRuleMatches, consentGroupForHost, contactFacetPath, CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH, personKey, readContactFacet, readCrmAssignmentSettings, roundRobinOrder } from "@aglyn/aglyn/server";
|
|
18
|
+
import { firebaseAdmin, getOrgForHost, notifyUsers } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
20
|
+
/** The words the run history and the log give each refusal. */ export const OWNER_ASSIGNMENT_REFUSALS = {
|
|
21
|
+
'no-org': 'this site has no organization',
|
|
22
|
+
'no-contact': 'the contact no longer exists',
|
|
23
|
+
'no-rule': 'no assignment rule matched and the site has no default owner',
|
|
24
|
+
'empty-pool': 'the round-robin pool has nobody on the roster in it',
|
|
25
|
+
'not-a-member': 'the owner named is not on the team',
|
|
26
|
+
failed: 'the owner could not be assigned'
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The doors whose lead lands after the contact they `void`ed — the ones the
|
|
30
|
+
* mirror is worth retrying for. An order, an import, a manual add and an
|
|
31
|
+
* API call file no lead, and a retry for them would be reads for nothing.
|
|
32
|
+
*/ const LEAD_FILING_SOURCES = new Set([
|
|
33
|
+
'form',
|
|
34
|
+
'member',
|
|
35
|
+
'booking'
|
|
36
|
+
]);
|
|
37
|
+
/** How long the detached lead mirror waits between its looks, in order. */ const LEAD_MIRROR_RETRY_MS = [
|
|
38
|
+
1000,
|
|
39
|
+
2000,
|
|
40
|
+
3000
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* The console path the owner's notification opens, in the `/{hostDocId}/rest`
|
|
44
|
+
* shape every host notification uses — the console rewrites it to
|
|
45
|
+
* `/{orgSlug}/hosts/{subdomain}/rest` when the link is followed, which is
|
|
46
|
+
* why `hostId` and `orgId` travel on the document. The same spelling the
|
|
47
|
+
* CRM's task notification uses; the hub's own route builder lives in the
|
|
48
|
+
* plugin, which this layer cannot import.
|
|
49
|
+
*/ function recordLink(hostId, record) {
|
|
50
|
+
const section = record.kind === 'contact' ? 'contacts' : 'leads';
|
|
51
|
+
return `/${hostId}/crm/${section}/${encodeURIComponent(record.id)}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Tells a member a record is now theirs — one console notification, typed
|
|
55
|
+
* by the record it opens — unless the member handed it to themselves.
|
|
56
|
+
* Shared by the pass above and by the routes where a person picks the
|
|
57
|
+
* owner outright (a lead conversion naming a colleague), so every server
|
|
58
|
+
* path that writes `ownerUid` says so in the same words. Never throws;
|
|
59
|
+
* the fan-out swallows its own failures.
|
|
60
|
+
*/ export async function notifyRecordAssigned(input) {
|
|
61
|
+
var _input_actorUid;
|
|
62
|
+
if (!input.ownerUid || input.ownerUid === ((_input_actorUid = input.actorUid) != null ? _input_actorUid : '')) return false;
|
|
63
|
+
const lead = input.record.kind === 'lead';
|
|
64
|
+
await notifyUsers([
|
|
65
|
+
input.ownerUid
|
|
66
|
+
], {
|
|
67
|
+
type: lead ? 'content.leadAssigned' : 'content.contactAssigned',
|
|
68
|
+
title: lead ? 'Lead assigned to you' : 'Contact assigned to you',
|
|
69
|
+
body: input.who,
|
|
70
|
+
link: recordLink(input.hostId, input.record),
|
|
71
|
+
orgId: input.orgId,
|
|
72
|
+
hostId: input.hostId
|
|
73
|
+
});
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
async function assignOwner(policy) {
|
|
77
|
+
const { input } = policy;
|
|
78
|
+
try {
|
|
79
|
+
const resolved = await getOrgForHost(input.hostId);
|
|
80
|
+
if (!resolved) return {
|
|
81
|
+
outcome: 'none',
|
|
82
|
+
reason: 'no-org'
|
|
83
|
+
};
|
|
84
|
+
const { orgId } = resolved;
|
|
85
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
86
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
87
|
+
const contactRef = orgRef.collection('contacts').doc(input.contactId);
|
|
88
|
+
const membersRef = orgRef.collection('members');
|
|
89
|
+
const leadKey = personKey(input.email);
|
|
90
|
+
const leadRef = leadKey ? firestore.collection('hosts').doc(input.hostId).collection('leads').doc(leadKey) : null;
|
|
91
|
+
let contactName = '';
|
|
92
|
+
const verdict = await firestore.runTransaction(async (tx)=>{
|
|
93
|
+
var _orgSnapshot_data, _contactSnapshot_data, _contact_name, _ref;
|
|
94
|
+
/*
|
|
95
|
+
* ALL READS BEFORE THE WRITE, which Firestore requires. The org
|
|
96
|
+
* document is read here rather than trusted from `getOrgForHost`,
|
|
97
|
+
* because the pointer it carries is the one thing a concurrent
|
|
98
|
+
* capture may have moved, and the rules and the pool are read from
|
|
99
|
+
* the same snapshot so the decision and the pointer agree.
|
|
100
|
+
*/ const [orgSnapshot, contactSnapshot, leadSnapshot] = await Promise.all([
|
|
101
|
+
tx.get(orgRef),
|
|
102
|
+
tx.get(contactRef),
|
|
103
|
+
leadRef ? tx.get(leadRef) : Promise.resolve(null)
|
|
104
|
+
]);
|
|
105
|
+
if (!contactSnapshot.exists) {
|
|
106
|
+
return {
|
|
107
|
+
outcome: 'none',
|
|
108
|
+
reason: 'no-contact'
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const org = (_orgSnapshot_data = orgSnapshot.data()) != null ? _orgSnapshot_data : {};
|
|
112
|
+
const contact = (_contactSnapshot_data = contactSnapshot.data()) != null ? _contactSnapshot_data : {};
|
|
113
|
+
contactName = String((_contact_name = contact['name']) != null ? _contact_name : '');
|
|
114
|
+
const group = consentGroupForHost(org, input.hostId);
|
|
115
|
+
const facet = readContactFacet(contact, group.groupId);
|
|
116
|
+
const current = typeof facet.ownerUid === 'string' ? facet.ownerUid : '';
|
|
117
|
+
if (policy.kind === 'capture' && current) {
|
|
118
|
+
return {
|
|
119
|
+
outcome: 'unchanged',
|
|
120
|
+
ownerUid: current
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const settings = readCrmAssignmentSettings(org);
|
|
124
|
+
// One read per candidate per attempt; a retried transaction reads
|
|
125
|
+
// afresh, as it must.
|
|
126
|
+
const onRoster = new Map();
|
|
127
|
+
const isMember = (uid)=>{
|
|
128
|
+
let known = onRoster.get(uid);
|
|
129
|
+
if (!known) {
|
|
130
|
+
known = tx.get(membersRef.doc(uid)).then((snapshot)=>snapshot.exists);
|
|
131
|
+
onRoster.set(uid, known);
|
|
132
|
+
}
|
|
133
|
+
return known;
|
|
134
|
+
};
|
|
135
|
+
const fromPool = async ()=>{
|
|
136
|
+
for (const uid of roundRobinOrder(settings.pool.memberUids, settings.pool.lastAssignedUid)){
|
|
137
|
+
if (await isMember(uid)) return uid;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
};
|
|
141
|
+
const resolveTarget = async (target)=>{
|
|
142
|
+
if ('roundRobin' in target) {
|
|
143
|
+
const uid = await fromPool();
|
|
144
|
+
return uid ? {
|
|
145
|
+
uid,
|
|
146
|
+
advancePointer: true
|
|
147
|
+
} : null;
|
|
148
|
+
}
|
|
149
|
+
return await isMember(target.memberUid) ? {
|
|
150
|
+
uid: target.memberUid,
|
|
151
|
+
advancePointer: false
|
|
152
|
+
} : null;
|
|
153
|
+
};
|
|
154
|
+
let decision = null;
|
|
155
|
+
let refusal = 'no-rule';
|
|
156
|
+
if (policy.kind === 'capture') {
|
|
157
|
+
var _policy_input_formId, _policy_input_tags, _facet_tags;
|
|
158
|
+
const capture = {
|
|
159
|
+
source: policy.input.source,
|
|
160
|
+
email: input.email,
|
|
161
|
+
formId: (_policy_input_formId = policy.input.formId) != null ? _policy_input_formId : null,
|
|
162
|
+
tags: [
|
|
163
|
+
...(_policy_input_tags = policy.input.tags) != null ? _policy_input_tags : [],
|
|
164
|
+
...(_facet_tags = facet.tags) != null ? _facet_tags : []
|
|
165
|
+
]
|
|
166
|
+
};
|
|
167
|
+
for (const rule of settings.rules){
|
|
168
|
+
if (!assignmentRuleMatches(rule.when, capture)) continue;
|
|
169
|
+
const hit = await resolveTarget(rule.assign);
|
|
170
|
+
if (hit) {
|
|
171
|
+
decision = _extends({}, hit, {
|
|
172
|
+
ownerUid: hit.uid,
|
|
173
|
+
by: 'rule',
|
|
174
|
+
ruleId: rule.id
|
|
175
|
+
});
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const fallback = settings.hostDefaultOwners[input.hostId];
|
|
180
|
+
if (!decision && fallback && await isMember(fallback)) {
|
|
181
|
+
decision = {
|
|
182
|
+
ownerUid: fallback,
|
|
183
|
+
by: 'default',
|
|
184
|
+
advancePointer: false
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const { assign } = policy.input;
|
|
189
|
+
const hit = await resolveTarget(assign);
|
|
190
|
+
if (hit) {
|
|
191
|
+
decision = _extends({}, hit, {
|
|
192
|
+
ownerUid: hit.uid,
|
|
193
|
+
by: 'roundRobin' in assign ? 'roundRobin' : 'member'
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
refusal = 'roundRobin' in assign ? 'empty-pool' : 'not-a-member';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!decision) return {
|
|
200
|
+
outcome: 'none',
|
|
201
|
+
reason: refusal
|
|
202
|
+
};
|
|
203
|
+
if (decision.ownerUid === current) {
|
|
204
|
+
return {
|
|
205
|
+
outcome: 'unchanged',
|
|
206
|
+
ownerUid: current
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
tx.update(contactRef, {
|
|
210
|
+
[contactFacetPath(group.groupId, 'ownerUid')]: decision.ownerUid,
|
|
211
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
212
|
+
});
|
|
213
|
+
if (decision.advancePointer) {
|
|
214
|
+
tx.update(orgRef, {
|
|
215
|
+
[CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH]: decision.ownerUid
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/*
|
|
219
|
+
* A lead somebody already assigned by hand keeps its owner on a
|
|
220
|
+
* capture — the contact was new, the lead may not have been — and
|
|
221
|
+
* follows the contact on a deliberate reassignment.
|
|
222
|
+
*/ const leadOwner = String((_ref = leadSnapshot == null ? void 0 : leadSnapshot.get('ownerUid')) != null ? _ref : '');
|
|
223
|
+
const leadMirrored = Boolean((leadSnapshot == null ? void 0 : leadSnapshot.exists) && (policy.kind === 'reassign' || !leadOwner));
|
|
224
|
+
if (leadMirrored && leadRef) {
|
|
225
|
+
tx.update(leadRef, {
|
|
226
|
+
ownerUid: decision.ownerUid,
|
|
227
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return _extends({
|
|
231
|
+
outcome: 'assigned',
|
|
232
|
+
ownerUid: decision.ownerUid,
|
|
233
|
+
by: decision.by
|
|
234
|
+
}, decision.ruleId ? {
|
|
235
|
+
ruleId: decision.ruleId
|
|
236
|
+
} : {}, {
|
|
237
|
+
leadMirrored,
|
|
238
|
+
notified: false
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
if (verdict.outcome !== 'assigned') return verdict;
|
|
242
|
+
if (!verdict.leadMirrored && leadRef && policy.kind === 'capture' && LEAD_FILING_SOURCES.has(policy.input.source)) {
|
|
243
|
+
void mirrorOwnerOntoLeadLater(leadRef, verdict.ownerUid);
|
|
244
|
+
}
|
|
245
|
+
const notified = await notifyRecordAssigned({
|
|
246
|
+
hostId: input.hostId,
|
|
247
|
+
orgId,
|
|
248
|
+
ownerUid: verdict.ownerUid,
|
|
249
|
+
actorUid: input.actorUid,
|
|
250
|
+
record: verdict.leadMirrored && leadKey ? {
|
|
251
|
+
kind: 'lead',
|
|
252
|
+
id: leadKey
|
|
253
|
+
} : {
|
|
254
|
+
kind: 'contact',
|
|
255
|
+
id: input.contactId
|
|
256
|
+
},
|
|
257
|
+
who: contactName || input.email
|
|
258
|
+
});
|
|
259
|
+
return _extends({}, verdict, {
|
|
260
|
+
notified
|
|
261
|
+
});
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.error('assignContactOwner failed', input.hostId, input.contactId, error);
|
|
264
|
+
return {
|
|
265
|
+
outcome: 'none',
|
|
266
|
+
reason: 'failed'
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The detached second look for a lead that lands after the contact.
|
|
272
|
+
*
|
|
273
|
+
* Bounded, and only ever ADDS an owner: a lead that arrived with one (a
|
|
274
|
+
* person assigned it in the meantime) is left alone, and a lead that never
|
|
275
|
+
* arrives costs three reads over six seconds and nothing else. Detached
|
|
276
|
+
* because the capture must not hold `contactCreated` back for it — an
|
|
277
|
+
* automation welcoming the contact has nothing to do with the lead's owner
|
|
278
|
+
* column.
|
|
279
|
+
*/ async function mirrorOwnerOntoLeadLater(leadRef, ownerUid, delaysMs = LEAD_MIRROR_RETRY_MS) {
|
|
280
|
+
for (const delay of delaysMs){
|
|
281
|
+
await new Promise((resolve)=>setTimeout(resolve, delay));
|
|
282
|
+
try {
|
|
283
|
+
var _lead_get;
|
|
284
|
+
const lead = await leadRef.get();
|
|
285
|
+
if (!lead.exists) continue;
|
|
286
|
+
if (String((_lead_get = lead.get('ownerUid')) != null ? _lead_get : '')) return false;
|
|
287
|
+
await leadRef.update({
|
|
288
|
+
ownerUid,
|
|
289
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
290
|
+
});
|
|
291
|
+
return true;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error('assignContactOwner lead mirror failed', leadRef.path, error);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The capture door's pass: the org's rules in order, then the capturing
|
|
301
|
+
* site's default owner, on a record that has no owner yet. See the module
|
|
302
|
+
* comment for the whole of it.
|
|
303
|
+
*/ export function assignOwnerForCapture(input) {
|
|
304
|
+
return assignOwner({
|
|
305
|
+
kind: 'capture',
|
|
306
|
+
input
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The deliberate assignment — the `assignContactOwner` step, and a person
|
|
311
|
+
* handing a record to somebody: a member by uid (verified on the roster) or
|
|
312
|
+
* the next in the round-robin pool, overwriting whoever held it. Naming the
|
|
313
|
+
* owner the record already has changes nothing and tells nobody.
|
|
314
|
+
*/ export function reassignContactOwner(input) {
|
|
315
|
+
return assignOwner({
|
|
316
|
+
kind: 'reassign',
|
|
317
|
+
input
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
export default assignOwnerForCapture;
|
|
321
|
+
|
|
322
|
+
//# sourceMappingURL=assign-contact-owner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/assign-contact-owner.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n assignmentRuleMatches,\n consentGroupForHost,\n contactFacetPath,\n type ContactSource,\n CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH,\n type CrmAssignmentTarget,\n personKey,\n readContactFacet,\n readCrmAssignmentSettings,\n roundRobinOrder,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * WHO A NEW RECORD BELONGS TO — the one place an owner is decided on the\n * server (AGL-2618).\n *\n * Two entry points over one transaction. `assignOwnerForCapture` is the\n * capture door's pass: it runs the org's assignment rules in order, falls\n * back to the capturing site's default owner, and touches only a record\n * that has NO owner — a door that named one (the console's drawer, an\n * import column, a conversion with a picked owner) has expressed a person's\n * choice. `reassignContactOwner` is the automation's: an author who put\n * \"assign an owner\" on a stage change means to reassign, and the step hands\n * this either a member or the rotation.\n *\n * ## One transaction, because the pointer is shared\n *\n * The round-robin pool's pointer lives on the org document, and two\n * captures landing together must take two different members. Firestore\n * transactions serialize on what they read, so the org document, the\n * contact and the roster documents are all read inside the transaction and\n * the pointer is advanced in the same commit that writes the owner: a\n * concurrent capture that read the same pointer retries and reads the moved\n * one. The alternative — read the pointer, decide, then write — is exactly\n * the read-then-write shape that lets two captures pick the same person.\n *\n * ## The roster is checked, once per member per attempt\n *\n * A rule may name a member who has since left, and a pool may hold one. A\n * rule naming nobody on the roster is passed over rather than stopping the\n * pass — the next rule may well assign — and a pool member who is gone is\n * skipped for the next in the order. `orgs/{orgId}/members/{uid}` is keyed\n * by uid, so the check is one document read per candidate, and only the\n * roster is consulted: a project-level Auth lookup would admit people who\n * are not on this organization at all (AGL-1122).\n *\n * ## The lead follows the contact\n *\n * The capture doors that file a lead (`addHostLead`: a form routed to leads,\n * a sign-up, a booking) key it by `personKey(email)`, so the lead's document\n * is addressable from the capture alone. When it exists it is given the same\n * owner in the same transaction; two doors write the lead AFTER the contact\n * they `void`ed, so for those sources the mirror is retried a few times over\n * the next seconds, detached, and the person's Leads list shows the owner\n * as soon as the lead is there.\n *\n * ## The owner is told, unless they chose themselves\n *\n * One console notification per assignment, for the record the recipient\n * will open: the lead when the site holds one for this person, the contact\n * otherwise. Not when the actor IS the new owner — a rep who converts a\n * lead into their own contact has not been handed anything.\n *\n * ## Never rejects\n *\n * The capture has already succeeded and a form submission must not fail\n * because the CRM could not decide who follows up; a failure is logged and\n * answered as `none`, and the record is exactly as assignable by hand as it\n * was before this existed. The automation executor turns a `none` into the\n * step's recorded error.\n */\n\nexport interface OwnerAssignmentInput {\n hostId: string\n /** `orgs/{orgId}/contacts/{contactId}` — the record the owner is written on. */\n contactId: string\n /** The contact's address, which keys the lead and names the person in the notification. */\n email: string\n /**\n * The signed-in person making the assignment, when there is one. Never\n * notified about their own choice; absent for a capture and an automation.\n */\n actorUid?: string | null\n}\n\nexport interface CaptureAssignmentInput extends OwnerAssignmentInput {\n source: ContactSource\n /** The form the capture came through, for a rule conditioned on it. */\n formId?: string | null\n /** The tags the capture carried; the contact's own are read from the facet. */\n tags?: readonly string[]\n}\n\nexport interface ReassignmentInput extends OwnerAssignmentInput {\n assign: CrmAssignmentTarget\n}\n\n/** Why an assignment assigned nobody — the automation reports these verbatim. */\nexport type OwnerAssignmentRefusal =\n | 'no-org'\n | 'no-contact'\n | 'no-rule'\n | 'empty-pool'\n | 'not-a-member'\n | 'failed'\n\n/** What decided an assignment: a rule, the site default, the step's member, or the rotation. */\nexport type OwnerAssignedBy = 'rule' | 'default' | 'member' | 'roundRobin'\n\nexport type OwnerAssignment =\n | {\n outcome: 'assigned'\n ownerUid: string\n by: OwnerAssignedBy\n /** The rule that decided it, when one did. */\n ruleId?: string\n /** The lead document existed and now carries the owner too. */\n leadMirrored: boolean\n /** A notification was fanned out to the new owner. */\n notified: boolean\n }\n /** The record already had this owner (a capture), or the same one was named again (a reassignment). */\n | { outcome: 'unchanged'; ownerUid: string }\n | { outcome: 'none'; reason: OwnerAssignmentRefusal }\n\n/** The words the run history and the log give each refusal. */\nexport const OWNER_ASSIGNMENT_REFUSALS: Record<OwnerAssignmentRefusal, string> = {\n 'no-org': 'this site has no organization',\n 'no-contact': 'the contact no longer exists',\n 'no-rule': 'no assignment rule matched and the site has no default owner',\n 'empty-pool': 'the round-robin pool has nobody on the roster in it',\n 'not-a-member': 'the owner named is not on the team',\n failed: 'the owner could not be assigned',\n}\n\n/**\n * The doors whose lead lands after the contact they `void`ed — the ones the\n * mirror is worth retrying for. An order, an import, a manual add and an\n * API call file no lead, and a retry for them would be reads for nothing.\n */\nconst LEAD_FILING_SOURCES: ReadonlySet<ContactSource> = new Set([\n 'form',\n 'member',\n 'booking',\n])\n\n/** How long the detached lead mirror waits between its looks, in order. */\nconst LEAD_MIRROR_RETRY_MS: readonly number[] = [1_000, 2_000, 3_000]\n\ntype Policy =\n | { kind: 'capture'; input: CaptureAssignmentInput }\n | { kind: 'reassign'; input: ReassignmentInput }\n\ninterface Decision {\n ownerUid: string\n by: OwnerAssignedBy\n ruleId?: string\n /** The pointer moves only when the rotation chose. */\n advancePointer: boolean\n}\n\n/**\n * The console path the owner's notification opens, in the `/{hostDocId}/rest`\n * shape every host notification uses — the console rewrites it to\n * `/{orgSlug}/hosts/{subdomain}/rest` when the link is followed, which is\n * why `hostId` and `orgId` travel on the document. The same spelling the\n * CRM's task notification uses; the hub's own route builder lives in the\n * plugin, which this layer cannot import.\n */\nfunction recordLink(hostId: string, record: AssignedRecord): string {\n const section = record.kind === 'contact' ? 'contacts' : 'leads'\n return `/${hostId}/crm/${section}/${encodeURIComponent(record.id)}`\n}\n\n/** The record an assignment notification opens. */\nexport type AssignedRecord =\n | { kind: 'contact'; id: string }\n | { kind: 'lead'; id: string }\n\n/**\n * Tells a member a record is now theirs — one console notification, typed\n * by the record it opens — unless the member handed it to themselves.\n * Shared by the pass above and by the routes where a person picks the\n * owner outright (a lead conversion naming a colleague), so every server\n * path that writes `ownerUid` says so in the same words. Never throws;\n * the fan-out swallows its own failures.\n */\nexport async function notifyRecordAssigned(input: {\n hostId: string\n orgId: string\n ownerUid: string\n actorUid?: string | null\n record: AssignedRecord\n /** How the person reads in the message: their name, else their address. */\n who: string\n}): Promise<boolean> {\n if (!input.ownerUid || input.ownerUid === (input.actorUid ?? '')) return false\n const lead = input.record.kind === 'lead'\n await notifyUsers([input.ownerUid], {\n type: lead ? 'content.leadAssigned' : 'content.contactAssigned',\n title: lead ? 'Lead assigned to you' : 'Contact assigned to you',\n body: input.who,\n link: recordLink(input.hostId, input.record),\n orgId: input.orgId,\n hostId: input.hostId,\n })\n return true\n}\n\nasync function assignOwner(policy: Policy): Promise<OwnerAssignment> {\n const { input } = policy\n try {\n const resolved = await getOrgForHost(input.hostId)\n if (!resolved) return { outcome: 'none', reason: 'no-org' }\n const { orgId } = resolved\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contactRef = orgRef.collection('contacts').doc(input.contactId)\n const membersRef = orgRef.collection('members')\n const leadKey = personKey(input.email)\n const leadRef = leadKey\n ? firestore\n .collection('hosts')\n .doc(input.hostId)\n .collection('leads')\n .doc(leadKey)\n : null\n\n let contactName = ''\n const verdict = await firestore.runTransaction(\n async (tx): Promise<OwnerAssignment> => {\n /*\n * ALL READS BEFORE THE WRITE, which Firestore requires. The org\n * document is read here rather than trusted from `getOrgForHost`,\n * because the pointer it carries is the one thing a concurrent\n * capture may have moved, and the rules and the pool are read from\n * the same snapshot so the decision and the pointer agree.\n */\n const [orgSnapshot, contactSnapshot, leadSnapshot] = await Promise.all([\n tx.get(orgRef),\n tx.get(contactRef),\n leadRef ? tx.get(leadRef) : Promise.resolve(null),\n ])\n if (!contactSnapshot.exists) {\n return { outcome: 'none', reason: 'no-contact' }\n }\n const org = (orgSnapshot.data() ?? {}) as Record<string, unknown>\n const contact = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n contactName = String(contact['name'] ?? '')\n const group = consentGroupForHost(org, input.hostId)\n const facet = readContactFacet(contact, group.groupId)\n const current = typeof facet.ownerUid === 'string' ? facet.ownerUid : ''\n if (policy.kind === 'capture' && current) {\n return { outcome: 'unchanged', ownerUid: current }\n }\n const settings = readCrmAssignmentSettings(org)\n\n // One read per candidate per attempt; a retried transaction reads\n // afresh, as it must.\n const onRoster = new Map<string, Promise<boolean>>()\n const isMember = (uid: string): Promise<boolean> => {\n let known = onRoster.get(uid)\n if (!known) {\n known = tx.get(membersRef.doc(uid)).then((snapshot) => snapshot.exists)\n onRoster.set(uid, known)\n }\n return known\n }\n const fromPool = async (): Promise<string | null> => {\n for (const uid of roundRobinOrder(\n settings.pool.memberUids,\n settings.pool.lastAssignedUid,\n )) {\n if (await isMember(uid)) return uid\n }\n return null\n }\n const resolveTarget = async (\n target: CrmAssignmentTarget,\n ): Promise<{ uid: string; advancePointer: boolean } | null> => {\n if ('roundRobin' in target) {\n const uid = await fromPool()\n return uid ? { uid, advancePointer: true } : null\n }\n return (await isMember(target.memberUid))\n ? { uid: target.memberUid, advancePointer: false }\n : null\n }\n\n let decision: Decision | null = null\n let refusal: OwnerAssignmentRefusal = 'no-rule'\n if (policy.kind === 'capture') {\n const capture = {\n source: policy.input.source,\n email: input.email,\n formId: policy.input.formId ?? null,\n tags: [...(policy.input.tags ?? []), ...(facet.tags ?? [])],\n }\n for (const rule of settings.rules) {\n if (!assignmentRuleMatches(rule.when, capture)) continue\n const hit = await resolveTarget(rule.assign)\n if (hit) {\n decision = { ...hit, ownerUid: hit.uid, by: 'rule', ruleId: rule.id }\n break\n }\n }\n const fallback = settings.hostDefaultOwners[input.hostId]\n if (!decision && fallback && (await isMember(fallback))) {\n decision = { ownerUid: fallback, by: 'default', advancePointer: false }\n }\n } else {\n const { assign } = policy.input\n const hit = await resolveTarget(assign)\n if (hit) {\n decision = {\n ...hit,\n ownerUid: hit.uid,\n by: 'roundRobin' in assign ? 'roundRobin' : 'member',\n }\n } else {\n refusal = 'roundRobin' in assign ? 'empty-pool' : 'not-a-member'\n }\n }\n if (!decision) return { outcome: 'none', reason: refusal }\n if (decision.ownerUid === current) {\n return { outcome: 'unchanged', ownerUid: current }\n }\n\n tx.update(contactRef, {\n [contactFacetPath(group.groupId, 'ownerUid')]: decision.ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n if (decision.advancePointer) {\n tx.update(orgRef, {\n [CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH]: decision.ownerUid,\n })\n }\n /*\n * A lead somebody already assigned by hand keeps its owner on a\n * capture — the contact was new, the lead may not have been — and\n * follows the contact on a deliberate reassignment.\n */\n const leadOwner = String(leadSnapshot?.get('ownerUid') ?? '')\n const leadMirrored = Boolean(\n leadSnapshot?.exists && (policy.kind === 'reassign' || !leadOwner),\n )\n if (leadMirrored && leadRef) {\n tx.update(leadRef, {\n ownerUid: decision.ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n return {\n outcome: 'assigned',\n ownerUid: decision.ownerUid,\n by: decision.by,\n ...(decision.ruleId ? { ruleId: decision.ruleId } : {}),\n leadMirrored,\n notified: false,\n }\n },\n )\n if (verdict.outcome !== 'assigned') return verdict\n\n if (\n !verdict.leadMirrored &&\n leadRef &&\n policy.kind === 'capture' &&\n LEAD_FILING_SOURCES.has(policy.input.source)\n ) {\n void mirrorOwnerOntoLeadLater(leadRef, verdict.ownerUid)\n }\n\n const notified = await notifyRecordAssigned({\n hostId: input.hostId,\n orgId,\n ownerUid: verdict.ownerUid,\n actorUid: input.actorUid,\n record:\n verdict.leadMirrored && leadKey\n ? { kind: 'lead', id: leadKey }\n : { kind: 'contact', id: input.contactId },\n who: contactName || input.email,\n })\n return { ...verdict, notified }\n } catch (error) {\n console.error(\n 'assignContactOwner failed',\n input.hostId,\n input.contactId,\n error,\n )\n return { outcome: 'none', reason: 'failed' }\n }\n}\n\n/**\n * The detached second look for a lead that lands after the contact.\n *\n * Bounded, and only ever ADDS an owner: a lead that arrived with one (a\n * person assigned it in the meantime) is left alone, and a lead that never\n * arrives costs three reads over six seconds and nothing else. Detached\n * because the capture must not hold `contactCreated` back for it — an\n * automation welcoming the contact has nothing to do with the lead's owner\n * column.\n */\nasync function mirrorOwnerOntoLeadLater(\n leadRef: FirebaseFirestore.DocumentReference,\n ownerUid: string,\n delaysMs: readonly number[] = LEAD_MIRROR_RETRY_MS,\n): Promise<boolean> {\n for (const delay of delaysMs) {\n await new Promise((resolve) => setTimeout(resolve, delay))\n try {\n const lead = await leadRef.get()\n if (!lead.exists) continue\n if (String(lead.get('ownerUid') ?? '')) return false\n await leadRef.update({\n ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n console.error('assignContactOwner lead mirror failed', leadRef.path, error)\n return false\n }\n }\n return false\n}\n\n/**\n * The capture door's pass: the org's rules in order, then the capturing\n * site's default owner, on a record that has no owner yet. See the module\n * comment for the whole of it.\n */\nexport function assignOwnerForCapture(\n input: CaptureAssignmentInput,\n): Promise<OwnerAssignment> {\n return assignOwner({ kind: 'capture', input })\n}\n\n/**\n * The deliberate assignment — the `assignContactOwner` step, and a person\n * handing a record to somebody: a member by uid (verified on the roster) or\n * the next in the round-robin pool, overwriting whoever held it. Naming the\n * owner the record already has changes nothing and tells nobody.\n */\nexport function reassignContactOwner(\n input: ReassignmentInput,\n): Promise<OwnerAssignment> {\n return assignOwner({ kind: 'reassign', input })\n}\n\nexport default assignOwnerForCapture\n"],"names":["assignmentRuleMatches","consentGroupForHost","contactFacetPath","CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH","personKey","readContactFacet","readCrmAssignmentSettings","roundRobinOrder","firebaseAdmin","getOrgForHost","notifyUsers","FieldValue","OWNER_ASSIGNMENT_REFUSALS","failed","LEAD_FILING_SOURCES","Set","LEAD_MIRROR_RETRY_MS","recordLink","hostId","record","section","kind","encodeURIComponent","id","notifyRecordAssigned","input","ownerUid","actorUid","lead","type","title","body","who","link","orgId","assignOwner","policy","resolved","outcome","reason","firestore","app","orgRef","collection","doc","contactRef","contactId","membersRef","leadKey","email","leadRef","contactName","verdict","runTransaction","tx","orgSnapshot","contactSnapshot","contact","leadSnapshot","Promise","all","get","resolve","exists","org","data","String","group","facet","groupId","current","settings","onRoster","Map","isMember","uid","known","then","snapshot","set","fromPool","pool","memberUids","lastAssignedUid","resolveTarget","target","advancePointer","memberUid","decision","refusal","capture","source","formId","tags","rule","rules","when","hit","assign","by","ruleId","fallback","hostDefaultOwners","update","updatedAt","serverTimestamp","leadOwner","leadMirrored","Boolean","notified","has","mirrorOwnerOntoLeadLater","error","console","delaysMs","delay","setTimeout","path","assignOwnerForCapture","reassignContactOwner"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,qBAAqB,EACrBC,mBAAmB,EACnBC,gBAAgB,EAEhBC,kCAAkC,EAElCC,SAAS,EACTC,gBAAgB,EAChBC,yBAAyB,EACzBC,eAAe,QACV,sBAAqB;AAC5B,SACEC,aAAa,EACbC,aAAa,EACbC,WAAW,QACN,2BAA0B;AACjC,SAASC,UAAU,QAAQ,2BAA0B;AAmHrD,6DAA6D,GAC7D,OAAO,MAAMC,4BAAoE;IAC/E,UAAU;IACV,cAAc;IACd,WAAW;IACX,cAAc;IACd,gBAAgB;IAChBC,QAAQ;AACV,EAAC;AAED;;;;CAIC,GACD,MAAMC,sBAAkD,IAAIC,IAAI;IAC9D;IACA;IACA;CACD;AAED,yEAAyE,GACzE,MAAMC,uBAA0C;IAAC;IAAO;IAAO;CAAM;AAcrE;;;;;;;CAOC,GACD,SAASC,WAAWC,MAAc,EAAEC,MAAsB;IACxD,MAAMC,UAAUD,OAAOE,IAAI,KAAK,YAAY,aAAa;IACzD,OAAO,CAAC,CAAC,EAAEH,OAAO,KAAK,EAAEE,QAAQ,CAAC,EAAEE,mBAAmBH,OAAOI,EAAE,GAAG;AACrE;AAOA;;;;;;;CAOC,GACD,OAAO,eAAeC,qBAAqBC,KAQ1C;QAC4CA;IAA3C,IAAI,CAACA,MAAMC,QAAQ,IAAID,MAAMC,QAAQ,OAAMD,kBAAAA,MAAME,QAAQ,YAAdF,kBAAkB,KAAK,OAAO;IACzE,MAAMG,OAAOH,MAAMN,MAAM,CAACE,IAAI,KAAK;IACnC,MAAMX,YAAY;QAACe,MAAMC,QAAQ;KAAC,EAAE;QAClCG,MAAMD,OAAO,yBAAyB;QACtCE,OAAOF,OAAO,yBAAyB;QACvCG,MAAMN,MAAMO,GAAG;QACfC,MAAMhB,WAAWQ,MAAMP,MAAM,EAAEO,MAAMN,MAAM;QAC3Ce,OAAOT,MAAMS,KAAK;QAClBhB,QAAQO,MAAMP,MAAM;IACtB;IACA,OAAO;AACT;AAEA,eAAeiB,YAAYC,MAAc;IACvC,MAAM,EAAEX,KAAK,EAAE,GAAGW;IAClB,IAAI;QACF,MAAMC,WAAW,MAAM5B,cAAcgB,MAAMP,MAAM;QACjD,IAAI,CAACmB,UAAU,OAAO;YAAEC,SAAS;YAAQC,QAAQ;QAAS;QAC1D,MAAM,EAAEL,KAAK,EAAE,GAAGG;QAClB,MAAMG,YAAYhC,cAAciC,GAAG,GAAGD,SAAS;QAC/C,MAAME,SAASF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACV;QAChD,MAAMW,aAAaH,OAAOC,UAAU,CAAC,YAAYC,GAAG,CAACnB,MAAMqB,SAAS;QACpE,MAAMC,aAAaL,OAAOC,UAAU,CAAC;QACrC,MAAMK,UAAU5C,UAAUqB,MAAMwB,KAAK;QACrC,MAAMC,UAAUF,UACZR,UACGG,UAAU,CAAC,SACXC,GAAG,CAACnB,MAAMP,MAAM,EAChByB,UAAU,CAAC,SACXC,GAAG,CAACI,WACP;QAEJ,IAAIG,cAAc;QAClB,MAAMC,UAAU,MAAMZ,UAAUa,cAAc,CAC5C,OAAOC;gBAgBQC,mBACIC,uBACIC;YAjBrB;;;;;;SAMC,GACD,MAAM,CAACF,aAAaC,iBAAiBE,aAAa,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBACrEN,GAAGO,GAAG,CAACnB;gBACPY,GAAGO,GAAG,CAAChB;gBACPK,UAAUI,GAAGO,GAAG,CAACX,WAAWS,QAAQG,OAAO,CAAC;aAC7C;YACD,IAAI,CAACN,gBAAgBO,MAAM,EAAE;gBAC3B,OAAO;oBAAEzB,SAAS;oBAAQC,QAAQ;gBAAa;YACjD;YACA,MAAMyB,OAAOT,oBAAAA,YAAYU,IAAI,cAAhBV,oBAAsB,CAAC;YACpC,MAAME,WAAWD,wBAAAA,gBAAgBS,IAAI,cAApBT,wBAA0B,CAAC;YAC5CL,cAAce,QAAOT,gBAAAA,OAAO,CAAC,OAAO,YAAfA,gBAAmB;YACxC,MAAMU,QAAQlE,oBAAoB+D,KAAKvC,MAAMP,MAAM;YACnD,MAAMkD,QAAQ/D,iBAAiBoD,SAASU,MAAME,OAAO;YACrD,MAAMC,UAAU,OAAOF,MAAM1C,QAAQ,KAAK,WAAW0C,MAAM1C,QAAQ,GAAG;YACtE,IAAIU,OAAOf,IAAI,KAAK,aAAaiD,SAAS;gBACxC,OAAO;oBAAEhC,SAAS;oBAAaZ,UAAU4C;gBAAQ;YACnD;YACA,MAAMC,WAAWjE,0BAA0B0D;YAE3C,kEAAkE;YAClE,sBAAsB;YACtB,MAAMQ,WAAW,IAAIC;YACrB,MAAMC,WAAW,CAACC;gBAChB,IAAIC,QAAQJ,SAASX,GAAG,CAACc;gBACzB,IAAI,CAACC,OAAO;oBACVA,QAAQtB,GAAGO,GAAG,CAACd,WAAWH,GAAG,CAAC+B,MAAME,IAAI,CAAC,CAACC,WAAaA,SAASf,MAAM;oBACtES,SAASO,GAAG,CAACJ,KAAKC;gBACpB;gBACA,OAAOA;YACT;YACA,MAAMI,WAAW;gBACf,KAAK,MAAML,OAAOpE,gBAChBgE,SAASU,IAAI,CAACC,UAAU,EACxBX,SAASU,IAAI,CAACE,eAAe,EAC5B;oBACD,IAAI,MAAMT,SAASC,MAAM,OAAOA;gBAClC;gBACA,OAAO;YACT;YACA,MAAMS,gBAAgB,OACpBC;gBAEA,IAAI,gBAAgBA,QAAQ;oBAC1B,MAAMV,MAAM,MAAMK;oBAClB,OAAOL,MAAM;wBAAEA;wBAAKW,gBAAgB;oBAAK,IAAI;gBAC/C;gBACA,OAAO,AAAC,MAAMZ,SAASW,OAAOE,SAAS,IACnC;oBAAEZ,KAAKU,OAAOE,SAAS;oBAAED,gBAAgB;gBAAM,IAC/C;YACN;YAEA,IAAIE,WAA4B;YAChC,IAAIC,UAAkC;YACtC,IAAIrD,OAAOf,IAAI,KAAK,WAAW;oBAInBe,sBACGA,oBAA8BgC;gBAJ3C,MAAMsB,UAAU;oBACdC,QAAQvD,OAAOX,KAAK,CAACkE,MAAM;oBAC3B1C,OAAOxB,MAAMwB,KAAK;oBAClB2C,MAAM,GAAExD,uBAAAA,OAAOX,KAAK,CAACmE,MAAM,YAAnBxD,uBAAuB;oBAC/ByD,MAAM;4BAAKzD,qBAAAA,OAAOX,KAAK,CAACoE,IAAI,YAAjBzD,qBAAqB,EAAE;4BAAOgC,cAAAA,MAAMyB,IAAI,YAAVzB,cAAc,EAAE;qBAAE;gBAC7D;gBACA,KAAK,MAAM0B,QAAQvB,SAASwB,KAAK,CAAE;oBACjC,IAAI,CAAC/F,sBAAsB8F,KAAKE,IAAI,EAAEN,UAAU;oBAChD,MAAMO,MAAM,MAAMb,cAAcU,KAAKI,MAAM;oBAC3C,IAAID,KAAK;wBACPT,WAAW,aAAKS;4BAAKvE,UAAUuE,IAAItB,GAAG;4BAAEwB,IAAI;4BAAQC,QAAQN,KAAKvE,EAAE;;wBACnE;oBACF;gBACF;gBACA,MAAM8E,WAAW9B,SAAS+B,iBAAiB,CAAC7E,MAAMP,MAAM,CAAC;gBACzD,IAAI,CAACsE,YAAYa,YAAa,MAAM3B,SAAS2B,WAAY;oBACvDb,WAAW;wBAAE9D,UAAU2E;wBAAUF,IAAI;wBAAWb,gBAAgB;oBAAM;gBACxE;YACF,OAAO;gBACL,MAAM,EAAEY,MAAM,EAAE,GAAG9D,OAAOX,KAAK;gBAC/B,MAAMwE,MAAM,MAAMb,cAAcc;gBAChC,IAAID,KAAK;oBACPT,WAAW,aACNS;wBACHvE,UAAUuE,IAAItB,GAAG;wBACjBwB,IAAI,gBAAgBD,SAAS,eAAe;;gBAEhD,OAAO;oBACLT,UAAU,gBAAgBS,SAAS,eAAe;gBACpD;YACF;YACA,IAAI,CAACV,UAAU,OAAO;gBAAElD,SAAS;gBAAQC,QAAQkD;YAAQ;YACzD,IAAID,SAAS9D,QAAQ,KAAK4C,SAAS;gBACjC,OAAO;oBAAEhC,SAAS;oBAAaZ,UAAU4C;gBAAQ;YACnD;YAEAhB,GAAGiD,MAAM,CAAC1D,YAAY;gBACpB,CAAC3C,iBAAiBiE,MAAME,OAAO,EAAE,YAAY,EAAEmB,SAAS9D,QAAQ;gBAChE8E,WAAW7F,WAAW8F,eAAe;YACvC;YACA,IAAIjB,SAASF,cAAc,EAAE;gBAC3BhC,GAAGiD,MAAM,CAAC7D,QAAQ;oBAChB,CAACvC,mCAAmC,EAAEqF,SAAS9D,QAAQ;gBACzD;YACF;YACA;;;;SAIC,GACD,MAAMgF,YAAYxC,eAAOR,gCAAAA,aAAcG,GAAG,CAAC,8BAAe;YAC1D,MAAM8C,eAAeC,QACnBlD,CAAAA,gCAAAA,aAAcK,MAAM,KAAK3B,CAAAA,OAAOf,IAAI,KAAK,cAAc,CAACqF,SAAQ;YAElE,IAAIC,gBAAgBzD,SAAS;gBAC3BI,GAAGiD,MAAM,CAACrD,SAAS;oBACjBxB,UAAU8D,SAAS9D,QAAQ;oBAC3B8E,WAAW7F,WAAW8F,eAAe;gBACvC;YACF;YACA,OAAO;gBACLnE,SAAS;gBACTZ,UAAU8D,SAAS9D,QAAQ;gBAC3ByE,IAAIX,SAASW,EAAE;eACXX,SAASY,MAAM,GAAG;gBAAEA,QAAQZ,SAASY,MAAM;YAAC,IAAI,CAAC;gBACrDO;gBACAE,UAAU;;QAEd;QAEF,IAAIzD,QAAQd,OAAO,KAAK,YAAY,OAAOc;QAE3C,IACE,CAACA,QAAQuD,YAAY,IACrBzD,WACAd,OAAOf,IAAI,KAAK,aAChBP,oBAAoBgG,GAAG,CAAC1E,OAAOX,KAAK,CAACkE,MAAM,GAC3C;YACA,KAAKoB,yBAAyB7D,SAASE,QAAQ1B,QAAQ;QACzD;QAEA,MAAMmF,WAAW,MAAMrF,qBAAqB;YAC1CN,QAAQO,MAAMP,MAAM;YACpBgB;YACAR,UAAU0B,QAAQ1B,QAAQ;YAC1BC,UAAUF,MAAME,QAAQ;YACxBR,QACEiC,QAAQuD,YAAY,IAAI3D,UACpB;gBAAE3B,MAAM;gBAAQE,IAAIyB;YAAQ,IAC5B;gBAAE3B,MAAM;gBAAWE,IAAIE,MAAMqB,SAAS;YAAC;YAC7Cd,KAAKmB,eAAe1B,MAAMwB,KAAK;QACjC;QACA,OAAO,aAAKG;YAASyD;;IACvB,EAAE,OAAOG,OAAO;QACdC,QAAQD,KAAK,CACX,6BACAvF,MAAMP,MAAM,EACZO,MAAMqB,SAAS,EACfkE;QAEF,OAAO;YAAE1E,SAAS;YAAQC,QAAQ;QAAS;IAC7C;AACF;AAEA;;;;;;;;;CASC,GACD,eAAewE,yBACb7D,OAA4C,EAC5CxB,QAAgB,EAChBwF,WAA8BlG,oBAAoB;IAElD,KAAK,MAAMmG,SAASD,SAAU;QAC5B,MAAM,IAAIvD,QAAQ,CAACG,UAAYsD,WAAWtD,SAASqD;QACnD,IAAI;gBAGSvF;YAFX,MAAMA,OAAO,MAAMsB,QAAQW,GAAG;YAC9B,IAAI,CAACjC,KAAKmC,MAAM,EAAE;YAClB,IAAIG,QAAOtC,YAAAA,KAAKiC,GAAG,CAAC,uBAATjC,YAAwB,KAAK,OAAO;YAC/C,MAAMsB,QAAQqD,MAAM,CAAC;gBACnB7E;gBACA8E,WAAW7F,WAAW8F,eAAe;YACvC;YACA,OAAO;QACT,EAAE,OAAOO,OAAO;YACdC,QAAQD,KAAK,CAAC,yCAAyC9D,QAAQmE,IAAI,EAAEL;YACrE,OAAO;QACT;IACF;IACA,OAAO;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASM,sBACd7F,KAA6B;IAE7B,OAAOU,YAAY;QAAEd,MAAM;QAAWI;IAAM;AAC9C;AAEA;;;;;CAKC,GACD,OAAO,SAAS8F,qBACd9F,KAAwB;IAExB,OAAOU,YAAY;QAAEd,MAAM;QAAYI;IAAM;AAC/C;AAEA,eAAe6F,sBAAqB"}
|