Skip to content

Commit

Permalink
Merge pull request #291 from TreeHacks/thijs/live-push-notifications
Browse files Browse the repository at this point in the history
[live] Push notification subscriptions
  • Loading branch information
tandpfun authored Dec 22, 2024
2 parents 1d7fbf0 + bd857bc commit a6b4d57
Show file tree
Hide file tree
Showing 6 changed files with 673 additions and 24,320 deletions.
11 changes: 11 additions & 0 deletions backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
setApplicationInfo,
submitApplicationInfo,
} from "./routes/application_info";
import { createEventPushSubscription, deleteEventPushSubscription, getEventSubscriptions } from "./routes/event_subscriptions"
import { getMeetInfo, setMeetInfo } from "./routes/meet_info";
import { getUsedMeals, setUsedMeals } from "./routes/used_meals";
import { getWorkshopList, setWorkshopList } from "./routes/workshop_info";
Expand Down Expand Up @@ -94,6 +95,11 @@ import {
getSponsorDetail,
createAdmin
} from "./routes/sponsors"
import LiveNotificationsService from "./services/live_notifications";

// Start the notification service
const notificationService = new LiveNotificationsService();
notificationService.start();

// Set up the Express app
const app = express();
Expand Down Expand Up @@ -149,6 +155,11 @@ apiRouter.get("/leaderboard", [anonymousRoute], leaderboard);
apiRouter.post("/mentor_create", [anonymousRoute], mentorCreate);
apiRouter.post("/sponsor/admin", createAdmin);

// Live push notifications, no auth required
apiRouter.post('/live/event_subscriptions', createEventPushSubscription);
apiRouter.delete('/live/event_subscriptions', deleteEventPushSubscription);
apiRouter.get('/live/event_subscriptions', getEventSubscriptions);

apiRouter.use("/", authenticatedRoute);

// Auth - user must be signed in:
Expand Down
29 changes: 29 additions & 0 deletions backend/models/LiveNotificationSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import mongoose from 'mongoose';
import { Model, Schema } from 'mongoose';
import { PushSubscription } from 'web-push';

// This model stores push notification subscriptions for use in TreeHacks Live.
interface LiveNotificationSubscription extends mongoose.Document {
_id: string;
eventId: string;
subscription: PushSubscription;
// Potentially add user or other fields here eventually.
}

const liveSubscriptionSchema: Schema = new mongoose.Schema({
eventId: String,
subscription: {
endpoint: String,
expirationTime: Number,
keys: {
p256dh: String,
auth: String,
},
},
});

const model: Model<LiveNotificationSubscription> = mongoose.model(
'LiveNotificationSubscription',
liveSubscriptionSchema
);
export default model;
86 changes: 86 additions & 0 deletions backend/routes/event_subscriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Push notifications for TreeHacks Live

import { Request, Response } from 'express';
import { PushSubscription } from 'web-push';
import axios from 'axios';
import LiveNotificationSubscription from '../models/LiveNotificationSubscription';
import { EventiveResponse } from '../services/live_notifications';

const EVENTS_API_URL = `https://api.eventive.org/event_buckets/${process.env.EVENTIVE_EVENT_BUCKET}/events?api_key=${process.env.EVENTIVE_API_KEY}`;

async function getEvent(eventId: string) {
const req = await axios.get<EventiveResponse>(EVENTS_API_URL);
const events = req.data.events;
return events.find((evt) => evt.id === eventId);
}

async function getSubscriptions(endpoint: string) {
const subscriptions = await LiveNotificationSubscription.find({
'subscription.endpoint': endpoint,
});
return subscriptions.map((sub) => sub.eventId);
}

export async function getEventSubscriptions(req: Request, res: Response) {
const endpoint = req.query.endpoint;

if (endpoint == null) {
return res.status(400).json({ error: 'Invalid subscription' });
}

const events = await getSubscriptions(endpoint);
return res.json(events);
}

export async function createEventPushSubscription(req: Request, res: Response) {
const sub: PushSubscription | null = req.body.subscription;
const eventId = req.body.eventId;

if (
sub == null ||
sub.endpoint == null ||
sub.keys == null ||
sub.keys.auth == null ||
sub.keys.p256dh == null ||
eventId == null
) {
return res.status(400).json({ error: 'Invalid subscription' });
}

const event = await getEvent(eventId);
if (!event) {
return res.status(400).json({ error: 'Event not found' });
}

const existingSub = await LiveNotificationSubscription.findOne({
'subscription.endpoint': sub.endpoint,
eventId,
});

if (existingSub != null) {
return res.status(400).json({ error: 'Subscription already exists' });
}

await new LiveNotificationSubscription({ subscription: sub, eventId }).save();

// Return the user's updated list of subscribed events
const events = await getSubscriptions(sub.endpoint);
return res.json({ subscriptions: events });
}

