@mobilizehub/payload-plugin 0.1.0 → 0.2.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 (77) hide show
  1. package/dist/adapters/index.d.ts +1 -0
  2. package/dist/adapters/index.js +3 -0
  3. package/dist/adapters/index.js.map +1 -0
  4. package/dist/adapters/resend-adapter.d.ts +34 -0
  5. package/dist/adapters/resend-adapter.js +219 -0
  6. package/dist/adapters/resend-adapter.js.map +1 -0
  7. package/dist/collections/broadcasts/generateBroadcastsCollection.d.ts +3 -0
  8. package/dist/collections/broadcasts/generateBroadcastsCollection.js +241 -0
  9. package/dist/collections/broadcasts/generateBroadcastsCollection.js.map +1 -0
  10. package/dist/collections/emails/generateEmailsCollection.d.ts +3 -0
  11. package/dist/collections/emails/generateEmailsCollection.js +204 -0
  12. package/dist/collections/emails/generateEmailsCollection.js.map +1 -0
  13. package/dist/collections/emails/hooks/sync-status-from-activity.d.ts +5 -0
  14. package/dist/collections/emails/hooks/sync-status-from-activity.js +64 -0
  15. package/dist/collections/emails/hooks/sync-status-from-activity.js.map +1 -0
  16. package/dist/collections/unsubscribe-tokens/generateUnsubscribeTokens.d.ts +2 -0
  17. package/dist/collections/unsubscribe-tokens/generateUnsubscribeTokens.js +48 -0
  18. package/dist/collections/unsubscribe-tokens/generateUnsubscribeTokens.js.map +1 -0
  19. package/dist/components/broadcast-metrics-card.d.ts +7 -0
  20. package/dist/components/broadcast-metrics-card.js +159 -0
  21. package/dist/components/broadcast-metrics-card.js.map +1 -0
  22. package/dist/components/broadcast-send-modal.d.ts +9 -0
  23. package/dist/components/broadcast-send-modal.js +51 -0
  24. package/dist/components/broadcast-send-modal.js.map +1 -0
  25. package/dist/components/broadcast-send-test-drawer.d.ts +7 -0
  26. package/dist/components/broadcast-send-test-drawer.js +154 -0
  27. package/dist/components/broadcast-send-test-drawer.js.map +1 -0
  28. package/dist/components/email-activity.d.ts +4 -0
  29. package/dist/components/email-activity.js +359 -0
  30. package/dist/components/email-activity.js.map +1 -0
  31. package/dist/components/email-preview.d.ts +2 -0
  32. package/dist/components/email-preview.js +95 -0
  33. package/dist/components/email-preview.js.map +1 -0
  34. package/dist/endpoints/sendBroadcastHandler.d.ts +9 -0
  35. package/dist/endpoints/sendBroadcastHandler.js +107 -0
  36. package/dist/endpoints/sendBroadcastHandler.js.map +1 -0
  37. package/dist/endpoints/sendTestBroadcastHandler.d.ts +10 -0
  38. package/dist/endpoints/sendTestBroadcastHandler.js +143 -0
  39. package/dist/endpoints/sendTestBroadcastHandler.js.map +1 -0
  40. package/dist/endpoints/unsubscribeHandler.d.ts +9 -0
  41. package/dist/endpoints/unsubscribeHandler.js +153 -0
  42. package/dist/endpoints/unsubscribeHandler.js.map +1 -0
  43. package/dist/exports/client.d.ts +3 -1
  44. package/dist/exports/client.js +3 -0
  45. package/dist/exports/client.js.map +1 -1
  46. package/dist/exports/rsc.d.ts +2 -1
  47. package/dist/exports/rsc.js +2 -0
  48. package/dist/exports/rsc.js.map +1 -1
  49. package/dist/index.js +48 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/react/index.d.ts +1 -0
  52. package/dist/react/index.js +3 -0
  53. package/dist/react/index.js.map +1 -0
  54. package/dist/react/unsubscribe.d.ts +6 -0
  55. package/dist/react/unsubscribe.js +16 -0
  56. package/dist/react/unsubscribe.js.map +1 -0
  57. package/dist/tasks/sendBroadcastsTask.d.ts +11 -0
  58. package/dist/tasks/sendBroadcastsTask.js +196 -0
  59. package/dist/tasks/sendBroadcastsTask.js.map +1 -0
  60. package/dist/tasks/sendEmailTask.d.ts +9 -0
  61. package/dist/tasks/sendEmailTask.js +167 -0
  62. package/dist/tasks/sendEmailTask.js.map +1 -0
  63. package/dist/types/index.d.ts +124 -1
  64. package/dist/types/index.js.map +1 -1
  65. package/dist/utils/api-response.d.ts +72 -0
  66. package/dist/utils/api-response.js +66 -0
  67. package/dist/utils/api-response.js.map +1 -0
  68. package/dist/utils/email.d.ts +36 -0
  69. package/dist/utils/email.js +40 -0
  70. package/dist/utils/email.js.map +1 -0
  71. package/dist/utils/lexical.d.ts +13 -0
  72. package/dist/utils/lexical.js +27 -0
  73. package/dist/utils/lexical.js.map +1 -0
  74. package/dist/utils/unsubscribe-token.d.ts +67 -0
  75. package/dist/utils/unsubscribe-token.js +103 -0
  76. package/dist/utils/unsubscribe-token.js.map +1 -0
  77. package/package.json +20 -9
package/dist/index.js CHANGED
@@ -1,14 +1,59 @@
1
+ import { generateBroadcastsCollection } from './collections/broadcasts/generateBroadcastsCollection.js';
1
2
  import { generateContactsCollection } from './collections/contacts/generateContactsCollection.js';
