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
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 });
Convert to UTC
Scheduled times are stored in UTC for consistency const scheduledDateUtc = messageDate . toUTC (). toJSDate ();
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.
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" );
}
Recurrence Patterns
The system supports six recurrence patterns with automatic message generation:
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
Daily Campaign (March 1-5, 2026)
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
Weekly Campaign (March 1-31, 2026)
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
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
Scheduled
Message created, waiting for scheduledAt time
isSent: false
isPicked: false
Picked for Delivery
Background worker has picked the message for processing
isPicked: true
isSent: false
Sent Successfully
Message delivered to WhatsApp
isSent: true
sentAt: DateTime
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:
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
Scheduling Recommendations
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
Daily Reminders
Weekly Updates
One-Time Campaign
{
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 {
startDate : "2026-03-01" ,
endDate : "2026-03-31" ,
messageTime : "10:00" ,
timeZone : "America/Chicago" ,
isRecurring : true ,
recurrence : "WEEKLY" ,
messageTemplate : "Week 1 Update * Week 2 Update * Week 3 Update * Week 4 Update * Week 5 Update"
}
Sends unique messages each week {
startDate : "2026-03-15" ,
endDate : "2026-03-15" ,
messageTime : "14:00" ,
timeZone : "America/Los_Angeles" ,
isRecurring : false ,
messageTemplate : "Join us for our special event today!"
}
Sends a single message on the specified date
Next Steps
Campaign Management Learn how to create and manage campaigns
Notifications Configure delivery notifications