@carlonicora/nextjs-jsonapi 6.15.0 → 6.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/{BlockNoteEditor-UIAX72XW.js → BlockNoteEditor-FAUTV5FW.js} +20 -20
  2. package/dist/{BlockNoteEditor-UIAX72XW.js.map → BlockNoteEditor-FAUTV5FW.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-I3J6YYXK.mjs → BlockNoteEditor-IGUHTERQ.mjs} +5 -5
  4. package/dist/{BlockNoteViewer-HIUAJQRI.mjs → BlockNoteViewer-X4UEVI2A.mjs} +3 -3
  5. package/dist/{BlockNoteViewer-APBOPT4B.js → BlockNoteViewer-XYKFY77F.js} +4 -4
  6. package/dist/{BlockNoteViewer-APBOPT4B.js.map → BlockNoteViewer-XYKFY77F.js.map} +1 -1
  7. package/dist/EmailPurpose-DOsSqM5K.d.mts +3 -0
  8. package/dist/EmailPurpose-DOsSqM5K.d.ts +3 -0
  9. package/dist/billing/index.js +492 -492
  10. package/dist/billing/index.mjs +4 -4
  11. package/dist/{chunk-RT7XFGIN.js → chunk-4OCBNCKH.js} +1041 -999
  12. package/dist/chunk-4OCBNCKH.js.map +1 -0
  13. package/dist/{chunk-4XLDBOSE.js → chunk-A7Y5YW7Q.js} +1 -1
  14. package/dist/{chunk-4XLDBOSE.js.map → chunk-A7Y5YW7Q.js.map} +1 -1
  15. package/dist/{chunk-ZYSNJKOP.js → chunk-DFA566KY.js} +4 -4
  16. package/dist/{chunk-ZYSNJKOP.js.map → chunk-DFA566KY.js.map} +1 -1
  17. package/dist/{chunk-DJXSCARG.js → chunk-I5BQLY7V.js} +9 -9
  18. package/dist/{chunk-DJXSCARG.js.map → chunk-I5BQLY7V.js.map} +1 -1
  19. package/dist/{chunk-RFKSJIQB.mjs → chunk-KMPBSOFB.mjs} +138 -5
  20. package/dist/chunk-KMPBSOFB.mjs.map +1 -0
  21. package/dist/{chunk-UOTHRJD2.mjs → chunk-OKBJEIOP.mjs} +1 -1
  22. package/dist/chunk-OKBJEIOP.mjs.map +1 -0
  23. package/dist/{chunk-PMESKNKL.js → chunk-OO2DBP6L.js} +1106 -973
  24. package/dist/chunk-OO2DBP6L.js.map +1 -0
  25. package/dist/{chunk-RYY7USSW.mjs → chunk-RINMQQZH.mjs} +56 -14
  26. package/dist/{chunk-RYY7USSW.mjs.map → chunk-RINMQQZH.mjs.map} +1 -1
  27. package/dist/{chunk-HAW5PNJG.mjs → chunk-TY5YWBLL.mjs} +3 -3
  28. package/dist/{chunk-OUEYZOLB.mjs → chunk-YVKWACDP.mjs} +2 -2
  29. package/dist/client/index.js +5 -5
  30. package/dist/client/index.mjs +4 -4
  31. package/dist/components/index.d.mts +10 -3
  32. package/dist/components/index.d.ts +10 -3
  33. package/dist/components/index.js +5 -5
  34. package/dist/components/index.mjs +4 -4
  35. package/dist/contexts/index.js +5 -5
  36. package/dist/contexts/index.mjs +4 -4
  37. package/dist/core/index.d.mts +92 -7
  38. package/dist/core/index.d.ts +92 -7
  39. package/dist/core/index.js +7 -3
  40. package/dist/core/index.js.map +1 -1
  41. package/dist/core/index.mjs +6 -2
  42. package/dist/{email-automation-enrollment.fields-BZvF14qx.d.mts → email-automation-enrollment.fields-CPsgxLif.d.mts} +11 -1
  43. package/dist/{email-automation-enrollment.fields-Cp3VMzFq.d.ts → email-automation-enrollment.fields-CVicSamF.d.ts} +11 -1
  44. package/dist/email-automations/index.d.mts +19 -5
  45. package/dist/email-automations/index.d.ts +19 -5
  46. package/dist/email-automations/index.js +356 -129
  47. package/dist/email-automations/index.js.map +1 -1
  48. package/dist/email-automations/index.mjs +306 -79
  49. package/dist/email-automations/index.mjs.map +1 -1
  50. package/dist/features/analytics/index.js +121 -121
  51. package/dist/features/analytics/index.mjs +4 -4
  52. package/dist/features/analytics/server.js +5 -5
  53. package/dist/features/analytics/server.mjs +3 -3
  54. package/dist/features/blog/index.js +151 -151
  55. package/dist/features/blog/index.mjs +4 -4
  56. package/dist/features/blog/server.js +17 -17
  57. package/dist/features/blog/server.mjs +2 -2
  58. package/dist/features/help/index.js +38 -38
  59. package/dist/features/help/index.mjs +4 -4
  60. package/dist/features/tokenusage/index.js +127 -127
  61. package/dist/features/tokenusage/index.mjs +4 -4
  62. package/dist/index.d.mts +4 -3
  63. package/dist/index.d.ts +4 -3
  64. package/dist/index.js +8 -4
  65. package/dist/index.js.map +1 -1
  66. package/dist/index.mjs +7 -3
  67. package/dist/offline/index.js +14 -14
  68. package/dist/offline/index.mjs +1 -1
  69. package/dist/{referral-admin.interface-RVO-ZTJz.d.mts → referral-admin.interface-DTj4LzXx.d.mts} +3 -0
  70. package/dist/{referral-admin.interface-CnytdDaQ.d.ts → referral-admin.interface-DeSCw3Pp.d.ts} +3 -0
  71. package/dist/server/index.js +4 -4
  72. package/dist/server/index.mjs +3 -3
  73. package/messages/en.json +35 -3
  74. package/messages/it.json +35 -3
  75. package/package.json +1 -1
  76. package/src/core/index.ts +1 -0
  77. package/src/core/registry/ModuleRegistry.ts +1 -0
  78. package/src/features/email-automation/EmailAutomationCandidateModule.ts +12 -0
  79. package/src/features/email-automation/EmailAutomationModule.ts +1 -0
  80. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +74 -9
  81. package/src/features/email-automation/components/forms/EmailAutomationAddPersonDialog.tsx +226 -0
  82. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +23 -0
  83. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +7 -3
  84. package/src/features/email-automation/components/index.ts +1 -0
  85. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +38 -20
  86. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +1 -0
  87. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +9 -4
  88. package/src/features/email-automation/data/EmailAutomation.ts +9 -0
  89. package/src/features/email-automation/data/EmailAutomationCandidate.ts +66 -0
  90. package/src/features/email-automation/data/EmailAutomationCandidateInterface.ts +19 -0
  91. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +16 -2
  92. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +7 -0
  93. package/src/features/email-automation/data/EmailAutomationInterface.ts +3 -0
  94. package/src/features/email-automation/data/EmailAutomationService.ts +53 -1
  95. package/src/features/email-automation/data/EmailPurpose.ts +1 -0
  96. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +70 -0
  97. package/src/features/email-automation/data/email-automation.fields.ts +1 -0
  98. package/src/features/email-automation/data/index.ts +2 -0
  99. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +19 -0
  100. package/src/features/email-automation/index.ts +2 -0
  101. package/src/features/email-blast/EmailBlastModule.ts +1 -0
  102. package/src/features/email-blast/components/forms/EmailBlastEditor.tsx +33 -4
  103. package/src/features/email-blast/components/lists/EmailBlastsList.tsx +1 -0
  104. package/src/features/email-blast/data/EmailBlast.ts +10 -1
  105. package/src/features/email-blast/data/EmailBlastInterface.ts +3 -0
  106. package/src/features/email-blast/data/__tests__/EmailBlast.spec.ts +18 -0
  107. package/src/features/email-blast/data/email-blast.fields.ts +1 -0
  108. package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +15 -0
  109. package/src/features/notification/components/notifications/PushNotificationProvider.tsx +14 -2
  110. package/src/features/notification/components/notifications/__tests__/PushNotificationProvider.spec.tsx +11 -0
  111. package/dist/chunk-PMESKNKL.js.map +0 -1
  112. package/dist/chunk-RFKSJIQB.mjs.map +0 -1
  113. package/dist/chunk-RT7XFGIN.js.map +0 -1
  114. package/dist/chunk-UOTHRJD2.mjs.map +0 -1
  115. /package/dist/{BlockNoteEditor-I3J6YYXK.mjs.map → BlockNoteEditor-IGUHTERQ.mjs.map} +0 -0
  116. /package/dist/{BlockNoteViewer-HIUAJQRI.mjs.map → BlockNoteViewer-X4UEVI2A.mjs.map} +0 -0
  117. /package/dist/{chunk-HAW5PNJG.mjs.map → chunk-TY5YWBLL.mjs.map} +0 -0
  118. /package/dist/{chunk-OUEYZOLB.mjs.map → chunk-YVKWACDP.mjs.map} +0 -0
