Skip to main content

Overview

The message scheduling system provides precise control over when WhatsApp messages are sent, with support for multiple timezones, recurring patterns, and automatic message generation based on campaign parameters.

Timezone Support

All messages are scheduled with timezone awareness using Luxon for accurate date/time handling.

Default Timezone

src/server/api/routers/messageCampaign.ts:20
timeZone: z.string().default('America/Chicago')

Timezone Conversion Flow

1

Parse User Input

Date and time are parsed in the user’s specified timezone
const startDt = DateTime.fromISO(startDate, { zone: timeZone })
  .set({ hour: hours, minute: minutes, second: 0, millisecond: 0 });
2

Convert to UTC

Scheduled times are stored in UTC for consistency
const scheduledDateUtc = messageDate.toUTC().toJSDate();
3

Schedule Delivery

Background workers process messages based on UTC time
Storing all times in UTC prevents issues with daylight saving time transitions and ensures consistent scheduling across different server locations.

Time Format Validation

Message times must follow the HH:MM format:
src/server/api/routers/messageCampaign.ts:40
const timeRegex = new RegExp(/^(\d{1,2}):(\d{2})$/);
const timeMatch = timeRegex.exec(messageTime);

if (!timeMatch?.[1] || !timeMatch?.[2]) {
  throw new Error("Invalid time format");
}

const hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);

if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
  throw new Error("Invalid time values");
}
  • 9:30 (9:30 AM)
  • 09:30 (9:30 AM)
  • 14:45 (2:45 PM)
  • 23:59 (11:59 PM)

Recurrence Patterns

The system supports six recurrence patterns with automatic message generation:
prisma/schema.prisma:103
enum Recurrence {
  DAILY
  WEEKLY
  SEMI_MONTHLY
  SEMI_ANNUALLY
  ANNUALLY
}

Recurrence Intervals

src/server/api/routers/messageCampaign.ts:31
const recurrenceDaysMap = {
  DAILY: 1,
  WEEKLY: 7,
  SEMI_MONTHLY: 15,
  MONTHLY: 30,
  SEMI_ANNUALLY: 182,
  ANNUALLY: 365,
}

Daily

Messages every day Interval: 1 day

Weekly

Messages every week Interval: 7 days

Semi-Monthly

Messages twice per month Interval: 15 days

Monthly

Messages once per month Interval: 30 days

Semi-Annually

Messages twice per year Interval: 182 days

Annually

Messages once per year Interval: 365 days

Message Generation Algorithm

Messages are automatically generated based on the date range and recurrence pattern:
src/server/api/routers/messageCampaign.ts:68
const messages = [];
let days_width = 1;

if (isRecurring) {
  days_width = recurrenceDaysMap[recurrence];
}

let i = 0;
let sequenceIndex = 0;
const daysDiff = Math.ceil(endDt.diff(startDt, 'days').days) + 1;
const sendTimeUtc = startDt.toUTC().toJSDate();

while (i < daysDiff) {
  const messageDate = startDt.plus({ days: i });
  const scheduledDateUtc = messageDate.toUTC().toJSDate();
  const daysLeft = daysDiff - i - 1;
  
  // Build message content
  let messageContent = '';
  
  if (!input.isFreeForm) {
    if (title) {
      messageContent += `Campaign Title: ${title}\n`;
    }
    messageContent += `Campaign Start Date: ${startDt.toFormat('yyyy-LL-dd')}\n`;
    messageContent += `Campaign End Date: ${endDt.toFormat('yyyy-LL-dd')}\n`;
    if (targetAmount) {
      messageContent += `Contribution Target Amount: ${targetAmount}\n`;
    }
    messageContent += `Days Remaining: ${daysLeft}\n\n`;
  }

  // Select message from sequence
  const messageText = isRecurring && messageSequence.length > 0
    ? messageSequence[sequenceIndex % messageSequence.length]
    : messageTemplate;
  messageContent += messageText?.replace(/{days_left}/g, daysLeft.toString());

  messages.push({
    sessionId,
    content: messageContent,
    scheduledAt: scheduledDateUtc,
  });

  i = i + days_width;
  sequenceIndex++;
}

Generation Example

Input:
- startDate: "2026-03-01"
- endDate: "2026-03-05"
- messageTime: "10:00"
- timeZone: "America/New_York"
- isRecurring: true
- recurrence: "DAILY"

Generated Messages:
1. March 1, 2026 at 10:00 AM EST
2. March 2, 2026 at 10:00 AM EST
3. March 3, 2026 at 10:00 AM EST
4. March 4, 2026 at 10:00 AM EST
5. March 5, 2026 at 10:00 AM EST

Total: 5 messages
Input:
- startDate: "2026-03-01"
- endDate: "2026-03-31"
- messageTime: "14:30"
- timeZone: "America/Chicago"
- isRecurring: true
- recurrence: "WEEKLY"

