@aglyn/plugins-crm 1.0.0-beta.198 → 1.0.0-beta.200
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 +13 -13
- package/src/lib/components/companies-section.d.ts +6 -6
- package/src/lib/components/companies-section.js +54 -84
- package/src/lib/components/companies-section.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +17 -4
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-picker.js +6 -2
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +14 -2
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.d.ts +3 -5
- package/src/lib/components/contact-list-columns.js +6 -8
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contacts-bulk-bar.js +10 -1
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +115 -163
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-next-activity-column.d.ts +4 -2
- package/src/lib/components/crm-next-activity-column.js +6 -3
- package/src/lib/components/crm-next-activity-column.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +8 -4
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-section.d.ts +2 -2
- package/src/lib/components/deals-section.js +59 -84
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +11 -3
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +9 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +16 -3
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.d.ts +2 -1
- package/src/lib/components/leads-section.js +110 -148
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/tasks-section.js +128 -121
- package/src/lib/components/tasks-section.js.map +1 -1
- package/src/lib/constants/company-filters.d.ts +13 -0
- package/src/lib/constants/company-filters.js +47 -17
- package/src/lib/constants/company-filters.js.map +1 -1
- package/src/lib/constants/contact-filters.d.ts +61 -10
- package/src/lib/constants/contact-filters.js +373 -141
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/constants/deal-filters.d.ts +44 -0
- package/src/lib/constants/deal-filters.js +78 -0
- package/src/lib/constants/deal-filters.js.map +1 -0
- package/src/lib/hooks/use-crm-list-query.d.ts +68 -0
- package/src/lib/hooks/use-crm-list-query.js +182 -0
- package/src/lib/hooks/use-crm-list-query.js.map +1 -0
- package/src/lib/hooks/use-crm-tasks.d.ts +23 -6
- package/src/lib/hooks/use-crm-tasks.js +44 -42
- package/src/lib/hooks/use-crm-tasks.js.map +1 -1
- package/src/lib/hooks/use-deals.d.ts +2 -34
- package/src/lib/hooks/use-deals.js +3 -62
- package/src/lib/hooks/use-deals.js.map +1 -1
- package/src/lib/model/crm-list-query.d.ts +91 -0
- package/src/lib/model/crm-list-query.js +182 -0
- package/src/lib/model/crm-list-query.js.map +1 -0
- package/src/lib/model/lead-filters.d.ts +49 -43
- package/src/lib/model/lead-filters.js +194 -88
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/list-fields-api.d.ts +26 -0
- package/src/lib/model/list-fields-api.js +47 -0
- package/src/lib/model/list-fields-api.js.map +1 -0
- package/src/lib/model/list-fields.d.ts +47 -0
- package/src/lib/model/list-fields.js +39 -0
- package/src/lib/model/list-fields.js.map +1 -0
- package/src/lib/model/task-views.d.ts +15 -10
- package/src/lib/model/task-views.js +68 -9
- package/src/lib/model/task-views.js.map +1 -1
- package/src/lib/server/companies-import.js +3 -3
- package/src/lib/server/companies-import.js.map +1 -1
- package/src/lib/server/company-delete.js +3 -1
- package/src/lib/server/company-delete.js.map +1 -1
- package/src/lib/server/consent-group-participant.js +4 -0
- package/src/lib/server/consent-group-participant.js.map +1 -1
- package/src/lib/server/contact-update.js +14 -8
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/contacts-import.js +4 -2
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/deals-import.js +4 -2
- package/src/lib/server/deals-import.js.map +1 -1
- package/src/lib/server/erase-person.js +2 -1
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/lead-create.js +4 -1
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-source-values.js +9 -5
- package/src/lib/server/lead-source-values.js.map +1 -1
- package/src/lib/server/leads-import.js +4 -1
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/list-fields-routes.d.ts +29 -0
- package/src/lib/server/list-fields-routes.js +85 -0
- package/src/lib/server/list-fields-routes.js.map +1 -0
- package/src/lib/server/record-email-state.js +5 -2
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-timeline.js +3 -2
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-routes.js +6 -3
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/server/tasks-import.js +5 -2
- package/src/lib/server/tasks-import.js.map +1 -1
- package/src/lib/server.js +10 -1
- package/src/lib/server.js.map +1 -1
- package/src/lib/hooks/use-org-leads.d.ts +0 -50
- package/src/lib/hooks/use-org-leads.js +0 -104
- package/src/lib/hooks/use-org-leads.js.map +0 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
18
|
+
import { CRM_LIST_FIELDS_IDS_MAX, crmListFieldsRouteUrl } from "./list-fields.js";
|
|
19
|
+
/**
|
|
20
|
+
* After a client-direct write the browser cannot follow up itself: ask the
|
|
21
|
+
* list-fields route to restamp the records (AGL-3321). Never throws — the
|
|
22
|
+
* write already happened, and a record left stale is what the backfill and
|
|
23
|
+
* the next write bring level.
|
|
24
|
+
*/ export async function restampCrmListFields(user, scope, collection, ids) {
|
|
25
|
+
const unique = [
|
|
26
|
+
...new Set(ids.filter(Boolean))
|
|
27
|
+
].slice(0, CRM_LIST_FIELDS_IDS_MAX);
|
|
28
|
+
if (!scope || !unique.length) return;
|
|
29
|
+
const body = _extends({}, scope, {
|
|
30
|
+
collection,
|
|
31
|
+
ids: unique
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
const response = await authorizedFetch(user, crmListFieldsRouteUrl(), {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json'
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify(body)
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) console.warn('[crm] list fields were not restamped', response.status);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.warn('[crm] list fields were not restamped', error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//# sourceMappingURL=list-fields-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/list-fields-api.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 { CrmListCollection } from '@aglyn/aglyn'\nimport {\n authorizedFetch,\n type MaybeTokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\nimport {\n CRM_LIST_FIELDS_IDS_MAX,\n type CrmListFieldsRequest,\n crmListFieldsRouteUrl,\n} from './list-fields'\nimport type { CrmTaskRouteScope } from './task-routes'\n\n/**\n * After a client-direct write the browser cannot follow up itself: ask the\n * list-fields route to restamp the records (AGL-3321). Never throws — the\n * write already happened, and a record left stale is what the backfill and\n * the next write bring level.\n */\nexport async function restampCrmListFields(\n user: MaybeTokenSource,\n scope: CrmTaskRouteScope | null,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<void> {\n const unique = [...new Set(ids.filter(Boolean))].slice(0, CRM_LIST_FIELDS_IDS_MAX)\n if (!scope || !unique.length) return\n const body: CrmListFieldsRequest = { ...scope, collection, ids: unique }\n try {\n const response = await authorizedFetch(user, crmListFieldsRouteUrl(), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!response.ok) console.warn('[crm] list fields were not restamped', response.status)\n } catch (error) {\n console.warn('[crm] list fields were not restamped', error)\n }\n}\n"],"names":["authorizedFetch","CRM_LIST_FIELDS_IDS_MAX","crmListFieldsRouteUrl","restampCrmListFields","user","scope","collection","ids","unique","Set","filter","Boolean","slice","length","body","response","method","headers","JSON","stringify","ok","console","warn","status","error"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,eAAe,QAEV,2CAA0C;AACjD,SACEC,uBAAuB,EAEvBC,qBAAqB,QAChB,mBAAe;AAGtB;;;;;CAKC,GACD,OAAO,eAAeC,qBACpBC,IAAsB,EACtBC,KAA+B,EAC/BC,UAA6B,EAC7BC,GAAsB;IAEtB,MAAMC,SAAS;WAAI,IAAIC,IAAIF,IAAIG,MAAM,CAACC;KAAU,CAACC,KAAK,CAAC,GAAGX;IAC1D,IAAI,CAACI,SAAS,CAACG,OAAOK,MAAM,EAAE;IAC9B,MAAMC,OAA6B,aAAKT;QAAOC;QAAYC,KAAKC;;IAChE,IAAI;QACF,MAAMO,WAAW,MAAMf,gBAAgBI,MAAMF,yBAAyB;YACpEc,QAAQ;YACRC,SAAS;gBAAE,gBAAgB;YAAmB;YAC9CH,MAAMI,KAAKC,SAAS,CAACL;QACvB;QACA,IAAI,CAACC,SAASK,EAAE,EAAEC,QAAQC,IAAI,CAAC,wCAAwCP,SAASQ,MAAM;IACxF,EAAE,OAAOC,OAAO;QACdH,QAAQC,IAAI,CAAC,wCAAwCE;IACvD;AACF"}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { CrmListCollection } from '@aglyn/aglyn';
|
|
18
|
+
import type { CrmTaskRouteScope } from './task-routes';
|
|
19
|
+
/**
|
|
20
|
+
* The contract between the console and the plugin's list-fields route
|
|
21
|
+
* (AGL-3321), the door a CLIENT-DIRECT write uses to keep the fields a CRM
|
|
22
|
+
* list queries honest.
|
|
23
|
+
*
|
|
24
|
+
* Those fields — the search tokens, a contact's facet keys — are derived
|
|
25
|
+
* from the record (`crmListFields` in `@aglyn/aglyn`). A client that
|
|
26
|
+
* creates a record, or edits one from the listener's copy, stamps them
|
|
27
|
+
* itself. A write the browser may not follow up — a holder letting go of a
|
|
28
|
+
* contact, which the rules narrow to removing its own half — asks this
|
|
29
|
+
* route to read the record back and restamp it. Best effort, like the
|
|
30
|
+
* next-activity route beside it: the write already happened.
|
|
31
|
+
*/
|
|
32
|
+
export declare const CRM_LIST_FIELDS_ROUTE: "crm/list-fields";
|
|
33
|
+
export declare const crmListFieldsRouteUrl: () => string;
|
|
34
|
+
/** The most records one request restamps — the bulk bar's own window. */
|
|
35
|
+
export declare const CRM_LIST_FIELDS_IDS_MAX = 200;
|
|
36
|
+
/** The collections the route restamps. */
|
|
37
|
+
export declare const CRM_LIST_FIELDS_COLLECTIONS: readonly CrmListCollection[];
|
|
38
|
+
export type CrmListFieldsRequest = CrmTaskRouteScope & {
|
|
39
|
+
collection: CrmListCollection;
|
|
40
|
+
ids: string[];
|
|
41
|
+
};
|
|
42
|
+
export interface CrmListFieldsResponse {
|
|
43
|
+
ok: true;
|
|
44
|
+
restamped: number;
|
|
45
|
+
current: number;
|
|
46
|
+
missing: number;
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* The contract between the console and the plugin's list-fields route
|
|
18
|
+
* (AGL-3321), the door a CLIENT-DIRECT write uses to keep the fields a CRM
|
|
19
|
+
* list queries honest.
|
|
20
|
+
*
|
|
21
|
+
* Those fields — the search tokens, a contact's facet keys — are derived
|
|
22
|
+
* from the record (`crmListFields` in `@aglyn/aglyn`). A client that
|
|
23
|
+
* creates a record, or edits one from the listener's copy, stamps them
|
|
24
|
+
* itself. A write the browser may not follow up — a holder letting go of a
|
|
25
|
+
* contact, which the rules narrow to removing its own half — asks this
|
|
26
|
+
* route to read the record back and restamp it. Best effort, like the
|
|
27
|
+
* next-activity route beside it: the write already happened.
|
|
28
|
+
*/ export const CRM_LIST_FIELDS_ROUTE = 'crm/list-fields';
|
|
29
|
+
export const crmListFieldsRouteUrl = ()=>`/api/${CRM_LIST_FIELDS_ROUTE}`;
|
|
30
|
+
/** The most records one request restamps — the bulk bar's own window. */ export const CRM_LIST_FIELDS_IDS_MAX = 200;
|
|
31
|
+
/** The collections the route restamps. */ export const CRM_LIST_FIELDS_COLLECTIONS = [
|
|
32
|
+
'leads',
|
|
33
|
+
'contacts',
|
|
34
|
+
'companies',
|
|
35
|
+
'deals',
|
|
36
|
+
'crmTasks'
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
//# sourceMappingURL=list-fields.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/list-fields.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 { CrmListCollection } from '@aglyn/aglyn'\nimport type { CrmTaskRouteScope } from './task-routes'\n\n/**\n * The contract between the console and the plugin's list-fields route\n * (AGL-3321), the door a CLIENT-DIRECT write uses to keep the fields a CRM\n * list queries honest.\n *\n * Those fields — the search tokens, a contact's facet keys — are derived\n * from the record (`crmListFields` in `@aglyn/aglyn`). A client that\n * creates a record, or edits one from the listener's copy, stamps them\n * itself. A write the browser may not follow up — a holder letting go of a\n * contact, which the rules narrow to removing its own half — asks this\n * route to read the record back and restamp it. Best effort, like the\n * next-activity route beside it: the write already happened.\n */\nexport const CRM_LIST_FIELDS_ROUTE = 'crm/list-fields' as const\n\nexport const crmListFieldsRouteUrl = (): string => `/api/${CRM_LIST_FIELDS_ROUTE}`\n\n/** The most records one request restamps — the bulk bar's own window. */\nexport const CRM_LIST_FIELDS_IDS_MAX = 200\n\n/** The collections the route restamps. */\nexport const CRM_LIST_FIELDS_COLLECTIONS: readonly CrmListCollection[] = [\n 'leads',\n 'contacts',\n 'companies',\n 'deals',\n 'crmTasks',\n]\n\nexport type CrmListFieldsRequest = CrmTaskRouteScope & {\n collection: CrmListCollection\n ids: string[]\n}\n\nexport interface CrmListFieldsResponse {\n ok: true\n restamped: number\n current: number\n missing: number\n}\n"],"names":["CRM_LIST_FIELDS_ROUTE","crmListFieldsRouteUrl","CRM_LIST_FIELDS_IDS_MAX","CRM_LIST_FIELDS_COLLECTIONS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAKD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMA,wBAAwB,kBAA0B;AAE/D,OAAO,MAAMC,wBAAwB,IAAc,CAAC,KAAK,EAAED,uBAAuB,CAAA;AAElF,uEAAuE,GACvE,OAAO,MAAME,0BAA0B,IAAG;AAE1C,wCAAwC,GACxC,OAAO,MAAMC,8BAA4D;IACvE;IACA;IACA;IACA;IACA;CACD,CAAA"}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import { type CrmTask, type CrmTaskKind, type CrmTaskPriority, type TaskDueState } from '@aglyn/aglyn';
|
|
18
|
+
import type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter';
|
|
19
|
+
import type { ListQueryDeclaration, ListQueryFilter, ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
18
20
|
import type { CrmRoutes } from './crm-routes';
|
|
19
21
|
/**
|
|
20
22
|
* The tasks section's views and the query each one is (AGL-2599).
|
|
@@ -35,16 +37,6 @@ export declare const CRM_TASK_KINDS: readonly CrmTaskKind[];
|
|
|
35
37
|
export declare const CRM_TASK_KIND_LABELS: Record<CrmTaskKind, string>;
|
|
36
38
|
export declare const CRM_TASK_PRIORITIES: readonly CrmTaskPriority[];
|
|
37
39
|
export declare const CRM_TASK_PRIORITY_LABELS: Record<CrmTaskPriority, string>;
|
|
38
|
-
/**
|
|
39
|
-
* How many rows a view reads at most.
|
|
40
|
-
*
|
|
41
|
-
* Every view is a listener, and a listener with no cap is a bill that grows
|
|
42
|
-
* with the org. Two hundred is more than a person works through in a sitting
|
|
43
|
-
* and far under what a table paints comfortably; the list says when the
|
|
44
|
-
* window is full so a reader knows to narrow the view rather than assume
|
|
45
|
-
* the end of the table is the end of the work.
|
|
46
|
-
*/
|
|
47
|
-
export declare const CRM_TASK_VIEW_LIMIT = 200;
|
|
48
40
|
/** Midnight at the START of the local calendar day `nowMs` falls in. */
|
|
49
41
|
export declare function startOfLocalDay(nowMs: number): number;
|
|
50
42
|
/**
|
|
@@ -76,6 +68,19 @@ export declare function crmTaskViewPlan(view: CrmTaskView, options: {
|
|
|
76
68
|
nowMs: number;
|
|
77
69
|
uid: string | null | undefined;
|
|
78
70
|
}): CrmTaskViewPlan;
|
|
71
|
+
/** The orders the list takes: soonest first, and Done's most recently due first. */
|
|
72
|
+
export declare const TASK_LIST_SORTS: readonly ListQuerySort[];
|
|
73
|
+
/** What the Filters panel may name on a task, besides the view. */
|
|
74
|
+
export declare const TASK_LIST_QUERY_FIELDS: readonly ListFilterField[];
|
|
75
|
+
export declare const TASK_LIST_DECLARATION: ListQueryDeclaration;
|
|
76
|
+
/** The view's own predicates, which the plan takes as its base beside the scope. */
|
|
77
|
+
export declare function crmTaskViewBase(plan: CrmTaskViewPlan): ListQueryFilter[];
|
|
78
|
+
/** The base's shape for `listQueryIndexes`: the view's two equalities. */
|
|
79
|
+
export declare const TASK_LIST_VIEW_BASE_INDEX: readonly [{
|
|
80
|
+
readonly path: "status";
|
|
81
|
+
}, {
|
|
82
|
+
readonly path: "assigneeUid";
|
|
83
|
+
}];
|
|
79
84
|
/**
|
|
80
85
|
* Rows in the order a view should paint them.
|
|
81
86
|
*
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { taskDueState } from "@aglyn/aglyn";
|
|
17
|
+
import { CRM_LIST_SEARCH, crmSelectField } from "./crm-list-query.js";
|
|
17
18
|
export const CRM_TASK_VIEWS = [
|
|
18
19
|
{
|
|
19
20
|
id: 'mine',
|
|
@@ -62,15 +63,6 @@ export const CRM_TASK_PRIORITY_LABELS = {
|
|
|
62
63
|
normal: 'Normal',
|
|
63
64
|
high: 'High'
|
|
64
65
|
};
|
|
65
|
-
/**
|
|
66
|
-
* How many rows a view reads at most.
|
|
67
|
-
*
|
|
68
|
-
* Every view is a listener, and a listener with no cap is a bill that grows
|
|
69
|
-
* with the org. Two hundred is more than a person works through in a sitting
|
|
70
|
-
* and far under what a table paints comfortably; the list says when the
|
|
71
|
-
* window is full so a reader knows to narrow the view rather than assume
|
|
72
|
-
* the end of the table is the end of the work.
|
|
73
|
-
*/ export const CRM_TASK_VIEW_LIMIT = 200;
|
|
74
66
|
/** Midnight at the START of the local calendar day `nowMs` falls in. */ export function startOfLocalDay(nowMs) {
|
|
75
67
|
const date = new Date(nowMs);
|
|
76
68
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
@@ -129,6 +121,73 @@ export function crmTaskViewPlan(view, options) {
|
|
|
129
121
|
};
|
|
130
122
|
}
|
|
131
123
|
}
|
|
124
|
+
/*==========================================
|
|
125
|
+
* THE TASKS LIST'S QUERY (AGL-3321).
|
|
126
|
+
*
|
|
127
|
+
* The view is the query's base — its status, the reader as assignee for
|
|
128
|
+
* "My tasks", and the `dueAtMs` window for Overdue, Today and Upcoming —
|
|
129
|
+
* and Kind, Priority, Assignee and the search word are predicates on the
|
|
130
|
+
* same query, paged by it. Every equality rides one `(field, dueAtMs)`
|
|
131
|
+
* composite per direction: ascending for the working views, descending for
|
|
132
|
+
* Done.
|
|
133
|
+
*=========================================*/ /** The orders the list takes: soonest first, and Done's most recently due first. */ export const TASK_LIST_SORTS = [
|
|
134
|
+
{
|
|
135
|
+
path: 'dueAtMs',
|
|
136
|
+
direction: 'asc'
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
path: 'dueAtMs',
|
|
140
|
+
direction: 'desc'
|
|
141
|
+
}
|
|
142
|
+
];
|
|
143
|
+
/** What the Filters panel may name on a task, besides the view. */ export const TASK_LIST_QUERY_FIELDS = [
|
|
144
|
+
crmSelectField('kind'),
|
|
145
|
+
crmSelectField('priority'),
|
|
146
|
+
crmSelectField('assigneeUid')
|
|
147
|
+
];
|
|
148
|
+
export const TASK_LIST_DECLARATION = {
|
|
149
|
+
fields: TASK_LIST_QUERY_FIELDS,
|
|
150
|
+
sorts: TASK_LIST_SORTS,
|
|
151
|
+
search: CRM_LIST_SEARCH
|
|
152
|
+
};
|
|
153
|
+
/** The view's own predicates, which the plan takes as its base beside the scope. */ export function crmTaskViewBase(plan) {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
path: 'status',
|
|
157
|
+
op: '==',
|
|
158
|
+
value: plan.status
|
|
159
|
+
},
|
|
160
|
+
...plan.assigneeUid ? [
|
|
161
|
+
{
|
|
162
|
+
path: 'assigneeUid',
|
|
163
|
+
op: '==',
|
|
164
|
+
value: plan.assigneeUid
|
|
165
|
+
}
|
|
166
|
+
] : [],
|
|
167
|
+
...plan.dueFrom !== undefined ? [
|
|
168
|
+
{
|
|
169
|
+
path: 'dueAtMs',
|
|
170
|
+
op: '>=',
|
|
171
|
+
value: plan.dueFrom
|
|
172
|
+
}
|
|
173
|
+
] : [],
|
|
174
|
+
...plan.dueBefore !== undefined ? [
|
|
175
|
+
{
|
|
176
|
+
path: 'dueAtMs',
|
|
177
|
+
op: '<',
|
|
178
|
+
value: plan.dueBefore
|
|
179
|
+
}
|
|
180
|
+
] : []
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
/** The base's shape for `listQueryIndexes`: the view's two equalities. */ export const TASK_LIST_VIEW_BASE_INDEX = [
|
|
184
|
+
{
|
|
185
|
+
path: 'status'
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
path: 'assigneeUid'
|
|
189
|
+
}
|
|
190
|
+
];
|
|
132
191
|
/**
|
|
133
192
|
* Rows in the order a view should paint them.
|
|
134
193
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/task-views.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 type CrmTask,\n type CrmTaskKind,\n type CrmTaskPriority,\n type TaskDueState,\n taskDueState,\n} from '@aglyn/aglyn'\nimport type { CrmRoutes } from './crm-routes'\n\n/**\n * The tasks section's views and the query each one is (AGL-2599).\n *\n * Pure: what a view MEANS is written here once, as a plan the hook turns into\n * Firestore constraints and a spec can read back without Firestore. \"Overdue\"\n * and \"today\" are not stored on the task — nothing runs at midnight to stamp\n * them — so each view is a window over `dueAtMs` computed at read time from\n * the reader's own clock, and the same day boundaries {@link taskDueState}\n * paints a row with.\n */\n\nexport type CrmTaskView =\n | 'mine'\n | 'overdue'\n | 'today'\n | 'upcoming'\n | 'open'\n | 'done'\n\nexport const CRM_TASK_VIEWS: ReadonlyArray<{ id: CrmTaskView; label: string }> =\n [\n { id: 'mine', label: 'My tasks' },\n { id: 'overdue', label: 'Overdue' },\n { id: 'today', label: 'Today' },\n { id: 'upcoming', label: 'Upcoming' },\n { id: 'open', label: 'All open' },\n { id: 'done', label: 'Done' },\n ]\n\nexport const CRM_TASK_KINDS: readonly CrmTaskKind[] = [\n 'call',\n 'email',\n 'meeting',\n 'todo',\n]\n\nexport const CRM_TASK_KIND_LABELS: Record<CrmTaskKind, string> = {\n call: 'Call',\n email: 'Email',\n meeting: 'Meeting',\n todo: 'To-do',\n}\n\nexport const CRM_TASK_PRIORITIES: readonly CrmTaskPriority[] = [\n 'low',\n 'normal',\n 'high',\n]\n\nexport const CRM_TASK_PRIORITY_LABELS: Record<CrmTaskPriority, string> = {\n low: 'Low',\n normal: 'Normal',\n high: 'High',\n}\n\n/**\n * How many rows a view reads at most.\n *\n * Every view is a listener, and a listener with no cap is a bill that grows\n * with the org. Two hundred is more than a person works through in a sitting\n * and far under what a table paints comfortably; the list says when the\n * window is full so a reader knows to narrow the view rather than assume\n * the end of the table is the end of the work.\n */\nexport const CRM_TASK_VIEW_LIMIT = 200\n\n/** Midnight at the START of the local calendar day `nowMs` falls in. */\nexport function startOfLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()\n}\n\n/**\n * Midnight at the start of the NEXT local day — built from the calendar\n * rather than by adding 24 hours, because a day that a clock change falls in\n * is 23 or 25 hours long and a task due at 00:30 on that morning belongs to\n * it either way.\n */\nexport function startOfNextLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(\n date.getFullYear(),\n date.getMonth(),\n date.getDate() + 1,\n ).getTime()\n}\n\n/**\n * One view as constraints, in the vocabulary of the `crmTasks` indexes.\n *\n * `assigneeUid` narrows to the reader and rides the\n * `(visibleTo, assigneeUid, status, dueAtMs)` index; every other view rides\n * `(visibleTo, status, dueAtMs)`. `dueFrom` is inclusive and `dueBefore`\n * exclusive so that two adjacent windows share a boundary and no task falls\n * between \"today\" and \"upcoming\". A task with no due date has `dueAtMs:\n * null`, which a range excludes and a bare order sorts first, so it appears\n * in \"My tasks\" and \"All open\" and in none of the dated views.\n */\nexport interface CrmTaskViewPlan {\n status: 'open' | 'done'\n assigneeUid?: string\n dueFrom?: number\n dueBefore?: number\n direction: 'asc' | 'desc'\n}\n\nexport function crmTaskViewPlan(\n view: CrmTaskView,\n options: { nowMs: number; uid: string | null | undefined },\n): CrmTaskViewPlan {\n const { nowMs, uid } = options\n const today = startOfLocalDay(nowMs)\n const tomorrow = startOfNextLocalDay(nowMs)\n switch (view) {\n case 'mine':\n return { status: 'open', assigneeUid: uid ?? '', direction: 'asc' }\n case 'overdue':\n return { status: 'open', dueBefore: today, direction: 'asc' }\n case 'today':\n return { status: 'open', dueFrom: today, dueBefore: tomorrow, direction: 'asc' }\n case 'upcoming':\n return { status: 'open', dueFrom: tomorrow, direction: 'asc' }\n case 'open':\n return { status: 'open', direction: 'asc' }\n case 'done':\n // Most recently due first: what was finished lately is what a reader\n // scrolls the Done view for, and the oldest completed task is the one\n // nobody comes back to.\n return { status: 'done', direction: 'desc' }\n }\n}\n\n/**\n * Rows in the order a view should paint them.\n *\n * Firestore sorts `null` before every number, so an ascending order by\n * `dueAtMs` puts the undated tasks at the TOP of \"All open\" — above the one\n * due in an hour. The query stays as it is (the index decides that); the\n * undated rows are moved to the end here, in a stable pass that leaves the\n * dated order the server chose alone.\n */\nexport function orderTaskRows<T extends Pick<CrmTask, 'dueAtMs'>>(\n rows: readonly T[],\n): T[] {\n const dated: T[] = []\n const undated: T[] = []\n for (const row of rows) {\n ;(typeof row.dueAtMs === 'number' ? dated : undated).push(row)\n }\n return [...dated, ...undated]\n}\n\n/** The MUI palette key a due date is painted with, by its state. */\nexport const TASK_DUE_COLORS: Record<TaskDueState, string> = {\n overdue: 'error.main',\n today: 'warning.main',\n upcoming: 'text.primary',\n none: 'text.secondary',\n done: 'text.secondary',\n}\n\n/**\n * The due date as a person reads it: \"Overdue · Tue, Sep 1, 9:00 AM\",\n * \"Today, 3:30 PM\", \"No due date\". The state word comes first because it is\n * the part a reader scans a column for; the timestamp is what they read once\n * a row has their attention.\n */\nexport function describeTaskDue(\n task: Pick<CrmTask, 'status' | 'dueAtMs'>,\n nowMs: number,\n locale?: string,\n): { state: TaskDueState; label: string } {\n const state = taskDueState(task, nowMs)\n const dueAtMs = task.dueAtMs\n if (typeof dueAtMs !== 'number' || !Number.isFinite(dueAtMs)) {\n return { state, label: 'No due date' }\n }\n const due = new Date(dueAtMs)\n const time = due.toLocaleTimeString(locale, {\n hour: 'numeric',\n minute: '2-digit',\n })\n switch (state) {\n case 'today':\n return { state, label: `Today, ${time}` }\n case 'overdue':\n return {\n state,\n label: `Overdue · ${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n default:\n return {\n state,\n label: `${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n }\n}\n\n/**\n * `datetime-local` ↔ epoch milliseconds, in the reader's own zone.\n *\n * The input's value is a wall-clock string with no zone; `new Date(string)`\n * reads such a string as LOCAL time, which is what a person typing \"9:00\"\n * into a due-date field means. Built by hand in the other direction because\n * `toISOString()` would render the UTC clock, and a task due at nine would\n * reopen in the drawer at whatever nine is in Greenwich.\n */\nexport function dueAtToLocalInput(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const date = new Date(ms)\n const pad = (value: number) => String(value).padStart(2, '0')\n return (\n `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` +\n `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`\n )\n}\n\nexport function localInputToDueAt(value: string): number | null {\n const text = String(value ?? '').trim()\n if (!text) return null\n const ms = new Date(text).getTime()\n return Number.isFinite(ms) ? ms : null\n}\n\n/**\n * The two snoozes a row and the drawer offer by name (AGL-2619), in the\n * order a person reaches for them; \"Pick a date\" is the third and takes a\n * date rather than an option.\n */\nexport type CrmTaskSnoozeOption = 'tomorrow' | 'nextWeek'\n\nexport const CRM_TASK_SNOOZE_OPTIONS: ReadonlyArray<{\n id: CrmTaskSnoozeOption\n label: string\n}> = [\n { id: 'tomorrow', label: 'Tomorrow' },\n { id: 'nextWeek', label: 'Next week' },\n]\n\n/** The hour a snoozed task lands on when it had no time of day of its own. */\nexport const CRM_TASK_SNOOZE_DEFAULT_HOUR = 9\n\n/**\n * Where a snoozed task is due next.\n *\n * Counted from NOW, not from the old due date: a task a week overdue\n * snoozed to \"tomorrow\" is due tomorrow, which is what the word means to the\n * person pressing it, and not due six days ago. The time of day is kept —\n * a nine o'clock call stays a nine o'clock call — and a task that had no\n * time lands at {@link CRM_TASK_SNOOZE_DEFAULT_HOUR}, a working hour rather\n * than midnight. Built from the calendar for the reason\n * {@link startOfNextLocalDay} gives.\n */\nexport function snoozeDueAt(\n option: CrmTaskSnoozeOption,\n dueAtMs: number | null | undefined,\n nowMs: number,\n): number {\n const had = typeof dueAtMs === 'number' && Number.isFinite(dueAtMs) ? new Date(dueAtMs) : null\n const hours = had ? had.getHours() : CRM_TASK_SNOOZE_DEFAULT_HOUR\n const minutes = had ? had.getMinutes() : 0\n const now = new Date(nowMs)\n const days = option === 'tomorrow' ? 1 : 7\n return new Date(\n now.getFullYear(),\n now.getMonth(),\n now.getDate() + days,\n hours,\n minutes,\n ).getTime()\n}\n\n/**\n * The record a task hangs off, as a link into the hub — or `null` for a\n * task that is nobody's in particular.\n *\n * One record per task on screen even when the document names more than one:\n * the contact wins, then the deal, then the company, because that is the\n * order of how specific each is about WHO the work is for. All three ids\n * stay on the document for the record pages' own queries.\n */\nexport function taskRecordLink(\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n routes: CrmRoutes,\n): { kind: 'contact' | 'deal' | 'company'; id: string; href: string } | null {\n if (task.contactId) {\n return { kind: 'contact', id: task.contactId, href: routes.contact(task.contactId) }\n }\n if (task.dealId) {\n return { kind: 'deal', id: task.dealId, href: routes.deal(task.dealId) }\n }\n if (task.companyId) {\n return { kind: 'company', id: task.companyId, href: routes.company(task.companyId) }\n }\n return null\n}\n"],"names":["taskDueState","CRM_TASK_VIEWS","id","label","CRM_TASK_KINDS","CRM_TASK_KIND_LABELS","call","email","meeting","todo","CRM_TASK_PRIORITIES","CRM_TASK_PRIORITY_LABELS","low","normal","high","CRM_TASK_VIEW_LIMIT","startOfLocalDay","nowMs","date","Date","getFullYear","getMonth","getDate","getTime","startOfNextLocalDay","crmTaskViewPlan","view","options","uid","today","tomorrow","status","assigneeUid","direction","dueBefore","dueFrom","orderTaskRows","rows","dated","undated","row","dueAtMs","push","TASK_DUE_COLORS","overdue","upcoming","none","done","describeTaskDue","task","locale","state","Number","isFinite","due","time","toLocaleTimeString","hour","minute","toLocaleDateString","weekday","month","day","dueAtToLocalInput","ms","pad","value","String","padStart","getHours","getMinutes","localInputToDueAt","text","trim","CRM_TASK_SNOOZE_OPTIONS","CRM_TASK_SNOOZE_DEFAULT_HOUR","snoozeDueAt","option","had","hours","minutes","now","days","taskRecordLink","routes","contactId","kind","href","contact","dealId","deal","companyId","company"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/task-views.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 type CrmTask,\n type CrmTaskKind,\n type CrmTaskPriority,\n type TaskDueState,\n taskDueState,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { CRM_LIST_SEARCH, crmSelectField } from './crm-list-query'\nimport type { CrmRoutes } from './crm-routes'\n\n/**\n * The tasks section's views and the query each one is (AGL-2599).\n *\n * Pure: what a view MEANS is written here once, as a plan the hook turns into\n * Firestore constraints and a spec can read back without Firestore. \"Overdue\"\n * and \"today\" are not stored on the task — nothing runs at midnight to stamp\n * them — so each view is a window over `dueAtMs` computed at read time from\n * the reader's own clock, and the same day boundaries {@link taskDueState}\n * paints a row with.\n */\n\nexport type CrmTaskView =\n | 'mine'\n | 'overdue'\n | 'today'\n | 'upcoming'\n | 'open'\n | 'done'\n\nexport const CRM_TASK_VIEWS: ReadonlyArray<{ id: CrmTaskView; label: string }> =\n [\n { id: 'mine', label: 'My tasks' },\n { id: 'overdue', label: 'Overdue' },\n { id: 'today', label: 'Today' },\n { id: 'upcoming', label: 'Upcoming' },\n { id: 'open', label: 'All open' },\n { id: 'done', label: 'Done' },\n ]\n\nexport const CRM_TASK_KINDS: readonly CrmTaskKind[] = [\n 'call',\n 'email',\n 'meeting',\n 'todo',\n]\n\nexport const CRM_TASK_KIND_LABELS: Record<CrmTaskKind, string> = {\n call: 'Call',\n email: 'Email',\n meeting: 'Meeting',\n todo: 'To-do',\n}\n\nexport const CRM_TASK_PRIORITIES: readonly CrmTaskPriority[] = [\n 'low',\n 'normal',\n 'high',\n]\n\nexport const CRM_TASK_PRIORITY_LABELS: Record<CrmTaskPriority, string> = {\n low: 'Low',\n normal: 'Normal',\n high: 'High',\n}\n\n\n/** Midnight at the START of the local calendar day `nowMs` falls in. */\nexport function startOfLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()\n}\n\n/**\n * Midnight at the start of the NEXT local day — built from the calendar\n * rather than by adding 24 hours, because a day that a clock change falls in\n * is 23 or 25 hours long and a task due at 00:30 on that morning belongs to\n * it either way.\n */\nexport function startOfNextLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(\n date.getFullYear(),\n date.getMonth(),\n date.getDate() + 1,\n ).getTime()\n}\n\n/**\n * One view as constraints, in the vocabulary of the `crmTasks` indexes.\n *\n * `assigneeUid` narrows to the reader and rides the\n * `(visibleTo, assigneeUid, status, dueAtMs)` index; every other view rides\n * `(visibleTo, status, dueAtMs)`. `dueFrom` is inclusive and `dueBefore`\n * exclusive so that two adjacent windows share a boundary and no task falls\n * between \"today\" and \"upcoming\". A task with no due date has `dueAtMs:\n * null`, which a range excludes and a bare order sorts first, so it appears\n * in \"My tasks\" and \"All open\" and in none of the dated views.\n */\nexport interface CrmTaskViewPlan {\n status: 'open' | 'done'\n assigneeUid?: string\n dueFrom?: number\n dueBefore?: number\n direction: 'asc' | 'desc'\n}\n\nexport function crmTaskViewPlan(\n view: CrmTaskView,\n options: { nowMs: number; uid: string | null | undefined },\n): CrmTaskViewPlan {\n const { nowMs, uid } = options\n const today = startOfLocalDay(nowMs)\n const tomorrow = startOfNextLocalDay(nowMs)\n switch (view) {\n case 'mine':\n return { status: 'open', assigneeUid: uid ?? '', direction: 'asc' }\n case 'overdue':\n return { status: 'open', dueBefore: today, direction: 'asc' }\n case 'today':\n return { status: 'open', dueFrom: today, dueBefore: tomorrow, direction: 'asc' }\n case 'upcoming':\n return { status: 'open', dueFrom: tomorrow, direction: 'asc' }\n case 'open':\n return { status: 'open', direction: 'asc' }\n case 'done':\n // Most recently due first: what was finished lately is what a reader\n // scrolls the Done view for, and the oldest completed task is the one\n // nobody comes back to.\n return { status: 'done', direction: 'desc' }\n }\n}\n\n/*==========================================\n * THE TASKS LIST'S QUERY (AGL-3321).\n *\n * The view is the query's base — its status, the reader as assignee for\n * \"My tasks\", and the `dueAtMs` window for Overdue, Today and Upcoming —\n * and Kind, Priority, Assignee and the search word are predicates on the\n * same query, paged by it. Every equality rides one `(field, dueAtMs)`\n * composite per direction: ascending for the working views, descending for\n * Done.\n *=========================================*/\n\n/** The orders the list takes: soonest first, and Done's most recently due first. */\nexport const TASK_LIST_SORTS: readonly ListQuerySort[] = [\n { path: 'dueAtMs', direction: 'asc' },\n { path: 'dueAtMs', direction: 'desc' },\n]\n\n/** What the Filters panel may name on a task, besides the view. */\nexport const TASK_LIST_QUERY_FIELDS: readonly ListFilterField[] = [\n crmSelectField('kind'),\n crmSelectField('priority'),\n crmSelectField('assigneeUid'),\n]\n\nexport const TASK_LIST_DECLARATION: ListQueryDeclaration = {\n fields: TASK_LIST_QUERY_FIELDS,\n sorts: TASK_LIST_SORTS,\n search: CRM_LIST_SEARCH,\n}\n\n/** The view's own predicates, which the plan takes as its base beside the scope. */\nexport function crmTaskViewBase(plan: CrmTaskViewPlan): ListQueryFilter[] {\n return [\n { path: 'status', op: '==', value: plan.status },\n ...(plan.assigneeUid ? [{ path: 'assigneeUid', op: '==' as const, value: plan.assigneeUid }] : []),\n ...(plan.dueFrom !== undefined ? [{ path: 'dueAtMs', op: '>=' as const, value: plan.dueFrom }] : []),\n ...(plan.dueBefore !== undefined ? [{ path: 'dueAtMs', op: '<' as const, value: plan.dueBefore }] : []),\n ]\n}\n\n/** The base's shape for `listQueryIndexes`: the view's two equalities. */\nexport const TASK_LIST_VIEW_BASE_INDEX = [{ path: 'status' }, { path: 'assigneeUid' }] as const\n\n/**\n * Rows in the order a view should paint them.\n *\n * Firestore sorts `null` before every number, so an ascending order by\n * `dueAtMs` puts the undated tasks at the TOP of \"All open\" — above the one\n * due in an hour. The query stays as it is (the index decides that); the\n * undated rows are moved to the end here, in a stable pass that leaves the\n * dated order the server chose alone.\n */\nexport function orderTaskRows<T extends Pick<CrmTask, 'dueAtMs'>>(\n rows: readonly T[],\n): T[] {\n const dated: T[] = []\n const undated: T[] = []\n for (const row of rows) {\n ;(typeof row.dueAtMs === 'number' ? dated : undated).push(row)\n }\n return [...dated, ...undated]\n}\n\n/** The MUI palette key a due date is painted with, by its state. */\nexport const TASK_DUE_COLORS: Record<TaskDueState, string> = {\n overdue: 'error.main',\n today: 'warning.main',\n upcoming: 'text.primary',\n none: 'text.secondary',\n done: 'text.secondary',\n}\n\n/**\n * The due date as a person reads it: \"Overdue · Tue, Sep 1, 9:00 AM\",\n * \"Today, 3:30 PM\", \"No due date\". The state word comes first because it is\n * the part a reader scans a column for; the timestamp is what they read once\n * a row has their attention.\n */\nexport function describeTaskDue(\n task: Pick<CrmTask, 'status' | 'dueAtMs'>,\n nowMs: number,\n locale?: string,\n): { state: TaskDueState; label: string } {\n const state = taskDueState(task, nowMs)\n const dueAtMs = task.dueAtMs\n if (typeof dueAtMs !== 'number' || !Number.isFinite(dueAtMs)) {\n return { state, label: 'No due date' }\n }\n const due = new Date(dueAtMs)\n const time = due.toLocaleTimeString(locale, {\n hour: 'numeric',\n minute: '2-digit',\n })\n switch (state) {\n case 'today':\n return { state, label: `Today, ${time}` }\n case 'overdue':\n return {\n state,\n label: `Overdue · ${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n default:\n return {\n state,\n label: `${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n }\n}\n\n/**\n * `datetime-local` ↔ epoch milliseconds, in the reader's own zone.\n *\n * The input's value is a wall-clock string with no zone; `new Date(string)`\n * reads such a string as LOCAL time, which is what a person typing \"9:00\"\n * into a due-date field means. Built by hand in the other direction because\n * `toISOString()` would render the UTC clock, and a task due at nine would\n * reopen in the drawer at whatever nine is in Greenwich.\n */\nexport function dueAtToLocalInput(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const date = new Date(ms)\n const pad = (value: number) => String(value).padStart(2, '0')\n return (\n `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` +\n `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`\n )\n}\n\nexport function localInputToDueAt(value: string): number | null {\n const text = String(value ?? '').trim()\n if (!text) return null\n const ms = new Date(text).getTime()\n return Number.isFinite(ms) ? ms : null\n}\n\n/**\n * The two snoozes a row and the drawer offer by name (AGL-2619), in the\n * order a person reaches for them; \"Pick a date\" is the third and takes a\n * date rather than an option.\n */\nexport type CrmTaskSnoozeOption = 'tomorrow' | 'nextWeek'\n\nexport const CRM_TASK_SNOOZE_OPTIONS: ReadonlyArray<{\n id: CrmTaskSnoozeOption\n label: string\n}> = [\n { id: 'tomorrow', label: 'Tomorrow' },\n { id: 'nextWeek', label: 'Next week' },\n]\n\n/** The hour a snoozed task lands on when it had no time of day of its own. */\nexport const CRM_TASK_SNOOZE_DEFAULT_HOUR = 9\n\n/**\n * Where a snoozed task is due next.\n *\n * Counted from NOW, not from the old due date: a task a week overdue\n * snoozed to \"tomorrow\" is due tomorrow, which is what the word means to the\n * person pressing it, and not due six days ago. The time of day is kept —\n * a nine o'clock call stays a nine o'clock call — and a task that had no\n * time lands at {@link CRM_TASK_SNOOZE_DEFAULT_HOUR}, a working hour rather\n * than midnight. Built from the calendar for the reason\n * {@link startOfNextLocalDay} gives.\n */\nexport function snoozeDueAt(\n option: CrmTaskSnoozeOption,\n dueAtMs: number | null | undefined,\n nowMs: number,\n): number {\n const had = typeof dueAtMs === 'number' && Number.isFinite(dueAtMs) ? new Date(dueAtMs) : null\n const hours = had ? had.getHours() : CRM_TASK_SNOOZE_DEFAULT_HOUR\n const minutes = had ? had.getMinutes() : 0\n const now = new Date(nowMs)\n const days = option === 'tomorrow' ? 1 : 7\n return new Date(\n now.getFullYear(),\n now.getMonth(),\n now.getDate() + days,\n hours,\n minutes,\n ).getTime()\n}\n\n/**\n * The record a task hangs off, as a link into the hub — or `null` for a\n * task that is nobody's in particular.\n *\n * One record per task on screen even when the document names more than one:\n * the contact wins, then the deal, then the company, because that is the\n * order of how specific each is about WHO the work is for. All three ids\n * stay on the document for the record pages' own queries.\n */\nexport function taskRecordLink(\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n routes: CrmRoutes,\n): { kind: 'contact' | 'deal' | 'company'; id: string; href: string } | null {\n if (task.contactId) {\n return { kind: 'contact', id: task.contactId, href: routes.contact(task.contactId) }\n }\n if (task.dealId) {\n return { kind: 'deal', id: task.dealId, href: routes.deal(task.dealId) }\n }\n if (task.companyId) {\n return { kind: 'company', id: task.companyId, href: routes.company(task.companyId) }\n }\n return null\n}\n"],"names":["taskDueState","CRM_LIST_SEARCH","crmSelectField","CRM_TASK_VIEWS","id","label","CRM_TASK_KINDS","CRM_TASK_KIND_LABELS","call","email","meeting","todo","CRM_TASK_PRIORITIES","CRM_TASK_PRIORITY_LABELS","low","normal","high","startOfLocalDay","nowMs","date","Date","getFullYear","getMonth","getDate","getTime","startOfNextLocalDay","crmTaskViewPlan","view","options","uid","today","tomorrow","status","assigneeUid","direction","dueBefore","dueFrom","TASK_LIST_SORTS","path","TASK_LIST_QUERY_FIELDS","TASK_LIST_DECLARATION","fields","sorts","search","crmTaskViewBase","plan","op","value","undefined","TASK_LIST_VIEW_BASE_INDEX","orderTaskRows","rows","dated","undated","row","dueAtMs","push","TASK_DUE_COLORS","overdue","upcoming","none","done","describeTaskDue","task","locale","state","Number","isFinite","due","time","toLocaleTimeString","hour","minute","toLocaleDateString","weekday","month","day","dueAtToLocalInput","ms","pad","String","padStart","getHours","getMinutes","localInputToDueAt","text","trim","CRM_TASK_SNOOZE_OPTIONS","CRM_TASK_SNOOZE_DEFAULT_HOUR","snoozeDueAt","option","had","hours","minutes","now","days","taskRecordLink","routes","contactId","kind","href","contact","dealId","deal","companyId","company"],"mappings":"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"}
|
|
@@ -46,7 +46,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
46
46
|
* hundred. A metered plan never refuses; a hard band refuses the rows past
|
|
47
47
|
* it as `records-band`, and updates to matched companies go through either
|
|
48
48
|
* way, because an update adds no record.
|
|
49
|
-
*/ import { checkCrmRecordsQuota, CRM_COLLECTIONS, fieldDefinitionsForObject, nameSearchFields, visibleToTokens } from "@aglyn/aglyn/server";
|
|
49
|
+
*/ import { checkCrmRecordsQuota, CRM_COLLECTIONS, crmCompanyListFields, crmNewRecordListFields, fieldDefinitionsForObject, nameSearchFields, visibleToTokens } from "@aglyn/aglyn/server";
|
|
50
50
|
import { COMPANY_IMPORT_CHUNK_SIZE, COMPANY_IMPORT_MAX_BODY_BYTES, companyImportMatchKey, normalizeCompanyImportRow } from "../model/crm-company-import.js";
|
|
51
51
|
import { crmRecordsQuotaForOrg, firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
52
52
|
import { FieldValue } from "firebase-admin/firestore";
|
|
@@ -181,7 +181,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
181
181
|
const existing = await findCompany(companies, context.readTokens, row);
|
|
182
182
|
const stored = storedFields(row, ownerUid);
|
|
183
183
|
if (existing) {
|
|
184
|
-
await existing.ref.update(_extends({}, stored, row.tags.length ? {
|
|
184
|
+
await existing.ref.update(_extends({}, stored, crmCompanyListFields(_extends({}, existing.data(), stored)), row.tags.length ? {
|
|
185
185
|
tags: FieldValue.arrayUnion(...row.tags)
|
|
186
186
|
} : {}, customUpdate(row), {
|
|
187
187
|
updatedAt: FieldValue.serverTimestamp()
|
|
@@ -203,7 +203,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
203
203
|
tags: row.tags
|
|
204
204
|
}, Object.keys(row.custom).length ? {
|
|
205
205
|
custom: row.custom
|
|
206
|
-
} : {}, stamp));
|
|
206
|
+
} : {}, stamp, crmNewRecordListFields('companies', _extends({}, stored, stamp))));
|
|
207
207
|
createdHere += 1;
|
|
208
208
|
created += 1;
|
|
209
209
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/companies-import.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 * `POST /api/crm/companies-import` — one chunk of a companies file, written\n * (AGL-2621).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link COMPANY_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-company-import.ts` defines. This route judges\n * each one through the same normalizers the company drawer runs, and\n * writes it — the ONLY place either happens, so a stale tab cannot store\n * something the server would have refused.\n *\n * ## Matched by domain, then by name\n *\n * A row is one company the org may already have. The domain is a key and\n * the name a spelling, so a row with a domain is looked up by it first,\n * and by the name's search key when no company carries the domain or the\n * row has none — two files calling one business \"Acme\" and \"Acme Inc\" meet\n * at `acme.com`, and a file with no domain column still finds \"Acme\" by\n * name. Both lookups are narrowed to what the caller's scope can see: two\n * clients of one agency may each have an \"Acme\" and must each get their\n * own. A match is UPDATED with what the row carries, tags unioned; no\n * match is CREATED with the stamp every CRM creator writes.\n *\n * ## The band is counted once per request\n *\n * A created company is a CRM record and meets the records band (AGL-2611).\n * The aggregate is taken once at the first create and the verdict re-judged\n * locally as the request creates more — `checkCrmRecordsQuota` is pure over\n * a count — so two hundred rows cost three aggregate reads, not six\n * hundred. A metered plan never refuses; a hard band refuses the rows past\n * it as `records-band`, and updates to matched companies go through either\n * way, because an update adds no record.\n */\n\nimport {\n checkCrmRecordsQuota,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n fieldDefinitionsForObject,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n COMPANY_IMPORT_CHUNK_SIZE,\n COMPANY_IMPORT_MAX_BODY_BYTES,\n type CompanyImportChunkResult,\n type CompanyImportRawRow,\n type CompanyImportRow,\n type CompanyImportSkippedRow,\n companyImportMatchKey,\n normalizeCompanyImportRow,\n} from '../model/crm-company-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\ntype Companies = FirebaseFirestore.CollectionReference\n\n/** The company a row refers to in this scope — by domain, then by name — or `null`. */\nasync function findCompany(\n companies: Companies,\n readTokens: readonly string[],\n row: Pick<CompanyImportRow, 'name' | 'domain'>,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | null> {\n const seen = (snapshot: FirebaseFirestore.QuerySnapshot) =>\n snapshot.docs.find((doc) => visibleToTokens(doc.get('visibleTo'), readTokens)) ?? null\n if (row.domain) {\n const byDomain = seen(\n await companies.where('domain', '==', row.domain).limit(10).get(),\n )\n if (byDomain) return byDomain\n }\n return seen(\n await companies\n .where('nameLower', '==', nameSearchFields(row.name).nameLower)\n .limit(10)\n .get(),\n )\n}\n\n/**\n * The fields a row writes — on a create and on an update alike. Only what\n * the row carries: an update must not clear a website the file did not\n * mention, so an absent optional is left absent here rather than deleted.\n */\nfunction storedFields(\n row: CompanyImportRow,\n ownerUid: string | undefined,\n): Record<string, unknown> {\n return {\n ...nameSearchFields(row.name),\n ...(row.domain ? { domain: row.domain } : {}),\n ...(row.website ? { website: row.website } : {}),\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.industry ? { industry: row.industry } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n }\n}\n\n/**\n * The row's custom values as a MERGE writes them: one dotted path per key,\n * so a company already filed keeps every value the file did not carry\n * (AGL-2661). A create spreads the map itself — see the loop below.\n */\nfunction customUpdate(row: CompanyImportRow): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(row.custom).map(([key, value]) => [`custom.${key}`, value]),\n )\n}\n\n/**\n * The org's live COMPANY field definitions (AGL-2661) — the contact\n * import's read, narrowed to the object this import writes. Read in full\n * and filtered in memory: a holder has a handful of fields, and a `where`\n * on `object` would need an index for a collection that fits in one page.\n */\nasync function loadCompanyFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return fieldDefinitionsForObject(\n snapshot.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'company',\n ).filter(\n (field) =>\n !!field.key && !field.retiredAt && visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/**\n * `POST crm/companies-import` — `{ hostId, rows }` → a {@link CompanyImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel: two rows\n * for one company racing each other would each miss the other's create.\n */\nexport const crmCompaniesImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<CompanyImportRawRow>(req, {\n maxBodyBytes: COMPANY_IMPORT_MAX_BODY_BYTES,\n chunkSize: COMPANY_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: CompanyImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: CompanyImportRow }[] = []\n const fields = await loadCompanyFieldDefinitions(context.orgId, context.readTokens)\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would update the first's record and the count would read\n * one created and one merged for one company in one file. Across\n * requests the lookup answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeCompanyImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, name: verdict.input, reason: verdict.reason })\n return\n }\n const key = companyImportMatchKey(verdict.row)\n if (seen.has(key)) {\n skipped.push({ index, name: verdict.row.name, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const stamp = creationStamp(context)\n let counted: { crmRecordsCount: number } | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const existing = await findCompany(companies, context.readTokens, row)\n const stored = storedFields(row, ownerUid)\n if (existing) {\n await existing.ref.update({\n ...stored,\n ...(row.tags.length ? { tags: FieldValue.arrayUnion(...row.tags) } : {}),\n ...customUpdate(row),\n updatedAt: FieldValue.serverTimestamp(),\n })\n merged += 1\n continue\n }\n if (!counted) counted = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n const room = checkCrmRecordsQuota(\n context.org as never,\n counted.crmRecordsCount + createdHere,\n )\n if (!room.allowed) {\n skipped.push({ index, name: row.name, reason: 'records-band' })\n continue\n }\n await companies.add({\n ...stored,\n tags: row.tags,\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n ...stamp,\n })\n createdHere += 1\n created += 1\n }\n\n const result: CompanyImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/companies-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/**\n * What every company this import CREATES carries beside its fields: the\n * scope every CRM creator stamps, the site as provenance, who imported it,\n * and the clocks. A record written without `visibleTo` is seen by nobody.\n */\nfunction creationStamp(\n context: Extract<ImportContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n"],"names":["checkCrmRecordsQuota","CRM_COLLECTIONS","fieldDefinitionsForObject","nameSearchFields","visibleToTokens","COMPANY_IMPORT_CHUNK_SIZE","COMPANY_IMPORT_MAX_BODY_BYTES","companyImportMatchKey","normalizeCompanyImportRow","crmRecordsQuotaForOrg","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","findCompany","companies","readTokens","row","seen","snapshot","docs","find","doc","get","domain","byDomain","where","limit","name","nameLower","storedFields","ownerUid","website","phone","industry","address","notes","customUpdate","Object","fromEntries","entries","custom","map","key","value","loadCompanyFieldDefinitions","orgId","app","firestore","collection","contactFields","data","filter","field","retiredAt","visibleTo","crmCompaniesImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","fields","Set","rows","forEach","raw","index","verdict","push","input","reason","has","add","entry","owners","ownersUnresolved","orgRef","stamp","creationStamp","counted","createdHere","created","merged","ownerEmail","existing","stored","ref","update","tags","length","arrayUnion","updatedAt","serverTimestamp","org","room","crmRecordsCount","allowed","keys","result","received","sort","a","b","console","hostId","scopeTokens","createdByUid","uid","createdAt"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/companies-import.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 * `POST /api/crm/companies-import` — one chunk of a companies file, written\n * (AGL-2621).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link COMPANY_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-company-import.ts` defines. This route judges\n * each one through the same normalizers the company drawer runs, and\n * writes it — the ONLY place either happens, so a stale tab cannot store\n * something the server would have refused.\n *\n * ## Matched by domain, then by name\n *\n * A row is one company the org may already have. The domain is a key and\n * the name a spelling, so a row with a domain is looked up by it first,\n * and by the name's search key when no company carries the domain or the\n * row has none — two files calling one business \"Acme\" and \"Acme Inc\" meet\n * at `acme.com`, and a file with no domain column still finds \"Acme\" by\n * name. Both lookups are narrowed to what the caller's scope can see: two\n * clients of one agency may each have an \"Acme\" and must each get their\n * own. A match is UPDATED with what the row carries, tags unioned; no\n * match is CREATED with the stamp every CRM creator writes.\n *\n * ## The band is counted once per request\n *\n * A created company is a CRM record and meets the records band (AGL-2611).\n * The aggregate is taken once at the first create and the verdict re-judged\n * locally as the request creates more — `checkCrmRecordsQuota` is pure over\n * a count — so two hundred rows cost three aggregate reads, not six\n * hundred. A metered plan never refuses; a hard band refuses the rows past\n * it as `records-band`, and updates to matched companies go through either\n * way, because an update adds no record.\n */\n\nimport {\n checkCrmRecordsQuota,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmCompanyListFields,\n crmNewRecordListFields,\n fieldDefinitionsForObject,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n COMPANY_IMPORT_CHUNK_SIZE,\n COMPANY_IMPORT_MAX_BODY_BYTES,\n type CompanyImportChunkResult,\n type CompanyImportRawRow,\n type CompanyImportRow,\n type CompanyImportSkippedRow,\n companyImportMatchKey,\n normalizeCompanyImportRow,\n} from '../model/crm-company-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\ntype Companies = FirebaseFirestore.CollectionReference\n\n/** The company a row refers to in this scope — by domain, then by name — or `null`. */\nasync function findCompany(\n companies: Companies,\n readTokens: readonly string[],\n row: Pick<CompanyImportRow, 'name' | 'domain'>,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | null> {\n const seen = (snapshot: FirebaseFirestore.QuerySnapshot) =>\n snapshot.docs.find((doc) => visibleToTokens(doc.get('visibleTo'), readTokens)) ?? null\n if (row.domain) {\n const byDomain = seen(\n await companies.where('domain', '==', row.domain).limit(10).get(),\n )\n if (byDomain) return byDomain\n }\n return seen(\n await companies\n .where('nameLower', '==', nameSearchFields(row.name).nameLower)\n .limit(10)\n .get(),\n )\n}\n\n/**\n * The fields a row writes — on a create and on an update alike. Only what\n * the row carries: an update must not clear a website the file did not\n * mention, so an absent optional is left absent here rather than deleted.\n */\nfunction storedFields(\n row: CompanyImportRow,\n ownerUid: string | undefined,\n): Record<string, unknown> {\n return {\n ...nameSearchFields(row.name),\n ...(row.domain ? { domain: row.domain } : {}),\n ...(row.website ? { website: row.website } : {}),\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.industry ? { industry: row.industry } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n }\n}\n\n/**\n * The row's custom values as a MERGE writes them: one dotted path per key,\n * so a company already filed keeps every value the file did not carry\n * (AGL-2661). A create spreads the map itself — see the loop below.\n */\nfunction customUpdate(row: CompanyImportRow): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(row.custom).map(([key, value]) => [`custom.${key}`, value]),\n )\n}\n\n/**\n * The org's live COMPANY field definitions (AGL-2661) — the contact\n * import's read, narrowed to the object this import writes. Read in full\n * and filtered in memory: a holder has a handful of fields, and a `where`\n * on `object` would need an index for a collection that fits in one page.\n */\nasync function loadCompanyFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return fieldDefinitionsForObject(\n snapshot.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'company',\n ).filter(\n (field) =>\n !!field.key && !field.retiredAt && visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/**\n * `POST crm/companies-import` — `{ hostId, rows }` → a {@link CompanyImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel: two rows\n * for one company racing each other would each miss the other's create.\n */\nexport const crmCompaniesImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<CompanyImportRawRow>(req, {\n maxBodyBytes: COMPANY_IMPORT_MAX_BODY_BYTES,\n chunkSize: COMPANY_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: CompanyImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: CompanyImportRow }[] = []\n const fields = await loadCompanyFieldDefinitions(context.orgId, context.readTokens)\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would update the first's record and the count would read\n * one created and one merged for one company in one file. Across\n * requests the lookup answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeCompanyImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, name: verdict.input, reason: verdict.reason })\n return\n }\n const key = companyImportMatchKey(verdict.row)\n if (seen.has(key)) {\n skipped.push({ index, name: verdict.row.name, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const stamp = creationStamp(context)\n let counted: { crmRecordsCount: number } | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const existing = await findCompany(companies, context.readTokens, row)\n const stored = storedFields(row, ownerUid)\n if (existing) {\n await existing.ref.update({\n ...stored,\n // The file may rename the company: what the list searches follows.\n ...crmCompanyListFields({ ...existing.data(), ...stored }),\n ...(row.tags.length ? { tags: FieldValue.arrayUnion(...row.tags) } : {}),\n ...customUpdate(row),\n updatedAt: FieldValue.serverTimestamp(),\n })\n merged += 1\n continue\n }\n if (!counted) counted = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n const room = checkCrmRecordsQuota(\n context.org as never,\n counted.crmRecordsCount + createdHere,\n )\n if (!room.allowed) {\n skipped.push({ index, name: row.name, reason: 'records-band' })\n continue\n }\n await companies.add({\n ...stored,\n tags: row.tags,\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n ...stamp,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...stored, ...stamp }),\n })\n createdHere += 1\n created += 1\n }\n\n const result: CompanyImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/companies-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/**\n * What every company this import CREATES carries beside its fields: the\n * scope every CRM creator stamps, the site as provenance, who imported it,\n * and the clocks. A record written without `visibleTo` is seen by nobody.\n */\nfunction creationStamp(\n context: Extract<ImportContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n"],"names":["checkCrmRecordsQuota","CRM_COLLECTIONS","crmCompanyListFields","crmNewRecordListFields","fieldDefinitionsForObject","nameSearchFields","visibleToTokens","COMPANY_IMPORT_CHUNK_SIZE","COMPANY_IMPORT_MAX_BODY_BYTES","companyImportMatchKey","normalizeCompanyImportRow","crmRecordsQuotaForOrg","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","findCompany","companies","readTokens","row","seen","snapshot","docs","find","doc","get","domain","byDomain","where","limit","name","nameLower","storedFields","ownerUid","website","phone","industry","address","notes","customUpdate","Object","fromEntries","entries","custom","map","key","value","loadCompanyFieldDefinitions","orgId","app","firestore","collection","contactFields","data","filter","field","retiredAt","visibleTo","crmCompaniesImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","fields","Set","rows","forEach","raw","index","verdict","push","input","reason","has","add","entry","owners","ownersUnresolved","orgRef","stamp","creationStamp","counted","createdHere","created","merged","ownerEmail","existing","stored","ref","update","tags","length","arrayUnion","updatedAt","serverTimestamp","org","room","crmRecordsCount","allowed","keys","result","received","sort","a","b","console","hostId","scopeTokens","createdByUid","uid","createdAt"],"mappings":"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"}
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
* known, staff included, before anything is read or unlinked
|
|
53
53
|
* (`suite-gate.ts`).
|
|
54
54
|
*/ import { CONTACT_COMPANY_IDS_FIELD, contactFacetPath, contactGroupsNamingCompany, CRM_COLLECTIONS, isOrgWideMember } from "@aglyn/aglyn/server";
|
|
55
|
-
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
55
|
+
import { firebaseAdmin, restampCrmListFields } from "@aglyn/tenant-data-admin";
|
|
56
56
|
import { FieldValue } from "firebase-admin/firestore";
|
|
57
57
|
import { COMPANY_DETACH_LIMIT } from "../model/company-delete-route.js";
|
|
58
58
|
import { typed } from "./contact-profile.js";
|
|
@@ -126,6 +126,8 @@ export const crmCompanyDeleteHandler = async (req, res)=>{
|
|
|
126
126
|
batch.update(snapshot.ref, companyDetachFields(snapshot.data(), companyId));
|
|
127
127
|
}
|
|
128
128
|
await batch.commit();
|
|
129
|
+
// The company is what the Contacts list filters by (AGL-3321).
|
|
130
|
+
await restampCrmListFields(firestore, writer.orgId, 'contacts', linked.map((snapshot)=>snapshot.id));
|
|
129
131
|
}
|
|
130
132
|
if (!moreRemain) await companyRef.delete();
|
|
131
133
|
const answer = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/company-delete.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 * `POST /api/crm/company-delete` — unlink the contacts that name a company,\n * then delete it (AGL-2804).\n *\n * Body: `{ hostId, companyId }` under a site, or `{ orgId, companyId }` at\n * the organization level. Answers `CompanyDeleteResponse`.\n *\n * ## Why a route\n *\n * Firestore does not cascade. A company deleted on its own leaves every\n * contact at it naming a record that no longer exists: their page links to\n * nothing and the `companyIds` mirror the company list queries keeps\n * matching a ghost. So a delete is a detach pass and then a delete, and the\n * detach clears each holder's facet that named the company — which the rules\n * refuse a client, because they cannot tell a company link inside a facet\n * from an owner or a stage beside it.\n *\n * ## Bounded, and honest past the bound\n *\n * One pass unlinks at most `COMPANY_DETACH_LIMIT` contacts, a batch's worth.\n * A company past that is left standing with a pass's worth unlinked, and the\n * answer says more remain, so the next delete continues where this one\n * stopped. A company is never deleted with a link still on a contact.\n *\n * ## Who may call it\n *\n * A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.\n * The contacts are found by the mirror, and that query cannot carry a scope\n * clause beside its `array-contains`: a member scoped to particular sites\n * could not read them in the browser either, and the route does not unlink\n * people on behalf of someone who may not see them.\n *\n * ## The plan\n *\n * Companies are the CRM's, and the CRM is included from Starter (AGL-2851):\n * a workspace whose plan does not carry it is refused once the writer is\n * known, staff included, before anything is read or unlinked\n * (`suite-gate.ts`).\n */\n\nimport {\n CONTACT_COMPANY_IDS_FIELD,\n contactFacetPath,\n contactGroupsNamingCompany,\n CRM_COLLECTIONS,\n isOrgWideMember,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n COMPANY_DETACH_LIMIT,\n type CompanyDeleteResponse,\n} from '../model/company-delete-route'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter, canReach } from './task-routes'\n\n/** What a member scoped to particular sites is told. */\nexport const COMPANY_DELETE_SCOPED_REFUSAL =\n 'Your access is limited to specific sites, so the contacts at this company ' +\n 'could not be read to unlink them. Ask an organization administrator to delete it.'\n\n/**\n * A contact's update when a company it names is deleted: the id out of the\n * mirror, and out of every holder's facet that named it. Another holder's\n * link to a different company is not read or written.\n */\nfunction companyDetachFields(\n contact: Record<string, unknown>,\n companyId: string,\n): Record<string, unknown> {\n const update: Record<string, unknown> = {\n [CONTACT_COMPANY_IDS_FIELD]: FieldValue.arrayRemove(companyId),\n updatedAt: FieldValue.serverTimestamp(),\n }\n for (const groupId of contactGroupsNamingCompany(contact, companyId)) {\n update[contactFacetPath(groupId, 'companyId')] = FieldValue.delete()\n }\n return update\n}\n\nexport const crmCompanyDeleteHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n const companyId = typed(body['companyId'], 200)\n if (!scope || !companyId || companyId.includes('/')) {\n res.status(400).json({ error: 'Missing hostId or companyId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: 'Deleting a company' })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (!(writer.staff || writer.level === 'org' || isOrgWideMember(writer.member))) {\n res.status(403).json({ error: COMPANY_DELETE_SCOPED_REFUSAL })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId)\n const company = await companyRef.get()\n if (!company.exists || !canReach(writer, company.get('visibleTo'))) {\n res.status(404).json({ error: 'That company no longer exists.' })\n return\n }\n\n // One past the bound, so \"more remain\" is a fact from the probe row\n // rather than a guess from a full page.\n const probe = await orgRef\n .collection('contacts')\n .where(CONTACT_COMPANY_IDS_FIELD, 'array-contains', companyId)\n .limit(COMPANY_DETACH_LIMIT + 1)\n .get()\n const linked = probe.docs.slice(0, COMPANY_DETACH_LIMIT)\n const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT\n if (linked.length) {\n const batch = firestore.batch()\n for (const snapshot of linked) {\n batch.update(\n snapshot.ref,\n companyDetachFields(snapshot.data() as Record<string, unknown>, companyId),\n )\n }\n await batch.commit()\n }\n if (!moreRemain) await companyRef.delete()\n\n const answer: CompanyDeleteResponse = {\n ok: true,\n deleted: !moreRemain,\n detached: linked.length,\n moreRemain,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] company-delete failed', companyId, error)\n res.status(500).json({ error: 'The company could not be deleted.' })\n }\n}\n"],"names":["CONTACT_COMPANY_IDS_FIELD","contactFacetPath","contactGroupsNamingCompany","CRM_COLLECTIONS","isOrgWideMember","firebaseAdmin","FieldValue","COMPANY_DETACH_LIMIT","typed","readCrmRouteScope","authorizeCrmWriter","canReach","COMPANY_DELETE_SCOPED_REFUSAL","companyDetachFields","contact","companyId","update","arrayRemove","updatedAt","serverTimestamp","groupId","delete","crmCompanyDeleteHandler","req","res","method","setHeader","status","json","error","body","scope","includes","writer","suiteAct","ok","staff","level","member","firestore","app","orgRef","collection","doc","orgId","companyRef","companies","company","get","exists","probe","where","limit","linked","docs","slice","moreRemain","length","batch","snapshot","ref","data","commit","answer","deleted","detached","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GAED,SACEA,yBAAyB,EACzBC,gBAAgB,EAChBC,0BAA0B,EAC1BC,eAAe,EACfC,eAAe,QAEV,sBAAqB;AAC5B,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,oBAAoB,QAEf,mCAA+B;AACtC,SAASC,KAAK,QAAQ,uBAAmB;AACzC,SAASC,iBAAiB,QAAQ,kBAAc;AAChD,SAASC,kBAAkB,EAAEC,QAAQ,QAAQ,mBAAe;AAE5D,sDAAsD,GACtD,OAAO,MAAMC,gCACX,+EACA,oFAAmF;AAErF;;;;CAIC,GACD,SAASC,oBACPC,OAAgC,EAChCC,SAAiB;IAEjB,MAAMC,SAAkC;QACtC,CAAChB,0BAA0B,EAAEM,WAAWW,WAAW,CAACF;QACpDG,WAAWZ,WAAWa,eAAe;IACvC;IACA,KAAK,MAAMC,WAAWlB,2BAA2BY,SAASC,WAAY;QACpEC,MAAM,CAACf,iBAAiBmB,SAAS,aAAa,GAAGd,WAAWe,MAAM;IACpE;IACA,OAAOL;AACT;AAEA,OAAO,MAAMM,0BAA4C,OAAOC,KAAKC;QAMrDD;IALd,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMC,QAAQP,YAAAA,IAAIO,IAAI,YAARP,YAAY,CAAC;IAC3B,MAAMQ,QAAQtB,kBAAkBqB;IAChC,MAAMf,YAAYP,MAAMsB,IAAI,CAAC,YAAY,EAAE;IAC3C,IAAI,CAACC,SAAS,CAAChB,aAAaA,UAAUiB,QAAQ,CAAC,MAAM;QACnDR,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;QAC5D;IACF;IAEA,IAAI;QACF,MAAMI,SAAS,MAAMvB,mBAAmBa,KAAKQ,OAAO;YAAEG,UAAU;QAAqB;QACrF,IAAID,OAAOE,EAAE,KAAK,OAAO;YACvBX,IAAIG,MAAM,CAACM,OAAON,MAAM,EAAEC,IAAI,CAACK,OAAOH,IAAI;YAC1C;QACF;QACA,IAAI,CAAEG,CAAAA,OAAOG,KAAK,IAAIH,OAAOI,KAAK,KAAK,SAASjC,gBAAgB6B,OAAOK,MAAM,CAAA,GAAI;YAC/Ed,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAOjB;YAA8B;YAC5D;QACF;QAEA,MAAM2B,YAAYlC,cAAcmC,GAAG,GAAGD,SAAS;QAC/C,MAAME,SAASF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACV,OAAOW,KAAK;QAC5D,MAAMC,aAAaJ,OAAOC,UAAU,CAACvC,gBAAgB2C,SAAS,EAAEH,GAAG,CAAC5B;QACpE,MAAMgC,UAAU,MAAMF,WAAWG,GAAG;QACpC,IAAI,CAACD,QAAQE,MAAM,IAAI,CAACtC,SAASsB,QAAQc,QAAQC,GAAG,CAAC,eAAe;YAClExB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAiC;YAC/D;QACF;QAEA,oEAAoE;QACpE,wCAAwC;QACxC,MAAMqB,QAAQ,MAAMT,OACjBC,UAAU,CAAC,YACXS,KAAK,CAACnD,2BAA2B,kBAAkBe,WACnDqC,KAAK,CAAC7C,uBAAuB,GAC7ByC,GAAG;QACN,MAAMK,SAASH,MAAMI,IAAI,CAACC,KAAK,CAAC,GAAGhD;QACnC,MAAMiD,aAAaN,MAAMI,IAAI,CAACG,MAAM,GAAGlD;QACvC,IAAI8C,OAAOI,MAAM,EAAE;YACjB,MAAMC,QAAQnB,UAAUmB,KAAK;YAC7B,KAAK,MAAMC,YAAYN,OAAQ;gBAC7BK,MAAM1C,MAAM,CACV2C,SAASC,GAAG,EACZ/C,oBAAoB8C,SAASE,IAAI,IAA+B9C;YAEpE;YACA,MAAM2C,MAAMI,MAAM;QACpB;QACA,IAAI,CAACN,YAAY,MAAMX,WAAWxB,MAAM;QAExC,MAAM0C,SAAgC;YACpC5B,IAAI;YACJ6B,SAAS,CAACR;YACVS,UAAUZ,OAAOI,MAAM;YACvBD;QACF;QACAhC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAACmC;IACvB,EAAE,OAAOlC,OAAO;QACdqC,QAAQrC,KAAK,CAAC,+BAA+Bd,WAAWc;QACxDL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAoC;IACpE;AACF,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/company-delete.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 * `POST /api/crm/company-delete` — unlink the contacts that name a company,\n * then delete it (AGL-2804).\n *\n * Body: `{ hostId, companyId }` under a site, or `{ orgId, companyId }` at\n * the organization level. Answers `CompanyDeleteResponse`.\n *\n * ## Why a route\n *\n * Firestore does not cascade. A company deleted on its own leaves every\n * contact at it naming a record that no longer exists: their page links to\n * nothing and the `companyIds` mirror the company list queries keeps\n * matching a ghost. So a delete is a detach pass and then a delete, and the\n * detach clears each holder's facet that named the company — which the rules\n * refuse a client, because they cannot tell a company link inside a facet\n * from an owner or a stage beside it.\n *\n * ## Bounded, and honest past the bound\n *\n * One pass unlinks at most `COMPANY_DETACH_LIMIT` contacts, a batch's worth.\n * A company past that is left standing with a pass's worth unlinked, and the\n * answer says more remain, so the next delete continues where this one\n * stopped. A company is never deleted with a link still on a contact.\n *\n * ## Who may call it\n *\n * A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.\n * The contacts are found by the mirror, and that query cannot carry a scope\n * clause beside its `array-contains`: a member scoped to particular sites\n * could not read them in the browser either, and the route does not unlink\n * people on behalf of someone who may not see them.\n *\n * ## The plan\n *\n * Companies are the CRM's, and the CRM is included from Starter (AGL-2851):\n * a workspace whose plan does not carry it is refused once the writer is\n * known, staff included, before anything is read or unlinked\n * (`suite-gate.ts`).\n */\n\nimport {\n CONTACT_COMPANY_IDS_FIELD,\n contactFacetPath,\n contactGroupsNamingCompany,\n CRM_COLLECTIONS,\n isOrgWideMember,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, restampCrmListFields } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n COMPANY_DETACH_LIMIT,\n type CompanyDeleteResponse,\n} from '../model/company-delete-route'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter, canReach } from './task-routes'\n\n/** What a member scoped to particular sites is told. */\nexport const COMPANY_DELETE_SCOPED_REFUSAL =\n 'Your access is limited to specific sites, so the contacts at this company ' +\n 'could not be read to unlink them. Ask an organization administrator to delete it.'\n\n/**\n * A contact's update when a company it names is deleted: the id out of the\n * mirror, and out of every holder's facet that named it. Another holder's\n * link to a different company is not read or written.\n */\nfunction companyDetachFields(\n contact: Record<string, unknown>,\n companyId: string,\n): Record<string, unknown> {\n const update: Record<string, unknown> = {\n [CONTACT_COMPANY_IDS_FIELD]: FieldValue.arrayRemove(companyId),\n updatedAt: FieldValue.serverTimestamp(),\n }\n for (const groupId of contactGroupsNamingCompany(contact, companyId)) {\n update[contactFacetPath(groupId, 'companyId')] = FieldValue.delete()\n }\n return update\n}\n\nexport const crmCompanyDeleteHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n const companyId = typed(body['companyId'], 200)\n if (!scope || !companyId || companyId.includes('/')) {\n res.status(400).json({ error: 'Missing hostId or companyId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: 'Deleting a company' })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (!(writer.staff || writer.level === 'org' || isOrgWideMember(writer.member))) {\n res.status(403).json({ error: COMPANY_DELETE_SCOPED_REFUSAL })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId)\n const company = await companyRef.get()\n if (!company.exists || !canReach(writer, company.get('visibleTo'))) {\n res.status(404).json({ error: 'That company no longer exists.' })\n return\n }\n\n // One past the bound, so \"more remain\" is a fact from the probe row\n // rather than a guess from a full page.\n const probe = await orgRef\n .collection('contacts')\n .where(CONTACT_COMPANY_IDS_FIELD, 'array-contains', companyId)\n .limit(COMPANY_DETACH_LIMIT + 1)\n .get()\n const linked = probe.docs.slice(0, COMPANY_DETACH_LIMIT)\n const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT\n if (linked.length) {\n const batch = firestore.batch()\n for (const snapshot of linked) {\n batch.update(\n snapshot.ref,\n companyDetachFields(snapshot.data() as Record<string, unknown>, companyId),\n )\n }\n await batch.commit()\n // The company is what the Contacts list filters by (AGL-3321).\n await restampCrmListFields(\n firestore,\n writer.orgId,\n 'contacts',\n linked.map((snapshot) => snapshot.id),\n )\n }\n if (!moreRemain) await companyRef.delete()\n\n const answer: CompanyDeleteResponse = {\n ok: true,\n deleted: !moreRemain,\n detached: linked.length,\n moreRemain,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] company-delete failed', companyId, error)\n res.status(500).json({ error: 'The company could not be deleted.' })\n }\n}\n"],"names":["CONTACT_COMPANY_IDS_FIELD","contactFacetPath","contactGroupsNamingCompany","CRM_COLLECTIONS","isOrgWideMember","firebaseAdmin","restampCrmListFields","FieldValue","COMPANY_DETACH_LIMIT","typed","readCrmRouteScope","authorizeCrmWriter","canReach","COMPANY_DELETE_SCOPED_REFUSAL","companyDetachFields","contact","companyId","update","arrayRemove","updatedAt","serverTimestamp","groupId","delete","crmCompanyDeleteHandler","req","res","method","setHeader","status","json","error","body","scope","includes","writer","suiteAct","ok","staff","level","member","firestore","app","orgRef","collection","doc","orgId","companyRef","companies","company","get","exists","probe","where","limit","linked","docs","slice","moreRemain","length","batch","snapshot","ref","data","commit","map","id","answer","deleted","detached","console"],"mappings":"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"}
|
|
@@ -60,6 +60,7 @@ import { CONTACT_FACETS_FIELD, interactionsForGroup } from "@aglyn/aglyn/app-uti
|
|
|
60
60
|
import { COMPANY_CONTACTS_COUNT_FIELD, CONTACT_COMPANY_IDS_FIELD, CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
|
|
61
61
|
import { CAPTURED_BY_HOST_FIELD, CONSENT_GROUP_ID_FIELD, MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD, MARKETING_CONSENT_SOURCE_FIELD } from "@aglyn/aglyn/app-utils/marketing-consent";
|
|
62
62
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
63
|
+
import { restampCrmListFieldsAt } from "@aglyn/tenant-data-admin/server/crm-records";
|
|
63
64
|
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
64
65
|
import { summarizeConsentGroupChange } from "../model/consent-group-summary.js";
|
|
65
66
|
/** Contacts one page of the refusal carry reads. */ export const REFUSAL_PAGE = 300;
|
|
@@ -414,6 +415,9 @@ function readCursor(raw, first) {
|
|
|
414
415
|
patch: planned.patch,
|
|
415
416
|
after: async ()=>{
|
|
416
417
|
count(planned);
|
|
418
|
+
// The holders moved, and with them the facet keys the Contacts
|
|
419
|
+
// list filters by (AGL-3321).
|
|
420
|
+
await restampCrmListFieldsAt(snapshot.ref, 'contacts');
|
|
417
421
|
// A count that could not move is logged and left: the company's own
|
|
418
422
|
// page takes the live aggregate, which corrects it.
|
|
419
423
|
await Promise.all(planned.companyCounts.map((change)=>companies.doc(change.companyId).update({
|