@aglyn/plugins-email 1.0.0-beta.196 → 1.0.0-beta.197

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.
Files changed (50) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/consent-group-access.d.ts +33 -0
  3. package/src/lib/components/consent-group-access.js +73 -0
  4. package/src/lib/components/consent-group-access.js.map +1 -0
  5. package/src/lib/components/consent-group-change-progress.d.ts +55 -0
  6. package/src/lib/components/consent-group-change-progress.js +282 -0
  7. package/src/lib/components/consent-group-change-progress.js.map +1 -0
  8. package/src/lib/components/consent-group-confirmation-card.d.ts +6 -16
  9. package/src/lib/components/consent-group-confirmation-card.js +7 -50
  10. package/src/lib/components/consent-group-confirmation-card.js.map +1 -1
  11. package/src/lib/components/consent-group-dialog.d.ts +53 -0
  12. package/src/lib/components/consent-group-dialog.js +495 -0
  13. package/src/lib/components/consent-group-dialog.js.map +1 -0
  14. package/src/lib/components/consent-group-editing.d.ts +127 -0
  15. package/src/lib/components/consent-group-editing.js +225 -0
  16. package/src/lib/components/consent-group-editing.js.map +1 -0
  17. package/src/lib/components/consent-group-review.d.ts +62 -0
  18. package/src/lib/components/consent-group-review.js +353 -0
  19. package/src/lib/components/consent-group-review.js.map +1 -0
  20. package/src/lib/components/consent-group-site-picker.d.ts +35 -0
  21. package/src/lib/components/consent-group-site-picker.js +190 -0
  22. package/src/lib/components/consent-group-site-picker.js.map +1 -0
  23. package/src/lib/components/consent-groups-api.d.ts +158 -0
  24. package/src/lib/components/consent-groups-api.js +250 -0
  25. package/src/lib/components/consent-groups-api.js.map +1 -0
  26. package/src/lib/components/consent-groups-card.d.ts +47 -0
  27. package/src/lib/components/consent-groups-card.js +351 -0
  28. package/src/lib/components/consent-groups-card.js.map +1 -0
  29. package/src/lib/components/email-screens-card.js +1 -1
  30. package/src/lib/components/email-screens-card.js.map +1 -1
  31. package/src/lib/components/emails-console-page.d.ts +1 -1
  32. package/src/lib/components/emails-console-page.js +62 -27
  33. package/src/lib/components/emails-console-page.js.map +1 -1
  34. package/src/lib/components/emails-console-sections.d.ts +1 -1
  35. package/src/lib/components/emails-console-sections.js +14 -1
  36. package/src/lib/components/emails-console-sections.js.map +1 -1
  37. package/src/lib/components/org-email-templates-card.js +1 -1
  38. package/src/lib/components/org-email-templates-card.js.map +1 -1
  39. package/src/lib/components/site-consent-group-card.d.ts +23 -0
  40. package/src/lib/components/site-consent-group-card.js +80 -0
  41. package/src/lib/components/site-consent-group-card.js.map +1 -0
  42. package/src/lib/components/suppressions-card.js +28 -7
  43. package/src/lib/components/suppressions-card.js.map +1 -1
  44. package/src/lib/server-console.js +4 -2
  45. package/src/lib/server-console.js.map +1 -1
  46. package/src/lib/server-list-gate.d.ts +9 -0
  47. package/src/lib/server-list-gate.js +23 -9
  48. package/src/lib/server-list-gate.js.map +1 -1
  49. package/src/lib/server-list-import.js +5 -2
  50. package/src/lib/server-list-import.js.map +1 -1
