Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cloud-functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from './auditLog';
export * from './attendanceStreak';
export * from './permanentCleanup';
export * from './volunteer';
export * from './retryDeadLetterEmail';
export * from './clubReputation';
export * from './onEventDelete';
export * from './events';
Expand Down
181 changes: 181 additions & 0 deletions cloud-functions/src/reminders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import * as admin from 'firebase-admin';

Check warning on line 1 in cloud-functions/src/reminders.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'admin' is defined but never used

Check warning on line 1 in cloud-functions/src/reminders.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'admin' is defined but never used

process.env.GCLOUD_PROJECT = 'demo-test';
process.env.FIREBASE_CONFIG = '{"projectId":"demo-test"}';

jest.mock('firebase-admin', () => {
const updateMock = jest.fn();
const setMock = jest.fn();
const queryGetMock = jest.fn();
const docGetMock = jest.fn();
const whereMock = jest.fn(() => ({
where: whereMock,
get: queryGetMock,
}));

const batchMock = {
set: setMock,
update: updateMock,
commit: jest.fn().mockResolvedValue(undefined),
};

const docRefMock = {
get: docGetMock,
collection: jest.fn(() => collectionRefMock),
update: jest.fn(),
};

const collectionRefMock = {
doc: jest.fn(() => docRefMock),
where: whereMock,
get: queryGetMock,
};

const firestoreInstance = {
collection: jest.fn(() => collectionRefMock),
batch: jest.fn(() => batchMock),
FieldValue: {
serverTimestamp: jest.fn(() => 'SERVER_TS'),
},
};

return {
apps: [],
initializeApp: jest.fn(),
firestore: jest.fn(() => firestoreInstance),
};
});

jest.mock('firebase-admin/firestore', () => ({
FieldValue: {
serverTimestamp: jest.fn(() => 'SERVER_TS'),
},
Timestamp: {
now: jest.fn(() => ({ toDate: () => new Date() })),
},
}));

jest.mock('expo-server-sdk', () => ({
Expo: { isExpoPushToken: jest.fn(() => false) },
}));

