@mobilizehub/payload-plugin 0.6.0 → 0.6.2

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.
@@ -15,6 +15,13 @@ const WEBHOOK_EVENT_TO_ACTIVITY = {
15
15
  'email.sent': 'sent'
16
16
  };
17
17
  async function sendResendEmail(apiKey, message, idempotencyKey) {
18
+ const headers = {
19
+ Authorization: `Bearer ${apiKey}`,
20
+ 'Content-Type': 'application/json'
21
+ };
22
+ if (idempotencyKey) {
23
+ headers['Idempotency-Key'] = idempotencyKey;
24
+ }
18
25
  const response = await fetch(RESEND_API_URL, {
19
26
  body: JSON.stringify({
20
27
  from: message.from,
@@ -22,11 +29,7 @@ async function sendResendEmail(apiKey, message, idempotencyKey) {
22
29
  subject: message.subject,
23
30
  to: message.to
24
31
  }),
25
- headers: {
26
- Authorization: `Bearer ${apiKey}`,
27
- 'Content-Type': 'application/json',
28
- 'Idempotency-Key': idempotencyKey || ''
29
- },
32
+ headers,
30
33
  method: 'POST'
31
34
  });
32
35
  if (!response.ok) {
@@ -183,7 +186,7 @@ async function handleWebhookEvent(payload, eventType, emailProviderId, logger) {
183
186
  * ]
184
187
  * ```
185
188
  */ export const resendAdapter = (opts)=>{
186
- return ()=>({
189
+ return ({ payload })=>({
187
190
  name: 'resend-mobilizehub-adapter',
188
191
  defaultFromAddress: opts.defaultFromAddress,
189
192
  defaultFromName: opts.defaultFromName,
@@ -195,7 +198,7 @@ async function handleWebhookEvent(payload, eventType, emailProviderId, logger) {
195
198
  html: message.html,
196
199
  subject: message.subject,
197
200
  to: message.to
198
- });
201
+ }, message.idempotencyKey);
199
202
  },
200
203
  webhookHandler: async (req)=>{
201
204
  const { payload } = req;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/adapters/resend-adapter.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\n\nimport crypto from 'crypto'\n\nimport type { EmailActivityType, EmailAdapter, EmailMessage } from '../types/index.js'\n\nimport { formatFromAddress } from '../utils/email.js'\n\ntype ResendAdapterOptions = {\n apiKey: string\n defaultFromAddress: string\n defaultFromName: string\n render: ReturnType<EmailAdapter>['render']\n webhookSecret: string\n}\n\ntype ResendWebhookPayload = {\n created_at: string\n data: {\n [key: string]: unknown\n email_id?: string\n }\n type: string\n}\n\nconst RESEND_API_URL = 'https://api.resend.com/emails'\nconst WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60 // 5 minutes\n\nconst WEBHOOK_EVENT_TO_ACTIVITY: Record<string, EmailActivityType> = {\n 'email.bounced': 'bounced',\n 'email.clicked': 'clicked',\n 'email.complained': 'complained',\n 'email.delivered': 'delivered',\n 'email.delivery_delayed': 'delivery_delayed',\n 'email.failed': 'failed',\n 'email.opened': 'opened',\n 'email.received': 'received',\n 'email.sent': 'sent',\n}\n\nasync function sendResendEmail(\n apiKey: string,\n message: EmailMessage,\n idempotencyKey?: string,\n): Promise<{ providerId: string }> {\n const response = await fetch(RESEND_API_URL, {\n body: JSON.stringify({\n from: message.from,\n html: message.html,\n subject: message.subject,\n to: message.to,\n }),\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n 'Idempotency-Key': idempotencyKey || '',\n },\n method: 'POST',\n })\n\n if (!response.ok) {\n const errorText = await response.text()\n throw new Error(`Resend API error: ${response.status} - ${errorText}`)\n }\n\n const data = (await response.json()) as { id: string }\n\n return { providerId: data.id }\n}\n\ntype SvixHeaders = {\n id: string\n signature: string\n timestamp: string\n}\n\nfunction extractSvixHeaders(req: PayloadRequest): SvixHeaders {\n const id = req.headers.get('svix-id')\n const timestamp = req.headers.get('svix-timestamp')\n const signature = req.headers.get('svix-signature')\n\n if (!id || !timestamp || !signature) {\n throw new Error('Missing required Svix headers')\n }\n\n return { id, signature, timestamp }\n}\n\nfunction verifyTimestamp(timestamp: string): void {\n const timestampSeconds = parseInt(timestamp, 10)\n const nowSeconds = Math.floor(Date.now() / 1000)\n\n if (Math.abs(nowSeconds - timestampSeconds) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS) {\n throw new Error('Webhook timestamp is too old')\n }\n}\n\nfunction computeExpectedSignature(\n svixId: string,\n svixTimestamp: string,\n body: string,\n webhookSecret: string,\n): string {\n const signedContent = `${svixId}.${svixTimestamp}.${body}`\n\n // Resend webhook secrets are prefixed with \"whsec_\" and base64 encoded\n const secretParts = webhookSecret.split('_')\n const secretBase64 = secretParts.length > 1 ? secretParts[1] : secretParts[0]\n const secretBytes = Buffer.from(secretBase64, 'base64')\n\n return crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64')\n}\n\nfunction verifySignature(svixSignature: string, expectedSignature: string): boolean {\n // Svix signatures can be multiple, space-delimited with version prefix (e.g., \"v1,<sig>\")\n const signatures = svixSignature.split(' ').map((sig) => {\n const [version, signature] = sig.split(',')\n return { signature, version }\n })\n\n return signatures.some(({ signature, version }) => {\n if (version !== 'v1') {\n return false\n }\n\n try {\n return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))\n } catch {\n return false\n }\n })\n}\n\nasync function verifyWebhookSignature(\n req: PayloadRequest,\n webhookSecret: string,\n): Promise<ResendWebhookPayload> {\n const headers = extractSvixHeaders(req)\n\n verifyTimestamp(headers.timestamp)\n\n if (!req.text) {\n throw new Error('Request does not have a text body method')\n }\n\n const body = await req.text()\n\n if (!body) {\n throw new Error('No body in request')\n }\n\n const expectedSignature = computeExpectedSignature(\n headers.id,\n headers.timestamp,\n body,\n webhookSecret,\n )\n\n if (!verifySignature(headers.signature, expectedSignature)) {\n throw new Error('Invalid webhook signature')\n }\n\n return JSON.parse(body) as ResendWebhookPayload\n}\n\nasync function findEmailByProviderId(\n payload: Payload,\n providerId: string,\n): Promise<{ activity?: unknown[]; id: number | string } | null> {\n const result = await payload.find({\n collection: 'emails',\n limit: 1,\n where: {\n providerId: { equals: providerId },\n },\n })\n\n return (result.docs[0] as { activity?: unknown[]; id: number | string }) || null\n}\n\nasync function addEmailActivity(\n payload: Payload,\n emailId: number | string,\n currentActivity: undefined | unknown[],\n activityType: EmailActivityType,\n): Promise<void> {\n const updatedActivity = [\n ...(currentActivity || []),\n {\n type: activityType,\n timestamp: new Date().toISOString(),\n },\n ]\n\n await payload.update({\n id: emailId,\n collection: 'emails',\n data: {\n activity: updatedActivity,\n },\n })\n}\n\nasync function handleWebhookEvent(\n payload: Payload,\n eventType: string,\n emailProviderId: string,\n logger: Payload['logger'],\n): Promise<void> {\n const activityType = WEBHOOK_EVENT_TO_ACTIVITY[eventType]\n\n if (!activityType) {\n logger.warn(`Unhandled webhook event type: ${eventType}`)\n return\n }\n\n const email = await findEmailByProviderId(payload, emailProviderId)\n\n if (!email) {\n logger.error(`No email record found for provider ID: ${emailProviderId}`)\n return\n }\n\n await addEmailActivity(payload, email.id, email.activity, activityType)\n\n // Log based on severity\n const warnEvents = ['email.bounced', 'email.complained', 'email.delivery_delayed']\n const errorEvents = ['email.failed']\n\n if (errorEvents.includes(eventType)) {\n logger.error(`Email ${activityType}: ${emailProviderId}`)\n } else if (warnEvents.includes(eventType)) {\n logger.warn(`Email ${activityType}: ${emailProviderId}`)\n } else {\n logger.info(`Email ${activityType}: ${emailProviderId}`)\n }\n}\n\n/**\n * Resend email adapter for MobilizeHub\n *\n * @example\n * ```typescript\n * import { resendAdapter } from '@mobilizehub/payload-plugin/adapters'\n *\n * const emailAdapter = resendAdapter({\n * apiKey: process.env.RESEND_API_KEY!,\n * webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,\n * defaultFromAddress: 'noreply@example.com',\n * defaultFromName: 'My App',\n * render: ({ html }) => html,\n * })\n *\n * // In your Payload config:\n * plugins: [\n * mobilizehub({\n * email: emailAdapter,\n * // ...\n * }),\n * ]\n * ```\n */\nexport const resendAdapter = (opts: ResendAdapterOptions): EmailAdapter => {\n return () => ({\n name: 'resend-mobilizehub-adapter',\n defaultFromAddress: opts.defaultFromAddress,\n defaultFromName: opts.defaultFromName,\n render: opts.render,\n\n sendEmail: async (message) => {\n const fromAddress =\n message.from || formatFromAddress(opts.defaultFromName, opts.defaultFromAddress)\n\n return sendResendEmail(opts.apiKey, {\n from: fromAddress,\n html: message.html,\n subject: message.subject,\n to: message.to,\n })\n },\n\n webhookHandler: async (req) => {\n const { payload } = req\n const logger = payload.logger\n\n try {\n const webhookPayload = await verifyWebhookSignature(req, opts.webhookSecret)\n\n const emailProviderId = webhookPayload.data.email_id\n\n if (!emailProviderId) {\n logger.warn(`Webhook event ${webhookPayload.type} has no email_id`)\n return\n }\n\n await handleWebhookEvent(payload, webhookPayload.type, emailProviderId, logger)\n } catch (error) {\n logger.error(\n `Resend webhook error: ${error instanceof Error ? error.message : 'Unknown error'}`,\n )\n throw error\n }\n },\n })\n}\n"],"names":["crypto","formatFromAddress","RESEND_API_URL","WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS","WEBHOOK_EVENT_TO_ACTIVITY","sendResendEmail","apiKey","message","idempotencyKey","response","fetch","body","JSON","stringify","from","html","subject","to","headers","Authorization","method","ok","errorText","text","Error","status","data","json","providerId","id","extractSvixHeaders","req","get","timestamp","signature","verifyTimestamp","timestampSeconds","parseInt","nowSeconds","Math","floor","Date","now","abs","computeExpectedSignature","svixId","svixTimestamp","webhookSecret","signedContent","secretParts","split","secretBase64","length","secretBytes","Buffer","createHmac","update","digest","verifySignature","svixSignature","expectedSignature","signatures","map","sig","version","some","timingSafeEqual","verifyWebhookSignature","parse","findEmailByProviderId","payload","result","find","collection","limit","where","equals","docs","addEmailActivity","emailId","currentActivity","activityType","updatedActivity","type","toISOString","activity","handleWebhookEvent","eventType","emailProviderId","logger","warn","email","error","warnEvents","errorEvents","includes","info","resendAdapter","opts","name","defaultFromAddress","defaultFromName","render","sendEmail","fromAddress","webhookHandler","webhookPayload","email_id"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/adapters/resend-adapter.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\n\nimport crypto from 'crypto'\n\nimport type { EmailActivityType, EmailAdapter, EmailMessage } from '../types/index.js'\n\nimport { formatFromAddress } from '../utils/email.js'\n\ntype ResendAdapterOptions = {\n apiKey: string\n defaultFromAddress: string\n defaultFromName: string\n render: ReturnType<EmailAdapter>['render']\n webhookSecret: string\n}\n\ntype ResendWebhookPayload = {\n created_at: string\n data: {\n [key: string]: unknown\n email_id?: string\n }\n type: string\n}\n\nconst RESEND_API_URL = 'https://api.resend.com/emails'\nconst WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60 // 5 minutes\n\nconst WEBHOOK_EVENT_TO_ACTIVITY: Record<string, EmailActivityType> = {\n 'email.bounced': 'bounced',\n 'email.clicked': 'clicked',\n 'email.complained': 'complained',\n 'email.delivered': 'delivered',\n 'email.delivery_delayed': 'delivery_delayed',\n 'email.failed': 'failed',\n 'email.opened': 'opened',\n 'email.received': 'received',\n 'email.sent': 'sent',\n}\n\nasync function sendResendEmail(\n apiKey: string,\n message: EmailMessage,\n idempotencyKey?: string,\n): Promise<{ providerId: string }> {\n const headers: Record<string, string> = {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n }\n\n if (idempotencyKey) {\n headers['Idempotency-Key'] = idempotencyKey\n }\n\n const response = await fetch(RESEND_API_URL, {\n body: JSON.stringify({\n from: message.from,\n html: message.html,\n subject: message.subject,\n to: message.to,\n }),\n headers,\n method: 'POST',\n })\n\n if (!response.ok) {\n const errorText = await response.text()\n throw new Error(`Resend API error: ${response.status} - ${errorText}`)\n }\n\n const data = (await response.json()) as { id: string }\n\n return { providerId: data.id }\n}\n\ntype SvixHeaders = {\n id: string\n signature: string\n timestamp: string\n}\n\nfunction extractSvixHeaders(req: PayloadRequest): SvixHeaders {\n const id = req.headers.get('svix-id')\n const timestamp = req.headers.get('svix-timestamp')\n const signature = req.headers.get('svix-signature')\n\n if (!id || !timestamp || !signature) {\n throw new Error('Missing required Svix headers')\n }\n\n return { id, signature, timestamp }\n}\n\nfunction verifyTimestamp(timestamp: string): void {\n const timestampSeconds = parseInt(timestamp, 10)\n const nowSeconds = Math.floor(Date.now() / 1000)\n\n if (Math.abs(nowSeconds - timestampSeconds) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS) {\n throw new Error('Webhook timestamp is too old')\n }\n}\n\nfunction computeExpectedSignature(\n svixId: string,\n svixTimestamp: string,\n body: string,\n webhookSecret: string,\n): string {\n const signedContent = `${svixId}.${svixTimestamp}.${body}`\n\n // Resend webhook secrets are prefixed with \"whsec_\" and base64 encoded\n const secretParts = webhookSecret.split('_')\n const secretBase64 = secretParts.length > 1 ? secretParts[1] : secretParts[0]\n const secretBytes = Buffer.from(secretBase64, 'base64')\n\n return crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64')\n}\n\nfunction verifySignature(svixSignature: string, expectedSignature: string): boolean {\n // Svix signatures can be multiple, space-delimited with version prefix (e.g., \"v1,<sig>\")\n const signatures = svixSignature.split(' ').map((sig) => {\n const [version, signature] = sig.split(',')\n return { signature, version }\n })\n\n return signatures.some(({ signature, version }) => {\n if (version !== 'v1') {\n return false\n }\n\n try {\n return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))\n } catch {\n return false\n }\n })\n}\n\nasync function verifyWebhookSignature(\n req: PayloadRequest,\n webhookSecret: string,\n): Promise<ResendWebhookPayload> {\n const headers = extractSvixHeaders(req)\n\n verifyTimestamp(headers.timestamp)\n\n if (!req.text) {\n throw new Error('Request does not have a text body method')\n }\n\n const body = await req.text()\n\n if (!body) {\n throw new Error('No body in request')\n }\n\n const expectedSignature = computeExpectedSignature(\n headers.id,\n headers.timestamp,\n body,\n webhookSecret,\n )\n\n if (!verifySignature(headers.signature, expectedSignature)) {\n throw new Error('Invalid webhook signature')\n }\n\n return JSON.parse(body) as ResendWebhookPayload\n}\n\nasync function findEmailByProviderId(\n payload: Payload,\n providerId: string,\n): Promise<{ activity?: unknown[]; id: number | string } | null> {\n const result = await payload.find({\n collection: 'emails',\n limit: 1,\n where: {\n providerId: { equals: providerId },\n },\n })\n\n return (result.docs[0] as { activity?: unknown[]; id: number | string }) || null\n}\n\nasync function addEmailActivity(\n payload: Payload,\n emailId: number | string,\n currentActivity: undefined | unknown[],\n activityType: EmailActivityType,\n): Promise<void> {\n const updatedActivity = [\n ...(currentActivity || []),\n {\n type: activityType,\n timestamp: new Date().toISOString(),\n },\n ]\n\n await payload.update({\n id: emailId,\n collection: 'emails',\n data: {\n activity: updatedActivity,\n },\n })\n}\n\nasync function handleWebhookEvent(\n payload: Payload,\n eventType: string,\n emailProviderId: string,\n logger: Payload['logger'],\n): Promise<void> {\n const activityType = WEBHOOK_EVENT_TO_ACTIVITY[eventType]\n\n if (!activityType) {\n logger.warn(`Unhandled webhook event type: ${eventType}`)\n return\n }\n\n const email = await findEmailByProviderId(payload, emailProviderId)\n\n if (!email) {\n logger.error(`No email record found for provider ID: ${emailProviderId}`)\n return\n }\n\n await addEmailActivity(payload, email.id, email.activity, activityType)\n\n // Log based on severity\n const warnEvents = ['email.bounced', 'email.complained', 'email.delivery_delayed']\n const errorEvents = ['email.failed']\n\n if (errorEvents.includes(eventType)) {\n logger.error(`Email ${activityType}: ${emailProviderId}`)\n } else if (warnEvents.includes(eventType)) {\n logger.warn(`Email ${activityType}: ${emailProviderId}`)\n } else {\n logger.info(`Email ${activityType}: ${emailProviderId}`)\n }\n}\n\n/**\n * Resend email adapter for MobilizeHub\n *\n * @example\n * ```typescript\n * import { resendAdapter } from '@mobilizehub/payload-plugin/adapters'\n *\n * const emailAdapter = resendAdapter({\n * apiKey: process.env.RESEND_API_KEY!,\n * webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,\n * defaultFromAddress: 'noreply@example.com',\n * defaultFromName: 'My App',\n * render: ({ html }) => html,\n * })\n *\n * // In your Payload config:\n * plugins: [\n * mobilizehub({\n * email: emailAdapter,\n * // ...\n * }),\n * ]\n * ```\n */\nexport const resendAdapter = (opts: ResendAdapterOptions): EmailAdapter => {\n return ({ payload }) => ({\n name: 'resend-mobilizehub-adapter',\n defaultFromAddress: opts.defaultFromAddress,\n defaultFromName: opts.defaultFromName,\n render: opts.render,\n\n sendEmail: async (message) => {\n const fromAddress =\n message.from || formatFromAddress(opts.defaultFromName, opts.defaultFromAddress)\n\n return sendResendEmail(\n opts.apiKey,\n {\n from: fromAddress,\n html: message.html,\n subject: message.subject,\n to: message.to,\n },\n message.idempotencyKey,\n )\n },\n\n webhookHandler: async (req) => {\n const { payload } = req\n const logger = payload.logger\n\n try {\n const webhookPayload = await verifyWebhookSignature(req, opts.webhookSecret)\n\n const emailProviderId = webhookPayload.data.email_id\n\n if (!emailProviderId) {\n logger.warn(`Webhook event ${webhookPayload.type} has no email_id`)\n return\n }\n\n await handleWebhookEvent(payload, webhookPayload.type, emailProviderId, logger)\n } catch (error) {\n logger.error(\n `Resend webhook error: ${error instanceof Error ? error.message : 'Unknown error'}`,\n )\n throw error\n }\n },\n })\n}\n"],"names":["crypto","formatFromAddress","RESEND_API_URL","WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS","WEBHOOK_EVENT_TO_ACTIVITY","sendResendEmail","apiKey","message","idempotencyKey","headers","Authorization","response","fetch","body","JSON","stringify","from","html","subject","to","method","ok","errorText","text","Error","status","data","json","providerId","id","extractSvixHeaders","req","get","timestamp","signature","verifyTimestamp","timestampSeconds","parseInt","nowSeconds","Math","floor","Date","now","abs","computeExpectedSignature","svixId","svixTimestamp","webhookSecret","signedContent","secretParts","split","secretBase64","length","secretBytes","Buffer","createHmac","update","digest","verifySignature","svixSignature","expectedSignature","signatures","map","sig","version","some","timingSafeEqual","verifyWebhookSignature","parse","findEmailByProviderId","payload","result","find","collection","limit","where","equals","docs","addEmailActivity","emailId","currentActivity","activityType","updatedActivity","type","toISOString","activity","handleWebhookEvent","eventType","emailProviderId","logger","warn","email","error","warnEvents","errorEvents","includes","info","resendAdapter","opts","name","defaultFromAddress","defaultFromName","render","sendEmail","fromAddress","webhookHandler","webhookPayload","email_id"],"mappings":"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"}
@@ -88,6 +88,7 @@ import { ErrorCodes, errorResponse, successResponse } from '../utils/api-respons
88
88
  data: {
89
89
  meta: {
90
90
  contactsCount: contactsCount.totalDocs,
91
+ lastProcessedContactId: 0,
91
92
  processedCount: 0
92
93
  },
93
94
  status: 'sending'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/sendBroadcastHandler.ts"],"sourcesContent":["import type { Payload, PayloadHandler, Where } from 'payload'\n\nimport { ErrorCodes, errorResponse, successResponse } from '../utils/api-response.js'\n\ntype SendBroadcastBody = {\n broadcastId?: unknown\n}\n\ntype Broadcast = {\n id: number | string\n status?: string\n tags?: Array<{ id: number | string } | number | string>\n to?: string\n}\n\n/**\n * Validates the request body contains a valid broadcastId.\n */\nfunction validateRequestBody(\n body: SendBroadcastBody,\n): { broadcastId: number | string; success: true } | { error: string; success: false } {\n if (!body.broadcastId) {\n return { error: 'broadcastId is required', success: false }\n }\n return { broadcastId: body.broadcastId as number | string, success: true }\n}\n\n/**\n * Fetches a broadcast by ID.\n * Returns null if the broadcast doesn't exist.\n */\nasync function findBroadcastById(\n payload: Payload,\n broadcastId: number | string,\n): Promise<Broadcast | null> {\n try {\n const broadcast = await payload.findByID({\n id: broadcastId,\n collection: 'broadcasts',\n })\n return broadcast as Broadcast | null\n } catch {\n return null\n }\n}\n\n/**\n * Builds the where clause for counting eligible contacts.\n * Adds tag filtering if the broadcast targets specific tags.\n */\nfunction buildContactsWhereClause(broadcast: Broadcast): Where {\n const whereClause: Where = {\n emailOptIn: { equals: true },\n }\n\n if (broadcast.to === 'tags' && Array.isArray(broadcast.tags) && broadcast.tags.length > 0) {\n const tagIds = broadcast.tags.map((t) => (typeof t === 'object' ? t.id : t))\n whereClause.tags = { in: tagIds }\n }\n\n return whereClause\n}\n\n/**\n * Creates the send-broadcast endpoint handler.\n *\n * Initiates sending a broadcast by validating the broadcast is in draft status,\n * counting eligible contacts, and updating the status to 'sending'. The actual\n * email delivery is handled by the send-broadcasts scheduled task.\n */\nexport const sendBroadcastHandler = (): PayloadHandler => {\n return async (req) => {\n const { payload } = req\n const logger = payload.logger\n\n if (!req.json) {\n return errorResponse(ErrorCodes.BAD_REQUEST, 'No JSON body provided', 400)\n }\n\n if (!req.user) {\n return errorResponse(ErrorCodes.UNAUTHORIZED, 'Unauthorized', 401)\n }\n\n try {\n const body = (await req.json()) as SendBroadcastBody\n\n const validation = validateRequestBody(body)\n if (!validation.success) {\n logger.error(validation.error)\n return errorResponse(ErrorCodes.BAD_REQUEST, validation.error, 400)\n }\n\n const { broadcastId } = validation\n\n const broadcast = await findBroadcastById(payload, broadcastId)\n\n if (!broadcast) {\n logger.error(`Broadcast ${broadcastId} not found`)\n return errorResponse(ErrorCodes.BROADCAST_NOT_FOUND, 'Broadcast not found', 404)\n }\n\n if (broadcast.status !== 'draft') {\n logger.error(`Broadcast ${broadcastId} is not in draft status (current: ${broadcast.status})`)\n return errorResponse(\n ErrorCodes.VALIDATION_ERROR,\n 'Broadcast must be in draft status to send',\n 400,\n )\n }\n\n const whereClause = buildContactsWhereClause(broadcast)\n\n const contactsCount = await payload.db.count({\n collection: 'contacts',\n where: whereClause,\n })\n\n await payload.update({\n id: broadcastId,\n collection: 'broadcasts',\n data: {\n meta: {\n contactsCount: contactsCount.totalDocs,\n processedCount: 0,\n },\n status: 'sending',\n },\n })\n\n logger.info(`Broadcast ${broadcastId} queued for sending`)\n\n return successResponse({ message: 'Broadcast queued for sending' }, 200)\n } catch (err) {\n logger.error(err, 'Error occurred while queueing broadcast')\n return errorResponse(ErrorCodes.INTERNAL_ERROR, 'Error queueing broadcast', 500)\n }\n }\n}\n"],"names":["ErrorCodes","errorResponse","successResponse","validateRequestBody","body","broadcastId","error","success","findBroadcastById","payload","broadcast","findByID","id","collection","buildContactsWhereClause","whereClause","emailOptIn","equals","to","Array","isArray","tags","length","tagIds","map","t","in","sendBroadcastHandler","req","logger","json","BAD_REQUEST","user","UNAUTHORIZED","validation","BROADCAST_NOT_FOUND","status","VALIDATION_ERROR","contactsCount","db","count","where","update","data","meta","totalDocs","processedCount","info","message","err","INTERNAL_ERROR"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/sendBroadcastHandler.ts"],"sourcesContent":["import type { Payload, PayloadHandler, Where } from 'payload'\n\nimport { ErrorCodes, errorResponse, successResponse } from '../utils/api-response.js'\n\ntype SendBroadcastBody = {\n broadcastId?: unknown\n}\n\ntype Broadcast = {\n id: number | string\n status?: string\n tags?: Array<{ id: number | string } | number | string>\n to?: string\n}\n\n/**\n * Validates the request body contains a valid broadcastId.\n */\nfunction validateRequestBody(\n body: SendBroadcastBody,\n): { broadcastId: number | string; success: true } | { error: string; success: false } {\n if (!body.broadcastId) {\n return { error: 'broadcastId is required', success: false }\n }\n return { broadcastId: body.broadcastId as number | string, success: true }\n}\n\n/**\n * Fetches a broadcast by ID.\n * Returns null if the broadcast doesn't exist.\n */\nasync function findBroadcastById(\n payload: Payload,\n broadcastId: number | string,\n): Promise<Broadcast | null> {\n try {\n const broadcast = await payload.findByID({\n id: broadcastId,\n collection: 'broadcasts',\n })\n return broadcast as Broadcast | null\n } catch {\n return null\n }\n}\n\n/**\n * Builds the where clause for counting eligible contacts.\n * Adds tag filtering if the broadcast targets specific tags.\n */\nfunction buildContactsWhereClause(broadcast: Broadcast): Where {\n const whereClause: Where = {\n emailOptIn: { equals: true },\n }\n\n if (broadcast.to === 'tags' && Array.isArray(broadcast.tags) && broadcast.tags.length > 0) {\n const tagIds = broadcast.tags.map((t) => (typeof t === 'object' ? t.id : t))\n whereClause.tags = { in: tagIds }\n }\n\n return whereClause\n}\n\n/**\n * Creates the send-broadcast endpoint handler.\n *\n * Initiates sending a broadcast by validating the broadcast is in draft status,\n * counting eligible contacts, and updating the status to 'sending'. The actual\n * email delivery is handled by the send-broadcasts scheduled task.\n */\nexport const sendBroadcastHandler = (): PayloadHandler => {\n return async (req) => {\n const { payload } = req\n const logger = payload.logger\n\n if (!req.json) {\n return errorResponse(ErrorCodes.BAD_REQUEST, 'No JSON body provided', 400)\n }\n\n if (!req.user) {\n return errorResponse(ErrorCodes.UNAUTHORIZED, 'Unauthorized', 401)\n }\n\n try {\n const body = (await req.json()) as SendBroadcastBody\n\n const validation = validateRequestBody(body)\n if (!validation.success) {\n logger.error(validation.error)\n return errorResponse(ErrorCodes.BAD_REQUEST, validation.error, 400)\n }\n\n const { broadcastId } = validation\n\n const broadcast = await findBroadcastById(payload, broadcastId)\n\n if (!broadcast) {\n logger.error(`Broadcast ${broadcastId} not found`)\n return errorResponse(ErrorCodes.BROADCAST_NOT_FOUND, 'Broadcast not found', 404)\n }\n\n if (broadcast.status !== 'draft') {\n logger.error(\n `Broadcast ${broadcastId} is not in draft status (current: ${broadcast.status})`,\n )\n return errorResponse(\n ErrorCodes.VALIDATION_ERROR,\n 'Broadcast must be in draft status to send',\n 400,\n )\n }\n\n const whereClause = buildContactsWhereClause(broadcast)\n\n const contactsCount = await payload.db.count({\n collection: 'contacts',\n where: whereClause,\n })\n\n await payload.update({\n id: broadcastId,\n collection: 'broadcasts',\n data: {\n meta: {\n contactsCount: contactsCount.totalDocs,\n lastProcessedContactId: 0,\n processedCount: 0,\n },\n status: 'sending',\n },\n })\n\n logger.info(`Broadcast ${broadcastId} queued for sending`)\n\n return successResponse({ message: 'Broadcast queued for sending' }, 200)\n } catch (err) {\n logger.error(err, 'Error occurred while queueing broadcast')\n return errorResponse(ErrorCodes.INTERNAL_ERROR, 'Error queueing broadcast', 500)\n }\n }\n}\n"],"names":["ErrorCodes","errorResponse","successResponse","validateRequestBody","body","broadcastId","error","success","findBroadcastById","payload","broadcast","findByID","id","collection","buildContactsWhereClause","whereClause","emailOptIn","equals","to","Array","isArray","tags","length","tagIds","map","t","in","sendBroadcastHandler","req","logger","json","BAD_REQUEST","user","UNAUTHORIZED","validation","BROADCAST_NOT_FOUND","status","VALIDATION_ERROR","contactsCount","db","count","where","update","data","meta","totalDocs","lastProcessedContactId","processedCount","info","message","err","INTERNAL_ERROR"],"mappings":"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"}
@@ -92,7 +92,6 @@ import z from 'zod';
92
92
  handler: async ({ req })=>{
93
93
  const { payload } = req;
94
94
  const logger = payload.logger;
95
- logger.info('Send Broadcast task handler called');
96
95
  const { docs } = await payload.find({
97
96
  collection: 'broadcasts',
98
97
  limit: 1,
@@ -1 +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 BROADCAST_QUEUE_NAME = pluginConfig.broadcastConfig?.broadcastQueueName || 'send-broadcasts'\n const EMAIL_QUEUE_NAME = pluginConfig.broadcastConfig?.emailQueueName || 'send-emails'\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: EMAIL_QUEUE_NAME,\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: BROADCAST_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","BROADCAST_QUEUE_NAME","broadcastQueueName","EMAIL_QUEUE_NAME","emailQueueName","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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"}
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 BROADCAST_QUEUE_NAME = pluginConfig.broadcastConfig?.broadcastQueueName || 'send-broadcasts'\n const EMAIL_QUEUE_NAME = pluginConfig.broadcastConfig?.emailQueueName || 'send-emails'\n\n return {\n slug: 'send-broadcasts',\n handler: async ({ req }) => {\n const { payload } = req\n const logger = payload.logger\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: EMAIL_QUEUE_NAME,\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: BROADCAST_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","BROADCAST_QUEUE_NAME","broadcastQueueName","EMAIL_QUEUE_NAME","emailQueueName","slug","handler","req","payload","logger","docs","find","collection","limit","sort","where","status","info","output","parsed","update","whereClause","contacts","Promise","all","jobs","queue","input","broadcastId","contactId","task","outputSchema","name","type","retries","schedule","cron"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobilizehub/payload-plugin",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Edvocacy plugin for Payload",
5
5
  "license": "MIT",
6
6
  "private": false,