When we sat down to spec birthday reminders, the first thing we threw out was the obvious approach: ask the user's OS to fire a local notification on the right date. It works, but it's fragile — depends on the device being on, the app being installed, the user not having cleared notification permissions. And it scales badly to multi-device users.
Instead we put the logic on the server. One cron job, runs once a day, queries who has a birthday, and pushes APNs notifications to everyone in their circle.
The cron
// NestJS @nestjs/schedule
@Cron('0 9 * * *', { name: 'birthday-reminders' })
async sendDailyBirthdayReminders(): Promise<void> {
this.logger.log('🎂 Running daily birthday reminders cron');
const sent = await this.runReminders();
this.logger.log(`🎂 Done — ${sent} push(es) sent`);
}Pattern 0 9 * * * = 9:00 every day, server timezone. Default Docker is UTC. For a Vietnam-based service, we set TZ=Asia/Ho_Chi_Minh in docker-compose.yml so it fires at 9am Hanoi time, not 4pm.
The query
MongoDB aggregation operators $month and $dayOfMonth let us match by MM-DD without storing month/day separately:
const now = new Date();
const month = now.getMonth() + 1;
const day = now.getDate();
const birthdayUsers = await this.userModel.find({
birthday: { $exists: true, $ne: null },
$expr: {
$and: [
{ $eq: [{ $month: '$birthday' }, month] },
{ $eq: [{ $dayOfMonth: '$birthday' }, day] },
],
},
// Privacy filter — opt-out users not surfaced
$or: [
{ 'privacySettings.shareBirthday': { $exists: false } },
{ 'privacySettings.shareBirthday': 'contacts' },
],
}).select('_id displayName').lean();The privacy filter is the important part. Users can opt out of sharing their birthday in PrivacySettingsView. We honor that at the query level — they're excluded entirely, not just masked at the response layer. No one gets a push about their birthday. They become invisible.
Fan-out
For each user with a birthday today, we query the reverse contact graph — who has added them as a contact — and push to all those owners:
for (const user of birthdayUsers) {
const contacts = await this.contactModel
.find({ contact: user._id })
.select('owner')
.lean();
if (contacts.length === 0) continue;
const ownerIds = contacts.map((c) => c.owner.toString());
await this.apns.sendCustomPush(
ownerIds,
'Sinh nhật người thân',
`🎂 Hôm nay là sinh nhật của ${user.displayName}`,
{ type: 'birthday', userId: user._id.toString() },
new Set(), // empty — push everyone, online or not
);
}Deep link routing
The push payload includes type: 'birthday'so the iOS client knows to open the contact's profile, not start a chat. In NotificationService:
// iOS
switch userInfo["type"] as? String {
case "birthday":
if let userId = userInfo["userId"] as? String {
NotificationCenter.default.post(
name: .openContact,
userInfo: ["userId": userId]
)
}
default:
// legacy / new_message — open chat
...
}The user lands on the contact's profile screen. From there they can voice-call, video-call, or tap "message" to send a wish. We don't auto-compose a template message — the whole point of an intimate app is that the wish is yours.
What we deliberately didn't build
- Year-of-birth display. We store the birthday Date but only render month/day. Age is none of the receiver's business unless the person tells them.
- Birthday countdown stories. No 7-day countdown stream. Just one push, on the day. Anything more is platform performance theater.
- Public birthday board. No tab listing "upcoming birthdays this week." The push is enough. We don't need to make a feature spectacle out of it.
Build target: TestFlight. We'll watch open rates and tap-throughs to see if "one push, one day" is enough, or if we need to soften it further (e.g. opt-in per contact). For now, the simplest thing that could possibly work.