@@ -0,0 +1,127 @@
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 ConsentGroupDeclarationEntry } from './consent-groups-api';
18
+ /** A declared group the org honors today, with its id. */
19
+ export interface ListedConsentGroup {
20
+ id: string;
21
+ name: string;
22
+ /** Sorted, as `readConsentGroups` returns them. */
23
+ hostIds: string[];
24
+ }
25
+ /**
26
+ * The org's usable groups, sorted by name.
27
+ *
28
+ * Only what `readConsentGroups` honors: a stored entry it drops pools nobody
29
+ * today, so it is not offered for editing, and the route removes it with the
30
+ * next change (the review lists it). Sorted because a map's key order is not
31
+ * something two readers agree on, and a table that reshuffled between loads
32
+ * would read as a change.
33
+ */
34
+ export declare function listConsentGroups(org: Record<string, unknown> | null | undefined): ListedConsentGroup[];
35
+ /**
36
+ * How many stored entries nothing honors — no name, too few or too many
37
+ * sites, or a site another group also claims.
38
+ */
39
+ export declare function unusableConsentGroupCount(org: Record<string, unknown> | null | undefined): number;
40
+ /**
41
+ * The raw declaration a change is made against — what the route compares
42
+ * with what it holds, so a change somebody else made in the meantime is
43
+ * refused instead of silently overwritten.
44
+ */
45
+ export declare function expectedConsentGroups(org: Record<string, unknown> | null | undefined): Record<string, unknown> | null;
46
+ /**
47
+ * The sentence a group's signup forms show, for a group that may not exist
48
+ * yet. The same function the forms call, so what the editor promises is
49
+ * character for character what a visitor reads.
50
+ */
51
+ export declare function draftDisclosure(name: string, hostIds: readonly string[], groupId?: string): string | null;
52
+ /** What the dialog is editing. */
53
+ export interface ConsentGroupDraft {
54
+ mode: 'create' | 'edit' | 'dissolve';
55
+ /** The group being edited or dissolved; `null` for a new one. */
56
+ groupId: string | null;
57
+ name: string;
58
+ hostIds: readonly string[];
59
+ }
60
+ /** Another group that gives up sites to the one being edited. */
61
+ export interface ConsentGroupDraftDonor {
62
+ groupId: string;
63
+ name: string;
64
+ /** Its sites that move into the edited group. */
65
+ losing: string[];
66
+ /** Its sites that stay. */
67
+ remaining: string[];
68
+ /** One site stays, and a group of one is no group: it dissolves. */
69
+ dissolves: boolean;
70
+ /** Every site moves: it is merged into the edited group. */
71
+ absorbed: boolean;
72
+ }
73
+ /** A draft, read as the change it makes. */
74
+ export interface ConsentGroupDraftChange {
75
+ kind: 'create' | 'edit' | 'dissolve';
76
+ groupId: string | null;
77
+ /** The name after the change, or the dissolved group's name. */
78
+ name: string;
79
+ previousName: string | null;
80
+ /** The group's sites after the change; empty for a dissolve. */
81
+ hostIds: string[];
82
+ previousHostIds: string[];
83
+ renamed: boolean;
84
+ /** Sites joining the group that were on their own before. */
85
+ added: string[];
86
+ /** Sites joining the group from another group. */
87
+ movedIn: Array<{
88
+ hostId: string;
89
+ fromGroupId: string;
90
+ fromName: string;
91
+ }>;
92
+ /** Sites leaving the group to send on their own; all of them on a dissolve. */
93
+ removed: string[];
94
+ /** The group's sites the draft no longer selects. */
95
+ unselected: string[];
96
+ donors: ConsentGroupDraftDonor[];
97
+ /** An edit left with fewer than two sites, so it dissolves the group. */
98
+ tooFewSites: boolean;
99
+ /** Nothing would change. */
100
+ empty: boolean;
101
+ }
102
+ /** Reads a draft against the groups the org holds now. */
103
+ export declare function describeConsentGroupDraft(current: readonly ListedConsentGroup[], draft: ConsentGroupDraft): ConsentGroupDraftChange;
104
+ /**
105
+ * The org's complete next declaration, and where the edited group sits in it.
106
+ *
107
+ * Every other group is carried over as it stands, less any site the edited
108
+ * group takes from it — and a group left with fewer than two sites is left
109
+ * out, which is how the route reads "dissolved". The edited group goes last,
110
+ * so a refusal that names a group by its index names it predictably.
111
+ */
112
+ export declare function buildConsentGroupDeclaration(current: readonly ListedConsentGroup[], draft: ConsentGroupDraft): {
113
+ groups: ConsentGroupDeclarationEntry[];
114
+ editedIndex: number | null;
115
+ };
116
+ /** What the edit step says is wrong, by field. */
117
+ export interface ConsentGroupDraftErrors {
118
+ name?: string;
119
+ sites?: string;
120
+ }
121
+ /**
122
+ * The checks the editor can make without asking, which is deliberately few:
123
+ * the route validates everything again and its answer is the one that
124
+ * counts. These exist so the obvious mistakes are said beside the field that
125
+ * has them, before anybody waits on a preview.
126
+ */
127
+ export declare function validateConsentGroupDraft(current: readonly ListedConsentGroup[], draft: ConsentGroupDraft): ConsentGroupDraftErrors;
@@ -0,0 +1,225 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * The consent group editor's arithmetic, kept pure so every rule it applies
19
+ * can be asserted without rendering a dialog.
20
+ *
21
+ * The dialog edits ONE group — a new one, or an existing one renamed, grown,
22
+ * shrunk or dissolved — but the route takes the org's WHOLE next declaration.
23
+ * Everything here turns the first into the second, and says in plain terms
24
+ * what that second one changes, so the review step can be written from it.
25
+ */ import { consentGroupDisclosure, CONSENT_GROUPS_FIELD, readConsentGroups } from "@aglyn/aglyn/app-utils/consent-groups";
26
+ import { CONSENT_GROUP_MIN_HOSTS, CONSENT_GROUP_NAME_MAX, MAX_CONSENT_GROUP_HOSTS } from "./consent-groups-api.js";
27
+ /**
28
+ * The org's usable groups, sorted by name.
29
+ *
30
+ * Only what `readConsentGroups` honors: a stored entry it drops pools nobody
31
+ * today, so it is not offered for editing, and the route removes it with the
32
+ * next change (the review lists it). Sorted because a map's key order is not
33
+ * something two readers agree on, and a table that reshuffled between loads
34
+ * would read as a change.
35
+ */ export function listConsentGroups(org) {
36
+ return Object.entries(readConsentGroups(org)).map(([id, group])=>({
37
+ id,
38
+ name: group.name,
39
+ hostIds: [
40
+ ...group.hostIds
41
+ ]
42
+ })).sort((a, b)=>a.name.localeCompare(b.name, 'en', {
43
+ sensitivity: 'base'
44
+ }) || a.id.localeCompare(b.id));
45
+ }
46
+ /**
47
+ * How many stored entries nothing honors — no name, too few or too many
48
+ * sites, or a site another group also claims.
49
+ */ export function unusableConsentGroupCount(org) {
50
+ const raw = (org != null ? org : {})[CONSENT_GROUPS_FIELD];
51
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 0;
52
+ const usable = readConsentGroups(org);
53
+ return Object.keys(raw).filter((id)=>!(id in usable)).length;
54
+ }
55
+ /**
56
+ * The raw declaration a change is made against — what the route compares
57
+ * with what it holds, so a change somebody else made in the meantime is
58
+ * refused instead of silently overwritten.
59
+ */ export function expectedConsentGroups(org) {
60
+ const raw = (org != null ? org : {})[CONSENT_GROUPS_FIELD];
61
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
62
+ }
63
+ /**
64
+ * The sentence a group's signup forms show, for a group that may not exist
65
+ * yet. The same function the forms call, so what the editor promises is
66
+ * character for character what a visitor reads.
67
+ */ export function draftDisclosure(name, hostIds, groupId = 'draft') {
68
+ const trimmed = name.trim();
69
+ if (!trimmed || hostIds.length < CONSENT_GROUP_MIN_HOSTS) return null;
70
+ return consentGroupDisclosure({
71
+ hostId: hostIds[0],
72
+ groupId,
73
+ name: trimmed,
74
+ hostIds: [
75
+ ...hostIds
76
+ ],
77
+ declared: true,
78
+ awaitsConfirmation: false
79
+ });
80
+ }
81
+ const sortedUnique = (ids)=>[
82
+ ...new Set(ids.filter(Boolean))
83
+ ].sort();
84
+ /** Reads a draft against the groups the org holds now. */ export function describeConsentGroupDraft(current, draft) {
85
+ var _current_find, _ref;
86
+ const original = draft.groupId != null ? (_current_find = current.find((group)=>group.id === draft.groupId)) != null ? _current_find : null : null;
87
+ const previousHostIds = original ? [
88
+ ...original.hostIds
89
+ ] : [];
90
+ const selected = sortedUnique(draft.hostIds);
91
+ const unselected = previousHostIds.filter((id)=>!selected.includes(id));
92
+ const tooFewSites = draft.mode === 'edit' && selected.length < CONSENT_GROUP_MIN_HOSTS;
93
+ const dissolving = draft.mode === 'dissolve' || tooFewSites;
94
+ if (dissolving) {
95
+ var _ref1, _ref2;
96
+ return {
97
+ kind: 'dissolve',
98
+ groupId: draft.groupId,
99
+ name: (_ref1 = original == null ? void 0 : original.name) != null ? _ref1 : draft.name.trim(),
100
+ previousName: (_ref2 = original == null ? void 0 : original.name) != null ? _ref2 : null,
101
+ hostIds: [],
102
+ previousHostIds,
103
+ renamed: false,
104
+ added: [],
105
+ movedIn: [],
106
+ removed: previousHostIds,
107
+ unselected: draft.mode === 'dissolve' ? previousHostIds : unselected,
108
+ donors: [],
109
+ tooFewSites,
110
+ empty: !original
111
+ };
112
+ }
113
+ const name = draft.name.trim();
114
+ const chosen = new Set(selected);
115
+ const before = new Set(previousHostIds);
116
+ const donors = [];
117
+ const movedIn = [];
118
+ for (const group of current){
119
+ if (group.id === draft.groupId) continue;
120
+ const losing = group.hostIds.filter((hostId)=>chosen.has(hostId));
121
+ if (!losing.length) continue;
122
+ const remaining = group.hostIds.filter((hostId)=>!chosen.has(hostId));
123
+ donors.push({
124
+ groupId: group.id,
125
+ name: group.name,
126
+ losing,
127
+ remaining,
128
+ dissolves: remaining.length > 0 && remaining.length < CONSENT_GROUP_MIN_HOSTS,
129
+ absorbed: remaining.length === 0
130
+ });
131
+ for (const hostId of losing){
132
+ movedIn.push({
133
+ hostId,
134
+ fromGroupId: group.id,
135
+ fromName: group.name
136
+ });
137
+ }
138
+ }
139
+ const moving = new Set(movedIn.map((move)=>move.hostId));
140
+ const added = selected.filter((id)=>!before.has(id) && !moving.has(id));
141
+ const removed = unselected;
142
+ const renamed = original != null && name !== original.name;
143
+ return {
144
+ kind: original ? 'edit' : 'create',
145
+ groupId: draft.groupId,
146
+ name,
147
+ previousName: (_ref = original == null ? void 0 : original.name) != null ? _ref : null,
148
+ hostIds: selected,
149
+ previousHostIds,
150
+ renamed,
151
+ added,
152
+ movedIn,
153
+ removed,
154
+ unselected,
155
+ donors,
156
+ tooFewSites: false,
157
+ empty: original != null && !renamed && !added.length && !movedIn.length && !removed.length
158
+ };
159
+ }
160
+ /**
161
+ * The org's complete next declaration, and where the edited group sits in it.
162
+ *
163
+ * Every other group is carried over as it stands, less any site the edited
164
+ * group takes from it — and a group left with fewer than two sites is left
165
+ * out, which is how the route reads "dissolved". The edited group goes last,
166
+ * so a refusal that names a group by its index names it predictably.
167
+ */ export function buildConsentGroupDeclaration(current, draft) {
168
+ const change = describeConsentGroupDraft(current, draft);
169
+ const taken = new Set(change.kind === 'dissolve' ? [] : change.hostIds);
170
+ const groups = [];
171
+ for (const group of current){
172
+ if (group.id === draft.groupId) continue;
173
+ const hostIds = group.hostIds.filter((hostId)=>!taken.has(hostId));
174
+ if (hostIds.length < CONSENT_GROUP_MIN_HOSTS) continue;
175
+ groups.push({
176
+ id: group.id,
177
+ name: group.name,
178
+ hostIds
179
+ });
180
+ }
181
+ if (change.kind === 'dissolve') return {
182
+ groups,
183
+ editedIndex: null
184
+ };
185
+ groups.push(_extends({}, draft.groupId != null ? {
186
+ id: draft.groupId
187
+ } : {}, {
188
+ name: change.name,
189
+ hostIds: change.hostIds
190
+ }));
191
+ return {
192
+ groups,
193
+ editedIndex: groups.length - 1
194
+ };
195
+ }
196
+ /**
197
+ * The checks the editor can make without asking, which is deliberately few:
198
+ * the route validates everything again and its answer is the one that
199
+ * counts. These exist so the obvious mistakes are said beside the field that
200
+ * has them, before anybody waits on a preview.
201
+ */ export function validateConsentGroupDraft(current, draft) {
202
+ const change = describeConsentGroupDraft(current, draft);
203
+ if (change.kind === 'dissolve') return {};
204
+ const errors = {};
205
+ if (!change.name) {
206
+ errors.name = 'Give the group a name. Signup forms show it.';
207
+ } else if (change.name.length > CONSENT_GROUP_NAME_MAX) {
208
+ errors.name = `Keep the name to ${CONSENT_GROUP_NAME_MAX} characters or fewer.`;
209
+ } else {
210
+ const lower = change.name.toLocaleLowerCase();
211
+ const { groups } = buildConsentGroupDeclaration(current, draft);
212
+ const clash = groups.some((group)=>group.id !== draft.groupId && group.id != null && group.name.toLocaleLowerCase() === lower);
213
+ if (clash) errors.name = 'Another consent group already has this name.';
214
+ }
215
+ if (change.hostIds.length < CONSENT_GROUP_MIN_HOSTS) {
216
+ errors.sites = 'Choose at least two sites.';
217
+ } else if (change.hostIds.length > MAX_CONSENT_GROUP_HOSTS) {
218
+ errors.sites = `A consent group can include at most ${MAX_CONSENT_GROUP_HOSTS} sites.`;
219
+ } else if (change.kind === 'edit' && !change.previousHostIds.some((hostId)=>change.hostIds.includes(hostId))) {
220
+ errors.sites = 'Keep at least one of this group’s sites, or create a new group for these sites instead.';
221
+ }
222
+ return errors;
223
+ }
224
+
225
+ //# sourceMappingURL=consent-group-editing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/consent-group-editing.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The consent group editor's arithmetic, kept pure so every rule it applies\n * can be asserted without rendering a dialog.\n *\n * The dialog edits ONE group — a new one, or an existing one renamed, grown,\n * shrunk or dissolved — but the route takes the org's WHOLE next declaration.\n * Everything here turns the first into the second, and says in plain terms\n * what that second one changes, so the review step can be written from it.\n */\n\nimport {\n consentGroupDisclosure,\n CONSENT_GROUPS_FIELD,\n readConsentGroups,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n CONSENT_GROUP_MIN_HOSTS,\n CONSENT_GROUP_NAME_MAX,\n MAX_CONSENT_GROUP_HOSTS,\n type ConsentGroupDeclarationEntry,\n} from './consent-groups-api'\n\n/** A declared group the org honors today, with its id. */\nexport interface ListedConsentGroup {\n id: string\n name: string\n /** Sorted, as `readConsentGroups` returns them. */\n hostIds: string[]\n}\n\n/**\n * The org's usable groups, sorted by name.\n *\n * Only what `readConsentGroups` honors: a stored entry it drops pools nobody\n * today, so it is not offered for editing, and the route removes it with the\n * next change (the review lists it). Sorted because a map's key order is not\n * something two readers agree on, and a table that reshuffled between loads\n * would read as a change.\n */\nexport function listConsentGroups(\n org: Record<string, unknown> | null | undefined,\n): ListedConsentGroup[] {\n return Object.entries(readConsentGroups(org))\n .map(([id, group]) => ({ id, name: group.name, hostIds: [...group.hostIds] }))\n .sort(\n (a, b) =>\n a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }) ||\n a.id.localeCompare(b.id),\n )\n}\n\n/**\n * How many stored entries nothing honors — no name, too few or too many\n * sites, or a site another group also claims.\n */\nexport function unusableConsentGroupCount(\n org: Record<string, unknown> | null | undefined,\n): number {\n const raw = (org ?? {})[CONSENT_GROUPS_FIELD]\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 0\n const usable = readConsentGroups(org)\n return Object.keys(raw as Record<string, unknown>).filter(\n (id) => !(id in usable),\n ).length\n}\n\n/**\n * The raw declaration a change is made against — what the route compares\n * with what it holds, so a change somebody else made in the meantime is\n * refused instead of silently overwritten.\n */\nexport function expectedConsentGroups(\n org: Record<string, unknown> | null | undefined,\n): Record<string, unknown> | null {\n const raw = (org ?? {})[CONSENT_GROUPS_FIELD]\n return raw && typeof raw === 'object' && !Array.isArray(raw)\n ? (raw as Record<string, unknown>)\n : null\n}\n\n/**\n * The sentence a group's signup forms show, for a group that may not exist\n * yet. The same function the forms call, so what the editor promises is\n * character for character what a visitor reads.\n */\nexport function draftDisclosure(\n name: string,\n hostIds: readonly string[],\n groupId = 'draft',\n): string | null {\n const trimmed = name.trim()\n if (!trimmed || hostIds.length < CONSENT_GROUP_MIN_HOSTS) return null\n return consentGroupDisclosure({\n hostId: hostIds[0],\n groupId,\n name: trimmed,\n hostIds: [...hostIds],\n declared: true,\n awaitsConfirmation: false,\n })\n}\n\n/** What the dialog is editing. */\nexport interface ConsentGroupDraft {\n mode: 'create' | 'edit' | 'dissolve'\n /** The group being edited or dissolved; `null` for a new one. */\n groupId: string | null\n name: string\n hostIds: readonly string[]\n}\n\n/** Another group that gives up sites to the one being edited. */\nexport interface ConsentGroupDraftDonor {\n groupId: string\n name: string\n /** Its sites that move into the edited group. */\n losing: string[]\n /** Its sites that stay. */\n remaining: string[]\n /** One site stays, and a group of one is no group: it dissolves. */\n dissolves: boolean\n /** Every site moves: it is merged into the edited group. */\n absorbed: boolean\n}\n\n/** A draft, read as the change it makes. */\nexport interface ConsentGroupDraftChange {\n kind: 'create' | 'edit' | 'dissolve'\n groupId: string | null\n /** The name after the change, or the dissolved group's name. */\n name: string\n previousName: string | null\n /** The group's sites after the change; empty for a dissolve. */\n hostIds: string[]\n previousHostIds: string[]\n renamed: boolean\n /** Sites joining the group that were on their own before. */\n added: string[]\n /** Sites joining the group from another group. */\n movedIn: Array<{ hostId: string; fromGroupId: string; fromName: string }>\n /** Sites leaving the group to send on their own; all of them on a dissolve. */\n removed: string[]\n /** The group's sites the draft no longer selects. */\n unselected: string[]\n donors: ConsentGroupDraftDonor[]\n /** An edit left with fewer than two sites, so it dissolves the group. */\n tooFewSites: boolean\n /** Nothing would change. */\n empty: boolean\n}\n\nconst sortedUnique = (ids: readonly string[]) =>\n [...new Set(ids.filter(Boolean))].sort()\n\n/** Reads a draft against the groups the org holds now. */\nexport function describeConsentGroupDraft(\n current: readonly ListedConsentGroup[],\n draft: ConsentGroupDraft,\n): ConsentGroupDraftChange {\n const original =\n draft.groupId != null\n ? (current.find((group) => group.id === draft.groupId) ?? null)\n : null\n const previousHostIds = original ? [...original.hostIds] : []\n const selected = sortedUnique(draft.hostIds)\n const unselected = previousHostIds.filter((id) => !selected.includes(id))\n const tooFewSites =\n draft.mode === 'edit' && selected.length < CONSENT_GROUP_MIN_HOSTS\n const dissolving = draft.mode === 'dissolve' || tooFewSites\n\n if (dissolving) {\n return {\n kind: 'dissolve',\n groupId: draft.groupId,\n name: original?.name ?? draft.name.trim(),\n previousName: original?.name ?? null,\n hostIds: [],\n previousHostIds,\n renamed: false,\n added: [],\n movedIn: [],\n removed: previousHostIds,\n unselected: draft.mode === 'dissolve' ? previousHostIds : unselected,\n donors: [],\n tooFewSites,\n empty: !original,\n }\n }\n\n const name = draft.name.trim()\n const chosen = new Set(selected)\n const before = new Set(previousHostIds)\n const donors: ConsentGroupDraftDonor[] = []\n const movedIn: ConsentGroupDraftChange['movedIn'] = []\n for (const group of current) {\n if (group.id === draft.groupId) continue\n const losing = group.hostIds.filter((hostId) => chosen.has(hostId))\n if (!losing.length) continue\n const remaining = group.hostIds.filter((hostId) => !chosen.has(hostId))\n donors.push({\n groupId: group.id,\n name: group.name,\n losing,\n remaining,\n dissolves: remaining.length > 0 && remaining.length < CONSENT_GROUP_MIN_HOSTS,\n absorbed: remaining.length === 0,\n })\n for (const hostId of losing) {\n movedIn.push({ hostId, fromGroupId: group.id, fromName: group.name })\n }\n }\n const moving = new Set(movedIn.map((move) => move.hostId))\n const added = selected.filter((id) => !before.has(id) && !moving.has(id))\n const removed = unselected\n const renamed = original != null && name !== original.name\n return {\n kind: original ? 'edit' : 'create',\n groupId: draft.groupId,\n name,\n previousName: original?.name ?? null,\n hostIds: selected,\n previousHostIds,\n renamed,\n added,\n movedIn,\n removed,\n unselected,\n donors,\n tooFewSites: false,\n empty:\n original != null &&\n !renamed &&\n !added.length &&\n !movedIn.length &&\n !removed.length,\n }\n}\n\n/**\n * The org's complete next declaration, and where the edited group sits in it.\n *\n * Every other group is carried over as it stands, less any site the edited\n * group takes from it — and a group left with fewer than two sites is left\n * out, which is how the route reads \"dissolved\". The edited group goes last,\n * so a refusal that names a group by its index names it predictably.\n */\nexport function buildConsentGroupDeclaration(\n current: readonly ListedConsentGroup[],\n draft: ConsentGroupDraft,\n): { groups: ConsentGroupDeclarationEntry[]; editedIndex: number | null } {\n const change = describeConsentGroupDraft(current, draft)\n const taken = new Set(change.kind === 'dissolve' ? [] : change.hostIds)\n const groups: ConsentGroupDeclarationEntry[] = []\n for (const group of current) {\n if (group.id === draft.groupId) continue\n const hostIds = group.hostIds.filter((hostId) => !taken.has(hostId))\n if (hostIds.length < CONSENT_GROUP_MIN_HOSTS) continue\n groups.push({ id: group.id, name: group.name, hostIds })\n }\n if (change.kind === 'dissolve') return { groups, editedIndex: null }\n groups.push({\n ...(draft.groupId != null ? { id: draft.groupId } : {}),\n name: change.name,\n hostIds: change.hostIds,\n })\n return { groups, editedIndex: groups.length - 1 }\n}\n\n/** What the edit step says is wrong, by field. */\nexport interface ConsentGroupDraftErrors {\n name?: string\n sites?: string\n}\n\n/**\n * The checks the editor can make without asking, which is deliberately few:\n * the route validates everything again and its answer is the one that\n * counts. These exist so the obvious mistakes are said beside the field that\n * has them, before anybody waits on a preview.\n */\nexport function validateConsentGroupDraft(\n current: readonly ListedConsentGroup[],\n draft: ConsentGroupDraft,\n): ConsentGroupDraftErrors {\n const change = describeConsentGroupDraft(current, draft)\n if (change.kind === 'dissolve') return {}\n const errors: ConsentGroupDraftErrors = {}\n if (!change.name) {\n errors.name = 'Give the group a name. Signup forms show it.'\n } else if (change.name.length > CONSENT_GROUP_NAME_MAX) {\n errors.name = `Keep the name to ${CONSENT_GROUP_NAME_MAX} characters or fewer.`\n } else {\n const lower = change.name.toLocaleLowerCase()\n const { groups } = buildConsentGroupDeclaration(current, draft)\n const clash = groups.some(\n (group) =>\n group.id !== draft.groupId &&\n group.id != null &&\n group.name.toLocaleLowerCase() === lower,\n )\n if (clash) errors.name = 'Another consent group already has this name.'\n }\n if (change.hostIds.length < CONSENT_GROUP_MIN_HOSTS) {\n errors.sites = 'Choose at least two sites.'\n } else if (change.hostIds.length > MAX_CONSENT_GROUP_HOSTS) {\n errors.sites = `A consent group can include at most ${MAX_CONSENT_GROUP_HOSTS} sites.`\n } else if (\n change.kind === 'edit' &&\n !change.previousHostIds.some((hostId) => change.hostIds.includes(hostId))\n ) {\n errors.sites =\n 'Keep at least one of this group’s sites, or create a new group for these sites instead.'\n }\n return errors\n}\n"],"names":["consentGroupDisclosure","CONSENT_GROUPS_FIELD","readConsentGroups","CONSENT_GROUP_MIN_HOSTS","CONSENT_GROUP_NAME_MAX","MAX_CONSENT_GROUP_HOSTS","listConsentGroups","org","Object","entries","map","id","group","name","hostIds","sort","a","b","localeCompare","sensitivity","unusableConsentGroupCount","raw","Array","isArray","usable","keys","filter","length","expectedConsentGroups","draftDisclosure","groupId","trimmed","trim","hostId","declared","awaitsConfirmation","sortedUnique","ids","Set","Boolean","describeConsentGroupDraft","current","draft","original","find","previousHostIds","selected","unselected","includes","tooFewSites","mode","dissolving","kind","previousName","renamed","added","movedIn","removed","donors","empty","chosen","before","losing","has","remaining","push","dissolves","absorbed","fromGroupId","fromName","moving","move","buildConsentGroupDeclaration","change","taken","groups","editedIndex","validateConsentGroupDraft","errors","lower","toLocaleLowerCase","clash","some","sites"],"mappings":"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"}
@@ -0,0 +1,62 @@
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
+ /**
18
+ * What the admin is told before a consent group change is applied.
19
+ *
20
+ * A change here decides who may email whom, so the review says it in the
21
+ * terms the admin will be held to rather than in the terms the code uses:
22
+ * what signup forms will say, who stops getting mail, who can still be
23
+ * mailed, and what happens to the CRM's records. Every number comes from the
24
+ * route's preview, which counted it without writing anything; a sentence
25
+ * whose number the preview did not supply is said without one rather than
26
+ * with a guess.
27
+ *
28
+ * Pure, so each wording can be asserted against a preview without rendering.
29
+ */
30
+ import type { ConsentGroupChangeEstimate, ConsentGroupChangePreview } from './consent-groups-api';
31
+ import { type ConsentGroupDraftChange } from './consent-group-editing';
32
+ /** One sentence of the review. `warning` is the tone of an irreversible loss. */
33
+ export interface ConsentGroupReviewItem {
34
+ id: string;
35
+ text: string;
36
+ tone: 'info' | 'warning';
37
+ }
38
+ export interface ConsentGroupReviewSection {
39
+ id: string;
40
+ heading: string | null;
41
+ items: ConsentGroupReviewItem[];
42
+ }
43
+ export interface ConsentGroupReview {
44
+ title: string;
45
+ /** The primary button, named for what it does. */
46
+ confirmLabel: string;
47
+ sections: ConsentGroupReviewSection[];
48
+ }
49
+ export interface ConsentGroupReviewInput {
50
+ change: ConsentGroupDraftChange;
51
+ /** `null` while the preview is still being counted. */
52
+ preview: ConsentGroupChangePreview | null;
53
+ siteName: (hostId: string) => string;
54
+ /** The org's switch: a confirmation one site waits on holds its siblings. */
55
+ awaitsConfirmation: boolean;
56
+ }
57
+ /** `A`, `A and B`, `A, B, and C`. */
58
+ export declare function joinNames(names: readonly string[]): string;
59
+ /** What an estimate means for the person about to wait on it. */
60
+ export declare const CONSENT_GROUP_ESTIMATE_TEXT: Readonly<Record<ConsentGroupChangeEstimate, string>>;
61
+ /** Builds every section of the review, in the order it is read. */
62
+ export declare function describeConsentGroupReview(input: ConsentGroupReviewInput): ConsentGroupReview;