@aglyn/plugins-email 1.0.0-beta.196 → 1.0.0-beta.198
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 +17 -17
- package/src/lib/components/consent-group-access.d.ts +33 -0
- package/src/lib/components/consent-group-access.js +73 -0
- package/src/lib/components/consent-group-access.js.map +1 -0
- package/src/lib/components/consent-group-change-progress.d.ts +55 -0
- package/src/lib/components/consent-group-change-progress.js +282 -0
- package/src/lib/components/consent-group-change-progress.js.map +1 -0
- package/src/lib/components/consent-group-confirmation-card.d.ts +6 -16
- package/src/lib/components/consent-group-confirmation-card.js +7 -50
- package/src/lib/components/consent-group-confirmation-card.js.map +1 -1
- package/src/lib/components/consent-group-dialog.d.ts +53 -0
- package/src/lib/components/consent-group-dialog.js +495 -0
- package/src/lib/components/consent-group-dialog.js.map +1 -0
- package/src/lib/components/consent-group-editing.d.ts +127 -0
- package/src/lib/components/consent-group-editing.js +225 -0
- package/src/lib/components/consent-group-editing.js.map +1 -0
- package/src/lib/components/consent-group-review.d.ts +62 -0
- package/src/lib/components/consent-group-review.js +353 -0
- package/src/lib/components/consent-group-review.js.map +1 -0
- package/src/lib/components/consent-group-site-picker.d.ts +35 -0
- package/src/lib/components/consent-group-site-picker.js +190 -0
- package/src/lib/components/consent-group-site-picker.js.map +1 -0
- package/src/lib/components/consent-groups-api.d.ts +158 -0
- package/src/lib/components/consent-groups-api.js +250 -0
- package/src/lib/components/consent-groups-api.js.map +1 -0
- package/src/lib/components/consent-groups-card.d.ts +47 -0
- package/src/lib/components/consent-groups-card.js +351 -0
- package/src/lib/components/consent-groups-card.js.map +1 -0
- package/src/lib/components/email-screens-card.js +1 -1
- package/src/lib/components/email-screens-card.js.map +1 -1
- package/src/lib/components/emails-console-page.d.ts +1 -1
- package/src/lib/components/emails-console-page.js +62 -27
- package/src/lib/components/emails-console-page.js.map +1 -1
- package/src/lib/components/emails-console-sections.d.ts +1 -1
- package/src/lib/components/emails-console-sections.js +14 -1
- package/src/lib/components/emails-console-sections.js.map +1 -1
- package/src/lib/components/org-email-templates-card.js +1 -1
- package/src/lib/components/org-email-templates-card.js.map +1 -1
- package/src/lib/components/site-consent-group-card.d.ts +23 -0
- package/src/lib/components/site-consent-group-card.js +80 -0
- package/src/lib/components/site-consent-group-card.js.map +1 -0
- package/src/lib/components/suppressions-card.js +28 -7
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/server-console.js +4 -2
- package/src/lib/server-console.js.map +1 -1
- package/src/lib/server-list-gate.d.ts +9 -0
- package/src/lib/server-list-gate.js +23 -9
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server-list-import.js +5 -2
- package/src/lib/server-list-import.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.198",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,22 +25,22 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-ui-theme": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
42
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
43
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.198",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.198",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.198",
|
|
31
|
+
"@aglyn/shared-data-types": "1.0.0-beta.198",
|
|
32
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.198",
|
|
33
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.198",
|
|
34
|
+
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.198",
|
|
35
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.198",
|
|
36
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.198",
|
|
37
|
+
"@aglyn/shared-ui-theme": "1.0.0-beta.198",
|
|
38
|
+
"@aglyn/shared-util-email": "1.0.0-beta.198",
|
|
39
|
+
"@aglyn/shared-util-http": "1.0.0-beta.198",
|
|
40
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.198",
|
|
41
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.198",
|
|
42
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.198",
|
|
43
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.198",
|
|
44
44
|
"@swc/helpers": "0.5.23"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type OrgPermission } from '@aglyn/aglyn';
|
|
2
|
+
/**
|
|
3
|
+
* What a member needs to change anything about the org's consent groups: the
|
|
4
|
+
* declaration itself (`/api/orgs/consent-groups`) or the switch over how its
|
|
5
|
+
* sites treat each other's confirmations (`/api/orgs/settings`). Both routes
|
|
6
|
+
* ask for the org-settings permission and for the permission that opens this
|
|
7
|
+
* console, so both cards ask the same question.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONSENT_GROUP_REQUIRED_PERMISSIONS: readonly OrgPermission[];
|
|
10
|
+
/** Whether the signed-in member may change consent groups, once known. */
|
|
11
|
+
export interface ConsentGroupAccess {
|
|
12
|
+
/** The label of the first permission they lack, or `null` when they hold both. */
|
|
13
|
+
missing: string | null;
|
|
14
|
+
/** False until their membership (and custom role, if any) has been read. */
|
|
15
|
+
ready: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Which permission the signed-in member lacks to change consent groups — its
|
|
19
|
+
* label, or `null` when they hold both — and whether that is known yet.
|
|
20
|
+
*
|
|
21
|
+
* Read off the member's own membership document, and their custom role when
|
|
22
|
+
* they hold one, both of which the rules let a member read. The shell's
|
|
23
|
+
* permission map carries the legacy keys and the plugin-declared ones, not
|
|
24
|
+
* these two, and the CRM's org settings cards answer the same question the
|
|
25
|
+
* same way. `ready` separates "no" from "not yet", so a control disables with
|
|
26
|
+
* a reason instead of hiding until the read lands. A membership that cannot
|
|
27
|
+
* be read resolves no permission at all; the route decides either way.
|
|
28
|
+
*
|
|
29
|
+
* Handed no org, it opens no read and stays not ready — which is how a card
|
|
30
|
+
* handed the answer by its section avoids a second listen on the same
|
|
31
|
+
* membership.
|
|
32
|
+
*/
|
|
33
|
+
export declare function useConsentGroupAccess(orgId: string | undefined): ConsentGroupAccess;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { orgPermissionLabel, resolveOrgPermissions } from "@aglyn/aglyn";
|
|
18
|
+
import { useFirestore, useFirestoreDoc, useUser } from "@aglyn/tenant-feature-instance";
|
|
19
|
+
import { doc } from "firebase/firestore";
|
|
20
|
+
/**
|
|
21
|
+
* What a member needs to change anything about the org's consent groups: the
|
|
22
|
+
* declaration itself (`/api/orgs/consent-groups`) or the switch over how its
|
|
23
|
+
* sites treat each other's confirmations (`/api/orgs/settings`). Both routes
|
|
24
|
+
* ask for the org-settings permission and for the permission that opens this
|
|
25
|
+
* console, so both cards ask the same question.
|
|
26
|
+
*/ export const CONSENT_GROUP_REQUIRED_PERMISSIONS = [
|
|
27
|
+
'org.settings',
|
|
28
|
+
'data.manage'
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Which permission the signed-in member lacks to change consent groups — its
|
|
32
|
+
* label, or `null` when they hold both — and whether that is known yet.
|
|
33
|
+
*
|
|
34
|
+
* Read off the member's own membership document, and their custom role when
|
|
35
|
+
* they hold one, both of which the rules let a member read. The shell's
|
|
36
|
+
* permission map carries the legacy keys and the plugin-declared ones, not
|
|
37
|
+
* these two, and the CRM's org settings cards answer the same question the
|
|
38
|
+
* same way. `ready` separates "no" from "not yet", so a control disables with
|
|
39
|
+
* a reason instead of hiding until the read lands. A membership that cannot
|
|
40
|
+
* be read resolves no permission at all; the route decides either way.
|
|
41
|
+
*
|
|
42
|
+
* Handed no org, it opens no read and stays not ready — which is how a card
|
|
43
|
+
* handed the answer by its section avoids a second listen on the same
|
|
44
|
+
* membership.
|
|
45
|
+
*/ export function useConsentGroupAccess(orgId) {
|
|
46
|
+
var _ref, _roleRead_data;
|
|
47
|
+
const firestore = useFirestore();
|
|
48
|
+
const { data: user } = useUser();
|
|
49
|
+
const uid = (_ref = user == null ? void 0 : user.uid) != null ? _ref : '';
|
|
50
|
+
const memberRead = useFirestoreDoc(()=>orgId && uid ? doc(firestore, 'orgs', orgId, 'members', uid) : null, [
|
|
51
|
+
firestore,
|
|
52
|
+
orgId,
|
|
53
|
+
uid
|
|
54
|
+
]);
|
|
55
|
+
const member = memberRead.status === 'success' ? memberRead.data : undefined;
|
|
56
|
+
const roleId = typeof (member == null ? void 0 : member.roleId) === 'string' ? member.roleId : '';
|
|
57
|
+
const roleRead = useFirestoreDoc(()=>orgId && roleId ? doc(firestore, 'orgs', orgId, 'roles', roleId) : null, [
|
|
58
|
+
firestore,
|
|
59
|
+
orgId,
|
|
60
|
+
roleId
|
|
61
|
+
]);
|
|
62
|
+
// A reference that is never built stays `loading`, so a member with no
|
|
63
|
+
// custom role has nothing left to wait for.
|
|
64
|
+
const ready = Boolean(orgId && uid) && memberRead.status !== 'loading' && (!roleId || roleRead.status !== 'loading');
|
|
65
|
+
const granted = resolveOrgPermissions(member != null ? member : null, roleId && roleRead.status === 'success' ? (_roleRead_data = roleRead.data) != null ? _roleRead_data : null : null);
|
|
66
|
+
const lacking = CONSENT_GROUP_REQUIRED_PERMISSIONS.find((key)=>granted[key] !== true);
|
|
67
|
+
return {
|
|
68
|
+
missing: lacking ? orgPermissionLabel(lacking) : null,
|
|
69
|
+
ready
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
//# sourceMappingURL=consent-group-access.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/consent-group-access.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'use client'\n\nimport {\n type AglynOrgCustomRole,\n type OrgPermission,\n orgPermissionLabel,\n resolveOrgPermissions,\n} from '@aglyn/aglyn'\nimport {\n useFirestore,\n useFirestoreDoc,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { doc } from 'firebase/firestore'\n\n/**\n * What a member needs to change anything about the org's consent groups: the\n * declaration itself (`/api/orgs/consent-groups`) or the switch over how its\n * sites treat each other's confirmations (`/api/orgs/settings`). Both routes\n * ask for the org-settings permission and for the permission that opens this\n * console, so both cards ask the same question.\n */\nexport const CONSENT_GROUP_REQUIRED_PERMISSIONS: readonly OrgPermission[] = [\n 'org.settings',\n 'data.manage',\n]\n\n/** A membership document, as the permission resolver reads one. */\ntype MemberRecord = NonNullable<Parameters<typeof resolveOrgPermissions>[0]>\n\n/** Whether the signed-in member may change consent groups, once known. */\nexport interface ConsentGroupAccess {\n /** The label of the first permission they lack, or `null` when they hold both. */\n missing: string | null\n /** False until their membership (and custom role, if any) has been read. */\n ready: boolean\n}\n\n/**\n * Which permission the signed-in member lacks to change consent groups — its\n * label, or `null` when they hold both — and whether that is known yet.\n *\n * Read off the member's own membership document, and their custom role when\n * they hold one, both of which the rules let a member read. The shell's\n * permission map carries the legacy keys and the plugin-declared ones, not\n * these two, and the CRM's org settings cards answer the same question the\n * same way. `ready` separates \"no\" from \"not yet\", so a control disables with\n * a reason instead of hiding until the read lands. A membership that cannot\n * be read resolves no permission at all; the route decides either way.\n *\n * Handed no org, it opens no read and stays not ready — which is how a card\n * handed the answer by its section avoids a second listen on the same\n * membership.\n */\nexport function useConsentGroupAccess(\n orgId: string | undefined,\n): ConsentGroupAccess {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const uid = user?.uid ?? ''\n const memberRead = useFirestoreDoc<MemberRecord>(\n () => (orgId && uid ? doc(firestore, 'orgs', orgId, 'members', uid) : null),\n [firestore, orgId, uid],\n )\n const member = memberRead.status === 'success' ? memberRead.data : undefined\n const roleId = typeof member?.roleId === 'string' ? member.roleId : ''\n const roleRead = useFirestoreDoc<AglynOrgCustomRole>(\n () => (orgId && roleId ? doc(firestore, 'orgs', orgId, 'roles', roleId) : null),\n [firestore, orgId, roleId],\n )\n // A reference that is never built stays `loading`, so a member with no\n // custom role has nothing left to wait for.\n const ready =\n Boolean(orgId && uid) &&\n memberRead.status !== 'loading' &&\n (!roleId || roleRead.status !== 'loading')\n const granted = resolveOrgPermissions(\n member ?? null,\n roleId && roleRead.status === 'success' ? (roleRead.data ?? null) : null,\n )\n const lacking = CONSENT_GROUP_REQUIRED_PERMISSIONS.find(\n (key) => granted[key] !== true,\n )\n return { missing: lacking ? orgPermissionLabel(lacking) : null, ready }\n}\n"],"names":["orgPermissionLabel","resolveOrgPermissions","useFirestore","useFirestoreDoc","useUser","doc","CONSENT_GROUP_REQUIRED_PERMISSIONS","useConsentGroupAccess","orgId","roleRead","firestore","data","user","uid","memberRead","member","status","undefined","roleId","ready","Boolean","granted","lacking","find","key","missing"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAGEA,kBAAkB,EAClBC,qBAAqB,QAChB,eAAc;AACrB,SACEC,YAAY,EACZC,eAAe,EACfC,OAAO,QACF,iCAAgC;AACvC,SAASC,GAAG,QAAQ,qBAAoB;AAExC;;;;;;CAMC,GACD,OAAO,MAAMC,qCAA+D;IAC1E;IACA;CACD,CAAA;AAaD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,sBACdC,KAAyB;cAuBoBC;IArB7C,MAAMC,YAAYR;IAClB,MAAM,EAAES,MAAMC,IAAI,EAAE,GAAGR;IACvB,MAAMS,cAAMD,wBAAAA,KAAMC,GAAG,mBAAI;IACzB,MAAMC,aAAaX,gBACjB,IAAOK,SAASK,MAAMR,IAAIK,WAAW,QAAQF,OAAO,WAAWK,OAAO,MACtE;QAACH;QAAWF;QAAOK;KAAI;IAEzB,MAAME,SAASD,WAAWE,MAAM,KAAK,YAAYF,WAAWH,IAAI,GAAGM;IACnE,MAAMC,SAAS,QAAOH,0BAAAA,OAAQG,MAAM,MAAK,WAAWH,OAAOG,MAAM,GAAG;IACpE,MAAMT,WAAWN,gBACf,IAAOK,SAASU,SAASb,IAAIK,WAAW,QAAQF,OAAO,SAASU,UAAU,MAC1E;QAACR;QAAWF;QAAOU;KAAO;IAE5B,uEAAuE;IACvE,4CAA4C;IAC5C,MAAMC,QACJC,QAAQZ,SAASK,QACjBC,WAAWE,MAAM,KAAK,aACrB,CAAA,CAACE,UAAUT,SAASO,MAAM,KAAK,SAAQ;IAC1C,MAAMK,UAAUpB,sBACdc,iBAAAA,SAAU,MACVG,UAAUT,SAASO,MAAM,KAAK,aAAaP,iBAAAA,SAASE,IAAI,YAAbF,iBAAiB,OAAQ;IAEtE,MAAMa,UAAUhB,mCAAmCiB,IAAI,CACrD,CAACC,MAAQH,OAAO,CAACG,IAAI,KAAK;IAE5B,OAAO;QAAEC,SAASH,UAAUtB,mBAAmBsB,WAAW;QAAMH;IAAM;AACxE"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ConsentGroupChangeMarker, type ConsentGroupChangePhase } from './consent-groups-api';
|
|
2
|
+
/**
|
|
3
|
+
* The least time between two `continue` calls from one page. A call that
|
|
4
|
+
* returns at once — the job is waiting on its sweep, or another runner holds
|
|
5
|
+
* it — must not become a loop that asks again the instant it is answered.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CONSENT_GROUP_CONTINUE_GAP_MS = 3000;
|
|
8
|
+
/** What each phase is called while it runs, and what it means for the reader. */
|
|
9
|
+
export declare const CONSENT_GROUP_PHASE_TEXT: Readonly<Record<ConsentGroupChangePhase, {
|
|
10
|
+
title: string;
|
|
11
|
+
body: string;
|
|
12
|
+
}>>;
|
|
13
|
+
export interface ConsentGroupChangeProgressProps {
|
|
14
|
+
orgId: string;
|
|
15
|
+
changeId: string;
|
|
16
|
+
/** The org's marker for this change, when the org document carries it yet. */
|
|
17
|
+
marker: ConsentGroupChangeMarker | null;
|
|
18
|
+
/**
|
|
19
|
+
* The reader may drive and stop the change. Anyone else sees where it is,
|
|
20
|
+
* and opens no read of the job for it.
|
|
21
|
+
*/
|
|
22
|
+
canDrive: boolean;
|
|
23
|
+
/** The change finished, or was stopped before it took effect. */
|
|
24
|
+
onFinished: (outcome: 'done' | 'canceled') => void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A consent group change while it runs (AGL-3320).
|
|
28
|
+
*
|
|
29
|
+
* ## It finishes on its own; an open page only makes it faster
|
|
30
|
+
*
|
|
31
|
+
* The route works in slices of under a minute and a cron resumes it every
|
|
32
|
+
* quarter hour, so closing the page never strands a change. While this is
|
|
33
|
+
* mounted for somebody who may drive it, it asks for the next slice as soon
|
|
34
|
+
* as nobody else holds the job — the job document's lease says when — so a
|
|
35
|
+
* change the admin is watching finishes in minutes rather than quarter hours.
|
|
36
|
+
*
|
|
37
|
+
* ## Only the first phase can be stopped
|
|
38
|
+
*
|
|
39
|
+
* Before the flip nothing an admin would notice has changed: refusals were
|
|
40
|
+
* copied, which never lets anybody be mailed who could not be before, and
|
|
41
|
+
* the old declaration still holds. So **Stop before it takes effect** is
|
|
42
|
+
* offered exactly then. After the flip the new declaration is what every
|
|
43
|
+
* send reads, and stopping would leave the CRM's records half moved.
|
|
44
|
+
*
|
|
45
|
+
* ## A stall is shown, not hidden
|
|
46
|
+
*
|
|
47
|
+
* After five failures in a row the job stops retrying itself on every call
|
|
48
|
+
* and records why. The banner shows that reason and **Retry now**; the cron
|
|
49
|
+
* keeps trying either way.
|
|
50
|
+
*/
|
|
51
|
+
export declare function ConsentGroupChangeProgress(props: ConsentGroupChangeProgressProps): import("react").JSX.Element;
|
|
52
|
+
export declare namespace ConsentGroupChangeProgress {
|
|
53
|
+
var displayName: string;
|
|
54
|
+
}
|
|
55
|
+
export default ConsentGroupChangeProgress;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
18
|
+
import { useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
19
|
+
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
20
|
+
import { useFirestore, useFirestoreDoc, useUser } from "@aglyn/tenant-feature-instance";
|
|
21
|
+
import { Alert, AlertTitle, Button, LinearProgress, Stack, Typography } from "@mui/material";
|
|
22
|
+
import { doc } from "firebase/firestore";
|
|
23
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
24
|
+
import { CONSENT_GROUP_CHANGES_COLLECTION, CONSENT_GROUP_SWEEP_DELAY_MS, postConsentGroups, readConsentGroupChangeJob } from "./consent-groups-api.js";
|
|
25
|
+
/**
|
|
26
|
+
* The least time between two `continue` calls from one page. A call that
|
|
27
|
+
* returns at once — the job is waiting on its sweep, or another runner holds
|
|
28
|
+
* it — must not become a loop that asks again the instant it is answered.
|
|
29
|
+
*/ export const CONSENT_GROUP_CONTINUE_GAP_MS = 3000;
|
|
30
|
+
/** What each phase is called while it runs, and what it means for the reader. */ export const CONSENT_GROUP_PHASE_TEXT = {
|
|
31
|
+
carry: {
|
|
32
|
+
title: 'Copying opt-outs — nothing has changed yet',
|
|
33
|
+
body: 'Opt-outs are being copied onto every site that will stop sharing them, ' + 'so nobody who opted out can be emailed after the change. Until this ' + 'finishes, your consent groups work exactly as they did.'
|
|
34
|
+
},
|
|
35
|
+
rehome: {
|
|
36
|
+
title: 'In effect — combining CRM records',
|
|
37
|
+
body: 'Every send already follows your new consent groups. The CRM records ' + 'these sites keep are being combined or copied, so some may show under ' + 'their old group for a little while.'
|
|
38
|
+
},
|
|
39
|
+
sweep: {
|
|
40
|
+
title: 'Finishing up',
|
|
41
|
+
body: 'A last pass for anything written while the change ran. You can make ' + 'another change once this finishes.'
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A consent group change while it runs (AGL-3320).
|
|
46
|
+
*
|
|
47
|
+
* ## It finishes on its own; an open page only makes it faster
|
|
48
|
+
*
|
|
49
|
+
* The route works in slices of under a minute and a cron resumes it every
|
|
50
|
+
* quarter hour, so closing the page never strands a change. While this is
|
|
51
|
+
* mounted for somebody who may drive it, it asks for the next slice as soon
|
|
52
|
+
* as nobody else holds the job — the job document's lease says when — so a
|
|
53
|
+
* change the admin is watching finishes in minutes rather than quarter hours.
|
|
54
|
+
*
|
|
55
|
+
* ## Only the first phase can be stopped
|
|
56
|
+
*
|
|
57
|
+
* Before the flip nothing an admin would notice has changed: refusals were
|
|
58
|
+
* copied, which never lets anybody be mailed who could not be before, and
|
|
59
|
+
* the old declaration still holds. So **Stop before it takes effect** is
|
|
60
|
+
* offered exactly then. After the flip the new declaration is what every
|
|
61
|
+
* send reads, and stopping would leave the CRM's records half moved.
|
|
62
|
+
*
|
|
63
|
+
* ## A stall is shown, not hidden
|
|
64
|
+
*
|
|
65
|
+
* After five failures in a row the job stops retrying itself on every call
|
|
66
|
+
* and records why. The banner shows that reason and **Retry now**; the cron
|
|
67
|
+
* keeps trying either way.
|
|
68
|
+
*/ export function ConsentGroupChangeProgress(props) {
|
|
69
|
+
var _ref, _ref1, _ref2, _job_lastError;
|
|
70
|
+
var _answer_progress, _answer_progress1;
|
|
71
|
+
const { orgId, changeId, marker, canDrive, onFinished } = props;
|
|
72
|
+
const firestore = useFirestore();
|
|
73
|
+
const { data: user } = useUser();
|
|
74
|
+
const { enqueueSnackbar } = useSnackbar();
|
|
75
|
+
const { confirm } = useConfirmationContext();
|
|
76
|
+
const jobRead = useFirestoreDoc(()=>canDrive && orgId && changeId ? doc(firestore, 'orgs', orgId, CONSENT_GROUP_CHANGES_COLLECTION, changeId) : null, [
|
|
77
|
+
firestore,
|
|
78
|
+
orgId,
|
|
79
|
+
changeId,
|
|
80
|
+
canDrive
|
|
81
|
+
]);
|
|
82
|
+
const job = readConsentGroupChangeJob(jobRead.status === 'success' ? jobRead.data : undefined);
|
|
83
|
+
const [answer, setAnswer] = useState(null);
|
|
84
|
+
const [busy, setBusy] = useState(false);
|
|
85
|
+
const [stopping, setStopping] = useState(false);
|
|
86
|
+
/** A refusal that stops this page driving until somebody retries. */ const [halted, setHalted] = useState(null);
|
|
87
|
+
/** Re-arms the driver after each answer. */ const [tick, setTick] = useState(0);
|
|
88
|
+
const lastCallAt = useRef(0);
|
|
89
|
+
const finished = useRef(false);
|
|
90
|
+
const mounted = useRef(true);
|
|
91
|
+
useEffect(()=>{
|
|
92
|
+
mounted.current = true;
|
|
93
|
+
return ()=>{
|
|
94
|
+
mounted.current = false;
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
const markerPhase = marker && marker.changeId === changeId ? marker.phase : null;
|
|
98
|
+
const answerPhase = answer && [
|
|
99
|
+
'carry',
|
|
100
|
+
'rehome',
|
|
101
|
+
'sweep'
|
|
102
|
+
].includes(String(answer.phase)) ? answer.phase : null;
|
|
103
|
+
const jobPhase = [
|
|
104
|
+
'carry',
|
|
105
|
+
'rehome',
|
|
106
|
+
'sweep'
|
|
107
|
+
].includes(String(job.phase)) ? job.phase : null;
|
|
108
|
+
const phase = (_ref = (_ref1 = markerPhase != null ? markerPhase : answerPhase) != null ? _ref1 : jobPhase) != null ? _ref : 'carry';
|
|
109
|
+
const stalled = job.stalled || (answer == null ? void 0 : (_answer_progress = answer.progress) == null ? void 0 : _answer_progress.stalled) === true;
|
|
110
|
+
const lastError = (_ref2 = (_job_lastError = job.lastError) != null ? _job_lastError : answer == null ? void 0 : (_answer_progress1 = answer.progress) == null ? void 0 : _answer_progress1.lastError) != null ? _ref2 : null;
|
|
111
|
+
const finish = useCallback((outcome)=>{
|
|
112
|
+
if (finished.current) return;
|
|
113
|
+
finished.current = true;
|
|
114
|
+
onFinished(outcome);
|
|
115
|
+
}, [
|
|
116
|
+
onFinished
|
|
117
|
+
]);
|
|
118
|
+
// The job can finish without this page: the cron, or another tab.
|
|
119
|
+
useEffect(()=>{
|
|
120
|
+
if (job.finished) finish(job.phase === 'canceled' ? 'canceled' : 'done');
|
|
121
|
+
}, [
|
|
122
|
+
job.finished,
|
|
123
|
+
job.phase,
|
|
124
|
+
finish
|
|
125
|
+
]);
|
|
126
|
+
const drive = useCallback(async ()=>{
|
|
127
|
+
if (!user || finished.current) return;
|
|
128
|
+
lastCallAt.current = Date.now();
|
|
129
|
+
setBusy(true);
|
|
130
|
+
const result = await postConsentGroups(user, {
|
|
131
|
+
orgId,
|
|
132
|
+
action: 'continue',
|
|
133
|
+
changeId
|
|
134
|
+
});
|
|
135
|
+
if (!mounted.current) return;
|
|
136
|
+
setBusy(false);
|
|
137
|
+
if (result.ok) {
|
|
138
|
+
setAnswer(result.body);
|
|
139
|
+
if (result.body.done === true) return void finish('done');
|
|
140
|
+
setTick((value)=>value + 1);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
setHalted(result.failure.message);
|
|
144
|
+
}, [
|
|
145
|
+
user,
|
|
146
|
+
orgId,
|
|
147
|
+
changeId,
|
|
148
|
+
finish
|
|
149
|
+
]);
|
|
150
|
+
/*
|
|
151
|
+
* The driver. It waits out the least gap between calls, any lease another
|
|
152
|
+
* runner holds, and the delay before the sweep may run — then asks once.
|
|
153
|
+
* Each answer re-arms it through `tick`; a lease renewed under it re-arms
|
|
154
|
+
* it through the job document.
|
|
155
|
+
*/ useEffect(()=>{
|
|
156
|
+
if (!canDrive || !user || finished.current) return;
|
|
157
|
+
if (busy || stopping || stalled || halted) return;
|
|
158
|
+
if (jobRead.status === 'loading') return;
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
let at = Math.max(now, lastCallAt.current + CONSENT_GROUP_CONTINUE_GAP_MS);
|
|
161
|
+
if (job.leaseUntilMs != null && job.leaseUntilMs > now) {
|
|
162
|
+
at = Math.max(at, job.leaseUntilMs + 1000);
|
|
163
|
+
}
|
|
164
|
+
if (phase === 'sweep' && (marker == null ? void 0 : marker.declaredAtMs)) {
|
|
165
|
+
at = Math.max(at, marker.declaredAtMs + CONSENT_GROUP_SWEEP_DELAY_MS);
|
|
166
|
+
}
|
|
167
|
+
const timer = setTimeout(()=>void drive(), at - now);
|
|
168
|
+
return ()=>clearTimeout(timer);
|
|
169
|
+
}, [
|
|
170
|
+
canDrive,
|
|
171
|
+
user,
|
|
172
|
+
busy,
|
|
173
|
+
stopping,
|
|
174
|
+
stalled,
|
|
175
|
+
halted,
|
|
176
|
+
jobRead.status,
|
|
177
|
+
job.leaseUntilMs,
|
|
178
|
+
phase,
|
|
179
|
+
marker == null ? void 0 : marker.declaredAtMs,
|
|
180
|
+
drive,
|
|
181
|
+
tick
|
|
182
|
+
]);
|
|
183
|
+
const retry = ()=>{
|
|
184
|
+
setHalted(null);
|
|
185
|
+
lastCallAt.current = 0;
|
|
186
|
+
void drive();
|
|
187
|
+
};
|
|
188
|
+
const stop = async ()=>{
|
|
189
|
+
if (!user || stopping) return;
|
|
190
|
+
const accepted = await confirm({
|
|
191
|
+
title: 'Stop this change?',
|
|
192
|
+
description: 'Nothing has taken effect yet, so your consent groups stay exactly as ' + 'they are. Opt-outs already copied stay in place — a copied opt-out ' + 'never lets anyone be emailed who couldn’t be before.',
|
|
193
|
+
confirmationText: 'Stop the change',
|
|
194
|
+
cancellationText: 'Keep going'
|
|
195
|
+
})// `confirm` resolves with no value and REJECTS on cancel (AGL-950).
|
|
196
|
+
.then(()=>true).catch(()=>false);
|
|
197
|
+
if (!accepted) return;
|
|
198
|
+
setStopping(true);
|
|
199
|
+
const result = await postConsentGroups(user, {
|
|
200
|
+
orgId,
|
|
201
|
+
action: 'cancel',
|
|
202
|
+
changeId
|
|
203
|
+
});
|
|
204
|
+
if (!mounted.current) return;
|
|
205
|
+
setStopping(false);
|
|
206
|
+
if (result.ok) {
|
|
207
|
+
enqueueSnackbar('Stopped. Your consent groups are unchanged.', {
|
|
208
|
+
variant: 'success',
|
|
209
|
+
persist: false
|
|
210
|
+
});
|
|
211
|
+
return void finish('canceled');
|
|
212
|
+
}
|
|
213
|
+
enqueueSnackbar(result.failure.message, {
|
|
214
|
+
variant: result.failure.kind === 'took-effect' ? 'info' : 'error',
|
|
215
|
+
allowDuplicate: true
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
const text = CONSENT_GROUP_PHASE_TEXT[phase];
|
|
219
|
+
return /*#__PURE__*/ _jsxs(Alert, {
|
|
220
|
+
severity: stalled || halted ? 'error' : 'info',
|
|
221
|
+
"data-testid": "consent-group-change-progress",
|
|
222
|
+
sx: {
|
|
223
|
+
'& .MuiAlert-message': {
|
|
224
|
+
width: '100%'
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
children: [
|
|
228
|
+
/*#__PURE__*/ _jsx(AlertTitle, {
|
|
229
|
+
children: text.title
|
|
230
|
+
}),
|
|
231
|
+
/*#__PURE__*/ _jsxs(Stack, {
|
|
232
|
+
spacing: 1,
|
|
233
|
+
children: [
|
|
234
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
235
|
+
variant: "body2",
|
|
236
|
+
children: text.body
|
|
237
|
+
}),
|
|
238
|
+
stalled ? /*#__PURE__*/ _jsx(Typography, {
|
|
239
|
+
variant: "body2",
|
|
240
|
+
children: `This change stopped after repeated errors${lastError ? `: ${lastError}` : '.'} It keeps trying on its own every 15 minutes, or you can retry now.`
|
|
241
|
+
}) : null,
|
|
242
|
+
halted ? /*#__PURE__*/ _jsx(Typography, {
|
|
243
|
+
variant: "body2",
|
|
244
|
+
children: halted
|
|
245
|
+
}) : null,
|
|
246
|
+
!canDrive ? /*#__PURE__*/ _jsx(Typography, {
|
|
247
|
+
variant: "body2",
|
|
248
|
+
color: "text.secondary",
|
|
249
|
+
children: 'It finishes on its own. Only members who can change consent groups can stop it.'
|
|
250
|
+
}) : null,
|
|
251
|
+
!stalled && !halted ? /*#__PURE__*/ _jsx(LinearProgress, {
|
|
252
|
+
"aria-label": "Consent group change in progress"
|
|
253
|
+
}) : null,
|
|
254
|
+
canDrive && (stalled || halted || phase === 'carry') ? /*#__PURE__*/ _jsxs(Stack, {
|
|
255
|
+
direction: "row",
|
|
256
|
+
spacing: 1,
|
|
257
|
+
children: [
|
|
258
|
+
stalled || halted ? /*#__PURE__*/ _jsx(Button, {
|
|
259
|
+
size: "small",
|
|
260
|
+
variant: "contained",
|
|
261
|
+
onClick: retry,
|
|
262
|
+
disabled: busy,
|
|
263
|
+
children: 'Retry now'
|
|
264
|
+
}) : null,
|
|
265
|
+
phase === 'carry' ? /*#__PURE__*/ _jsx(Button, {
|
|
266
|
+
size: "small",
|
|
267
|
+
color: "inherit",
|
|
268
|
+
onClick: ()=>void stop(),
|
|
269
|
+
disabled: stopping,
|
|
270
|
+
children: 'Stop before it takes effect'
|
|
271
|
+
}) : null
|
|
272
|
+
]
|
|
273
|
+
}) : null
|
|
274
|
+
]
|
|
275
|
+
})
|
|
276
|
+
]
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
ConsentGroupChangeProgress.displayName = 'ConsentGroupChangeProgress';
|
|
280
|
+
export default ConsentGroupChangeProgress;
|
|
281
|
+
|
|
282
|
+
//# sourceMappingURL=consent-group-change-progress.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/consent-group-change-progress.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreDoc,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n AlertTitle,\n Button,\n LinearProgress,\n Stack,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport {\n CONSENT_GROUP_CHANGES_COLLECTION,\n CONSENT_GROUP_SWEEP_DELAY_MS,\n postConsentGroups,\n readConsentGroupChangeJob,\n type ConsentGroupChangeMarker,\n type ConsentGroupChangePhase,\n type ConsentGroupsProgressResponse,\n} from './consent-groups-api'\n\n/**\n * The least time between two `continue` calls from one page. A call that\n * returns at once — the job is waiting on its sweep, or another runner holds\n * it — must not become a loop that asks again the instant it is answered.\n */\nexport const CONSENT_GROUP_CONTINUE_GAP_MS = 3_000\n\n/** What each phase is called while it runs, and what it means for the reader. */\nexport const CONSENT_GROUP_PHASE_TEXT: Readonly<\n Record<ConsentGroupChangePhase, { title: string; body: string }>\n> = {\n carry: {\n title: 'Copying opt-outs — nothing has changed yet',\n body:\n 'Opt-outs are being copied onto every site that will stop sharing them, ' +\n 'so nobody who opted out can be emailed after the change. Until this ' +\n 'finishes, your consent groups work exactly as they did.',\n },\n rehome: {\n title: 'In effect — combining CRM records',\n body:\n 'Every send already follows your new consent groups. The CRM records ' +\n 'these sites keep are being combined or copied, so some may show under ' +\n 'their old group for a little while.',\n },\n sweep: {\n title: 'Finishing up',\n body:\n 'A last pass for anything written while the change ran. You can make ' +\n 'another change once this finishes.',\n },\n}\n\nexport interface ConsentGroupChangeProgressProps {\n orgId: string\n changeId: string\n /** The org's marker for this change, when the org document carries it yet. */\n marker: ConsentGroupChangeMarker | null\n /**\n * The reader may drive and stop the change. Anyone else sees where it is,\n * and opens no read of the job for it.\n */\n canDrive: boolean\n /** The change finished, or was stopped before it took effect. */\n onFinished: (outcome: 'done' | 'canceled') => void\n}\n\n/**\n * A consent group change while it runs (AGL-3320).\n *\n * ## It finishes on its own; an open page only makes it faster\n *\n * The route works in slices of under a minute and a cron resumes it every\n * quarter hour, so closing the page never strands a change. While this is\n * mounted for somebody who may drive it, it asks for the next slice as soon\n * as nobody else holds the job — the job document's lease says when — so a\n * change the admin is watching finishes in minutes rather than quarter hours.\n *\n * ## Only the first phase can be stopped\n *\n * Before the flip nothing an admin would notice has changed: refusals were\n * copied, which never lets anybody be mailed who could not be before, and\n * the old declaration still holds. So **Stop before it takes effect** is\n * offered exactly then. After the flip the new declaration is what every\n * send reads, and stopping would leave the CRM's records half moved.\n *\n * ## A stall is shown, not hidden\n *\n * After five failures in a row the job stops retrying itself on every call\n * and records why. The banner shows that reason and **Retry now**; the cron\n * keeps trying either way.\n */\nexport function ConsentGroupChangeProgress(props: ConsentGroupChangeProgressProps) {\n const { orgId, changeId, marker, canDrive, onFinished } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n const jobRead = useFirestoreDoc<Record<string, unknown>>(\n () =>\n canDrive && orgId && changeId\n ? doc(firestore, 'orgs', orgId, CONSENT_GROUP_CHANGES_COLLECTION, changeId)\n : null,\n [firestore, orgId, changeId, canDrive],\n )\n const job = readConsentGroupChangeJob(\n jobRead.status === 'success' ? jobRead.data : undefined,\n )\n\n const [answer, setAnswer] = useState<ConsentGroupsProgressResponse | null>(null)\n const [busy, setBusy] = useState(false)\n const [stopping, setStopping] = useState(false)\n /** A refusal that stops this page driving until somebody retries. */\n const [halted, setHalted] = useState<string | null>(null)\n /** Re-arms the driver after each answer. */\n const [tick, setTick] = useState(0)\n const lastCallAt = useRef(0)\n const finished = useRef(false)\n const mounted = useRef(true)\n useEffect(() => {\n mounted.current = true\n return () => {\n mounted.current = false\n }\n }, [])\n\n const markerPhase =\n marker && marker.changeId === changeId ? marker.phase : null\n const answerPhase =\n answer && ['carry', 'rehome', 'sweep'].includes(String(answer.phase))\n ? (answer.phase as ConsentGroupChangePhase)\n : null\n const jobPhase = ['carry', 'rehome', 'sweep'].includes(String(job.phase))\n ? (job.phase as ConsentGroupChangePhase)\n : null\n const phase: ConsentGroupChangePhase =\n markerPhase ?? answerPhase ?? jobPhase ?? 'carry'\n const stalled = job.stalled || answer?.progress?.stalled === true\n const lastError = job.lastError ?? answer?.progress?.lastError ?? null\n\n const finish = useCallback(\n (outcome: 'done' | 'canceled') => {\n if (finished.current) return\n finished.current = true\n onFinished(outcome)\n },\n [onFinished],\n )\n\n // The job can finish without this page: the cron, or another tab.\n useEffect(() => {\n if (job.finished) finish(job.phase === 'canceled' ? 'canceled' : 'done')\n }, [job.finished, job.phase, finish])\n\n const drive = useCallback(async () => {\n if (!user || finished.current) return\n lastCallAt.current = Date.now()\n setBusy(true)\n const result = await postConsentGroups<ConsentGroupsProgressResponse>(user, {\n orgId,\n action: 'continue',\n changeId,\n })\n if (!mounted.current) return\n setBusy(false)\n if (result.ok) {\n setAnswer(result.body)\n if (result.body.done === true) return void finish('done')\n setTick((value) => value + 1)\n return\n }\n setHalted(result.failure.message)\n }, [user, orgId, changeId, finish])\n\n /*\n * The driver. It waits out the least gap between calls, any lease another\n * runner holds, and the delay before the sweep may run — then asks once.\n * Each answer re-arms it through `tick`; a lease renewed under it re-arms\n * it through the job document.\n */\n useEffect(() => {\n if (!canDrive || !user || finished.current) return\n if (busy || stopping || stalled || halted) return\n if (jobRead.status === 'loading') return\n const now = Date.now()\n let at = Math.max(now, lastCallAt.current + CONSENT_GROUP_CONTINUE_GAP_MS)\n if (job.leaseUntilMs != null && job.leaseUntilMs > now) {\n at = Math.max(at, job.leaseUntilMs + 1_000)\n }\n if (phase === 'sweep' && marker?.declaredAtMs) {\n at = Math.max(at, marker.declaredAtMs + CONSENT_GROUP_SWEEP_DELAY_MS)\n }\n const timer = setTimeout(() => void drive(), at - now)\n return () => clearTimeout(timer)\n }, [\n canDrive,\n user,\n busy,\n stopping,\n stalled,\n halted,\n jobRead.status,\n job.leaseUntilMs,\n phase,\n marker?.declaredAtMs,\n drive,\n tick,\n ])\n\n const retry = () => {\n setHalted(null)\n lastCallAt.current = 0\n void drive()\n }\n\n const stop = async () => {\n if (!user || stopping) return\n const accepted = await confirm({\n title: 'Stop this change?',\n description:\n 'Nothing has taken effect yet, so your consent groups stay exactly as ' +\n 'they are. Opt-outs already copied stay in place — a copied opt-out ' +\n 'never lets anyone be emailed who couldn’t be before.',\n confirmationText: 'Stop the change',\n cancellationText: 'Keep going',\n })\n // `confirm` resolves with no value and REJECTS on cancel (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n setStopping(true)\n const result = await postConsentGroups(user, {\n orgId,\n action: 'cancel',\n changeId,\n })\n if (!mounted.current) return\n setStopping(false)\n if (result.ok) {\n enqueueSnackbar('Stopped. Your consent groups are unchanged.', {\n variant: 'success',\n persist: false,\n })\n return void finish('canceled')\n }\n enqueueSnackbar(result.failure.message, {\n variant: result.failure.kind === 'took-effect' ? 'info' : 'error',\n allowDuplicate: true,\n })\n }\n\n const text = CONSENT_GROUP_PHASE_TEXT[phase]\n return (\n <Alert\n severity={stalled || halted ? 'error' : 'info'}\n data-testid=\"consent-group-change-progress\"\n sx={{ '& .MuiAlert-message': { width: '100%' } }}\n >\n <AlertTitle>{text.title}</AlertTitle>\n <Stack spacing={1}>\n <Typography variant=\"body2\">{text.body}</Typography>\n {stalled ? (\n <Typography variant=\"body2\">\n {`This change stopped after repeated errors${\n lastError ? `: ${lastError}` : '.'\n } It keeps trying on its own every 15 minutes, or you can retry now.`}\n </Typography>\n ) : null}\n {halted ? <Typography variant=\"body2\">{halted}</Typography> : null}\n {!canDrive ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'It finishes on its own. Only members who can change consent groups can stop it.'}\n </Typography>\n ) : null}\n {!stalled && !halted ? (\n <LinearProgress aria-label=\"Consent group change in progress\" />\n ) : null}\n {canDrive && (stalled || halted || phase === 'carry') ? (\n <Stack direction=\"row\" spacing={1}>\n {stalled || halted ? (\n <Button size=\"small\" variant=\"contained\" onClick={retry} disabled={busy}>\n {'Retry now'}\n </Button>\n ) : null}\n {phase === 'carry' ? (\n <Button\n size=\"small\"\n color=\"inherit\"\n onClick={() => void stop()}\n disabled={stopping}\n >\n {'Stop before it takes effect'}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n </Stack>\n </Alert>\n )\n}\nConsentGroupChangeProgress.displayName = 'ConsentGroupChangeProgress'\n\nexport default ConsentGroupChangeProgress\n"],"names":["useConfirmationContext","useSnackbar","useFirestore","useFirestoreDoc","useUser","Alert","AlertTitle","Button","LinearProgress","Stack","Typography","doc","useCallback","useEffect","useRef","useState","CONSENT_GROUP_CHANGES_COLLECTION","CONSENT_GROUP_SWEEP_DELAY_MS","postConsentGroups","readConsentGroupChangeJob","CONSENT_GROUP_CONTINUE_GAP_MS","CONSENT_GROUP_PHASE_TEXT","carry","title","body","rehome","sweep","ConsentGroupChangeProgress","props","markerPhase","job","answer","orgId","changeId","marker","canDrive","onFinished","firestore","data","user","enqueueSnackbar","confirm","jobRead","status","undefined","setAnswer","busy","setBusy","stopping","setStopping","halted","setHalted","tick","setTick","lastCallAt","finished","mounted","current","phase","answerPhase","includes","String","jobPhase","stalled","progress","lastError","finish","outcome","drive","Date","now","result","action","ok","done","value","failure","message","at","Math","max","leaseUntilMs","declaredAtMs","timer","setTimeout","clearTimeout","retry","stop","accepted","description","confirmationText","cancellationText","then","catch","variant","persist","kind","allowDuplicate","text","severity","data-testid","sx","width","spacing","color","aria-label","direction","size","onClick","disabled","displayName"],"mappings":"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"}
|
|
@@ -1,22 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
* Which permission the signed-in member lacks for this switch — its label, or
|
|
3
|
-
* `null` when they hold both — and whether that is known yet.
|
|
4
|
-
*
|
|
5
|
-
* Read off the member's own membership document, and their custom role when
|
|
6
|
-
* they hold one, both of which the rules let a member read. The shell's
|
|
7
|
-
* permission map carries the legacy keys and the plugin-declared ones, not
|
|
8
|
-
* these two, and the CRM's org settings cards answer the same question the
|
|
9
|
-
* same way. `ready` separates "no" from "not yet", so the switch disables
|
|
10
|
-
* with a reason instead of hiding until the read lands. A membership that
|
|
11
|
-
* cannot be read resolves no permission at all; the route decides either way.
|
|
12
|
-
*/
|
|
13
|
-
export declare function useConsentGroupConfirmationAccess(orgId: string | undefined): {
|
|
14
|
-
missing: string | null;
|
|
15
|
-
ready: boolean;
|
|
16
|
-
};
|
|
1
|
+
import { type ConsentGroupAccess } from './consent-group-access';
|
|
17
2
|
export interface ConsentGroupConfirmationCardProps {
|
|
18
3
|
/** The org document the shell passes, kept live by its listener. */
|
|
19
4
|
org?: Record<string, unknown> | null;
|
|
5
|
+
/**
|
|
6
|
+
* Whether the reader may move the switch, when the section already asked.
|
|
7
|
+
* Absent, the card reads the reader's membership itself.
|
|
8
|
+
*/
|
|
9
|
+
access?: ConsentGroupAccess;
|
|
20
10
|
}
|
|
21
11
|
/**
|
|
22
12
|
* "Wait for confirmation across a consent group" — the org's one switch over
|