@@ -8,18 +8,20 @@ import { Modules } from "../../../../core";
8
8
  import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
9
9
  import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../shadcnui";
10
10
  import { useEmailAutomation } from "../../contexts/EmailAutomationContext";
11
+ import { EmailAutomationAddPersonDialog } from "../forms/EmailAutomationAddPersonDialog";
11
12
  import { EmailAutomationEnrollmentFields } from "../../data/email-automation-enrollment.fields";
12
13
  import { EmailAutomationEnrollmentInterface } from "../../data/EmailAutomationEnrollmentInterface";
13
14
  import { EmailAutomationService } from "../../data/EmailAutomationService";
14
15
 
15
16
  /**
16
- * Read-only list of the people in this automation (owner decision 6: no row
17
- * actions). Rendered inside a tab, so it keeps its own bordered card (no
18
- * `fullWidth`), as PricesList does inside ProductContainer.
17
+ * List of the people in this automation (owner decision 6: no row actions; a
18
+ * person is added by hand through "Add person" in the filters). Rendered inside
19
+ * a tab, so it keeps its own bordered card (no `fullWidth`), as PricesList does
20
+ * inside ProductContainer.
19
21
  */
20
22
  export function EmailAutomationRecipientsList() {
21
23
  const t = useTranslations();
22
- const { automation } = useEmailAutomation();
24
+ const { automation, reload } = useEmailAutomation();
23
25
  const [statusFilter, setStatusFilter] = useState<string>("all");
24
26
 
25
27
  const data: DataListRetriever<EmailAutomationEnrollmentInterface> = useDataListRetriever({
@@ -48,6 +50,17 @@ export function EmailAutomationRecipientsList() {
48
50
 
49
51
  const filters = (
50
52
  <div className="flex items-center gap-4">
53
+ {automation && (
54
+ <EmailAutomationAddPersonDialog
55
+ automationId={automation.id}
56
+ disabled={automation.stepCount === 0}
57
+ onAdded={() => {
58
+ void data.refresh();
59
+ void reload();
60
+ }}
61
+ />
62
+ )}
63
+
51
64
  <Select items={statusItems} value={statusFilter} onValueChange={handleStatusChange}>
52
65
  <SelectTrigger className="w-40">
53
66
  <SelectValue />
@@ -69,22 +82,27 @@ export function EmailAutomationRecipientsList() {
69
82
  );
70
83
 
71
84
  return (
72
- <ContentListTable
73
- data={data}
74
- fields={[
75
- EmailAutomationEnrollmentFields.email,
76
- EmailAutomationEnrollmentFields.name,
77
- EmailAutomationEnrollmentFields.status,
78
- EmailAutomationEnrollmentFields.currentStep,
79
- EmailAutomationEnrollmentFields.nextSendAt,
80
- EmailAutomationEnrollmentFields.sentCount,
81
- ]}
82
- tableGeneratorType={Modules.EmailAutomationEnrollment}
83
- filters={filters}
84
- /* The enrolments endpoint takes no search term, so rendering the box would
85
+ <div data-testid="email-automation-recipients">
86
+ <ContentListTable
87
+ data={data}
88
+ fields={[
89
+ EmailAutomationEnrollmentFields.email,
90
+ EmailAutomationEnrollmentFields.name,
91
+ EmailAutomationEnrollmentFields.status,
92
+ EmailAutomationEnrollmentFields.currentStep,
93
+ EmailAutomationEnrollmentFields.nextSendAt,
94
+ EmailAutomationEnrollmentFields.sentCount,
95
+ ]}
96
+ tableGeneratorType={Modules.EmailAutomationEnrollment}
97
+ /* ContentListTable renders its filters only inside the title bar, so the
98
+ list needs a title for "Add person" and the status filter to show. */
99
+ title={t("emailAutomations.tabs.recipients")}
100
+ filters={filters}
101
+ /* The enrolments endpoint takes no search term, so rendering the box would
85
102
  give the page a control that silently does nothing. */
86
- allowSearch={false}
87
- emptyState={t("emailAutomations.recipients.empty")}
88
- />
103
+ allowSearch={false}
104
+ emptyState={t("emailAutomations.recipients.empty")}
105
+ />
106
+ </div>
89
107
  );
90
108
  }
@@ -58,6 +58,7 @@ export function EmailAutomationsList({ fullWidth }: EmailAutomationsListProps =
58
58
  data={data}
59
59
  fields={[
60
60
  EmailAutomationFields.name,
61
+ EmailAutomationFields.purpose,
61
62
  EmailAutomationFields.trigger,
62
63
  EmailAutomationFields.status,
63
64
  EmailAutomationFields.steps,
@@ -27,6 +27,8 @@ export function EmailUnsubscribe({ token, scope, source = "automation" }: Props)
27
27
  const [state, setState] = useState<UnsubscribeState>("loading");
28
28
  const [stoppedScope, setStoppedScope] = useState<UnsubscribeScope>(scope);
29
29
  const [automationName, setAutomationName] = useState<string>("");
30
+ // A Service automation is not a marketing email: its page offers only "Stop these emails".
31
+ const [isServiceAutomation, setIsServiceAutomation] = useState<boolean>(false);
30
32
  const [isStoppingAll, setIsStoppingAll] = useState<boolean>(false);
31
33
 
32
34
  useEffect(() => {
@@ -40,6 +42,7 @@ export function EmailUnsubscribe({ token, scope, source = "automation" }: Props)
40
42
  }
41
43
  const enrollment = await EmailAutomationEnrollmentService.unsubscribe({ token, scope });
42
44
  setAutomationName(enrollment?.automation?.name ?? "");
45
+ setIsServiceAutomation(enrollment?.automation?.purpose === "service");
43
46
  setStoppedScope(scope);
44
47
  setState("success");
45
48
  } catch {
@@ -100,10 +103,12 @@ export function EmailUnsubscribe({ token, scope, source = "automation" }: Props)
100
103
  <p className="text-muted-foreground max-w-md text-sm leading-relaxed">
101
104
  {t("emailAutomations.unsubscribe.stoppedAutomation", { name: automationName })}
102
105
  </p>
103
- <Button variant="secondary" onClick={stopAll} disabled={isStoppingAll}>
104
- {isStoppingAll && <Loader2 className="h-4 w-4 animate-spin" />}
105
- {t("emailAutomations.unsubscribe.alsoStopAll")}
106
- </Button>
106
+ {!isServiceAutomation && (
107
+ <Button variant="secondary" onClick={stopAll} disabled={isStoppingAll}>
108
+ {isStoppingAll && <Loader2 className="h-4 w-4 animate-spin" />}
109
+ {t("emailAutomations.unsubscribe.alsoStopAll")}
110
+ </Button>
111
+ )}
107
112
  </>
108
113
  )}
109
114
  </div>
@@ -1,5 +1,6 @@
1
1
  import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
2
  import { EmailAutomationInput, EmailAutomationInterface } from "./EmailAutomationInterface";
3
+ import { EmailPurpose } from "./EmailPurpose";
3
4
 
4
5
  export class EmailAutomation extends AbstractApiData implements EmailAutomationInterface {
5
6
  private _name?: string;
@@ -7,6 +8,7 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
7
8
  private _exitTrigger?: string;
8
9
  private _replyTo?: string;
9
10
  private _isActive: boolean = false;
11
+ private _purpose: EmailPurpose = "marketing";
10
12
  private _stepCount?: number;
11
13
  private _activeCount?: number;
12
14
  private _completedCount?: number;
@@ -32,6 +34,10 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
32
34
  return this._isActive;
33
35
  }
34
36
 
37
+ get purpose(): EmailPurpose {
38
+ return this._purpose;
39
+ }
40
+
35
41
  get stepCount(): number {
36
42
  return this._stepCount ?? 0;
37
43
  }
@@ -56,6 +62,8 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
56
62
  this._exitTrigger = data.jsonApi.attributes.exitTrigger ?? undefined;
57
63
  this._replyTo = data.jsonApi.attributes.replyTo ?? undefined;
58
64
  this._isActive = data.jsonApi.attributes.isActive === true;
65
+ // A record saved before purpose existed counts as Marketing.
66
+ this._purpose = data.jsonApi.attributes.purpose === "service" ? "service" : "marketing";
59
67
  this._stepCount = data.jsonApi.attributes.stepCount;
60
68
  this._activeCount = data.jsonApi.attributes.activeCount;
61
69
  this._completedCount = data.jsonApi.attributes.completedCount;
@@ -81,6 +89,7 @@ export class EmailAutomation extends AbstractApiData implements EmailAutomationI
81
89
  if (data.exitTrigger !== undefined) response.data.attributes.exitTrigger = data.exitTrigger;
82
90
  if (data.replyTo !== undefined) response.data.attributes.replyTo = data.replyTo;
83
91
  if (data.isActive !== undefined) response.data.attributes.isActive = data.isActive;
92
+ if (data.purpose !== undefined) response.data.attributes.purpose = data.purpose;
84
93
 
85
94
  return response;
86
95
  }
@@ -0,0 +1,66 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import {
3
+ EmailAutomationCandidateInterface,
4
+ EmailAutomationCandidateReason,
5
+ EmailAutomationRecipientType,
6
+ } from "./EmailAutomationCandidateInterface";
7
+
8
+ export class EmailAutomationCandidate extends AbstractApiData implements EmailAutomationCandidateInterface {
9
+ private _recipientType: EmailAutomationRecipientType = "user";
10
+ private _recipientId?: string;
11
+ private _email?: string;
12
+ private _name?: string;
13
+ private _addable?: boolean;
14
+ private _reason?: EmailAutomationCandidateReason;
15
+
16
+ get recipientType(): EmailAutomationRecipientType {
17
+ return this._recipientType;
18
+ }
19
+
20
+ get recipientId(): string {
21
+ return this._recipientId ?? "";
22
+ }
23
+
24
+ get email(): string {
25
+ return this._email ?? "";
26
+ }
27
+
28
+ get name(): string | undefined {
29
+ return this._name;
30
+ }
31
+
32
+ get addable(): boolean {
33
+ return this._addable ?? false;
34
+ }
35
+
36
+ get reason(): EmailAutomationCandidateReason | undefined {
37
+ return this._reason;
38
+ }
39
+
40
+ rehydrate(data: JsonApiHydratedDataInterface): this {
41
+ super.rehydrate(data);
42
+
43
+ const attributes = data.jsonApi.attributes;
44
+ this._recipientType = attributes.recipientType === "waitlist" ? "waitlist" : "user";
45
+ this._recipientId = attributes.recipientId;
46
+ this._email = attributes.email;
47
+ this._name = attributes.name ?? undefined;
48
+ this._addable = attributes.addable === true;
49
+ this._reason = attributes.reason ?? undefined;
50
+
51
+ return this;
52
+ }
53
+
54
+ /**
55
+ * The API computes candidates and never accepts one as a request body; this
56
+ * only identifies the resource.
57
+ */
58
+ createJsonApi(data: { id: string }) {
59
+ return {
60
+ data: {
61
+ type: Modules.EmailAutomationCandidate.name,
62
+ id: data.id,
63
+ },
64
+ };
65
+ }
66
+ }
@@ -0,0 +1,19 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ export type EmailAutomationRecipientType = "user" | "waitlist";
4
+
5
+ export type EmailAutomationCandidateReason = "already_enrolled" | "opted_out" | "no_consent";
6
+
7
+ /**
8
+ * A person the administrator may add to an automation by hand. The API decides
9
+ * whether they can be added (`addable`) and why not (`reason`); the browser only
10
+ * displays it.
11
+ */
12
+ export interface EmailAutomationCandidateInterface extends ApiDataInterface {
13
+ get recipientType(): EmailAutomationRecipientType;
14
+ get recipientId(): string;
15
+ get email(): string;
16
+ get name(): string | undefined;
17
+ get addable(): boolean;
18
+ get reason(): EmailAutomationCandidateReason | undefined;
19
+ }
@@ -1,5 +1,6 @@
1
1
  import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
2
  import {
3
+ EmailAutomationEnrollmentInput,
3
4
  EmailAutomationEnrollmentInterface,
4
5
  EmailAutomationEnrollmentStatus,
5
6
  } from "./EmailAutomationEnrollmentInterface";
@@ -85,8 +86,21 @@ export class EmailAutomationEnrollment extends AbstractApiData implements EmailA
85
86
  return this;
86
87
  }
87
88
 
88
- createJsonApi(_data?: any): any {
89
- throw new Error("EmailAutomationEnrollment is created by the API and cannot be created from the client");
89
+ /**
90
+ * Body of POST email-automations/:id/email-automation-enrollments: an
91
+ * administrator adds a person by hand. The API enrols them at the first step.
92
+ */
93
+ createJsonApi(data: EmailAutomationEnrollmentInput) {
94
+ return {
95
+ data: {
96
+ type: Modules.EmailAutomationEnrollment.name,
97
+ id: data.id,
98
+ attributes: {
99
+ recipientType: data.recipientType,
100
+ recipientId: data.recipientId,
101
+ },
102
+ },
103
+ };
90
104
  }
91
105
 
92
106
  /**
@@ -3,6 +3,13 @@ import { EmailAutomationInterface } from "./EmailAutomationInterface";
3
3
 
4
4
  export type EmailAutomationEnrollmentStatus = "active" | "completed" | "unsubscribed" | "exited" | "failed";
5
5
 
6
+ /** Body of POST email-automations/:id/email-automation-enrollments (manual add). */
7
+ export type EmailAutomationEnrollmentInput = {
8
+ id: string;
9
+ recipientType: "user" | "waitlist";
10
+ recipientId: string;
11
+ };
12
+
6
13
  export interface EmailAutomationEnrollmentInterface extends ApiDataInterface {
7
14
  get email(): string;
8
15
  get name(): string | undefined;
@@ -1,4 +1,5 @@
1
1
  import { ApiDataInterface } from "../../../core";
2
+ import { EmailPurpose } from "./EmailPurpose";
2
3
 
3
4
  export type EmailAutomationInput = {
4
5
  id: string;
@@ -7,6 +8,7 @@ export type EmailAutomationInput = {
7
8
  exitTrigger?: string;
8
9
  replyTo?: string;
9
10
  isActive?: boolean;
11
+ purpose?: EmailPurpose;
10
12
  };
11
13
 
12
14
  export interface EmailAutomationInterface extends ApiDataInterface {
@@ -15,6 +17,7 @@ export interface EmailAutomationInterface extends ApiDataInterface {
15
17
  get exitTrigger(): string | undefined;
16
18
  get replyTo(): string | undefined;
17
19
  get isActive(): boolean;
20
+ get purpose(): EmailPurpose;
18
21
  get stepCount(): number;
19
22
  get activeCount(): number;
20
23
  get completedCount(): number;
@@ -1,5 +1,9 @@
1
1
  import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef, PreviousRef } from "../../../core";
2
- import { EmailAutomationEnrollmentInterface } from "./EmailAutomationEnrollmentInterface";
2
+ import { EmailAutomationCandidateInterface } from "./EmailAutomationCandidateInterface";
3
+ import {
4
+ EmailAutomationEnrollmentInput,
5
+ EmailAutomationEnrollmentInterface,
6
+ } from "./EmailAutomationEnrollmentInterface";
3
7
  import { EmailAutomationInput, EmailAutomationInterface } from "./EmailAutomationInterface";
4
8
  import { EmailAutomationTriggerInterface } from "./EmailAutomationTriggerInterface";
5
9
 
@@ -103,4 +107,52 @@ export class EmailAutomationService extends AbstractService {
103
107
  previous: params.previous,
104
108
  });
105
109
  }
110
+
111
+ /**
112
+ * GET email-automations/:id/email-automation-candidates: users and waitlist
113
+ * entries matching the search, each flagged addable or not by the API. Paged:
114
+ * read through useDataListRetriever, which passes the next/previous refs.
115
+ */
116
+ static async findCandidates(params: {
117
+ automationId: string;
118
+ search?: string;
119
+ next?: NextRef;
120
+ previous?: PreviousRef;
121
+ }): Promise<EmailAutomationCandidateInterface[]> {
122
+ const endpoint = new EndpointCreator({
123
+ endpoint: Modules.EmailAutomation,
124
+ id: params.automationId,
125
+ childEndpoint: Modules.EmailAutomationCandidate,
126
+ });
127
+
128
+ if (params.search) endpoint.addAdditionalParam("search", params.search);
129
+
130
+ return this.callApi<EmailAutomationCandidateInterface[]>({
131
+ type: Modules.EmailAutomationCandidate,
132
+ method: HttpMethod.GET,
133
+ endpoint: endpoint.generate(),
134
+ next: params.next,
135
+ previous: params.previous,
136
+ });
137
+ }
138
+
139
+ /**
140
+ * POST email-automations/:id/email-automation-enrollments: adds one person by
141
+ * hand. The API re-checks every rule and answers 400 with the reason.
142
+ */
143
+ static async addEnrollment(params: {
144
+ automationId: string;
145
+ input: EmailAutomationEnrollmentInput;
146
+ }): Promise<EmailAutomationEnrollmentInterface> {
147
+ return this.callApi<EmailAutomationEnrollmentInterface>({
148
+ type: Modules.EmailAutomationEnrollment,
149
+ method: HttpMethod.POST,
150
+ endpoint: new EndpointCreator({
151
+ endpoint: Modules.EmailAutomation,
152
+ id: params.automationId,
153
+ childEndpoint: Modules.EmailAutomationEnrollment,
154
+ }).generate(),
155
+ input: params.input,
156
+ });
157
+ }
106
158
  }
@@ -0,0 +1 @@
1
+ export type EmailPurpose = "service" | "marketing";
@@ -1,11 +1,13 @@
1
1
  import { beforeAll, describe, expect, it } from "vitest";
2
2
  import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
3
3
  import { ModuleFactory, ModuleWithPermissions } from "../../../../permissions/types";
4
+ import { EmailAutomationCandidateModule } from "../../EmailAutomationCandidateModule";
4
5
  import { EmailAutomationEnrollmentModule } from "../../EmailAutomationEnrollmentModule";
5
6
  import { EmailAutomationModule } from "../../EmailAutomationModule";
6
7
  import { EmailAutomationStepModule } from "../../EmailAutomationStepModule";
7
8
  import { EmailAutomationTriggerModule } from "../../EmailAutomationTriggerModule";
8
9
  import { EmailAutomation } from "../EmailAutomation";
10
+ import { EmailAutomationCandidate } from "../EmailAutomationCandidate";
9
11
  import { EMAIL_AUTOMATION_UNSUBSCRIBE_TYPE, EmailAutomationEnrollment } from "../EmailAutomationEnrollment";
10
12
  import { EmailAutomationStep } from "../EmailAutomationStep";
11
13
 
@@ -24,6 +26,7 @@ beforeAll(() => {
24
26
  ModuleRegistry.register("EmailAutomationStep", EmailAutomationStepModule(moduleFactory));
25
27
  ModuleRegistry.register("EmailAutomationEnrollment", EmailAutomationEnrollmentModule(moduleFactory));
26
28
  ModuleRegistry.register("EmailAutomationTrigger", EmailAutomationTriggerModule(moduleFactory));
29
+ ModuleRegistry.register("EmailAutomationCandidate", EmailAutomationCandidateModule(moduleFactory));
27
30
  });
28
31
 
29
32
  const hydrate = (attributes: Record<string, unknown>) =>
@@ -100,4 +103,71 @@ describe("email automation models", () => {
100
103
  data: { type: EMAIL_AUTOMATION_UNSUBSCRIBE_TYPE, attributes: { scope: "all" } },
101
104
  });
102
105
  });
106
+
107
+ it("EmailAutomationCandidate rehydrates kind, id, email, addable and reason", () => {
108
+ const blocked = new EmailAutomationCandidate().rehydrate(
109
+ hydrate({
110
+ recipientType: "waitlist",
111
+ recipientId: "w1",
112
+ email: "ada@example.com",
113
+ addable: false,
114
+ reason: "no_consent",
115
+ }),
116
+ );
117
+ expect(blocked.recipientType).toBe("waitlist");
118
+ expect(blocked.recipientId).toBe("w1");
119
+ expect(blocked.email).toBe("ada@example.com");
120
+ expect(blocked.name).toBeUndefined();
121
+ expect(blocked.addable).toBe(false);
122
+ expect(blocked.reason).toBe("no_consent");
123
+
124
+ const open = new EmailAutomationCandidate().rehydrate(
125
+ hydrate({
126
+ recipientType: "user",
127
+ recipientId: "u1",
128
+ email: "grace@example.com",
129
+ name: "Grace",
130
+ addable: true,
131
+ reason: null,
132
+ }),
133
+ );
134
+ expect(open.recipientType).toBe("user");
135
+ expect(open.name).toBe("Grace");
136
+ expect(open.addable).toBe(true);
137
+ expect(open.reason).toBeUndefined();
138
+ });
139
+
140
+ it("EmailAutomationCandidate createJsonApi returns type and id only", () => {
141
+ expect(new EmailAutomationCandidate().createJsonApi({ id: "u1" })).toEqual({
142
+ data: { type: "email-automation-candidates", id: "u1" },
143
+ });
144
+ });
145
+
146
+ it("EmailAutomationEnrollment.createJsonApi builds type, id, recipientType and recipientId", () => {
147
+ expect(
148
+ new EmailAutomationEnrollment().createJsonApi({ id: "e1", recipientType: "user", recipientId: "u1" }),
149
+ ).toEqual({
150
+ data: {
151
+ type: "email-automation-enrollments",
152
+ id: "e1",
153
+ attributes: { recipientType: "user", recipientId: "u1" },
154
+ },
155
+ });
156
+ });
157
+ it('EmailAutomation rehydrates purpose "service"', () => {
158
+ const m = new EmailAutomation().rehydrate(hydrate({ name: "Welcome", purpose: "service" }));
159
+ expect(m.purpose).toBe("service");
160
+ });
161
+
162
+ it("missing purpose rehydrates as marketing", () => {
163
+ expect(new EmailAutomation().rehydrate(hydrate({ name: "Welcome" })).purpose).toBe("marketing");
164
+ expect(new EmailAutomation().rehydrate(hydrate({ name: "Welcome", purpose: "other" })).purpose).toBe("marketing");
165
+ });
166
+
167
+ it("createJsonApi sends purpose when set", () => {
168
+ const body = new EmailAutomation().createJsonApi({ id: "a1", name: "Welcome", purpose: "service" });
169
+ expect(body.data.attributes.purpose).toBe("service");
170
+ const without = new EmailAutomation().createJsonApi({ id: "a1", name: "Welcome" });
171
+ expect("purpose" in without.data.attributes).toBe(false);
172
+ });
103
173
  });
@@ -1,5 +1,6 @@
1
1
  export enum EmailAutomationFields {
2
2
  name = "name",
3
+ purpose = "purpose",
3
4
  trigger = "trigger",
4
5
  status = "status",
5
6
  steps = "steps",
@@ -9,5 +9,7 @@ export * from "./EmailAutomationEnrollment";
9
9
  export * from "./EmailAutomationEnrollmentService";
10
10
  export * from "./EmailAutomationTriggerInterface";
11
11
  export * from "./EmailAutomationTrigger";
12
+ export * from "./EmailAutomationCandidateInterface";
13
+ export * from "./EmailAutomationCandidate";
12
14
  export * from "./email-automation.fields";
13
15
  export * from "./email-automation-enrollment.fields";
@@ -62,6 +62,25 @@ export const useEmailAutomationTableStructure: UseTableStructureHook<
62
62
  enableSorting: false,
63
63
  enableHiding: false,
64
64
  }),