Generated Messages:
1. March 1, 2026 at 2:30 PM CST
2. March 8, 2026 at 2:30 PM CST
3. March 15, 2026 at 2:30 PM CST
4. March 22, 2026 at 2:30 PM CST
5. March 29, 2026 at 2:30 PM CST

Total: 5 messages

Message Sequences

For recurring campaigns, you can provide unique messages for each occurrence by separating them with asterisks:
src/server/api/routers/messageCampaign.ts:75
const hasMessageSequence = messageTemplate.includes('*');
const messageSequence = hasMessageSequence 
  ? messageTemplate.split('*').map(msg => msg.trim()).filter(msg => msg.length > 0)
  : [messageTemplate];

Sequence Validation

src/server/api/routers/messageCampaign.ts:81
if (isRecurring && hasMessageSequence) {
  const daysDiff = Math.ceil(endDt.diff(startDt, 'days').days) + 1;
  const requiredMessageCount = Math.ceil(daysDiff / recurrenceDaysMap[recurrence]);
  
  if (messageSequence.length !== requiredMessageCount) {
    throw new Error(
      `For the selected date range and ${recurrence.toLowerCase()} recurrence, ` +
      `you need exactly ${requiredMessageCount} unique message${requiredMessageCount > 1 ? 's' : ''} ` +
      `separated by asterisks (*). Or remove the asterisks to use the same message for all occurrences.`
    );
  }
}
The system validates that the number of messages in the sequence matches the calculated number of occurrences. Provide exactly the right number of messages or use a single message without asterisks.

Message Data Model

prisma/schema.prisma:112
model Message {
  id                String    @id @default(cuid())
  sessionId         String
  content           String
  scheduledAt       DateTime
  sentAt            DateTime?
  isPicked          Boolean   @default(false)
  retryCount        Int       @default(0)
  maxRetries        Int       @default(3)
  MessageCampaignId String
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @updatedAt
  isDeleted         Boolean   @default(false)
  isEdited          Boolean   @default(false)
  isSent            Boolean   @default(false)
  isFailed          Boolean   @default(false)
  failedReason      String?

  MessageCampaign MessageCampaign @relation(fields: [MessageCampaignId], references: [id], onDelete: Cascade)
}

Message States

1

Scheduled

Message created, waiting for scheduledAt time
  • isSent: false
  • isPicked: false
2

Picked for Delivery

Background worker has picked the message for processing
  • isPicked: true
  • isSent: false
3

Sent Successfully

Message delivered to WhatsApp
  • isSent: true
  • sentAt: DateTime
4

Failed

Delivery failed after retries
  • isFailed: true
  • failedReason: string

Retry Logic

Messages support automatic retry on failure:
retryCount: Int @default(0)
maxRetries: Int @default(3)
If delivery fails, the system will retry up to 3 times before marking the message as permanently failed.

Date Validation

The system validates date ranges before creating messages:
src/server/api/routers/messageCampaign.ts:59
if (!startDt.isValid || !endDt.isValid) {
  throw new Error("Invalid date format");
}

if (endDt < startDt) {
  throw new Error("End date must be after start date");
}
Dates must be provided in ISO 8601 format (YYYY-MM-DD). The system will reject invalid dates or end dates before start dates.

Dynamic Variables

Messages support template variables that are replaced during generation:
{days_left}
number
Replaced with the number of days remaining until campaign end

Variable Replacement

src/server/api/routers/messageCampaign.ts:128
messageContent += messageText?.replace(/{days_left}/g, daysLeft.toString());
Example:
Input Template:
"Only {days_left} days left to contribute!"

Generated Messages:
Day 1: "Only 4 days left to contribute!"
Day 2: "Only 3 days left to contribute!"
Day 3: "Only 2 days left to contribute!"
Day 4: "Only 1 days left to contribute!"
Day 5: "Only 0 days left to contribute!"

Best Practices

  • Always specify the timezone where your audience is located
  • Use IANA timezone identifiers (e.g., “America/New_York”, “Europe/London”)
  • Account for daylight saving time by using timezone-aware dates
  • Avoid scheduling messages during late night hours (10 PM - 7 AM)
  • Consider your audience’s time zone when selecting message times
  • Test with a small campaign before scheduling large-scale campaigns
  • Calculate required message count before creating sequences
  • Use asterisks (*) only when you want unique messages per occurrence
  • For repeating messages, use a single template without asterisks
  • DAILY: Best for time-sensitive campaigns (fundraisers, events)
  • WEEKLY: Good for regular updates and newsletters
  • MONTHLY: Ideal for membership reminders and billing
  • Choose based on audience engagement expectations

Common Use Cases

{
  startDate: "2026-03-10",
  endDate: "2026-03-20",
  messageTime: "09:00",
  timeZone: "America/New_York",
  isRecurring: true,
  recurrence: "DAILY",
  messageTemplate: "Don't forget to check in today!"
}
Sends the same message every day at 9 AM EST

Next Steps

Campaign Management

Learn how to create and manage campaigns

Notifications

Configure delivery notifications

Build docs developers (and LLMs) love