3
+ import { generateEmailsCollection } from './collections/emails/generateEmailsCollection.js';
2
4
  import { generateTagsCollection } from './collections/tags/generateTagsCollection.js';
5
+ import { generateUnsubscribeTokensCollection } from './collections/unsubscribe-tokens/generateUnsubscribeTokens.js';
6
+ import { sendBroadcastHandler } from './endpoints/sendBroadcastHandler.js';
7
+ import { sendTestEmailHandler } from './endpoints/sendTestBroadcastHandler.js';
8
+ import { unsubscribeHandler } from './endpoints/unsubscribeHandler.js';
9
+ import { createSendBroadcastsTask } from './tasks/sendBroadcastsTask.js';
10
+ import { createSendEmailTask } from './tasks/sendEmailTask.js';
3
11
  export * from './types/index.js';
4
12
  export const mobilizehubPlugin = (pluginOptions)=>(config)=>{
13
+ if (pluginOptions.disabled) {
14
+ return config;
15
+ }
5
16
  if (!config.collections) {
6
17
  config.collections = [];
7
18
  }
8
- config.collections.push(generateTagsCollection(pluginOptions), generateContactsCollection(pluginOptions));
9
- if (pluginOptions.disabled) {
10
- return config;
19
+ config.collections.push(generateTagsCollection(pluginOptions), generateContactsCollection(pluginOptions), generateBroadcastsCollection(pluginOptions), generateEmailsCollection(pluginOptions), generateUnsubscribeTokensCollection());
20
+ if (!config.endpoints) {
21
+ config.endpoints = [];
22
+ }
23
+ const endpoints = [
24
+ {
25
+ handler: sendBroadcastHandler(),
26
+ method: 'post',
27
+ path: '/send-broadcast'
28
+ },
29
+ {
30
+ handler: sendTestEmailHandler(pluginOptions),
31
+ method: 'post',
32
+ path: '/send-test-email'
33
+ },
34
+ {
35
+ handler: unsubscribeHandler(),
36
+ method: 'post',
37
+ path: '/unsubscribe'
38
+ }
39
+ ];
40
+ config.endpoints = [
41
+ ...config.endpoints,
42
+ ...endpoints
43
+ ];
44
+ if (!config.jobs) {
45
+ config.jobs = {
46
+ tasks: []
47
+ };
11
48
  }
49
+ const tasks = [
50
+ createSendBroadcastsTask(pluginOptions),
51
+ createSendEmailTask(pluginOptions)
52
+ ];
53
+ config.jobs.tasks = [
54
+ ...config.jobs.tasks ?? [],
55
+ ...tasks
56
+ ];
12
57
  const incomingOnInit = config.onInit;
13
58
  config.onInit = async (payload)=>{
14
59
  if (incomingOnInit) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nimport type { MobilizehubPluginConfig } from './types/index.js'\n\nimport { generateContactsCollection } from './collections/contacts/generateContactsCollection.js'\nimport { generateTagsCollection } from './collections/tags/generateTagsCollection.js'\n\nexport * from './types/index.js'\n\nexport const mobilizehubPlugin =\n (pluginOptions: MobilizehubPluginConfig) =>\n (config: Config): Config => {\n if (!config.collections) {\n config.collections = []\n }\n\n config.collections.push(\n generateTagsCollection(pluginOptions),\n generateContactsCollection(pluginOptions),\n )\n\n if (pluginOptions.disabled) {\n return config\n }\n\n const incomingOnInit = config.onInit\n\n config.onInit = async (payload) => {\n if (incomingOnInit) {\n await incomingOnInit(payload)\n }\n }\n\n return config\n }\n"],"names":["generateContactsCollection","generateTagsCollection","mobilizehubPlugin","pluginOptions","config","collections","push","disabled","incomingOnInit","onInit","payload"],"mappings":"AAIA,SAASA,0BAA0B,QAAQ,uDAAsD;AACjG,SAASC,sBAAsB,QAAQ,+CAA8C;AAErF,cAAc,mBAAkB;AAEhC,OAAO,MAAMC,oBACX,CAACC,gBACD,CAACC;QACC,IAAI,CAACA,OAAOC,WAAW,EAAE;YACvBD,OAAOC,WAAW,GAAG,EAAE;QACzB;QAEAD,OAAOC,WAAW,CAACC,IAAI,CACrBL,uBAAuBE,gBACvBH,2BAA2BG;QAG7B,IAAIA,cAAcI,QAAQ,EAAE;YAC1B,OAAOH;QACT;QAEA,MAAMI,iBAAiBJ,OAAOK,MAAM;QAEpCL,OAAOK,MAAM,GAAG,OAAOC;YACrB,IAAIF,gBAAgB;gBAClB,MAAMA,eAAeE;YACvB;QACF;QAEA,OAAON;IACT,EAAC"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Config, Endpoint, TaskConfig } from 'payload'\n\nimport type { MobilizehubPluginConfig } from './types/index.js'\n\nimport { generateBroadcastsCollection } from './collections/broadcasts/generateBroadcastsCollection.js'\nimport { generateContactsCollection } from './collections/contacts/generateContactsCollection.js'\nimport { generateEmailsCollection } from './collections/emails/generateEmailsCollection.js'\nimport { generateTagsCollection } from './collections/tags/generateTagsCollection.js'\nimport { generateUnsubscribeTokensCollection } from './collections/unsubscribe-tokens/generateUnsubscribeTokens.js'\nimport { sendBroadcastHandler } from './endpoints/sendBroadcastHandler.js'\nimport { sendTestEmailHandler } from './endpoints/sendTestBroadcastHandler.js'\nimport { unsubscribeHandler } from './endpoints/unsubscribeHandler.js'\nimport { createSendBroadcastsTask } from './tasks/sendBroadcastsTask.js'\nimport { createSendEmailTask } from './tasks/sendEmailTask.js'\n\nexport * from './types/index.js'\n\nexport const mobilizehubPlugin =\n (pluginOptions: MobilizehubPluginConfig) =>\n (config: Config): Config => {\n if (pluginOptions.disabled) {\n return config\n }\n\n if (!config.collections) {\n config.collections = []\n }\n\n config.collections.push(\n generateTagsCollection(pluginOptions),\n generateContactsCollection(pluginOptions),\n generateBroadcastsCollection(pluginOptions),\n generateEmailsCollection(pluginOptions),\n generateUnsubscribeTokensCollection(),\n )\n\n if (!config.endpoints) {\n config.endpoints = []\n }\n\n const endpoints: Endpoint[] = [\n {\n handler: sendBroadcastHandler(),\n method: 'post',\n path: '/send-broadcast',\n },\n {\n handler: sendTestEmailHandler(pluginOptions),\n method: 'post',\n path: '/send-test-email',\n },\n {\n handler: unsubscribeHandler(),\n method: 'post',\n path: '/unsubscribe',\n },\n ]\n\n config.endpoints = [...config.endpoints, ...endpoints]\n\n if (!config.jobs) {\n config.jobs = {\n tasks: [],\n }\n }\n\n const tasks: TaskConfig[] = [\n createSendBroadcastsTask(pluginOptions),\n createSendEmailTask(pluginOptions),\n ]\n\n config.jobs.tasks = [...(config.jobs.tasks ?? []), ...tasks]\n\n const incomingOnInit = config.onInit\n\n config.onInit = async (payload) => {\n if (incomingOnInit) {\n await incomingOnInit(payload)\n }\n }\n\n return config\n }\n"],"names":["generateBroadcastsCollection","generateContactsCollection","generateEmailsCollection","generateTagsCollection","generateUnsubscribeTokensCollection","sendBroadcastHandler","sendTestEmailHandler","unsubscribeHandler","createSendBroadcastsTask","createSendEmailTask","mobilizehubPlugin","pluginOptions","config","disabled","collections","push","endpoints","handler","method","path","jobs","tasks","incomingOnInit","onInit","payload"],"mappings":"AAIA,SAASA,4BAA4B,QAAQ,2DAA0D;AACvG,SAASC,0BAA0B,QAAQ,uDAAsD;AACjG,SAASC,wBAAwB,QAAQ,mDAAkD;AAC3F,SAASC,sBAAsB,QAAQ,+CAA8C;AACrF,SAASC,mCAAmC,QAAQ,gEAA+D;AACnH,SAASC,oBAAoB,QAAQ,sCAAqC;AAC1E,SAASC,oBAAoB,QAAQ,0CAAyC;AAC9E,SAASC,kBAAkB,QAAQ,oCAAmC;AACtE,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,cAAc,mBAAkB;AAEhC,OAAO,MAAMC,oBACX,CAACC,gBACD,CAACC;QACC,IAAID,cAAcE,QAAQ,EAAE;YAC1B,OAAOD;QACT;QAEA,IAAI,CAACA,OAAOE,WAAW,EAAE;YACvBF,OAAOE,WAAW,GAAG,EAAE;QACzB;QAEAF,OAAOE,WAAW,CAACC,IAAI,CACrBZ,uBAAuBQ,gBACvBV,2BAA2BU,gBAC3BX,6BAA6BW,gBAC7BT,yBAAyBS,gBACzBP;QAGF,IAAI,CAACQ,OAAOI,SAAS,EAAE;YACrBJ,OAAOI,SAAS,GAAG,EAAE;QACvB;QAEA,MAAMA,YAAwB;YAC5B;gBACEC,SAASZ;gBACTa,QAAQ;gBACRC,MAAM;YACR;YACA;gBACEF,SAASX,qBAAqBK;gBAC9BO,QAAQ;gBACRC,MAAM;YACR;YACA;gBACEF,SAASV;gBACTW,QAAQ;gBACRC,MAAM;YACR;SACD;QAEDP,OAAOI,SAAS,GAAG;eAAIJ,OAAOI,SAAS;eAAKA;SAAU;QAEtD,IAAI,CAACJ,OAAOQ,IAAI,EAAE;YAChBR,OAAOQ,IAAI,GAAG;gBACZC,OAAO,EAAE;YACX;QACF;QAEA,MAAMA,QAAsB;YAC1Bb,yBAAyBG;YACzBF,oBAAoBE;SACrB;QAEDC,OAAOQ,IAAI,CAACC,KAAK,GAAG;eAAKT,OAAOQ,IAAI,CAACC,KAAK,IAAI,EAAE;eAAMA;SAAM;QAE5D,MAAMC,iBAAiBV,OAAOW,MAAM;QAEpCX,OAAOW,MAAM,GAAG,OAAOC;YACrB,IAAIF,gBAAgB;gBAClB,MAAMA,eAAeE;YACvB;QACF;QAEA,OAAOZ;IACT,EAAC"}
@@ -0,0 +1 @@
1
+ export { confirmUnsubscribe } from './unsubscribe.js';
@@ -0,0 +1,3 @@
1
+ export { confirmUnsubscribe } from './unsubscribe.js';
2
+
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/index.ts"],"sourcesContent":["export { confirmUnsubscribe } from './unsubscribe.js'\n"],"names":["confirmUnsubscribe"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,mBAAkB"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Confirms an unsubscribe request by sending the token to the backend.
3
+ */
4
+ export declare function confirmUnsubscribe({ token }: {
5
+ token: string;
6
+ }): Promise<any>;
@@ -0,0 +1,16 @@
1
+ const apiUrl = '/api/unsubscribe';
2
+ /**
3
+ * Confirms an unsubscribe request by sending the token to the backend.
4
+ */ export async function confirmUnsubscribe({ token }) {
5
+ return fetch(apiUrl, {
6
+ body: JSON.stringify({
7
+ token
8
+ }),
9
+ headers: {
10
+ 'Content-Type': 'application/json'
11
+ },
12
+ method: 'POST'
13
+ }).then((res)=>res.json());
14
+ }
15
+
16
+ //# sourceMappingURL=unsubscribe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/unsubscribe.ts"],"sourcesContent":["const apiUrl = '/api/unsubscribe'\n\n/**\n * Confirms an unsubscribe request by sending the token to the backend.\n */\nexport async function confirmUnsubscribe({ token }: { token: string }) {\n return fetch(apiUrl, {\n body: JSON.stringify({ token }),\n headers: {\n 'Content-Type': 'application/json',\n },\n method: 'POST',\n }).then((res) => res.json())\n}\n"],"names":["apiUrl","confirmUnsubscribe","token","fetch","body","JSON","stringify","headers","method","then","res","json"],"mappings":"AAAA,MAAMA,SAAS;AAEf;;CAEC,GACD,OAAO,eAAeC,mBAAmB,EAAEC,KAAK,EAAqB;IACnE,OAAOC,MAAMH,QAAQ;QACnBI,MAAMC,KAAKC,SAAS,CAAC;YAAEJ;QAAM;QAC7BK,SAAS;YACP,gBAAgB;QAClB;QACAC,QAAQ;IACV,GAAGC,IAAI,CAAC,CAACC,MAAQA,IAAIC,IAAI;AAC3B"}
@@ -0,0 +1,11 @@
1
+ import type { TaskConfig } from 'payload';
2
+ import type { MobilizehubPluginConfig } from '../types/index.js';
3
+ /**
4
+ * Creates the send-broadcasts scheduled task.
5
+ *
6
+ * Processes broadcasts by polling for documents with status 'sending' and
7
+ * queuing batches of send-email jobs. Each invocation processes one broadcast
8
+ * and one batch of contacts, allowing the task to be distributed across
9
+ * multiple schedule intervals.
10
+ */
11
+ export declare const createSendBroadcastsTask: (pluginConfig: MobilizehubPluginConfig) => TaskConfig;
@@ -0,0 +1,196 @@
1
+ import z from 'zod';
2
+ /**
3
+ * Schema for validating broadcast documents before processing.
4
+ */ const BroadcastSchema = z.object({
5
+ id: z.number(),
6
+ content: z.any().refine((val)=>val !== undefined && val !== null, {
7
+ message: 'Broadcast content is missing'
8
+ }),
9
+ fromAddress: z.email({
10
+ message: 'Invalid from address email format'
11
+ }),
12
+ fromName: z.string().min(1, {
13
+ message: 'From name is required'
14
+ }),
15
+ meta: z.object({
16
+ contactsCount: z.number().min(0),
17
+ lastProcessedContactId: z.number().min(0).default(0),
18
+ processedCount: z.number().min(0)
19
+ }),
20
+ subject: z.string().min(1, {
21
+ message: 'Subject is required'
22
+ }),
23
+ tags: z.array(z.any()).optional(),
24
+ to: z.literal('all').or(z.literal('tags'))
25
+ });
26
+ /**
27
+ * Validates a broadcast document against the schema.
28
+ * Returns typed data on success, or flattened field errors on failure.
29
+ */ function safeParseBroadcast(broadcast) {
30
+ const result = BroadcastSchema.safeParse(broadcast);
31
+ if (!result.success) {
32
+ return {
33
+ data: null,
34
+ errors: z.flattenError(result.error).fieldErrors,
35
+ success: false
36
+ };
37
+ }
38
+ return {
39
+ data: result.data,
40
+ success: true
41
+ };
42
+ }
43
+ /**
44
+ * Builds the where clause for fetching the next batch of contacts.
45
+ * Uses cursor-based pagination (id > lastProcessedContactId) for consistent
46
+ * ordering and efficient queries at scale.
47
+ */ function buildContactsWhereClause(broadcast, rawBroadcast) {
48
+ const conditions = [
49
+ {
50
+ emailOptIn: {
51
+ equals: true
52
+ }
53
+ },
54
+ {
55
+ id: {
56
+ greater_than: broadcast.meta.lastProcessedContactId
57
+ }
58
+ }
59
+ ];
60
+ // Tags may be populated objects or raw IDs depending on query depth
61
+ if (broadcast.to === 'tags' && Array.isArray(rawBroadcast.tags) && rawBroadcast.tags.length > 0) {
62
+ const tagIds = rawBroadcast.tags.map((t)=>typeof t === 'object' ? t.id : t);
63
+ conditions.push({
64
+ tags: {
65
+ in: tagIds
66
+ }
67
+ });
68
+ }
69
+ return {
70
+ and: conditions
71
+ };
72
+ }
73
+ /**
74
+ * Extracts a numeric ID from a contact, handling string IDs from some database adapters.
75
+ */ function getLastContactId(contact) {
76
+ return typeof contact.id === 'number' ? contact.id : parseInt(contact.id, 10);
77
+ }
78
+ /**
79
+ * Creates the send-broadcasts scheduled task.
80
+ *
81
+ * Processes broadcasts by polling for documents with status 'sending' and
82
+ * queuing batches of send-email jobs. Each invocation processes one broadcast
83
+ * and one batch of contacts, allowing the task to be distributed across
84
+ * multiple schedule intervals.
85
+ */ export const createSendBroadcastsTask = (pluginConfig)=>{
86
+ const BATCH_SIZE = pluginConfig.broadcastConfig?.batchSize || 100;
87
+ const TASK_SCHEDULE = pluginConfig.broadcastConfig?.taskSchedule || '*/5 * * * *';
88
+ const QUEUE_NAME = pluginConfig.broadcastConfig?.broadcastQueueName || 'send-broadcasts';
89
+ return {
90
+ slug: 'send-broadcasts',
91
+ handler: async ({ req })=>{
92
+ const { payload } = req;
93
+ const logger = payload.logger;
94
+ logger.info('Send Broadcast task handler called');
95
+ const { docs } = await payload.find({
96
+ collection: 'broadcasts',
97
+ limit: 1,
98
+ sort: 'id',
99
+ where: {
100
+ status: {
101
+ equals: 'sending'
102
+ }
103
+ }
104
+ });
105
+ const rawBroadcast = docs[0];
106
+ if (!rawBroadcast) {
107
+ logger.info('No broadcasts with status "sending" found');
108
+ return {
109
+ output: {
110
+ success: true
111
+ }
112
+ };
113
+ }
114
+ const parsed = safeParseBroadcast(rawBroadcast);
115
+ if (!parsed.success) {
116
+ logger.error({
117
+ errors: parsed.errors
118
+ }, `Broadcast ${rawBroadcast.id} validation failed`);
119
+ await payload.update({
120
+ id: rawBroadcast.id,
121
+ collection: 'broadcasts',
122
+ data: {
123
+ status: 'failed'
124
+ }
125
+ });
126
+ return {
127
+ output: {
128
+ success: false
129
+ }
130
+ };
131
+ }
132
+ const broadcast = parsed.data;
133
+ const whereClause = buildContactsWhereClause(broadcast, rawBroadcast);
134
+ const { docs: contacts } = await payload.find({
135
+ collection: 'contacts',
136
+ limit: BATCH_SIZE,
137
+ sort: 'id',
138
+ where: whereClause
139
+ });
140
+ if (contacts.length === 0) {
141
+ logger.info(`Broadcast ${broadcast.id} complete. ` + `Processed: ${broadcast.meta.processedCount}, Expected: ${broadcast.meta.contactsCount}`);
142
+ await payload.update({
143
+ id: broadcast.id,
144
+ collection: 'broadcasts',
145
+ data: {
146
+ status: 'sent'
147
+ }
148
+ });
149
+ return {
150
+ output: {
151
+ success: true
152
+ }
153
+ };
154
+ }
155
+ await Promise.all(contacts.map((contact)=>payload.jobs.queue({
156
+ input: {
157
+ broadcastId: broadcast.id,
158
+ contactId: contact.id
159
+ },
160
+ queue: 'send-emails',
161
+ task: 'send-email'
162
+ })));
163
+ await payload.update({
164
+ id: broadcast.id,
165
+ collection: 'broadcasts',
166
+ data: {
167
+ meta: {
168
+ lastProcessedContactId: getLastContactId(contacts[contacts.length - 1]),
169
+ processedCount: broadcast.meta.processedCount + contacts.length
170
+ }
171
+ }
172
+ });
173
+ logger.info(`Broadcast ${broadcast.id}: queued ${contacts.length} emails, ` + `total processed: ${broadcast.meta.processedCount + contacts.length}`);
174
+ return {
175
+ output: {
176
+ success: true
177
+ }
178
+ };
179
+ },
180
+ outputSchema: [
181
+ {
182
+ name: 'success',
183
+ type: 'checkbox'
184
+ }
185
+ ],
186
+ retries: 3,
187
+ schedule: [
188
+ {
189
+ cron: TASK_SCHEDULE,
190
+ queue: QUEUE_NAME
191
+ }
192
+ ]
193
+ };
194
+ };
195
+
196
+ //# sourceMappingURL=sendBroadcastsTask.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/tasks/sendBroadcastsTask.ts"],"sourcesContent":["import type { TaskConfig, Where } from 'payload'\n\nimport z from 'zod'\n\nimport type { MobilizehubPluginConfig } from '../types/index.js'\n\n/**\n * Schema for validating broadcast documents before processing.\n */\nconst BroadcastSchema = z.object({\n id: z.number(),\n content: z.any().refine((val) => val !== undefined && val !== null, {\n message: 'Broadcast content is missing',\n }),\n fromAddress: z.email({ message: 'Invalid from address email format' }),\n fromName: z.string().min(1, { message: 'From name is required' }),\n meta: z.object({\n contactsCount: z.number().min(0),\n lastProcessedContactId: z.number().min(0).default(0),\n processedCount: z.number().min(0),\n }),\n subject: z.string().min(1, { message: 'Subject is required' }),\n tags: z.array(z.any()).optional(),\n to: z.literal('all').or(z.literal('tags')),\n})\n\ntype ParsedBroadcast = z.infer<typeof BroadcastSchema>\n\n/**\n * Validates a broadcast document against the schema.\n * Returns typed data on success, or flattened field errors on failure.\n */\nfunction safeParseBroadcast(broadcast: unknown) {\n const result = BroadcastSchema.safeParse(broadcast)\n\n if (!result.success) {\n return {\n data: null,\n errors: z.flattenError(result.error).fieldErrors,\n success: false as const,\n }\n }\n\n return {\n data: result.data,\n success: true as const,\n }\n}\n\n/**\n * Builds the where clause for fetching the next batch of contacts.\n * Uses cursor-based pagination (id > lastProcessedContactId) for consistent\n * ordering and efficient queries at scale.\n */\nfunction buildContactsWhereClause(\n broadcast: ParsedBroadcast,\n rawBroadcast: Record<string, unknown>,\n): Where {\n const conditions: Where[] = [\n { emailOptIn: { equals: true } },\n { id: { greater_than: broadcast.meta.lastProcessedContactId } },\n ]\n\n // Tags may be populated objects or raw IDs depending on query depth\n if (broadcast.to === 'tags' && Array.isArray(rawBroadcast.tags) && rawBroadcast.tags.length > 0) {\n const tagIds = rawBroadcast.tags.map((t: { id: number | string } | number | string) =>\n typeof t === 'object' ? t.id : t,\n )\n conditions.push({ tags: { in: tagIds } })\n }\n\n return { and: conditions }\n}\n\n/**\n * Extracts a numeric ID from a contact, handling string IDs from some database adapters.\n */\nfunction getLastContactId(contact: { id: number | string }): number {\n return typeof contact.id === 'number' ? contact.id : parseInt(contact.id, 10)\n}\n\n/**\n * Creates the send-broadcasts scheduled task.\n *\n * Processes broadcasts by polling for documents with status 'sending' and\n * queuing batches of send-email jobs. Each invocation processes one broadcast\n * and one batch of contacts, allowing the task to be distributed across\n * multiple schedule intervals.\n */\nexport const createSendBroadcastsTask = (pluginConfig: MobilizehubPluginConfig): TaskConfig => {\n const BATCH_SIZE = pluginConfig.broadcastConfig?.batchSize || 100\n const TASK_SCHEDULE = pluginConfig.broadcastConfig?.taskSchedule || '*/5 * * * *'\n const QUEUE_NAME = pluginConfig.broadcastConfig?.broadcastQueueName || 'send-broadcasts'\n\n return {\n slug: 'send-broadcasts',\n handler: async ({ req }) => {\n const { payload } = req\n const logger = payload.logger\n\n logger.info('Send Broadcast task handler called')\n\n const { docs } = await payload.find({\n collection: 'broadcasts',\n limit: 1,\n sort: 'id',\n where: { status: { equals: 'sending' } },\n })\n\n const rawBroadcast = docs[0]\n\n if (!rawBroadcast) {\n logger.info('No broadcasts with status \"sending\" found')\n return { output: { success: true } }\n }\n\n const parsed = safeParseBroadcast(rawBroadcast)\n\n if (!parsed.success) {\n logger.error({ errors: parsed.errors }, `Broadcast ${rawBroadcast.id} validation failed`)\n await payload.update({\n id: rawBroadcast.id,\n collection: 'broadcasts',\n data: { status: 'failed' },\n })\n return { output: { success: false } }\n }\n\n const broadcast = parsed.data\n const whereClause = buildContactsWhereClause(\n broadcast,\n rawBroadcast as Record<string, unknown>,\n )\n\n const { docs: contacts } = await payload.find({\n collection: 'contacts',\n limit: BATCH_SIZE,\n sort: 'id',\n where: whereClause,\n })\n\n if (contacts.length === 0) {\n logger.info(\n `Broadcast ${broadcast.id} complete. ` +\n `Processed: ${broadcast.meta.processedCount}, Expected: ${broadcast.meta.contactsCount}`,\n )\n await payload.update({\n id: broadcast.id,\n collection: 'broadcasts',\n data: { status: 'sent' },\n })\n return { output: { success: true } }\n }\n\n await Promise.all(\n contacts.map((contact) =>\n payload.jobs.queue({\n input: { broadcastId: broadcast.id, contactId: contact.id },\n queue: 'send-emails',\n task: 'send-email',\n }),\n ),\n )\n\n await payload.update({\n id: broadcast.id,\n collection: 'broadcasts',\n data: {\n meta: {\n lastProcessedContactId: getLastContactId(contacts[contacts.length - 1]),\n processedCount: broadcast.meta.processedCount + contacts.length,\n },\n },\n })\n\n logger.info(\n `Broadcast ${broadcast.id}: queued ${contacts.length} emails, ` +\n `total processed: ${broadcast.meta.processedCount + contacts.length}`,\n )\n\n return { output: { success: true } }\n },\n outputSchema: [\n {\n name: 'success',\n type: 'checkbox',\n },\n ],\n retries: 3,\n schedule: [\n {\n cron: TASK_SCHEDULE,\n queue: QUEUE_NAME,\n },\n ],\n }\n}\n"],"names":["z","BroadcastSchema","object","id","number","content","any","refine","val","undefined","message","fromAddress","email","fromName","string","min","meta","contactsCount","lastProcessedContactId","default","processedCount","subject","tags","array","optional","to","literal","or","safeParseBroadcast","broadcast","result","safeParse","success","data","errors","flattenError","error","fieldErrors","buildContactsWhereClause","rawBroadcast","conditions","emailOptIn","equals","greater_than","Array","isArray","length","tagIds","map","t","push","in","and","getLastContactId","contact","parseInt","createSendBroadcastsTask","pluginConfig","BATCH_SIZE","broadcastConfig","batchSize","TASK_SCHEDULE","taskSchedule","QUEUE_NAME","broadcastQueueName","slug","handler","req","payload","logger","info","docs","find","collection","limit","sort","where","status","output","parsed","update","whereClause","contacts","Promise","all","jobs","queue","input","broadcastId","contactId","task","outputSchema","name","type","retries","schedule","cron"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { TaskConfig } from 'payload';
2
+ import type { MobilizehubPluginConfig } from '../types/index.js';
3
+ /**
4
+ * Creates the send-email task configuration.
5
+ *
6
+ * Handles delivery of a single email for one contact within a broadcast.
7
+ * Queued by the send-broadcasts task, one job per contact.
8
+ */
9
+ export declare const createSendEmailTask: (pluginConfig: MobilizehubPluginConfig) => TaskConfig;
@@ -0,0 +1,167 @@
1
+ import { formatFromAddress } from '../utils/email.js';
2
+ import { parseLexicalContent } from '../utils/lexical.js';
3
+ import { generateUnsubscribeToken } from '../utils/unsubscribe-token.js';
4
+ /**
5
+ * Resolves collection slugs from plugin config, falling back to defaults.
6
+ */ function getCollectionSlugs(config) {
7
+ return {
8
+ broadcasts: config.broadcastsOverrides?.slug || 'broadcasts',
9
+ contacts: config.contactsOverrides?.slug || 'contacts',
10
+ emails: config.emailsOverrides?.slug || 'emails'
11
+ };
12
+ }
13
+ /**
14
+ * Checks if an email record already exists for a broadcast-contact pair.
15
+ */ async function checkEmailExists(payload, collection, broadcastId, contactId) {
16
+ const { docs } = await payload.find({
17
+ collection,
18
+ limit: 1,
19
+ where: {
20
+ and: [
21
+ {
22
+ broadcast: {
23
+ equals: broadcastId
24
+ }
25
+ },
26
+ {
27
+ contact: {
28
+ equals: contactId
29
+ }
30
+ }
31
+ ]
32
+ }
33
+ });
34
+ return docs[0] ?? null;
35
+ }
36
+ /**
37
+ * Creates the send-email task configuration.
38
+ *
39
+ * Handles delivery of a single email for one contact within a broadcast.
40
+ * Queued by the send-broadcasts task, one job per contact.
41
+ */ export const createSendEmailTask = (pluginConfig)=>{
42
+ const collections = getCollectionSlugs(pluginConfig);
43
+ return {
44
+ slug: 'send-email',
45
+ handler: async ({ input, req })=>{
46
+ const { payload } = req;
47
+ const logger = payload.logger;
48
+ const { broadcastId, contactId } = input;
49
+ // Skip if already processed (handles job retries)
50
+ const existingEmail = await checkEmailExists(payload, collections.emails, broadcastId, contactId);
51
+ if (existingEmail) {
52
+ logger.info(`Email already exists for broadcast ${broadcastId}, contact ${contactId} ` + `(email ID: ${existingEmail.id}, status: ${existingEmail.status})`);
53
+ return {
54
+ output: {
55
+ success: true
56
+ }
57
+ };
58
+ }
59
+ const [contact, broadcast] = await Promise.all([
60
+ payload.findByID({
61
+ id: contactId,
62
+ collection: collections.contacts
63
+ }),
64
+ payload.findByID({
65
+ id: broadcastId,
66
+ collection: collections.broadcasts
67
+ })
68
+ ]);
69
+ if (!contact) {
70
+ throw new Error(`Contact ${contactId} not found`);
71
+ }
72
+ if (!broadcast) {
73
+ throw new Error(`Broadcast ${broadcastId} not found`);
74
+ }
75
+ const sender = pluginConfig.email({
76
+ payload
77
+ });
78
+ const fromAddress = formatFromAddress(broadcast.fromName, broadcast.fromAddress);
79
+ const tokenId = crypto.randomUUID();
80
+ const unsubscribeToken = generateUnsubscribeToken({
81
+ tokenId
82
+ });
83
+ const parsedContent = await parseLexicalContent(broadcast.content, payload.config);
84
+ const html = sender.render({
85
+ from: fromAddress,
86
+ html: parsedContent.html,
87
+ markdown: parsedContent.markdown,
88
+ plainText: parsedContent.plainText,
89
+ subject: broadcast.subject,
90
+ to: contact.email,
91
+ token: unsubscribeToken
92
+ });
93
+ const email = await payload.create({
94
+ collection: collections.emails,
95
+ data: {
96
+ broadcast: broadcast.id,
97
+ contact: contact.id,
98
+ from: fromAddress,
99
+ html,
100
+ status: 'queued',
101
+ subject: broadcast.subject,
102
+ to: contact.email
103
+ }
104
+ });
105
+ await payload.create({
106
+ collection: 'emailUnsubscribeTokens',
107
+ data: {
108
+ id: tokenId,
109
+ emailId: email.id
110
+ }
111
+ });
112
+ const result = await sender.sendEmail({
113
+ from: fromAddress,
114
+ html,
115
+ idempotencyKey: `broadcast-${broadcastId}-contact-${contactId}`,
116
+ markdown: parsedContent.markdown,
117
+ plainText: parsedContent.plainText,
118
+ previewText: broadcast.previewText,
119
+ replyTo: broadcast.replyTo,
120
+ subject: broadcast.subject,
121
+ to: contact.email,
122
+ token: unsubscribeToken
123
+ });
124
+ await payload.update({
125
+ id: email.id,
126
+ collection: collections.emails,
127
+ data: {
128
+ activity: [
129
+ {
130
+ type: 'sent',
131
+ timestamp: new Date().toISOString()
132
+ }
133
+ ],
134
+ providerId: result?.providerId,
135
+ status: 'sent'
136
+ }
137
+ });
138
+ logger.info(`Sent email ${email.id} for broadcast ${broadcastId} to ${contact.email}`);
139
+ return {
140
+ output: {
141
+ success: true
142
+ }
143
+ };
144
+ },
145
+ inputSchema: [
146
+ {
147
+ name: 'contactId',
148
+ type: 'number',
149
+ required: true
150
+ },
151
+ {
152
+ name: 'broadcastId',
153
+ type: 'number',
154
+ required: true
155
+ }
156
+ ],
157
+ outputSchema: [
158
+ {
159
+ name: 'success',
160
+ type: 'checkbox'
161
+ }
162
+ ],
163
+ retries: 3
164
+ };
165
+ };
166
+
167
+ //# sourceMappingURL=sendEmailTask.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/tasks/sendEmailTask.ts"],"sourcesContent":["import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'\nimport type { BasePayload, TaskConfig } from 'payload'\n\nimport type { MobilizehubPluginConfig } from '../types/index.js'\n\nimport { formatFromAddress } from '../utils/email.js'\nimport { parseLexicalContent } from '../utils/lexical.js'\nimport { generateUnsubscribeToken } from '../utils/unsubscribe-token.js'\n\n/**\n * Resolves collection slugs from plugin config, falling back to defaults.\n */\nfunction getCollectionSlugs(config: MobilizehubPluginConfig) {\n return {\n broadcasts: config.broadcastsOverrides?.slug || 'broadcasts',\n contacts: config.contactsOverrides?.slug || 'contacts',\n emails: config.emailsOverrides?.slug || 'emails',\n }\n}\n\n/**\n * Checks if an email record already exists for a broadcast-contact pair.\n */\nasync function checkEmailExists(\n payload: BasePayload,\n collection: string,\n broadcastId: number | string,\n contactId: number | string,\n) {\n const { docs } = await payload.find({\n collection,\n limit: 1,\n where: {\n and: [{ broadcast: { equals: broadcastId } }, { contact: { equals: contactId } }],\n },\n })\n\n return docs[0] ?? null\n}\n\n/**\n * Creates the send-email task configuration.\n *\n * Handles delivery of a single email for one contact within a broadcast.\n * Queued by the send-broadcasts task, one job per contact.\n */\nexport const createSendEmailTask = (pluginConfig: MobilizehubPluginConfig): TaskConfig => {\n const collections = getCollectionSlugs(pluginConfig)\n\n return {\n slug: 'send-email',\n handler: async ({ input, req }) => {\n const { payload } = req\n const logger = payload.logger\n\n const { broadcastId, contactId } = input as { broadcastId: number; contactId: number }\n\n // Skip if already processed (handles job retries)\n const existingEmail = await checkEmailExists(\n payload,\n collections.emails,\n broadcastId,\n contactId,\n )\n\n if (existingEmail) {\n logger.info(\n `Email already exists for broadcast ${broadcastId}, contact ${contactId} ` +\n `(email ID: ${existingEmail.id}, status: ${existingEmail.status})`,\n )\n return { output: { success: true } }\n }\n\n const [contact, broadcast] = await Promise.all([\n payload.findByID({ id: contactId, collection: collections.contacts }),\n payload.findByID({ id: broadcastId, collection: collections.broadcasts }),\n ])\n\n if (!contact) {\n throw new Error(`Contact ${contactId} not found`)\n }\n if (!broadcast) {\n throw new Error(`Broadcast ${broadcastId} not found`)\n }\n\n const sender = pluginConfig.email({ payload })\n const fromAddress = formatFromAddress(broadcast.fromName, broadcast.fromAddress)\n\n const tokenId = crypto.randomUUID()\n const unsubscribeToken = generateUnsubscribeToken({ tokenId })\n\n const parsedContent = await parseLexicalContent(\n broadcast.content as SerializedEditorState,\n payload.config,\n )\n\n const html = sender.render({\n from: fromAddress,\n html: parsedContent.html,\n markdown: parsedContent.markdown,\n plainText: parsedContent.plainText,\n subject: broadcast.subject,\n to: contact.email,\n token: unsubscribeToken,\n })\n\n const email = await payload.create({\n collection: collections.emails,\n data: {\n broadcast: broadcast.id,\n contact: contact.id,\n from: fromAddress,\n html,\n status: 'queued',\n subject: broadcast.subject,\n to: contact.email,\n },\n })\n\n await payload.create({\n collection: 'emailUnsubscribeTokens',\n data: { id: tokenId, emailId: email.id },\n })\n\n const result = await sender.sendEmail({\n from: fromAddress,\n html,\n idempotencyKey: `broadcast-${broadcastId}-contact-${contactId}`,\n markdown: parsedContent.markdown,\n plainText: parsedContent.plainText,\n previewText: broadcast.previewText,\n replyTo: broadcast.replyTo,\n subject: broadcast.subject,\n to: contact.email,\n token: unsubscribeToken,\n })\n\n await payload.update({\n id: email.id,\n collection: collections.emails,\n data: {\n activity: [{ type: 'sent', timestamp: new Date().toISOString() }],\n providerId: result?.providerId,\n status: 'sent',\n },\n })\n\n logger.info(`Sent email ${email.id} for broadcast ${broadcastId} to ${contact.email}`)\n\n return { output: { success: true } }\n },\n inputSchema: [\n { name: 'contactId', type: 'number', required: true },\n { name: 'broadcastId', type: 'number', required: true },\n ],\n outputSchema: [{ name: 'success', type: 'checkbox' }],\n retries: 3,\n }\n}\n"],"names":["formatFromAddress","parseLexicalContent","generateUnsubscribeToken","getCollectionSlugs","config","broadcasts","broadcastsOverrides","slug","contacts","contactsOverrides","emails","emailsOverrides","checkEmailExists","payload","collection","broadcastId","contactId","docs","find","limit","where","and","broadcast","equals","contact","createSendEmailTask","pluginConfig","collections","handler","input","req","logger","existingEmail","info","id","status","output","success","Promise","all","findByID","Error","sender","email","fromAddress","fromName","tokenId","crypto","randomUUID","unsubscribeToken","parsedContent","content","html","render","from","markdown","plainText","subject","to","token","create","data","emailId","result","sendEmail","idempotencyKey","previewText","replyTo","update","activity","type","timestamp","Date","toISOString","providerId","inputSchema","name","required","outputSchema","retries"],"mappings":"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"}