jest.mock('./utils/push', () => ({
sendPushNotifications: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('./utils/emailSender', () => ({
sendEmailWithRetry: jest.fn().mockResolvedValue({ success: true }),
}));

jest.mock('./lib/participants', () => ({
getParticipantContacts: jest.fn(),
}));

const { sendEmailWithRetry } = require('./utils/emailSender');
const { getParticipantContacts } = require('./lib/participants');
const { processDueReminders } = require('./reminders');
const adminMock = require('firebase-admin');

const remindersQueryGet = () => adminMock.firestore().collection('reminders').where().get;

const eventsDocGet = () => adminMock.firestore().collection('events').doc().get;

beforeEach(() => {
jest.clearAllMocks();
eventsDocGet().mockResolvedValue(makeEvent());
});

const makeReminder = data => ({
id: 'rem-1',
data: jest.fn(() => data),
ref: { id: 'rem-1' },
});

const makeEvent = (overrides = {}) => ({
exists: true,
data: () => ({
title: 'Hack Night',
startAt: '2026-09-01T10:00:00.000Z',
location: 'Auditorium',
eventMode: 'offline',
...overrides,
}),
});

beforeEach(() => {
jest.clearAllMocks();
});

describe('processDueReminders — email reminders (#671)', () => {
it('sends an email reminder to every participant email', async () => {
getParticipantContacts.mockResolvedValue([
{ id: 'p1', email: 'alice@example.com' },
{ id: 'p2', email: 'bob@example.com' },
]);
const reminder = makeReminder({ userId: 'u1', eventId: 'evt1', remindAt: {} });
remindersQueryGet().mockResolvedValue({ empty: false, docs: [reminder], size: 1 });
eventsDocGet().mockResolvedValue(makeEvent());

await processDueReminders(adminMock.firestore());

expect(sendEmailWithRetry).toHaveBeenCalledTimes(2);
expect(sendEmailWithRetry).toHaveBeenCalledWith(
expect.objectContaining({
to: 'alice@example.com',
subject: expect.stringContaining('Hack Night'),
templateName: 'universal_email_template',
}),
expect.objectContaining({ eventId: 'evt1', attempts: 3 }),
);
const updateCall = adminMock.firestore().batch().update.mock.calls[0];
expect(updateCall[1]).toMatchObject({ sent: true, emailed: true, emailCount: 2 });
});

it('does not resend when the reminder was already emailed', async () => {
const reminder = makeReminder({
userId: 'u1',
eventId: 'evt1',
remindAt: {},
emailed: true,
});
remindersQueryGet().mockResolvedValue({ empty: false, docs: [reminder], size: 1 });

await processDueReminders(adminMock.firestore());

expect(sendEmailWithRetry).not.toHaveBeenCalled();
const updateCall = adminMock.firestore().batch().update.mock.calls[0];
expect(updateCall[1]).toEqual({ sent: true });
});

it('skips emails when the event no longer exists', async () => {
getParticipantContacts.mockResolvedValue([{ id: 'p1', email: 'a@example.com' }]);
const reminder = makeReminder({ userId: 'u1', eventId: 'gone', remindAt: {} });
remindersQueryGet().mockResolvedValue({ empty: false, docs: [reminder], size: 1 });
eventsDocGet().mockResolvedValue({ exists: false });

await processDueReminders(adminMock.firestore());

expect(sendEmailWithRetry).not.toHaveBeenCalled();
});

it('filters malformed emails and computes the email count correctly', async () => {
getParticipantContacts.mockResolvedValue([
{ id: 'p1', email: 'ok@example.com' },
{ id: 'p2', email: 'not-an-email' },
{ id: 'p3' },
]);
const reminder = makeReminder({ userId: 'u1', eventId: 'evt1', remindAt: {} });
remindersQueryGet().mockResolvedValue({ empty: false, docs: [reminder], size: 1 });
eventsDocGet().mockResolvedValue(makeEvent());

await processDueReminders(adminMock.firestore());

expect(sendEmailWithRetry).toHaveBeenCalledTimes(1);
expect(sendEmailWithRetry).toHaveBeenCalledWith(
expect.objectContaining({ to: 'ok@example.com' }),
expect.anything(),
);
const updateCall = adminMock.firestore().batch().update.mock.calls[0];
expect(updateCall[1]).toMatchObject({ emailed: true, emailCount: 1 });
});
});
136 changes: 126 additions & 10 deletions cloud-functions/src/reminders.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,109 @@
import * as admin from 'firebase-admin';
import * as functions from 'firebase-functions';
import { FieldValue, Timestamp } from 'firebase-admin/firestore';
import * as ExpoSdk from 'expo-server-sdk';
import { sendPushNotifications } from './utils/push';
const { Expo } = require('expo-server-sdk');
import { sendEmailWithRetry } from './utils/emailSender';
import { getParticipantContacts } from './lib/participants';

const { Expo } = ExpoSdk;

const MAX_EMAILS_PER_RUN = 100;
const MAX_PARTICIPANTS_PER_EVENT = 100;

const WEBSITE_BASE_URL = process.env.WEBSITE_URL || 'https://unievent.web.app';

function formatEventDate(startAt: string | undefined): string {
if (!startAt) return 'soon';
const date = new Date(startAt);
if (Number.isNaN(date.getTime())) return 'soon';
return date.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

/**
* Scheduled function to check for reminders.
* Runs every minute.
* Sends email reminders for a due reminder to the event's registered
* participants (#671). Failures flow through the email retry/circuit-
* breaker/DLQ pipeline so nothing is silently lost.
*/
export const checkReminders = functions.pubsub.schedule('every 1 minutes').onRun(async context => {
const db = admin.firestore();
async function sendReminderEmails(
db: admin.firestore.Firestore,
reminder: admin.firestore.DocumentSnapshot,
eventCache: Map<string, admin.firestore.DocumentData | null>,
emailBudget: { remaining: number },
): Promise<{ sent: number; attempted: boolean }> {
const data = reminder.data() || {};
const eventId = data.eventId as string | undefined;

if (!eventId) {
return { sent: 0, attempted: false };
}
if (data.emailed === true || emailBudget.remaining <= 0) {
return { sent: 0, attempted: false };
}

if (!eventCache.has(eventId)) {
const eventSnap = await db.collection('events').doc(eventId).get();
eventCache.set(eventId, eventSnap.exists ? (eventSnap.data() ?? null) : null);
}
const event = eventCache.get(eventId) ?? null;
if (!event) {
return { sent: 0, attempted: false };
}

const title = event.title || 'Upcoming event';
const startAt = formatEventDate(event.startAt);
const location =
event.eventMode === 'online' ? event.meetLink || 'online' : event.location || 'TBD';

const participants = await getParticipantContacts(db, eventId);
const emails = [
...new Set(
participants
.slice(0, MAX_PARTICIPANTS_PER_EVENT)
.map(p => (typeof p.email === 'string' ? p.email.trim() : ''))
.filter(email => email.length > 0 && email.includes('@')),
),
].slice(0, emailBudget.remaining);

for (const email of emails) {
emailBudget.remaining -= 1;
await sendEmailWithRetry(
{
to: email,
subject: `⏰ Reminder: ${title} starts ${startAt}`,
templateName: 'universal_email_template',
templateData: {
subject: `${title} starts ${startAt}`,
to_name: '',
message: `Don't forget — <strong>${title}</strong> is starting ${startAt}.\n\nWhere: ${location}\n\nYou're registered, so we'll see you there!`,
event_title: title,
date: startAt,
event_link: `${WEBSITE_BASE_URL}/event/${eventId}`,
cert_display: 'none',
download_btn_display: 'none',
browse_btn_display: 'block',
},
},
{ eventId, attempts: 3 },
);
}

return { sent: emails.length, attempted: true };
}

/**
* Processes all due reminders: in-app + push notifications plus email
* reminders to registered participants (#671).
*
* Extracted from the scheduler so it can be unit-tested directly.
*/
export async function processDueReminders(db: admin.firestore.Firestore): Promise<number> {
const now = Timestamp.now();

// Find reminders that need to be sent (remindAt <= now) and haven't been sent yet
Expand All @@ -19,11 +113,13 @@ export const checkReminders = functions.pubsub.schedule('every 1 minutes').onRun
const snapshot = await q.get();

if (snapshot.empty) {
return null;
return 0;
}

const batch = db.batch();
const messages = [];
const eventCache = new Map();
const emailBudget = { remaining: MAX_EMAILS_PER_RUN };

// We need to fetch user tokens
// To handle many reminders, we might need efficient querying, but loop is fine for now
Expand Down Expand Up @@ -58,8 +154,20 @@ export const checkReminders = functions.pubsub.schedule('every 1 minutes').onRun
}
}

// 3. Mark reminder as sent
batch.update(docSnapshot.ref, { sent: true });
// 3. Email reminder to registered participants (#671)
const emailResult = await sendReminderEmails(db, docSnapshot, eventCache, emailBudget);

// 4. Mark reminder as sent
batch.update(docSnapshot.ref, {
sent: true,
...(emailResult.attempted
? {
emailed: true,
emailedAt: FieldValue.serverTimestamp(),
emailCount: emailResult.sent,
}
: {}),
});
}

// Send Pushes
Expand All @@ -69,5 +177,13 @@ export const checkReminders = functions.pubsub.schedule('every 1 minutes').onRun

await batch.commit();
console.log(`Processed ${snapshot.size} reminders.`);
return null;
});
return snapshot.size;
}

/**
* Scheduled function to check for reminders.
* Runs every minute.
*/
export const checkReminders = functions.pubsub
.schedule('every 1 minutes')
.onRun(async () => processDueReminders(admin.firestore()));
Loading
Loading