65
+ [EmailAutomationFields.purpose]: () => ({
66
+ id: "purpose",
67
+ accessorKey: "purpose",
68
+ header: t("emailAutomations.list.columns.purpose"),
69
+ cell: ({ row }: { row: TableContent<EmailAutomationInterface> }) => {
70
+ const automation: EmailAutomationInterface = row.original.jsonApiData;
71
+ return automation.purpose === "service" ? (
72
+ <Badge variant="softBlue" data-testid="email-automation-purpose-badge">
73
+ {t("emailPurpose.service")}
74
+ </Badge>
75
+ ) : (
76
+ <Badge variant="softGray" data-testid="email-automation-purpose-badge">
77
+ {t("emailPurpose.marketing")}
78
+ </Badge>
79
+ );
80
+ },
81
+ enableSorting: false,
82
+ enableHiding: false,
83
+ }),
65
84
  [EmailAutomationFields.trigger]: () => ({
66
85
  id: "trigger",
67
86
  accessorKey: "trigger",
@@ -2,4 +2,6 @@ export * from "./EmailAutomationModule";
2
2
  export * from "./EmailAutomationStepModule";
3
3
  export * from "./EmailAutomationEnrollmentModule";
4
4
  export * from "./EmailAutomationTriggerModule";
5
+ export * from "./EmailAutomationCandidateModule";
5
6
  export * from "./data";
7
+ export * from "./data/EmailPurpose";
@@ -15,6 +15,7 @@ export const EmailBlastModule = (factory: ModuleFactory) =>
15
15
  fields: [
16
16
  createJsonApiInclusion("email-blasts", [
17
17
  "subject",
18
+ "purpose",
18
19
  "status",
19
20
  "sentAt",
20
21
  "recipientCount",
@@ -6,14 +6,17 @@ import { ReactNode, useCallback, useMemo } from "react";
6
6
  import { useForm } from "react-hook-form";
7
7
  import { v4 } from "uuid";
8
8
  import { z } from "zod";
9
- import { EditorSheet, FormInput, FormTextarea } from "../../../../components";
9
+ import { EditorSheet, FormInput, FormSelect, FormTextarea } from "../../../../components";
10
10
  import { Modules } from "../../../../core";
11
11
  import { useI18nRouter } from "../../../../i18n";
12
+ import { EmailPurpose } from "../../../email-automation/data/EmailPurpose";
12
13
  import { EmailBlastInput, EmailBlastInterface } from "../../data/EmailBlastInterface";
13
14
  import { EmailBlastService } from "../../data/EmailBlastService";
14
15
 
15
16
  /** The fixed variable set the blast renderer fills. */
16
- const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}", "{{unsubscribeAllUrl}}"];
17
+ const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}"];
18
+ /** Only a Marketing blast fills it: a Service blast carries no unsubscribe link. */
19
+ const MARKETING_ONLY_VARIABLES = ["{{unsubscribeAllUrl}}"];
17
20
 
18
21
  export type EmailBlastEditorProps = {
19
22
  blast?: EmailBlastInterface;
@@ -49,6 +52,7 @@ function EmailBlastEditorInternal({
49
52
  replyTo: z.string().refine((value) => value === "" || z.email().safeParse(value).success, {
50
53
  message: t("emailBlasts.editor.fields.replyTo.error"),
51
54
  }),
55
+ purpose: z.enum(["service", "marketing"], { message: t("emailPurpose.error") }),
52
56
  }),
53
57
  [t],
54
58
  );
@@ -59,6 +63,8 @@ function EmailBlastEditorInternal({
59
63
  subject: blast?.subject ?? "",
60
64
  body: blast?.body ?? "",
61
65
  replyTo: blast?.replyTo ?? "",
66
+ // No preselected value on create: the form refuses to save without a choice.
67
+ purpose: (blast?.purpose ?? "") as EmailPurpose,
62
68
  }),
63
69
  [blast],
64
70
  );
@@ -68,6 +74,17 @@ function EmailBlastEditorInternal({
68
74
  defaultValues: getDefaultValues(),
69
75
  });
70
76
 
77
+ const purpose = form.watch("purpose");
78
+ const templateVariables =
79
+ purpose === "service" ? TEMPLATE_VARIABLES : [...TEMPLATE_VARIABLES, ...MARKETING_ONLY_VARIABLES];
80
+ const purposeValues = useMemo(
81
+ () => [
82
+ { id: "service", text: t("emailPurpose.service") },
83
+ { id: "marketing", text: t("emailPurpose.marketing") },
84
+ ],
85
+ [t],
86
+ );
87
+
71
88
  return (
72
89
  <EditorSheet
73
90
  form={form}
@@ -86,6 +103,7 @@ function EmailBlastEditorInternal({
86
103
  subject: values.subject,
87
104
  body: values.body,
88
105
  replyTo: values.replyTo || undefined,
106
+ purpose: values.purpose,
89
107
  };
90
108
  return isEdit ? await EmailBlastService.update(payload) : await EmailBlastService.create(payload);
91
109
  }}
@@ -98,6 +116,15 @@ function EmailBlastEditorInternal({
98
116
  >
99
117
  <div className="flex w-full flex-col gap-y-4">
100
118
  <FormInput form={form} id="subject" name={t("emailBlasts.editor.fields.subject.label")} isRequired />
119
+ <FormSelect
120
+ form={form}
121
+ id="purpose"
122
+ name={t("emailPurpose.label")}
123
+ placeholder={t("emailPurpose.placeholder")}
124
+ values={purposeValues}
125
+ description={purpose === "service" ? t("emailPurpose.serviceWarning") : undefined}
126
+ isRequired
127
+ />
101
128
  <FormInput form={form} id="replyTo" name={t("emailBlasts.editor.fields.replyTo.label")} />
102
129
  {/* FormTextarea has no `rows` prop; min-h-72 is the height of ~14 rows. */}
103
130
  <FormTextarea
@@ -110,13 +137,15 @@ function EmailBlastEditorInternal({
110
137
  <>
111
138
  <span className="block">
112
139
  {t("emailBlasts.editor.variablesHint")}{" "}
113
- {TEMPLATE_VARIABLES.map((variable) => (
140
+ {templateVariables.map((variable) => (
114
141
  <span key={variable} className="me-2 text-foreground">
115
142
  {variable}
116
143
  </span>
117
144
  ))}
118
145
  </span>
119
- <span className="mt-1 block">{t("emailBlasts.editor.unsubscribeHint")}</span>
146
+ <span className="mt-1 block">
147
+ {purpose === "service" ? t("emailBlasts.editor.serviceHint") : t("emailBlasts.editor.unsubscribeHint")}
148
+ </span>
120
149
  </>
121
150
  }
122
151
  />
@@ -41,6 +41,7 @@ export function EmailBlastsList({ fullWidth }: EmailBlastsListProps = {}) {
41
41
  data={data}
42
42
  fields={[
43
43
  EmailBlastFields.subject,
44
+ EmailBlastFields.purpose,
44
45
  EmailBlastFields.status,
45
46
  EmailBlastFields.sentAt,
46
47
  EmailBlastFields.sent,