@aglyn/plugins-crm 1.0.0-beta.293 → 1.0.0
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 +14 -14
- package/src/lib/components/activity-list.js +6 -1
- package/src/lib/components/activity-list.js.map +1 -1
- package/src/lib/components/contact-properties-card.js +8 -0
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/deal-board.d.ts +10 -0
- package/src/lib/components/deal-board.js +119 -61
- package/src/lib/components/deal-board.js.map +1 -1
- package/src/lib/components/deal-stage-card.d.ts +11 -7
- package/src/lib/components/deal-stage-card.js +99 -23
- package/src/lib/components/deal-stage-card.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +26 -14
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/picklist-values-card.js +7 -4
- package/src/lib/components/picklist-values-card.js.map +1 -1
- package/src/lib/components/reports/activity-card.js +1 -1
- package/src/lib/components/reports/activity-card.js.map +1 -1
- package/src/lib/hooks/use-scroll-edges.d.ts +38 -0
- package/src/lib/hooks/use-scroll-edges.js +75 -0
- package/src/lib/hooks/use-scroll-edges.js.map +1 -0
- package/src/lib/server/api-v1/crm-activities.js +2 -8
- package/src/lib/server/api-v1/crm-activities.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +7 -5
- package/src/lib/server/task-reminders-route.js +82 -15
- package/src/lib/server/task-reminders-route.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/activity-card.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 * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportExport } from './report-export'\nimport { reportFilename } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\nimport {\n activityLeaderboard,\n type ActivityLeaderboardRow,\n deltaPercent,\n} from '../../model/crm-reports'\n\n/**\n * How many activities and how many completed tasks the leaderboard is\n * grouped from.\n *\n * The tiles are server counts and never meet these bounds; the table needs\n * each activity's kind and author and each task's completer, so it reads\n * the period's newest thousand of each and says when there were more —\n * the contacts list's bound, and the one every window on this page shares.\n */\nconst ACTIVITY_CEILING = 1000\nconst TASK_DONE_CEILING = 1000\n\ntype ActivityRow = Aglyn.CrmActivityRow\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The table's columns, which are also the CSV's. */\nconst COLUMNS = [\n 'Teammate',\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]),\n 'Activities',\n 'Tasks done',\n] as const\n\nexport interface ActivityCardProps {\n report: CrmReportScope\n}\n\n/**\n * Activity by teammate (AGL-2624): the calls, emails, meetings and notes\n * each member logged in the period, and the tasks they ticked off,\n * busiest first.\n *\n * ## Two windows, two indexes, one table\n *\n * Activities are counted within the period on `atMs`; completed tasks are\n * counted the same way on `completedAtMs`, under `status == 'done'`. Each\n * count is also drawn as a window, newest first, for the leaderboard.\n *\n * ## Why that is four indexes and not one\n *\n * The counts and the windows do not share an index, and neither do the two\n * audiences.\n *\n * A COUNT here names no `orderBy`. Firestore then orders implicitly by the\n * range field, ASCENDING, so a count wants `completedAtMs` ascending — while\n * the window beside it asks for `desc` explicitly and wants the descending\n * one. A composite index serves one direction or its exact reverse, never\n * both, so declaring only the direction the window reads leaves the count\n * with nothing: `FAILED_PRECONDITION`, one column of dashes, and a card that\n * otherwise looks fine.\n *\n * Then the visibility clause doubles it. `visibleToClause` contributes\n * nothing when the reader's tokens are null, which is every organization-level\n * read, so the shape arriving at Firestore has no leading array field — a\n * different index, not a prefix of the one with it. `(visibleTo, status,\n * completedAtMs)` serves a site and `(status, completedAtMs)` the org above\n * it; one without the other is a card that works for exactly one audience.\n *\n * The two reads are independent so that a missing task index degrades ONE\n * column to a dash and a notice rather than the whole card: the activities\n * were read, and the leaderboard draws them.\n *\n * A task ordered by `completedAtMs` is a task the complete route stamped;\n * a task marked done some other way, without the stamp, is not in the\n * window and the caption says what the column is grouped from.\n *\n * ## Names\n *\n * Every activity carries the name it was signed with, for the reason\n * `CrmActivity.byName` gives — a scoped reader cannot resolve a colleague's\n * uid. The roster is read too, through the members route any member may\n * ask, and wins when it knows the uid, so a renamed teammate reads by\n * their current name; the signed name covers a uid the roster no longer\n * has, and the uid itself covers the rest.\n */\nexport function ActivityCard(props: ActivityCardProps) {\n const { report } = props\n const { scope, tokens, range, period, routes } = report\n const firestore = useFirestore()\n const roster = useOrgMemberOptions(scope[1])\n\n const activitiesBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.activities),\n ...visibleToClause(tokens),\n where('atMs', '>=', from),\n where('atMs', '<', to),\n )\n const tasksDoneBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'done'),\n where('completedAtMs', '>=', from),\n where('completedAtMs', '<', to),\n )\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n const activityCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(activitiesBetween(range.from, range.to)),\n countOf(activitiesBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:counts') },\n )\n const taskCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(tasksDoneBetween(range.from, range.to)),\n countOf(tasksDoneBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-counts') },\n )\n\n const activityWindow = useWindowRead<ActivityRow>(\n () =>\n query(\n activitiesBetween(range.from, range.to),\n orderBy('atMs', 'desc'),\n limit(ACTIVITY_CEILING + 1),\n ),\n ACTIVITY_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:window') },\n )\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n tasksDoneBetween(range.from, range.to),\n orderBy('completedAtMs', 'desc'),\n limit(TASK_DONE_CEILING + 1),\n ),\n TASK_DONE_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-window') },\n )\n\n const rows = useMemo(\n () => activityLeaderboard(activityWindow.rows, taskWindow.rows),\n [activityWindow, taskWindow],\n )\n const nameOf = (row: ActivityLeaderboardRow): string => {\n if (!row.uid) return 'No teammate'\n return Aglyn.findOrgMember(roster.options, row.uid)?.label ?? row.name ?? row.uid\n }\n const teammates = rows.filter((row) => row.uid).length\n\n const activitiesRead = activityWindow.status === 'success'\n const tasksRead = taskWindow.status === 'success'\n const settled = activityWindow.status !== 'loading' && taskWindow.status !== 'loading'\n const caption = [\n activityWindow.truncated\n ? `Grouped from the ${ACTIVITY_CEILING.toLocaleString()} most recent activities in the period`\n : null,\n taskWindow.truncated\n ? `the ${TASK_DONE_CEILING.toLocaleString()} most recently completed tasks`\n : null,\n ]\n .filter(Boolean)\n .join(' and ')\n const activityFigures = activityCounts.value\n const taskFigures = taskCounts.value\n\n return (\n <CardDisplay\n header={'Activity by teammate'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#activity-by-teammate',\n excerpt:\n 'The calls, emails, meetings and notes each teammate logged in ' +\n 'the period, and the tasks they completed, busiest first. ' +\n 'Grouped from the period’s newest thousand of each.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <ReportExport\n filename={reportFilename('activity', period)}\n columns={COLUMNS}\n rows={() =>\n rows.map((row) => [\n nameOf(row),\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => row.kinds[kind]),\n row.activities,\n tasksRead ? row.tasksDone : '',\n ])\n }\n disabled={!activitiesRead || !rows.length}\n />\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack useFlexGap direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Activities logged'}\n value={activityFigures ? activityFigures.current.toLocaleString() : null}\n deltaPct={\n activityFigures\n ? deltaPercent(activityFigures.current, activityFigures.previous)\n : null\n }\n note={'counted on the server'}\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Tasks completed'}\n value={taskFigures ? taskFigures.current.toLocaleString() : null}\n deltaPct={\n taskFigures\n ? deltaPercent(taskFigures.current, taskFigures.previous)\n : null\n }\n note={'ticked off in the period'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Teammates active'}\n value={settled ? teammates.toLocaleString() : null}\n note={'logged or completed something'}\n />\n </Stack>\n {activityCounts.status === 'error' || activityWindow.status === 'error' ? (\n <Alert severity=\"warning\">{'The activities could not be read.'}</Alert>\n ) : null}\n {taskCounts.status === 'error' || taskWindow.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The completed-task counts could not be read; the Tasks done column is left blank.'}\n </Alert>\n ) : null}\n <Section title={'Who did what'}>\n {rows.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.uid || '$nobody'}>\n <TableCell>{nameOf(row)}</TableCell>\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <TableCell key={kind} align=\"right\">\n {row.kinds[kind].toLocaleString()}\n </TableCell>\n ))}\n <TableCell align=\"right\">{row.activities.toLocaleString()}</TableCell>\n <TableCell align=\"right\">\n {tasksRead ? row.tasksDone.toLocaleString() : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled ? 'Nothing logged or completed in this period.' : 'Reading…'}\n </Typography>\n )}\n {rows.some((row) => !row.uid) ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No teammate — activities an automation logged, and tasks completed with nobody assigned.'}\n </Typography>\n ) : null}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${caption}; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nActivityCard.displayName = 'ActivityCard'\n\nexport default ActivityCard\n"],"names":["Aglyn","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","getCountFromServer","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","activityLeaderboard","deltaPercent","ACTIVITY_CEILING","TASK_DONE_CEILING","COLUMNS","CRM_ACTIVITY_KINDS","map","kind","CRM_ACTIVITY_KIND_LABELS","ActivityCard","props","report","scope","tokens","range","period","routes","firestore","roster","activitiesBetween","from","to","CRM_COLLECTIONS","activities","tasksDoneBetween","tasks","countOf","target","then","snapshot","data","count","activityCounts","Promise","all","previousFrom","previousTo","current","previous","cacheKey","taskCounts","activityWindow","taskWindow","rows","nameOf","row","uid","findOrgMember","options","label","name","teammates","filter","length","activitiesRead","status","tasksRead","settled","caption","truncated","toLocaleString","Boolean","join","activityFigures","value","taskFigures","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","filename","columns","kinds","tasksDone","disabled","spacing","useFlexGap","direction","sx","flexWrap","deltaPct","note","href","section","severity","title","size","column","index","align","variant","color","some","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,YAAYA,WAAW,eAAc;AACrC,SAASC,WAAW,QAAQ,uBAAsB;AAClD,SAASC,OAAO,QAAQ,6DAA4D;AACpF,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SACEC,KAAK,EACLC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SACEC,kBAAkB,EAClBC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,mBAAmB,QAAQ,wCAAoC;AACxE,SAASC,YAAY,QAAQ,qBAAiB;AAC9C,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SAEEC,cAAc,EACdC,gBAAgB,EAChBC,eAAe,QACV,oBAAgB;AACvB,SAASC,cAAc,QAAQ,wBAAoB;AACnD,SAASC,gBAAgB,EAAEC,aAAa,QAAQ,0BAAsB;AACtE,SACEC,mBAAmB,EAEnBC,YAAY,QACP,6BAAyB;AAEhC;;;;;;;;CAQC,GACD,MAAMC,mBAAmB;AACzB,MAAMC,oBAAoB;AAK1B,mDAAmD,GACnD,MAAMC,UAAU;IACd;OACG/B,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASlC,MAAMmC,wBAAwB,CAACD,KAAK;IAC9E;IACA;CACD;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+CC,GACD,OAAO,SAASE,aAAaC,KAAwB;IACnD,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAM,EAAEE,KAAK,EAAEC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,MAAM,EAAE,GAAGL;IACjD,MAAMM,YAAY3B;IAClB,MAAM4B,SAAS3B,oBAAoBqB,KAAK,CAAC,EAAE;IAE3C,MAAMO,oBAAoB,CAACC,MAAcC,KACvClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACC,UAAU,MAChE3B,gBAAgBiB,SACnBzB,MAAM,QAAQ,MAAMgC,OACpBhC,MAAM,QAAQ,KAAKiC;IAEvB,MAAMG,mBAAmB,CAACJ,MAAcC,KACtClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACG,KAAK,MAC3D7B,gBAAgBiB,SACnBzB,MAAM,UAAU,MAAM,SACtBA,MAAM,iBAAiB,MAAMgC,OAC7BhC,MAAM,iBAAiB,KAAKiC;IAEhC,MAAMK,UAAU,CAACC,SACf3C,mBAAmB2C,QAAQC,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,GAAGC,KAAK;IAErE,MAAMC,iBAAiBlC,iBACrB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQP,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC9CK,QAAQP,kBAAkBL,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC/D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM6B,aAAa1C,iBACjB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQF,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC7CK,QAAQF,iBAAiBV,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC9D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAM8B,iBAAiB1C,cACrB,IACEZ,MACEgC,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACtCnC,QAAQ,QAAQ,SAChBD,MAAMiB,mBAAmB,KAE7BA,kBACA;QAACe;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM+B,aAAa3C,cACjB,IACEZ,MACEqC,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACrCnC,QAAQ,iBAAiB,SACzBD,MAAMkB,oBAAoB,KAE9BA,mBACA;QAACc;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAMgC,OAAOtD,QACX,IAAMW,oBAAoByC,eAAeE,IAAI,EAAED,WAAWC,IAAI,GAC9D;QAACF;QAAgBC;KAAW;IAE9B,MAAME,SAAS,CAACC;YAEPxE;YAAAA;QADP,IAAI,CAACwE,IAAIC,GAAG,EAAE,OAAO;QACrB,QAAOzE,iBAAAA,uBAAAA,MAAM0E,aAAa,CAAC7B,OAAO8B,OAAO,EAAEH,IAAIC,GAAG,sBAA3CzE,qBAA8C4E,KAAK,oBAAIJ,IAAIK,IAAI,YAA/D7E,OAAmEwE,IAAIC,GAAG;IACnF;IACA,MAAMK,YAAYR,KAAKS,MAAM,CAAC,CAACP,MAAQA,IAAIC,GAAG,EAAEO,MAAM;IAEtD,MAAMC,iBAAiBb,eAAec,MAAM,KAAK;IACjD,MAAMC,YAAYd,WAAWa,MAAM,KAAK;IACxC,MAAME,UAAUhB,eAAec,MAAM,KAAK,aAAab,WAAWa,MAAM,KAAK;IAC7E,MAAMG,UAAU;QACdjB,eAAekB,SAAS,GACpB,CAAC,iBAAiB,EAAEzD,iBAAiB0D,cAAc,GAAG,qCAAqC,CAAC,GAC5F;QACJlB,WAAWiB,SAAS,GAChB,CAAC,IAAI,EAAExD,kBAAkByD,cAAc,GAAG,8BAA8B,CAAC,GACzE;KACL,CACER,MAAM,CAACS,SACPC,IAAI,CAAC;IACR,MAAMC,kBAAkB/B,eAAegC,KAAK;IAC5C,MAAMC,cAAczB,WAAWwB,KAAK;IAEpC,qBACE,KAAC1F;QACC4F,QAAQ;QACRC,MAAM9F,MAAM+F,cAAc,CAAC,cAAc;YACvCC,QAAQ;YACRC,SACE,mEACA,8DACA;QACJ;QACAC,cAAc;QACdC,cAAc;QACdC,aAAa;YACXC,sBACE,KAAClF;gBACCmF,UAAUlF,eAAe,YAAYsB;gBACrC6D,SAASxE;gBACTuC,MAAM,IACJA,KAAKrC,GAAG,CAAC,CAACuC,MAAQ;4BAChBD,OAAOC;+BACJxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASsC,IAAIgC,KAAK,CAACtE,KAAK;4BACzDsC,IAAItB,UAAU;4BACdiC,YAAYX,IAAIiC,SAAS,GAAG;yBAC7B;gBAEHC,UAAU,CAACzB,kBAAkB,CAACX,KAAKU,MAAM;;QAG/C;kBAEA,cAAA,MAAC3E;YAAMsG,SAAS;;8BACd,MAACtG;oBAAMuG,UAAU;oBAACC,WAAU;oBAAMF,SAAS;oBAAGG,IAAI;wBAAEC,UAAU;oBAAO;;sCACnE,KAACvF;4BACCoD,OAAO;4BACPe,OAAOD,kBAAkBA,gBAAgB1B,OAAO,CAACuB,cAAc,KAAK;4BACpEyB,UACEtB,kBACI9D,aAAa8D,gBAAgB1B,OAAO,EAAE0B,gBAAgBzB,QAAQ,IAC9D;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOC,cAAcA,YAAY5B,OAAO,CAACuB,cAAc,KAAK;4BAC5DyB,UACEpB,cACIhE,aAAagE,YAAY5B,OAAO,EAAE4B,YAAY3B,QAAQ,IACtD;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOP,UAAUN,UAAUS,cAAc,KAAK;4BAC9C0B,MAAM;;;;gBAGTtD,eAAeuB,MAAM,KAAK,WAAWd,eAAec,MAAM,KAAK,wBAC9D,KAAC9E;oBAAMgH,UAAS;8BAAW;qBACzB;gBACHjD,WAAWe,MAAM,KAAK,WAAWb,WAAWa,MAAM,KAAK,wBACtD,KAAC9E;oBAAMgH,UAAS;8BACb;qBAED;8BACJ,MAAClH;oBAAQmH,OAAO;;wBACb/C,KAAKU,MAAM,iBACV,MAAC7E;4BAAYmH,MAAK;;8CAChB,KAAC9G;8CACC,cAAA,KAACC;kDACEsB,QAAQE,GAAG,CAAC,CAACsF,QAAQC,sBACpB,KAACjH;gDAAuBkH,OAAOD,QAAQ,UAAU;0DAC9CD;+CADaA;;;8CAMtB,KAACjH;8CACEgE,KAAKrC,GAAG,CAAC,CAACuC,oBACT,MAAC/D;;8DACC,KAACF;8DAAWgE,OAAOC;;gDAClBxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,qBAC7B,KAAC3B;wDAAqBkH,OAAM;kEACzBjD,IAAIgC,KAAK,CAACtE,KAAK,CAACqD,cAAc;uDADjBrD;8DAIlB,KAAC3B;oDAAUkH,OAAM;8DAASjD,IAAItB,UAAU,CAACqC,cAAc;;8DACvD,KAAChF;oDAAUkH,OAAM;8DACdtC,YAAYX,IAAIiC,SAAS,CAAClB,cAAc,KAAK;;;2CATnCf,IAAIC,GAAG,IAAI;;;2CAgBhC,KAAC/D;4BAAWgH,SAAQ;4BAAQC,OAAM;sCAC/BvC,UAAU,gDAAgD;;wBAG9Dd,KAAKsD,IAAI,CAAC,CAACpD,MAAQ,CAACA,IAAIC,GAAG,kBAC1B,KAAC/D;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC;6BAED;wBAEHtC,wBACC,KAAC3E;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC,GAAGtC,QAAQ,sCAAsC,CAAC;6BAEnD;;;;;;AAKd;AACAjD,aAAayF,WAAW,GAAG;AAE3B,eAAezF,aAAY"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/activity-card.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 * as Aglyn from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgMemberOptions } from '../../hooks/use-org-member-options'\nimport { ReportExport } from './report-export'\nimport { reportFilename } from './report-format'\nimport {\n type CrmReportScope,\n reportCacheKey,\n scopedCollection,\n visibleToClause,\n} from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport { useAggregateRead, useWindowRead } from './use-aggregate-read'\nimport {\n activityLeaderboard,\n type ActivityLeaderboardRow,\n deltaPercent,\n} from '../../model/crm-reports'\n\n/**\n * How many activities and how many completed tasks the leaderboard is\n * grouped from.\n *\n * The tiles are server counts and never meet these bounds; the table needs\n * each activity's kind and author and each task's completer, so it reads\n * the period's newest thousand of each and says when there were more —\n * the contacts list's bound, and the one every window on this page shares.\n */\nconst ACTIVITY_CEILING = 1000\nconst TASK_DONE_CEILING = 1000\n\ntype ActivityRow = Aglyn.CrmActivityRow\ntype TaskRow = Aglyn.CrmTask & { $id: string }\n\n/** The table's columns, which are also the CSV's. */\nconst COLUMNS = [\n 'Teammate',\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]),\n 'Activities',\n 'Tasks done',\n] as const\n\nexport interface ActivityCardProps {\n report: CrmReportScope\n}\n\n/**\n * Activity by teammate (AGL-2624): the calls, emails, meetings and notes\n * each member logged in the period, and the tasks they ticked off,\n * busiest first.\n *\n * ## Two windows, two indexes, one table\n *\n * Activities are counted within the period on `atMs`; completed tasks are\n * counted the same way on `completedAtMs`, under `status == 'done'`. Each\n * count is also drawn as a window, newest first, for the leaderboard.\n *\n * ## Why that is four indexes and not one\n *\n * The counts and the windows do not share an index, and neither do the two\n * audiences.\n *\n * A COUNT here names no `orderBy`. Firestore then orders implicitly by the\n * range field, ASCENDING, so a count wants `completedAtMs` ascending — while\n * the window beside it asks for `desc` explicitly and wants the descending\n * one. A composite index serves one direction or its exact reverse, never\n * both, so declaring only the direction the window reads leaves the count\n * with nothing: `FAILED_PRECONDITION`, one column of dashes, and a card that\n * otherwise looks fine.\n *\n * Then the visibility clause doubles it. `visibleToClause` contributes\n * nothing when the reader's tokens are null, which is every organization-level\n * read, so the shape arriving at Firestore has no leading array field — a\n * different index, not a prefix of the one with it. `(visibleTo, status,\n * completedAtMs)` serves a site and `(status, completedAtMs)` the org above\n * it; one without the other is a card that works for exactly one audience.\n *\n * The two reads are independent so that a missing task index degrades ONE\n * column to a dash and a notice rather than the whole card: the activities\n * were read, and the leaderboard draws them.\n *\n * A task ordered by `completedAtMs` is a task the complete route stamped;\n * a task marked done some other way, without the stamp, is not in the\n * window and the caption says what the column is grouped from.\n *\n * ## Names\n *\n * Every activity carries the name it was signed with, for the reason\n * `CrmActivity.byName` gives — a scoped reader cannot resolve a colleague's\n * uid. The roster is read too, through the members route any member may\n * ask, and wins when it knows the uid, so a renamed teammate reads by\n * their current name; the signed name covers a uid the roster no longer\n * has, and the uid itself covers the rest.\n */\nexport function ActivityCard(props: ActivityCardProps) {\n const { report } = props\n const { scope, tokens, range, period, routes } = report\n const firestore = useFirestore()\n const roster = useOrgMemberOptions(scope[1])\n\n const activitiesBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.activities),\n ...visibleToClause(tokens),\n where('atMs', '>=', from),\n where('atMs', '<', to),\n )\n const tasksDoneBetween = (from: number, to: number) =>\n query(\n scopedCollection(firestore, scope, Aglyn.CRM_COLLECTIONS.tasks),\n ...visibleToClause(tokens),\n where('status', '==', 'done'),\n where('completedAtMs', '>=', from),\n where('completedAtMs', '<', to),\n )\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n const activityCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(activitiesBetween(range.from, range.to)),\n countOf(activitiesBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:counts') },\n )\n const taskCounts = useAggregateRead(\n () =>\n Promise.all([\n countOf(tasksDoneBetween(range.from, range.to)),\n countOf(tasksDoneBetween(range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-counts') },\n )\n\n const activityWindow = useWindowRead<ActivityRow>(\n () =>\n query(\n activitiesBetween(range.from, range.to),\n orderBy('atMs', 'desc'),\n limit(ACTIVITY_CEILING + 1),\n ),\n ACTIVITY_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:window') },\n )\n const taskWindow = useWindowRead<TaskRow>(\n () =>\n query(\n tasksDoneBetween(range.from, range.to),\n orderBy('completedAtMs', 'desc'),\n limit(TASK_DONE_CEILING + 1),\n ),\n TASK_DONE_CEILING,\n [firestore, scope, tokens, range],\n { cacheKey: reportCacheKey(report, 'activity:tasks-window') },\n )\n\n const rows = useMemo(\n () => activityLeaderboard(activityWindow.rows, taskWindow.rows),\n [activityWindow, taskWindow],\n )\n const nameOf = (row: ActivityLeaderboardRow): string => {\n if (!row.uid) return 'No teammate'\n return Aglyn.findOrgMember(roster.options, row.uid)?.label ?? row.name ?? row.uid\n }\n const teammates = rows.filter((row) => row.uid).length\n\n const activitiesRead = activityWindow.status === 'success'\n const tasksRead = taskWindow.status === 'success'\n const settled = activityWindow.status !== 'loading' && taskWindow.status !== 'loading'\n const caption = [\n activityWindow.truncated\n ? `Grouped from the ${ACTIVITY_CEILING.toLocaleString()} most recent activities in the period`\n : null,\n taskWindow.truncated\n ? `the ${TASK_DONE_CEILING.toLocaleString()} most recently completed tasks`\n : null,\n ]\n .filter(Boolean)\n .join(' and ')\n const activityFigures = activityCounts.value\n const taskFigures = taskCounts.value\n\n return (\n <CardDisplay\n header={'Activity by teammate'}\n help={Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#activity-by-teammate',\n excerpt:\n 'The calls, emails, messages, meetings and notes each teammate logged in ' +\n 'the period, and the tasks they completed, busiest first. ' +\n 'Grouped from the period’s newest thousand of each.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <ReportExport\n filename={reportFilename('activity', period)}\n columns={COLUMNS}\n rows={() =>\n rows.map((row) => [\n nameOf(row),\n ...Aglyn.CRM_ACTIVITY_KINDS.map((kind) => row.kinds[kind]),\n row.activities,\n tasksRead ? row.tasksDone : '',\n ])\n }\n disabled={!activitiesRead || !rows.length}\n />\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack useFlexGap direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Activities logged'}\n value={activityFigures ? activityFigures.current.toLocaleString() : null}\n deltaPct={\n activityFigures\n ? deltaPercent(activityFigures.current, activityFigures.previous)\n : null\n }\n note={'counted on the server'}\n href={routes.section('contacts')}\n />\n <ReportStatTile\n label={'Tasks completed'}\n value={taskFigures ? taskFigures.current.toLocaleString() : null}\n deltaPct={\n taskFigures\n ? deltaPercent(taskFigures.current, taskFigures.previous)\n : null\n }\n note={'ticked off in the period'}\n href={routes.section('tasks')}\n />\n <ReportStatTile\n label={'Teammates active'}\n value={settled ? teammates.toLocaleString() : null}\n note={'logged or completed something'}\n />\n </Stack>\n {activityCounts.status === 'error' || activityWindow.status === 'error' ? (\n <Alert severity=\"warning\">{'The activities could not be read.'}</Alert>\n ) : null}\n {taskCounts.status === 'error' || taskWindow.status === 'error' ? (\n <Alert severity=\"warning\">\n {'The completed-task counts could not be read; the Tasks done column is left blank.'}\n </Alert>\n ) : null}\n <Section title={'Who did what'}>\n {rows.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n {COLUMNS.map((column, index) => (\n <TableCell key={column} align={index ? 'right' : 'left'}>\n {column}\n </TableCell>\n ))}\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.uid || '$nobody'}>\n <TableCell>{nameOf(row)}</TableCell>\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <TableCell key={kind} align=\"right\">\n {row.kinds[kind].toLocaleString()}\n </TableCell>\n ))}\n <TableCell align=\"right\">{row.activities.toLocaleString()}</TableCell>\n <TableCell align=\"right\">\n {tasksRead ? row.tasksDone.toLocaleString() : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled ? 'Nothing logged or completed in this period.' : 'Reading…'}\n </Typography>\n )}\n {rows.some((row) => !row.uid) ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No teammate — activities an automation logged, and tasks completed with nobody assigned.'}\n </Typography>\n ) : null}\n {/* What the table, and so the export, is grouped from when that is not everything. */}\n {caption ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${caption}; the tiles are counted on the server.`}\n </Typography>\n ) : null}\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nActivityCard.displayName = 'ActivityCard'\n\nexport default ActivityCard\n"],"names":["Aglyn","CardDisplay","Section","ScrollTable","Alert","Stack","TableBody","TableCell","TableHead","TableRow","Typography","getCountFromServer","limit","orderBy","query","where","useMemo","useFirestore","useOrgMemberOptions","ReportExport","reportFilename","reportCacheKey","scopedCollection","visibleToClause","ReportStatTile","useAggregateRead","useWindowRead","activityLeaderboard","deltaPercent","ACTIVITY_CEILING","TASK_DONE_CEILING","COLUMNS","CRM_ACTIVITY_KINDS","map","kind","CRM_ACTIVITY_KIND_LABELS","ActivityCard","props","report","scope","tokens","range","period","routes","firestore","roster","activitiesBetween","from","to","CRM_COLLECTIONS","activities","tasksDoneBetween","tasks","countOf","target","then","snapshot","data","count","activityCounts","Promise","all","previousFrom","previousTo","current","previous","cacheKey","taskCounts","activityWindow","taskWindow","rows","nameOf","row","uid","findOrgMember","options","label","name","teammates","filter","length","activitiesRead","status","tasksRead","settled","caption","truncated","toLocaleString","Boolean","join","activityFigures","value","taskFigures","header","help","pluginDocsHelp","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","filename","columns","kinds","tasksDone","disabled","spacing","useFlexGap","direction","sx","flexWrap","deltaPct","note","href","section","severity","title","size","column","index","align","variant","color","some","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,YAAYA,WAAW,eAAc;AACrC,SAASC,WAAW,QAAQ,uBAAsB;AAClD,SAASC,OAAO,QAAQ,6DAA4D;AACpF,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SACEC,KAAK,EACLC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SACEC,kBAAkB,EAClBC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,mBAAmB,QAAQ,wCAAoC;AACxE,SAASC,YAAY,QAAQ,qBAAiB;AAC9C,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SAEEC,cAAc,EACdC,gBAAgB,EAChBC,eAAe,QACV,oBAAgB;AACvB,SAASC,cAAc,QAAQ,wBAAoB;AACnD,SAASC,gBAAgB,EAAEC,aAAa,QAAQ,0BAAsB;AACtE,SACEC,mBAAmB,EAEnBC,YAAY,QACP,6BAAyB;AAEhC;;;;;;;;CAQC,GACD,MAAMC,mBAAmB;AACzB,MAAMC,oBAAoB;AAK1B,mDAAmD,GACnD,MAAMC,UAAU;IACd;OACG/B,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASlC,MAAMmC,wBAAwB,CAACD,KAAK;IAC9E;IACA;CACD;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+CC,GACD,OAAO,SAASE,aAAaC,KAAwB;IACnD,MAAM,EAAEC,MAAM,EAAE,GAAGD;IACnB,MAAM,EAAEE,KAAK,EAAEC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,MAAM,EAAE,GAAGL;IACjD,MAAMM,YAAY3B;IAClB,MAAM4B,SAAS3B,oBAAoBqB,KAAK,CAAC,EAAE;IAE3C,MAAMO,oBAAoB,CAACC,MAAcC,KACvClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACC,UAAU,MAChE3B,gBAAgBiB,SACnBzB,MAAM,QAAQ,MAAMgC,OACpBhC,MAAM,QAAQ,KAAKiC;IAEvB,MAAMG,mBAAmB,CAACJ,MAAcC,KACtClC,MACEQ,iBAAiBsB,WAAWL,OAAOvC,MAAMiD,eAAe,CAACG,KAAK,MAC3D7B,gBAAgBiB,SACnBzB,MAAM,UAAU,MAAM,SACtBA,MAAM,iBAAiB,MAAMgC,OAC7BhC,MAAM,iBAAiB,KAAKiC;IAEhC,MAAMK,UAAU,CAACC,SACf3C,mBAAmB2C,QAAQC,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,GAAGC,KAAK;IAErE,MAAMC,iBAAiBlC,iBACrB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQP,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC9CK,QAAQP,kBAAkBL,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC/D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM6B,aAAa1C,iBACjB,IACEmC,QAAQC,GAAG,CAAC;YACVR,QAAQF,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE;YAC7CK,QAAQF,iBAAiBV,MAAMqB,YAAY,EAAErB,MAAMsB,UAAU;SAC9D,EAAER,IAAI,CAAC,CAAC,CAACS,SAASC,SAAS,GAAM,CAAA;gBAAED;gBAASC;YAAS,CAAA,IACxD;QAACrB;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAM8B,iBAAiB1C,cACrB,IACEZ,MACEgC,kBAAkBL,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACtCnC,QAAQ,QAAQ,SAChBD,MAAMiB,mBAAmB,KAE7BA,kBACA;QAACe;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAmB;IAExD,MAAM+B,aAAa3C,cACjB,IACEZ,MACEqC,iBAAiBV,MAAMM,IAAI,EAAEN,MAAMO,EAAE,GACrCnC,QAAQ,iBAAiB,SACzBD,MAAMkB,oBAAoB,KAE9BA,mBACA;QAACc;QAAWL;QAAOC;QAAQC;KAAM,EACjC;QAAEyB,UAAU7C,eAAeiB,QAAQ;IAAyB;IAG9D,MAAMgC,OAAOtD,QACX,IAAMW,oBAAoByC,eAAeE,IAAI,EAAED,WAAWC,IAAI,GAC9D;QAACF;QAAgBC;KAAW;IAE9B,MAAME,SAAS,CAACC;YAEPxE;YAAAA;QADP,IAAI,CAACwE,IAAIC,GAAG,EAAE,OAAO;QACrB,QAAOzE,iBAAAA,uBAAAA,MAAM0E,aAAa,CAAC7B,OAAO8B,OAAO,EAAEH,IAAIC,GAAG,sBAA3CzE,qBAA8C4E,KAAK,oBAAIJ,IAAIK,IAAI,YAA/D7E,OAAmEwE,IAAIC,GAAG;IACnF;IACA,MAAMK,YAAYR,KAAKS,MAAM,CAAC,CAACP,MAAQA,IAAIC,GAAG,EAAEO,MAAM;IAEtD,MAAMC,iBAAiBb,eAAec,MAAM,KAAK;IACjD,MAAMC,YAAYd,WAAWa,MAAM,KAAK;IACxC,MAAME,UAAUhB,eAAec,MAAM,KAAK,aAAab,WAAWa,MAAM,KAAK;IAC7E,MAAMG,UAAU;QACdjB,eAAekB,SAAS,GACpB,CAAC,iBAAiB,EAAEzD,iBAAiB0D,cAAc,GAAG,qCAAqC,CAAC,GAC5F;QACJlB,WAAWiB,SAAS,GAChB,CAAC,IAAI,EAAExD,kBAAkByD,cAAc,GAAG,8BAA8B,CAAC,GACzE;KACL,CACER,MAAM,CAACS,SACPC,IAAI,CAAC;IACR,MAAMC,kBAAkB/B,eAAegC,KAAK;IAC5C,MAAMC,cAAczB,WAAWwB,KAAK;IAEpC,qBACE,KAAC1F;QACC4F,QAAQ;QACRC,MAAM9F,MAAM+F,cAAc,CAAC,cAAc;YACvCC,QAAQ;YACRC,SACE,6EACA,8DACA;QACJ;QACAC,cAAc;QACdC,cAAc;QACdC,aAAa;YACXC,sBACE,KAAClF;gBACCmF,UAAUlF,eAAe,YAAYsB;gBACrC6D,SAASxE;gBACTuC,MAAM,IACJA,KAAKrC,GAAG,CAAC,CAACuC,MAAQ;4BAChBD,OAAOC;+BACJxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,OAASsC,IAAIgC,KAAK,CAACtE,KAAK;4BACzDsC,IAAItB,UAAU;4BACdiC,YAAYX,IAAIiC,SAAS,GAAG;yBAC7B;gBAEHC,UAAU,CAACzB,kBAAkB,CAACX,KAAKU,MAAM;;QAG/C;kBAEA,cAAA,MAAC3E;YAAMsG,SAAS;;8BACd,MAACtG;oBAAMuG,UAAU;oBAACC,WAAU;oBAAMF,SAAS;oBAAGG,IAAI;wBAAEC,UAAU;oBAAO;;sCACnE,KAACvF;4BACCoD,OAAO;4BACPe,OAAOD,kBAAkBA,gBAAgB1B,OAAO,CAACuB,cAAc,KAAK;4BACpEyB,UACEtB,kBACI9D,aAAa8D,gBAAgB1B,OAAO,EAAE0B,gBAAgBzB,QAAQ,IAC9D;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOC,cAAcA,YAAY5B,OAAO,CAACuB,cAAc,KAAK;4BAC5DyB,UACEpB,cACIhE,aAAagE,YAAY5B,OAAO,EAAE4B,YAAY3B,QAAQ,IACtD;4BAENgD,MAAM;4BACNC,MAAMvE,OAAOwE,OAAO,CAAC;;sCAEvB,KAAC3F;4BACCoD,OAAO;4BACPe,OAAOP,UAAUN,UAAUS,cAAc,KAAK;4BAC9C0B,MAAM;;;;gBAGTtD,eAAeuB,MAAM,KAAK,WAAWd,eAAec,MAAM,KAAK,wBAC9D,KAAC9E;oBAAMgH,UAAS;8BAAW;qBACzB;gBACHjD,WAAWe,MAAM,KAAK,WAAWb,WAAWa,MAAM,KAAK,wBACtD,KAAC9E;oBAAMgH,UAAS;8BACb;qBAED;8BACJ,MAAClH;oBAAQmH,OAAO;;wBACb/C,KAAKU,MAAM,iBACV,MAAC7E;4BAAYmH,MAAK;;8CAChB,KAAC9G;8CACC,cAAA,KAACC;kDACEsB,QAAQE,GAAG,CAAC,CAACsF,QAAQC,sBACpB,KAACjH;gDAAuBkH,OAAOD,QAAQ,UAAU;0DAC9CD;+CADaA;;;8CAMtB,KAACjH;8CACEgE,KAAKrC,GAAG,CAAC,CAACuC,oBACT,MAAC/D;;8DACC,KAACF;8DAAWgE,OAAOC;;gDAClBxE,MAAMgC,kBAAkB,CAACC,GAAG,CAAC,CAACC,qBAC7B,KAAC3B;wDAAqBkH,OAAM;kEACzBjD,IAAIgC,KAAK,CAACtE,KAAK,CAACqD,cAAc;uDADjBrD;8DAIlB,KAAC3B;oDAAUkH,OAAM;8DAASjD,IAAItB,UAAU,CAACqC,cAAc;;8DACvD,KAAChF;oDAAUkH,OAAM;8DACdtC,YAAYX,IAAIiC,SAAS,CAAClB,cAAc,KAAK;;;2CATnCf,IAAIC,GAAG,IAAI;;;2CAgBhC,KAAC/D;4BAAWgH,SAAQ;4BAAQC,OAAM;sCAC/BvC,UAAU,gDAAgD;;wBAG9Dd,KAAKsD,IAAI,CAAC,CAACpD,MAAQ,CAACA,IAAIC,GAAG,kBAC1B,KAAC/D;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC;6BAED;wBAEHtC,wBACC,KAAC3E;4BAAWgH,SAAQ;4BAAUC,OAAM;sCACjC,GAAGtC,QAAQ,sCAAsC,CAAC;6BAEnD;;;;;;AAKd;AACAjD,aAAayF,WAAW,GAAG;AAE3B,eAAezF,aAAY"}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
export interface ScrollEdges {
|
|
18
|
+
/** Callback ref for the box that scrolls sideways. */
|
|
19
|
+
ref: (node: HTMLElement | null) => void;
|
|
20
|
+
/** Whether there is more to the left of what is in view. */
|
|
21
|
+
start: boolean;
|
|
22
|
+
/** Whether there is more to the right of what is in view. */
|
|
23
|
+
end: boolean;
|
|
24
|
+
/** Scrolls most of a view's width toward the start (`-1`) or the end (`1`). */
|
|
25
|
+
page: (direction: -1 | 1) => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Which way a sideways-scrolling box has more to show, and a way to page
|
|
29
|
+
* it — the facts a pair of scroll arrows needs, the way MUI's Tabs draws
|
|
30
|
+
* them. A scrollbar alone says nothing on a trackpad Mac, where it stays
|
|
31
|
+
* hidden until the reader already knows to scroll.
|
|
32
|
+
*
|
|
33
|
+
* Re-measured on scroll and whenever the box or its content changes size,
|
|
34
|
+
* so a column collapsing, a deal arriving or the window narrowing moves the
|
|
35
|
+
* arrows with it. A page is most of a view rather than all of it, so the
|
|
36
|
+
* column at the edge stays in sight as a landmark.
|
|
37
|
+
*/
|
|
38
|
+
export declare function useScrollEdges(): ScrollEdges;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { useCallback, useEffect, useState } from "react";
|
|
18
|
+
/**
|
|
19
|
+
* Which way a sideways-scrolling box has more to show, and a way to page
|
|
20
|
+
* it — the facts a pair of scroll arrows needs, the way MUI's Tabs draws
|
|
21
|
+
* them. A scrollbar alone says nothing on a trackpad Mac, where it stays
|
|
22
|
+
* hidden until the reader already knows to scroll.
|
|
23
|
+
*
|
|
24
|
+
* Re-measured on scroll and whenever the box or its content changes size,
|
|
25
|
+
* so a column collapsing, a deal arriving or the window narrowing moves the
|
|
26
|
+
* arrows with it. A page is most of a view rather than all of it, so the
|
|
27
|
+
* column at the edge stays in sight as a landmark.
|
|
28
|
+
*/ export function useScrollEdges() {
|
|
29
|
+
const [node, setNode] = useState(null);
|
|
30
|
+
const [edges, setEdges] = useState({
|
|
31
|
+
start: false,
|
|
32
|
+
end: false
|
|
33
|
+
});
|
|
34
|
+
useEffect(()=>{
|
|
35
|
+
if (!node) return;
|
|
36
|
+
const measure = ()=>{
|
|
37
|
+
const max = node.scrollWidth - node.clientWidth;
|
|
38
|
+
const start = node.scrollLeft > 1;
|
|
39
|
+
const end = node.scrollLeft < max - 1;
|
|
40
|
+
setEdges((previous)=>previous.start === start && previous.end === end ? previous : {
|
|
41
|
+
start,
|
|
42
|
+
end
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
measure();
|
|
46
|
+
node.addEventListener('scroll', measure, {
|
|
47
|
+
passive: true
|
|
48
|
+
});
|
|
49
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
|
|
50
|
+
observer == null ? void 0 : observer.observe(node);
|
|
51
|
+
if (node.firstElementChild) observer == null ? void 0 : observer.observe(node.firstElementChild);
|
|
52
|
+
return ()=>{
|
|
53
|
+
node.removeEventListener('scroll', measure);
|
|
54
|
+
observer == null ? void 0 : observer.disconnect();
|
|
55
|
+
};
|
|
56
|
+
}, [
|
|
57
|
+
node
|
|
58
|
+
]);
|
|
59
|
+
const page = useCallback((direction)=>{
|
|
60
|
+
node == null ? void 0 : node.scrollBy({
|
|
61
|
+
left: direction * node.clientWidth * 0.8,
|
|
62
|
+
behavior: 'smooth'
|
|
63
|
+
});
|
|
64
|
+
}, [
|
|
65
|
+
node
|
|
66
|
+
]);
|
|
67
|
+
return {
|
|
68
|
+
ref: setNode,
|
|
69
|
+
start: edges.start,
|
|
70
|
+
end: edges.end,
|
|
71
|
+
page
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
//# sourceMappingURL=use-scroll-edges.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-scroll-edges.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'use client'\n\nimport { useCallback, useEffect, useState } from 'react'\n\nexport interface ScrollEdges {\n /** Callback ref for the box that scrolls sideways. */\n ref: (node: HTMLElement | null) => void\n /** Whether there is more to the left of what is in view. */\n start: boolean\n /** Whether there is more to the right of what is in view. */\n end: boolean\n /** Scrolls most of a view's width toward the start (`-1`) or the end (`1`). */\n page: (direction: -1 | 1) => void\n}\n\n/**\n * Which way a sideways-scrolling box has more to show, and a way to page\n * it — the facts a pair of scroll arrows needs, the way MUI's Tabs draws\n * them. A scrollbar alone says nothing on a trackpad Mac, where it stays\n * hidden until the reader already knows to scroll.\n *\n * Re-measured on scroll and whenever the box or its content changes size,\n * so a column collapsing, a deal arriving or the window narrowing moves the\n * arrows with it. A page is most of a view rather than all of it, so the\n * column at the edge stays in sight as a landmark.\n */\nexport function useScrollEdges(): ScrollEdges {\n const [node, setNode] = useState<HTMLElement | null>(null)\n const [edges, setEdges] = useState({ start: false, end: false })\n\n useEffect(() => {\n if (!node) return\n const measure = () => {\n const max = node.scrollWidth - node.clientWidth\n const start = node.scrollLeft > 1\n const end = node.scrollLeft < max - 1\n setEdges((previous) =>\n previous.start === start && previous.end === end ? previous : { start, end },\n )\n }\n measure()\n node.addEventListener('scroll', measure, { passive: true })\n const observer =\n typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure)\n observer?.observe(node)\n if (node.firstElementChild) observer?.observe(node.firstElementChild)\n return () => {\n node.removeEventListener('scroll', measure)\n observer?.disconnect()\n }\n }, [node])\n\n const page = useCallback(\n (direction: -1 | 1) => {\n node?.scrollBy({ left: direction * node.clientWidth * 0.8, behavior: 'smooth' })\n },\n [node],\n )\n\n return { ref: setNode, start: edges.start, end: edges.end, page }\n}\n"],"names":["useCallback","useEffect","useState","useScrollEdges","node","setNode","edges","setEdges","start","end","measure","max","scrollWidth","clientWidth","scrollLeft","previous","addEventListener","passive","observer","ResizeObserver","observe","firstElementChild","removeEventListener","disconnect","page","direction","scrollBy","left","behavior","ref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;AAEA,SAASA,WAAW,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAaxD;;;;;;;;;;CAUC,GACD,OAAO,SAASC;IACd,MAAM,CAACC,MAAMC,QAAQ,GAAGH,SAA6B;IACrD,MAAM,CAACI,OAAOC,SAAS,GAAGL,SAAS;QAAEM,OAAO;QAAOC,KAAK;IAAM;IAE9DR,UAAU;QACR,IAAI,CAACG,MAAM;QACX,MAAMM,UAAU;YACd,MAAMC,MAAMP,KAAKQ,WAAW,GAAGR,KAAKS,WAAW;YAC/C,MAAML,QAAQJ,KAAKU,UAAU,GAAG;YAChC,MAAML,MAAML,KAAKU,UAAU,GAAGH,MAAM;YACpCJ,SAAS,CAACQ,WACRA,SAASP,KAAK,KAAKA,SAASO,SAASN,GAAG,KAAKA,MAAMM,WAAW;oBAAEP;oBAAOC;gBAAI;QAE/E;QACAC;QACAN,KAAKY,gBAAgB,CAAC,UAAUN,SAAS;YAAEO,SAAS;QAAK;QACzD,MAAMC,WACJ,OAAOC,mBAAmB,cAAc,OAAO,IAAIA,eAAeT;QACpEQ,4BAAAA,SAAUE,OAAO,CAAChB;QAClB,IAAIA,KAAKiB,iBAAiB,EAAEH,4BAAAA,SAAUE,OAAO,CAAChB,KAAKiB,iBAAiB;QACpE,OAAO;YACLjB,KAAKkB,mBAAmB,CAAC,UAAUZ;YACnCQ,4BAAAA,SAAUK,UAAU;QACtB;IACF,GAAG;QAACnB;KAAK;IAET,MAAMoB,OAAOxB,YACX,CAACyB;QACCrB,wBAAAA,KAAMsB,QAAQ,CAAC;YAAEC,MAAMF,YAAYrB,KAAKS,WAAW,GAAG;YAAKe,UAAU;QAAS;IAChF,GACA;QAACxB;KAAK;IAGR,OAAO;QAAEyB,KAAKxB;QAASG,OAAOF,MAAME,KAAK;QAAEC,KAAKH,MAAMG,GAAG;QAAEe;IAAK;AAClE"}
|
|
@@ -25,17 +25,11 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
25
25
|
* rather than what somebody later wished had been. It also has to hang off
|
|
26
26
|
* something — a contact, a company or a deal — because an activity attached
|
|
27
27
|
* to nothing is a sentence nobody can find from anywhere.
|
|
28
|
-
*/ import { CRM_COLLECTIONS, CRM_ACTIVITY_DIRECTIONS, CRM_ACTIVITY_LOG_FULL_MESSAGE, createResourceUid, crmActivityDirection, crmActivityLogHasRoom } from "@aglyn/aglyn/server";
|
|
28
|
+
*/ import { CRM_COLLECTIONS, CRM_ACTIVITY_DIRECTIONS, CRM_ACTIVITY_KINDS, CRM_ACTIVITY_LOG_FULL_MESSAGE, createResourceUid, crmActivityDirection, crmActivityLogHasRoom } from "@aglyn/aglyn/server";
|
|
29
29
|
import { apiJson, ApiErrors, countCrmActivitiesForRecord } from "@aglyn/tenant-data-admin";
|
|
30
30
|
import { claimWrite, readJsonBody, requireScope } from "@aglyn/tenant-data-admin/server/api-v1-kit";
|
|
31
31
|
import { CRM_LABEL_MAX, CRM_TEXT_MAX, CRM_TITLE_MAX, createPayload, crmCollection, crmCreateStamp, crmRefErrors, crmTimes, crmValidationFailed, isoFromMs, listCrm, memberError, parseIsoInstant, readChoice, readCrmSite, readEqualityFilters, readOptionalText, readRefId, refuseUnknownKeys } from "./crm-shared.js";
|
|
32
|
-
const ACTIVITY_KINDS =
|
|
33
|
-
'call',
|
|
34
|
-
'email',
|
|
35
|
-
'meeting',
|
|
36
|
-
'note',
|
|
37
|
-
'other'
|
|
38
|
-
];
|
|
32
|
+
/** The kinds the API accepts: the CRM's own list, so a kind the console logs is one the API takes. */ const ACTIVITY_KINDS = CRM_ACTIVITY_KINDS;
|
|
39
33
|
/** The activity object as published. */ function activityView(doc) {
|
|
40
34
|
var _doc_data, _data_kind, _data_body, _data_byUid, _data_contactId, _data_companyId, _data_dealId, _data_outcome, _data_hostId;
|
|
41
35
|
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-activities.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 * `/v1/activities` (AGL-2606) — one thing that happened, logged by a person\n * or by an integration: a call made, a meeting held, a note taken.\n *\n * An activity is a LOG ENTRY, and a log is written once. There is no\n * `PATCH`: an entry that turns out to be wrong is deleted and logged again,\n * which keeps every row's `at` and `byUid` the record of what was logged\n * rather than what somebody later wished had been. It also has to hang off\n * something — a contact, a company or a deal — because an activity attached\n * to nothing is a sentence nobody can find from anywhere.\n */\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmActivityKind,\n CRM_ACTIVITY_DIRECTIONS,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n type CrmActivityDirection,\n createResourceUid,\n crmActivityDirection,\n crmActivityLogHasRoom,\n} from '@aglyn/aglyn/server'\nimport {\n apiJson,\n ApiErrors,\n countCrmActivitiesForRecord,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ApiV1Context,\n claimWrite,\n readJsonBody,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport {\n CRM_LABEL_MAX,\n CRM_TEXT_MAX,\n CRM_TITLE_MAX,\n createPayload,\n crmCollection,\n crmCreateStamp,\n crmRefErrors,\n crmTimes,\n crmValidationFailed,\n isoFromMs,\n listCrm,\n memberError,\n parseIsoInstant,\n readChoice,\n readCrmSite,\n readEqualityFilters,\n readOptionalText,\n readRefId,\n refuseUnknownKeys,\n} from './crm-shared'\n\nconst ACTIVITY_KINDS = ['call', 'email', 'meeting', 'note', 'other'] as const\n\n/** The activity object as published. */\nfunction activityView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = (doc.data() ?? {}) as Partial<CrmActivity>\n return {\n id: doc.id,\n object: 'activity',\n kind: data.kind ?? 'note',\n body: data.body ?? null,\n at: isoFromMs(data.atMs),\n byUid: data.byUid ?? null,\n contactId: data.contactId ?? null,\n companyId: data.companyId ?? null,\n dealId: data.dealId ?? null,\n outcome: data.outcome ?? null,\n durationMinutes:\n typeof data.durationMinutes === 'number' ? data.durationMinutes : null,\n direction: crmActivityDirection(data.kind, data.direction),\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\nconst ACTIVITY_WRITABLE = new Set([\n 'kind',\n 'body',\n 'at',\n 'byUid',\n 'contactId',\n 'companyId',\n 'dealId',\n 'outcome',\n 'durationMinutes',\n 'direction',\n 'consentSiteId',\n])\n\ninterface ActivityInput {\n kind?: CrmActivityKind\n body: string\n atMs?: number\n byUid?: string\n contactId?: string\n companyId?: string\n dealId?: string\n outcome?: string\n durationMinutes?: number\n direction?: CrmActivityDirection\n}\n\nfunction readActivityInput(\n body: Record<string, unknown>,\n): { values: ActivityInput } | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n refuseUnknownKeys(body, ACTIVITY_WRITABLE, 'activity', errors)\n\n const text = String(body.body ?? '')\n .trim()\n .slice(0, CRM_TEXT_MAX)\n if (!text) errors.body = 'A body is required'\n const values: ActivityInput = { body: text }\n\n const kind = readChoice(body, 'kind', ACTIVITY_KINDS, errors)\n if (kind) values.kind = kind\n\n if (body.at !== undefined) {\n const ms = parseIsoInstant(body.at)\n if (ms === null) errors.at = 'Must be an ISO 8601 instant, like 2026-09-04T16:30:00Z'\n else values.atMs = ms\n }\n\n const byUid = readOptionalText(body, 'byUid', CRM_TITLE_MAX, errors)\n if (byUid) values.byUid = byUid\n for (const field of ['contactId', 'companyId', 'dealId'] as const) {\n const id = readRefId(body, field, errors)\n if (id) values[field] = id\n }\n if (!values.contactId && !values.companyId && !values.dealId && !errors.contactId) {\n errors.contactId = 'Name a contactId, companyId or dealId — an activity is logged against something'\n }\n\n const outcome = readOptionalText(body, 'outcome', CRM_LABEL_MAX, errors)\n if (outcome) values.outcome = outcome\n\n if (body.durationMinutes !== undefined && body.durationMinutes !== null) {\n if (\n typeof body.durationMinutes !== 'number' ||\n !Number.isInteger(body.durationMinutes) ||\n body.durationMinutes < 0\n ) {\n errors.durationMinutes = 'Must be a whole number of minutes, 0 or more'\n } else {\n values.durationMinutes = body.durationMinutes\n }\n }\n\n // Which way a call or an email went (AGL-3517); no other kind takes one.\n if (body.direction !== undefined && body.direction !== null) {\n const kind = values.kind ?? 'note'\n const direction = crmActivityDirection(kind, body.direction)\n const allowed = CRM_ACTIVITY_DIRECTIONS[kind] ?? []\n if (direction) values.direction = direction\n else {\n errors.direction = allowed.length\n ? `Must be one of: ${allowed.join(', ')}`\n : 'Only a call or an email takes a direction'\n }\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\n/**\n * `POST /v1/activities`. `at` defaults to now and `byUid` to `'api'`: an\n * integration logging a call as it ends is the common case, and the one\n * logging it on behalf of a member names them.\n */\nasync function createActivity(request: Request, ctx: ApiV1Context): Promise<Response> {\n const body = await readJsonBody(request)\n const parsed = readActivityInput(body)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'activity', parsed.errors)\n const site = readCrmSite(ctx, 'activity', body)\n if ('response' in site) return site.response\n const [by, refs] = await Promise.all([\n memberError(ctx, 'byUid', parsed.values.byUid),\n crmRefErrors(ctx, {\n contactId: parsed.values.contactId,\n companyId: parsed.values.companyId,\n dealId: parsed.values.dealId,\n }),\n ])\n const refErrors = { ...by, ...refs }\n if (Object.keys(refErrors).length) {\n return crmValidationFailed(ctx, 'activity', refErrors)\n }\n\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activities',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n const { kind, atMs, byUid, ...rest } = parsed.values\n // The per-record ceiling (AGL-2611): one aggregate on the record the\n // activity is filed under. A platform bound rather than a plan one, so\n // it is a conflict with the record's state, not a plan refusal.\n const logged = await countCrmActivitiesForRecord(\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n parsed.values,\n )\n if (!crmActivityLogHasRoom(logged)) {\n await claim.release()\n return ApiErrors.conflict({\n message: CRM_ACTIVITY_LOG_FULL_MESSAGE,\n code: 'activity_log_full',\n headers: ctx.headers,\n })\n }\n const id = createResourceUid()\n const stamp = crmCreateStamp(ctx, site.siteId)\n await collection.doc(id).create({\n kind: kind ?? 'note',\n // The instant the record was created, from the same clock as its\n // `createdAt`, so an activity logged as it happened reads as one moment.\n atMs: atMs ?? stamp.createdAt.toMillis(),\n byUid: byUid ?? 'api',\n ...createPayload(rest),\n ...stamp,\n })\n const view = activityView(await collection.doc(id).get())\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `DELETE /v1/activities/{id}`. */\nasync function deleteActivity(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activity-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n try {\n const snap = await ref.get()\n if (!snap.exists) {\n await claim.release()\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n await ref.delete()\n const view = { id: ref.id, object: 'activity', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `GET /v1/activities` filters, most selective first. */\nasync function listActivities(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawKind = url.searchParams.get('kind')\n if (rawKind !== null && rawKind.trim() !== '' && !(ACTIVITY_KINDS as readonly string[]).includes(rawKind.trim())) {\n return crmValidationFailed(ctx, 'activity filter', {\n kind: `Must be one of: ${ACTIVITY_KINDS.join(', ')}`,\n })\n }\n const filters = readEqualityFilters(url, ['dealId', 'contactId', 'companyId', 'kind'])\n return listCrm(ctx, collection, url, filters, activityView)\n}\n\nexport async function handleActivities(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const [, activityId] = segments\n\n if (!activityId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n return listActivities(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return createActivity(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const ref = collection.doc(activityId)\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n return apiJson(activityView(snap), { headers: ctx.headers })\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return deleteActivity(request, ctx, ref)\n }\n // No PATCH — see the module header. The `Allow` header says so rather\n // than a 404 that reads as \"no such activity\".\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, DELETE' },\n })\n}\n"],"names":["CRM_COLLECTIONS","CRM_ACTIVITY_DIRECTIONS","CRM_ACTIVITY_LOG_FULL_MESSAGE","createResourceUid","crmActivityDirection","crmActivityLogHasRoom","apiJson","ApiErrors","countCrmActivitiesForRecord","claimWrite","readJsonBody","requireScope","CRM_LABEL_MAX","CRM_TEXT_MAX","CRM_TITLE_MAX","createPayload","crmCollection","crmCreateStamp","crmRefErrors","crmTimes","crmValidationFailed","isoFromMs","listCrm","memberError","parseIsoInstant","readChoice","readCrmSite","readEqualityFilters","readOptionalText","readRefId","refuseUnknownKeys","ACTIVITY_KINDS","activityView","doc","data","id","object","kind","body","at","atMs","byUid","contactId","companyId","dealId","outcome","durationMinutes","direction","siteId","hostId","ACTIVITY_WRITABLE","Set","readActivityInput","errors","text","String","trim","slice","values","undefined","ms","field","Number","isInteger","allowed","length","join","Object","keys","createActivity","request","ctx","parsed","site","response","by","refs","Promise","all","refErrors","collection","activities","claimed","headers","get","replay","claim","rest","logged","firestore","orgId","release","conflict","message","code","stamp","create","createdAt","toMillis","view","record","status","error","deleteActivity","ref","snap","exists","notFound","delete","deleted","listActivities","url","rawKind","searchParams","includes","filters","handleActivities","segments","activityId","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-activities.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 * `/v1/activities` (AGL-2606) — one thing that happened, logged by a person\n * or by an integration: a call made, a meeting held, a note taken.\n *\n * An activity is a LOG ENTRY, and a log is written once. There is no\n * `PATCH`: an entry that turns out to be wrong is deleted and logged again,\n * which keeps every row's `at` and `byUid` the record of what was logged\n * rather than what somebody later wished had been. It also has to hang off\n * something — a contact, a company or a deal — because an activity attached\n * to nothing is a sentence nobody can find from anywhere.\n */\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmActivityKind,\n CRM_ACTIVITY_DIRECTIONS,\n CRM_ACTIVITY_KINDS,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n type CrmActivityDirection,\n createResourceUid,\n crmActivityDirection,\n crmActivityLogHasRoom,\n} from '@aglyn/aglyn/server'\nimport {\n apiJson,\n ApiErrors,\n countCrmActivitiesForRecord,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ApiV1Context,\n claimWrite,\n readJsonBody,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport {\n CRM_LABEL_MAX,\n CRM_TEXT_MAX,\n CRM_TITLE_MAX,\n createPayload,\n crmCollection,\n crmCreateStamp,\n crmRefErrors,\n crmTimes,\n crmValidationFailed,\n isoFromMs,\n listCrm,\n memberError,\n parseIsoInstant,\n readChoice,\n readCrmSite,\n readEqualityFilters,\n readOptionalText,\n readRefId,\n refuseUnknownKeys,\n} from './crm-shared'\n\n/** The kinds the API accepts: the CRM's own list, so a kind the console logs is one the API takes. */\nconst ACTIVITY_KINDS = CRM_ACTIVITY_KINDS\n\n/** The activity object as published. */\nfunction activityView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = (doc.data() ?? {}) as Partial<CrmActivity>\n return {\n id: doc.id,\n object: 'activity',\n kind: data.kind ?? 'note',\n body: data.body ?? null,\n at: isoFromMs(data.atMs),\n byUid: data.byUid ?? null,\n contactId: data.contactId ?? null,\n companyId: data.companyId ?? null,\n dealId: data.dealId ?? null,\n outcome: data.outcome ?? null,\n durationMinutes:\n typeof data.durationMinutes === 'number' ? data.durationMinutes : null,\n direction: crmActivityDirection(data.kind, data.direction),\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\nconst ACTIVITY_WRITABLE = new Set([\n 'kind',\n 'body',\n 'at',\n 'byUid',\n 'contactId',\n 'companyId',\n 'dealId',\n 'outcome',\n 'durationMinutes',\n 'direction',\n 'consentSiteId',\n])\n\ninterface ActivityInput {\n kind?: CrmActivityKind\n body: string\n atMs?: number\n byUid?: string\n contactId?: string\n companyId?: string\n dealId?: string\n outcome?: string\n durationMinutes?: number\n direction?: CrmActivityDirection\n}\n\nfunction readActivityInput(\n body: Record<string, unknown>,\n): { values: ActivityInput } | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n refuseUnknownKeys(body, ACTIVITY_WRITABLE, 'activity', errors)\n\n const text = String(body.body ?? '')\n .trim()\n .slice(0, CRM_TEXT_MAX)\n if (!text) errors.body = 'A body is required'\n const values: ActivityInput = { body: text }\n\n const kind = readChoice(body, 'kind', ACTIVITY_KINDS, errors)\n if (kind) values.kind = kind\n\n if (body.at !== undefined) {\n const ms = parseIsoInstant(body.at)\n if (ms === null) errors.at = 'Must be an ISO 8601 instant, like 2026-09-04T16:30:00Z'\n else values.atMs = ms\n }\n\n const byUid = readOptionalText(body, 'byUid', CRM_TITLE_MAX, errors)\n if (byUid) values.byUid = byUid\n for (const field of ['contactId', 'companyId', 'dealId'] as const) {\n const id = readRefId(body, field, errors)\n if (id) values[field] = id\n }\n if (!values.contactId && !values.companyId && !values.dealId && !errors.contactId) {\n errors.contactId = 'Name a contactId, companyId or dealId — an activity is logged against something'\n }\n\n const outcome = readOptionalText(body, 'outcome', CRM_LABEL_MAX, errors)\n if (outcome) values.outcome = outcome\n\n if (body.durationMinutes !== undefined && body.durationMinutes !== null) {\n if (\n typeof body.durationMinutes !== 'number' ||\n !Number.isInteger(body.durationMinutes) ||\n body.durationMinutes < 0\n ) {\n errors.durationMinutes = 'Must be a whole number of minutes, 0 or more'\n } else {\n values.durationMinutes = body.durationMinutes\n }\n }\n\n // Which way a call or an email went (AGL-3517); no other kind takes one.\n if (body.direction !== undefined && body.direction !== null) {\n const kind = values.kind ?? 'note'\n const direction = crmActivityDirection(kind, body.direction)\n const allowed = CRM_ACTIVITY_DIRECTIONS[kind] ?? []\n if (direction) values.direction = direction\n else {\n errors.direction = allowed.length\n ? `Must be one of: ${allowed.join(', ')}`\n : 'Only a call or an email takes a direction'\n }\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\n/**\n * `POST /v1/activities`. `at` defaults to now and `byUid` to `'api'`: an\n * integration logging a call as it ends is the common case, and the one\n * logging it on behalf of a member names them.\n */\nasync function createActivity(request: Request, ctx: ApiV1Context): Promise<Response> {\n const body = await readJsonBody(request)\n const parsed = readActivityInput(body)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'activity', parsed.errors)\n const site = readCrmSite(ctx, 'activity', body)\n if ('response' in site) return site.response\n const [by, refs] = await Promise.all([\n memberError(ctx, 'byUid', parsed.values.byUid),\n crmRefErrors(ctx, {\n contactId: parsed.values.contactId,\n companyId: parsed.values.companyId,\n dealId: parsed.values.dealId,\n }),\n ])\n const refErrors = { ...by, ...refs }\n if (Object.keys(refErrors).length) {\n return crmValidationFailed(ctx, 'activity', refErrors)\n }\n\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activities',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n const { kind, atMs, byUid, ...rest } = parsed.values\n // The per-record ceiling (AGL-2611): one aggregate on the record the\n // activity is filed under. A platform bound rather than a plan one, so\n // it is a conflict with the record's state, not a plan refusal.\n const logged = await countCrmActivitiesForRecord(\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n parsed.values,\n )\n if (!crmActivityLogHasRoom(logged)) {\n await claim.release()\n return ApiErrors.conflict({\n message: CRM_ACTIVITY_LOG_FULL_MESSAGE,\n code: 'activity_log_full',\n headers: ctx.headers,\n })\n }\n const id = createResourceUid()\n const stamp = crmCreateStamp(ctx, site.siteId)\n await collection.doc(id).create({\n kind: kind ?? 'note',\n // The instant the record was created, from the same clock as its\n // `createdAt`, so an activity logged as it happened reads as one moment.\n atMs: atMs ?? stamp.createdAt.toMillis(),\n byUid: byUid ?? 'api',\n ...createPayload(rest),\n ...stamp,\n })\n const view = activityView(await collection.doc(id).get())\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `DELETE /v1/activities/{id}`. */\nasync function deleteActivity(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activity-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n try {\n const snap = await ref.get()\n if (!snap.exists) {\n await claim.release()\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n await ref.delete()\n const view = { id: ref.id, object: 'activity', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `GET /v1/activities` filters, most selective first. */\nasync function listActivities(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawKind = url.searchParams.get('kind')\n if (rawKind !== null && rawKind.trim() !== '' && !(ACTIVITY_KINDS as readonly string[]).includes(rawKind.trim())) {\n return crmValidationFailed(ctx, 'activity filter', {\n kind: `Must be one of: ${ACTIVITY_KINDS.join(', ')}`,\n })\n }\n const filters = readEqualityFilters(url, ['dealId', 'contactId', 'companyId', 'kind'])\n return listCrm(ctx, collection, url, filters, activityView)\n}\n\nexport async function handleActivities(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const [, activityId] = segments\n\n if (!activityId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n return listActivities(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return createActivity(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const ref = collection.doc(activityId)\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n return apiJson(activityView(snap), { headers: ctx.headers })\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return deleteActivity(request, ctx, ref)\n }\n // No PATCH — see the module header. The `Allow` header says so rather\n // than a 404 that reads as \"no such activity\".\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, DELETE' },\n })\n}\n"],"names":["CRM_COLLECTIONS","CRM_ACTIVITY_DIRECTIONS","CRM_ACTIVITY_KINDS","CRM_ACTIVITY_LOG_FULL_MESSAGE","createResourceUid","crmActivityDirection","crmActivityLogHasRoom","apiJson","ApiErrors","countCrmActivitiesForRecord","claimWrite","readJsonBody","requireScope","CRM_LABEL_MAX","CRM_TEXT_MAX","CRM_TITLE_MAX","createPayload","crmCollection","crmCreateStamp","crmRefErrors","crmTimes","crmValidationFailed","isoFromMs","listCrm","memberError","parseIsoInstant","readChoice","readCrmSite","readEqualityFilters","readOptionalText","readRefId","refuseUnknownKeys","ACTIVITY_KINDS","activityView","doc","data","id","object","kind","body","at","atMs","byUid","contactId","companyId","dealId","outcome","durationMinutes","direction","siteId","hostId","ACTIVITY_WRITABLE","Set","readActivityInput","errors","text","String","trim","slice","values","undefined","ms","field","Number","isInteger","allowed","length","join","Object","keys","createActivity","request","ctx","parsed","site","response","by","refs","Promise","all","refErrors","collection","activities","claimed","headers","get","replay","claim","rest","logged","firestore","orgId","release","conflict","message","code","stamp","create","createdAt","toMillis","view","record","status","error","deleteActivity","ref","snap","exists","notFound","delete","deleted","listActivities","url","rawKind","searchParams","includes","filters","handleActivities","segments","activityId","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
*
|
|
20
20
|
* The digest beside this route says once a morning what is late and what
|
|
21
21
|
* is due today; nothing said "the 3:00 call is at 3:00". Every hour this
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* finds the orgs with an open task whose `remindAtMs` has come
|
|
23
|
+
* (`orgIdsWithDueReminders`), reads those orgs' tasks and tells each one's
|
|
24
|
+
* assignee ONCE — a console notification per task
|
|
24
25
|
* (`content.taskReminder`, opening the record the task is for) and one
|
|
25
26
|
* email per member per run listing everything of theirs that fell due.
|
|
26
27
|
* The words and the rule live in `crm-task-reminders.ts`; this file is the
|
|
@@ -48,8 +49,8 @@
|
|
|
48
49
|
* not come back every hour. A reminder is left unstamped only when the run
|
|
49
50
|
* never reached a verdict: the send-rate governor refused, or the org
|
|
50
51
|
* threw. Orgs are swept in id order behind a cursor, as the digest sweeps
|
|
51
|
-
* them, so the workflow's `done:false` loop carries
|
|
52
|
-
*
|
|
52
|
+
* them, so the workflow's `done:false` loop carries any number of them
|
|
53
|
+
* across invocations.
|
|
53
54
|
*
|
|
54
55
|
* The task query reads the NEWEST reminders that have come due, not the
|
|
55
56
|
* oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a
|
|
@@ -70,7 +71,8 @@ export declare const CRM_TASK_REMINDERS_JOB_ID = "crm-task-reminders";
|
|
|
70
71
|
* More than the digest's twenty-five: an org here costs one task query and,
|
|
71
72
|
* only when something fell due, the roster and the sends. The common hour
|
|
72
73
|
* finds nothing due in most orgs, and a chunk that reads a hundred empty
|
|
73
|
-
* queries finishes well inside `maxDuration = 60
|
|
74
|
+
* queries finishes well inside `maxDuration = 60`, which is what the whole
|
|
75
|
+
* platform costs on the night `orgIdsWithDueReminders` cannot answer.
|
|
74
76
|
*/
|
|
75
77
|
export declare const CRM_TASK_REMINDERS_ORG_CHUNK = 100;
|
|
76
78
|
/**
|
|
@@ -49,8 +49,9 @@ import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailParts, composeCr
|
|
|
49
49
|
*
|
|
50
50
|
* The digest beside this route says once a morning what is late and what
|
|
51
51
|
* is due today; nothing said "the 3:00 call is at 3:00". Every hour this
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
* finds the orgs with an open task whose `remindAtMs` has come
|
|
53
|
+
* (`orgIdsWithDueReminders`), reads those orgs' tasks and tells each one's
|
|
54
|
+
* assignee ONCE — a console notification per task
|
|
54
55
|
* (`content.taskReminder`, opening the record the task is for) and one
|
|
55
56
|
* email per member per run listing everything of theirs that fell due.
|
|
56
57
|
* The words and the rule live in `crm-task-reminders.ts`; this file is the
|
|
@@ -78,8 +79,8 @@ import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailParts, composeCr
|
|
|
78
79
|
* not come back every hour. A reminder is left unstamped only when the run
|
|
79
80
|
* never reached a verdict: the send-rate governor refused, or the org
|
|
80
81
|
* threw. Orgs are swept in id order behind a cursor, as the digest sweeps
|
|
81
|
-
* them, so the workflow's `done:false` loop carries
|
|
82
|
-
*
|
|
82
|
+
* them, so the workflow's `done:false` loop carries any number of them
|
|
83
|
+
* across invocations.
|
|
83
84
|
*
|
|
84
85
|
* The task query reads the NEWEST reminders that have come due, not the
|
|
85
86
|
* oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a
|
|
@@ -98,13 +99,55 @@ import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailParts, composeCr
|
|
|
98
99
|
* More than the digest's twenty-five: an org here costs one task query and,
|
|
99
100
|
* only when something fell due, the roster and the sends. The common hour
|
|
100
101
|
* finds nothing due in most orgs, and a chunk that reads a hundred empty
|
|
101
|
-
* queries finishes well inside `maxDuration = 60
|
|
102
|
+
* queries finishes well inside `maxDuration = 60`, which is what the whole
|
|
103
|
+
* platform costs on the night `orgIdsWithDueReminders` cannot answer.
|
|
102
104
|
*/ export const CRM_TASK_REMINDERS_ORG_CHUNK = 100;
|
|
105
|
+
/** Tasks per page of the platform-wide due query. */ const DUE_TASK_PAGE = 500;
|
|
103
106
|
/**
|
|
104
107
|
* The most members per org whose address is chased through the auth pools
|
|
105
108
|
* when their member document carries none — the digest's bound, for the
|
|
106
109
|
* digest's reason: work, not audience.
|
|
107
110
|
*/ export const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5;
|
|
111
|
+
/**
|
|
112
|
+
* THE ORGS WITH A REMINDER OWED, found from the tasks (AGL-4121).
|
|
113
|
+
*
|
|
114
|
+
* The sweep used to walk the orgs to find them: every org document read, and
|
|
115
|
+
* every entitled org's tasks asked, every hour, to learn that nothing had come
|
|
116
|
+
* due in almost all of them. Asked of the collection group, an hour with
|
|
117
|
+
* nothing due is one query and no org at all.
|
|
118
|
+
*
|
|
119
|
+
* Every page is read, not the newest, and that is what keeps this honest.
|
|
120
|
+
* `CRM_TASK_REMINDER_CEILING` is safe per org because the newest five hundred
|
|
121
|
+
* of ONE org begin with what fell due since the last run; across the platform
|
|
122
|
+
* one busy org's handled reminders could bury a quiet org's first one. This
|
|
123
|
+
* pays once for the same open tasks the org queries read, projected to what
|
|
124
|
+
* the rule needs, and `remindOrg` then decides from its own window exactly as
|
|
125
|
+
* it always has. An org found here with nothing inside that window reports
|
|
126
|
+
* `nothing-due`, as it did.
|
|
127
|
+
*
|
|
128
|
+
* Needs the COLLECTION_GROUP `(status, remindAtMs desc)` index, and the
|
|
129
|
+
* `crmTasks` entry for it is deployed after the code that first asks: see the
|
|
130
|
+
* caller for what happens until then.
|
|
131
|
+
*/ async function orgIdsWithDueReminders(firestore, nowMs) {
|
|
132
|
+
const orgIds = new Set();
|
|
133
|
+
let after;
|
|
134
|
+
for(;;){
|
|
135
|
+
let query = firestore.collectionGroup(CRM_COLLECTIONS.tasks).where('status', '==', 'open').where('remindAtMs', '<=', nowMs).orderBy('remindAtMs', 'desc').select('status', 'remindAtMs', 'reminderSentAtMs').limit(DUE_TASK_PAGE);
|
|
136
|
+
if (after) query = query.startAfter(after);
|
|
137
|
+
const page = await query.get();
|
|
138
|
+
for (const doc of page.docs){
|
|
139
|
+
const org = doc.ref.parent.parent;
|
|
140
|
+
if ((org == null ? void 0 : org.parent.id) !== 'orgs') continue;
|
|
141
|
+
if (crmTaskReminderDue(doc.data(), nowMs)) {
|
|
142
|
+
orgIds.add(org.id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (page.docs.length < DUE_TASK_PAGE) return [
|
|
146
|
+
...orgIds
|
|
147
|
+
].sort();
|
|
148
|
+
after = page.docs[page.docs.length - 1];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
108
151
|
async function remindOrg(ctx, orgDoc) {
|
|
109
152
|
var _orgDoc_data, _org_slug, _org_name;
|
|
110
153
|
const { firestore, nowMs, timeZone, dryRun } = ctx;
|
|
@@ -415,8 +458,6 @@ export async function crmTaskRemindersRoute(request) {
|
|
|
415
458
|
// nothing due still ran.
|
|
416
459
|
if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_JOB_ID);
|
|
417
460
|
try {
|
|
418
|
-
var _ref;
|
|
419
|
-
var _orgDocs_;
|
|
420
461
|
const firestore = firebaseAdmin.app().firestore();
|
|
421
462
|
const nowMs = Date.now();
|
|
422
463
|
const ctx = {
|
|
@@ -428,17 +469,43 @@ export async function crmTaskRemindersRoute(request) {
|
|
|
428
469
|
origin: consoleOrigin()
|
|
429
470
|
};
|
|
430
471
|
// The resumable sweep, in the shape the digest reads orgs (AGL-2220):
|
|
431
|
-
// ordered by id,
|
|
432
|
-
// validated as an org id before it becomes a reference.
|
|
472
|
+
// ordered by id, one more than a chunk to learn whether there is more, the
|
|
473
|
+
// cursor validated as an org id before it becomes a reference.
|
|
433
474
|
const requestedLimit = Number(body == null ? void 0 : body.limit);
|
|
434
475
|
const pageSize = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(Math.floor(requestedLimit), CRM_TASK_REMINDERS_ORG_CHUNK) : CRM_TASK_REMINDERS_ORG_CHUNK;
|
|
435
476
|
const rawCursor = body == null ? void 0 : body.cursor;
|
|
436
477
|
const cursor = typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/') ? rawCursor : null;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
478
|
+
/*
|
|
479
|
+
* Only the orgs with something owed. A due query that fails is not an hour
|
|
480
|
+
* with nothing due, so it never answers for one: it falls back to walking
|
|
481
|
+
* every org, which needs no collection-group index and is what makes this
|
|
482
|
+
* safe to ship ahead of the index it uses. Composite indexes deploy in the
|
|
483
|
+
* promotion's tail, and a reminder must not wait on the order of that tail.
|
|
484
|
+
*/ const withDue = await orgIdsWithDueReminders(firestore, nowMs).catch((error)=>{
|
|
485
|
+
console.warn('[crm] task reminders: the due query failed, sweeping every org', error);
|
|
486
|
+
return null;
|
|
487
|
+
});
|
|
488
|
+
let orgDocs;
|
|
489
|
+
let hasMore;
|
|
490
|
+
/** Where the next chunk starts, whichever way this one was chosen. */ let lastOrgId;
|
|
491
|
+
if (withDue) {
|
|
492
|
+
var _chunk_;
|
|
493
|
+
const ahead = cursor ? withDue.filter((orgId)=>orgId > cursor) : withDue;
|
|
494
|
+
const chunk = ahead.slice(0, pageSize);
|
|
495
|
+
hasMore = ahead.length > pageSize;
|
|
496
|
+
lastOrgId = (_chunk_ = chunk[chunk.length - 1]) != null ? _chunk_ : null;
|
|
497
|
+
// A task can outlive its org by a moment; there is nobody to tell.
|
|
498
|
+
orgDocs = chunk.length ? (await firestore.getAll(...chunk.map((orgId)=>firestore.collection('orgs').doc(orgId)))).filter((doc)=>doc.exists) : [];
|
|
499
|
+
} else {
|
|
500
|
+
var _ref;
|
|
501
|
+
var _orgDocs_;
|
|
502
|
+
let orgQuery = firestore.collection('orgs').orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(pageSize + 1);
|
|
503
|
+
if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor));
|
|
504
|
+
const page = await orgQuery.get();
|
|
505
|
+
hasMore = page.docs.length > pageSize;
|
|
506
|
+
orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs;
|
|
507
|
+
lastOrgId = (_ref = (_orgDocs_ = orgDocs[orgDocs.length - 1]) == null ? void 0 : _orgDocs_.id) != null ? _ref : null;
|
|
508
|
+
}
|
|
442
509
|
const orgs = {};
|
|
443
510
|
const failures = {};
|
|
444
511
|
let due = 0;
|
|
@@ -481,7 +548,7 @@ export async function crmTaskRemindersRoute(request) {
|
|
|
481
548
|
} : {}, {
|
|
482
549
|
// A deferred run reports `done: true` — see the module comment —
|
|
483
550
|
// so the workflow's loop does not re-POST into a full window.
|
|
484
|
-
nextCursor: deferred || !hasMore ? null :
|
|
551
|
+
nextCursor: deferred || !hasMore ? null : lastOrgId,
|
|
485
552
|
done: deferred || !hasMore
|
|
486
553
|
}), // 207: this chunk finished and some org in it needs a person, which is
|
|
487
554
|
// what fails the workflow (its one alerting channel).
|