export async function deleteEventPushSubscription(req: Request, res: Response) {
const sub = req.body.subscription;
const eventId = req.body.eventId;

if (sub == null || sub.endpoint == null || eventId == null) {
return res.status(400).json({ error: 'Invalid subscription' });
}

await LiveNotificationSubscription.findOneAndDelete({
'subscription.endpoint': sub.endpoint,
eventId,
});

const events = await getSubscriptions(sub.endpoint);
return res.json({ subscriptions: events });
}
156 changes: 156 additions & 0 deletions backend/services/live_notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// The push notifications service for TreeHacks Live
// Sends a "begins in 5 minutes" and "starting now" notification for upcoming events

import axios from 'axios';
import { sendNotification, setVapidDetails } from 'web-push';
import LiveNotificationSubscription from '../models/LiveNotificationSubscription';

setVapidDetails(
'mailto:hello@treehacks.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);

export interface Event {
id: string;
name: string;
description: string;
start_time: string;
end_time: string;
location: string;
tags: string[];
updated_at: string;
}

interface NotificationQueueItem {
eventId: string;
time: number;
type: 'SOON' | 'NOW';
}

export interface EventiveResponse {
events: Event[];
}

const FIVE_MINUTES = 5 * 60 * 1000;

export default class LiveNotificationsService {
eventiveUrl: string;
events: Event[];
notificationQueue: NotificationQueueItem[];
timeoutId: NodeJS.Timeout | null;

constructor() {
this.eventiveUrl = `https://api.eventive.org/event_buckets/${process.env.EVENTIVE_EVENT_BUCKET}/events?api_key=${process.env.EVENTIVE_API_KEY}`;

this.events = [];
this.notificationQueue = [];
this.timeoutId = null;
}

async start() {
await this.fetchEvents();

// Fetch events every 10 minutes
setInterval(this.fetchEvents.bind(this), 2 * FIVE_MINUTES);
}

async fetchEvents() {
const req = await axios.get<EventiveResponse>(this.eventiveUrl);

if (req == null || req.status !== 200) {
return;
}

// The API returns the events in sorted order
const events = req.data.events;
this.events = events;

// Clear the existing notification queue
this.notificationQueue = [];

// Put all future events into the queue
const futureEvents = events.filter(
(evt) => new Date(evt.start_time) > new Date()
);

for (const evt of futureEvents) {
const startTime = new Date(evt.start_time).getTime();

// If there's more than 5 minutes until the event starts,
// enqueue the "starts in 5 minutes" notification
if (startTime - Date.now() > FIVE_MINUTES) {
this.notificationQueue.push({
eventId: evt.id,
time: startTime - FIVE_MINUTES,
type: 'SOON',
});
}

this.notificationQueue.push({
eventId: evt.id,
time: startTime,
type: 'NOW',
});
}

// Sort the notification queue
this.notificationQueue.sort((a, b) => a.time - b.time);

// Start the timeout for the next notification
this.startTimeout();
}

startTimeout() {
if (this.timeoutId != null) {
clearTimeout(this.timeoutId);
}

if (this.notificationQueue.length === 0) {
return;
}

const nextNotification = this.notificationQueue[0];
const delay = nextNotification.time - Date.now();

this.timeoutId = setTimeout(() => {
this.sendNotificationsForEvent(nextNotification);

// Remove the notification from the queue
this.notificationQueue.shift();

// Start the next timeout
this.startTimeout();
}, delay);
}

async sendNotificationsForEvent(notification: NotificationQueueItem) {
const event = this.events.find((evt) => evt.id === notification.eventId);

if (event == null) {
return;
}

// Get all devices subscribed to the event
const subscriptions = await LiveNotificationSubscription.find({
eventId: notification.eventId,
});
const data = {
title: event.name,
body: `is starting ${notification.type === 'SOON' ? 'in 5 min' : 'now'}${
event.location != null ? ` at ${event.location}` : ''
}.`,
};
const payload = JSON.stringify(data);

// Send all of the notification requests at once
const promises = subscriptions.map((sub) =>
sendNotification(sub.subscription, payload)
);
const result = await Promise.all(promises);
const failed = result.filter((r) => r.statusCode !== 201);
console.log(
`Sent ${promises.length} notifications for ${event.name}, ${failed.length} failed`
);
}
}
Loading

0 comments on commit a6b4d57

Please sign in to comment.