Skip to content
This repository was archived by the owner on Oct 14, 2025. It is now read-only.

Commit f606123

Browse files
authored
Merge pull request #297 from TreeHacks/thijs/live-admin-panel
[live] Admin panel section for live notifications
2 parents 6aa77b8 + 733e716 commit f606123

File tree

9 files changed

+306
-15
lines changed

9 files changed

+306
-15
lines changed

backend/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
setApplicationInfo,
2727
submitApplicationInfo,
2828
} from "./routes/application_info";
29-
import { createEventPushSubscription, deleteEventPushSubscription, getEventSubscriptions } from "./routes/event_subscriptions"
29+
import { createEventPushSubscription, deleteEventPushSubscription, getEventSubscriptions, getLiveStats, sendLiveNotification } from "./routes/event_subscriptions"
3030
import { getMeetInfo, setMeetInfo } from "./routes/meet_info";
3131
import { getMealInfo, setMealInfo } from "./routes/meal_info";
3232
import { getWorkshopList, setWorkshopList } from "./routes/workshop_info";
@@ -157,10 +157,12 @@ apiRouter.get("/leaderboard", [anonymousRoute], leaderboard);
157157
apiRouter.post("/mentor_create", [anonymousRoute], mentorCreate);
158158
apiRouter.post("/sponsor/admin", createAdmin);
159159

160-
// Live push notifications, no auth required
160+
// Live push notifications
161161
apiRouter.post('/live/event_subscriptions', createEventPushSubscription);
162162
apiRouter.delete('/live/event_subscriptions', deleteEventPushSubscription);
163163
apiRouter.get('/live/event_subscriptions', getEventSubscriptions);
164+
authenticatedRoute.get("/live/stats", [adminRoute], getLiveStats);
165+
authenticatedRoute.post("/live/notifications", [adminRoute], sendLiveNotification);
164166

165167
apiRouter.use("/", authenticatedRoute);
166168

backend/routes/event_subscriptions.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
// Push notifications for TreeHacks Live
22

33
import { Request, Response } from 'express';
4-
import { PushSubscription } from 'web-push';
4+
import webpush, { PushSubscription } from 'web-push';
55
import axios from 'axios';
6-
import LiveNotificationSubscription from '../models/LiveNotificationSubscription';
76
import { EventiveResponse } from '../services/live_notifications';
7+
import LiveNotificationSubscription from '../models/LiveNotificationSubscription';
8+
9+
webpush.setVapidDetails(
10+
'mailto:hello@treehacks.com',
11+
process.env.VAPID_PUBLIC_KEY,
12+
process.env.VAPID_PRIVATE_KEY
13+
);
814

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

11-
async function getEvent(eventId: string) {
17+
async function getEvents() {
1218
const req = await axios.get<EventiveResponse>(EVENTS_API_URL);
13-
const events = req.data.events;
19+
return req.data.events;
20+
}
21+
22+
async function getEvent(eventId: string) {
23+
const events = await getEvents();
1424
return events.find((evt) => evt.id === eventId);
1525
}
1626

@@ -84,3 +94,67 @@ export async function deleteEventPushSubscription(req: Request, res: Response) {
8494
const events = await getSubscriptions(sub.endpoint);
8595
return res.json({ subscriptions: events });
8696
}
97+
98+
// Admin endpoints
99+
100+
export interface LiveStats {
101+
numDevices: number;
102+
numSubscriptions: number;
103+
events: Array<{ id: string; name: string; numSubscriptions: number }>;
104+
}
105+
106+
export async function getLiveStats(req: Request, res: Response) {
107+
const subscriptions = await LiveNotificationSubscription.find();
108+
const numDevices = new Set(
109+
subscriptions.map((sub) => sub.subscription.endpoint)
110+
).size;
111+
const numSubscriptions = subscriptions.length;
112+
113+
const eventData = await getEvents();
114+
const groupedSubscriptions = await LiveNotificationSubscription.aggregate([
115+
{ $group: { _id: '$eventId', count: { $sum: 1 } } },
116+
]);
117+
118+
const eventStats = await Promise.all(
119+
groupedSubscriptions.map(async (event) => {
120+
const evt = eventData.find((e) => e.id === event._id);
121+
return evt != null
122+
? { id: evt.id, name: evt.name, numSubscriptions: event.count }
123+
: null;
124+
})
125+
).then((events) => events.filter((e) => e != null));
126+
127+
return res.json({ numDevices, numSubscriptions, events: eventStats });
128+
}
129+
130+
export async function sendLiveNotification(req: Request, res: Response) {
131+
const title = req.body.title;
132+
const body = req.body.body;
133+
134+
if (title == null || body == null) {
135+
return res.status(400).json({ error: 'Invalid notification' });
136+
}
137+
138+
const uniqueSubscriptions = await LiveNotificationSubscription.aggregate([
139+
{
140+
$group: {
141+
_id: '$subscription.endpoint',
142+
subscription: { $first: '$subscription' },
143+
},
144+
},
145+
]);
146+
147+
const payload = JSON.stringify({ title, body });
148+
149+
await Promise.all(
150+
uniqueSubscriptions.map(async (sub) => {
151+
try {
152+
await webpush.sendNotification(sub.subscription, payload);
153+
} catch (err) {
154+
console.error('Error sending notification', err);
155+
}
156+
})
157+
);
158+
159+
return res.json({ success: true });
160+
}

backend/services/live_notifications.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ export default class LiveNotificationsService {
113113
const nextNotification = this.notificationQueue[0];
114114
const delay = nextNotification.time - Date.now();
115115

116+
// If the event is more than 20 days in the future, don't schedule the notification, or the timeout will run instantly
117+
if (delay > 20 * 24 * 60 * 60 * 1000) {
118+
return;
119+
}
120+
116121
this.timeoutId = setTimeout(() => {
117122
this.sendNotificationsForEvent(nextNotification);
118123

src/Admin/Admin.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import React from 'react';
22
import { Route, Switch, Redirect } from 'react-router-dom';
3-
import { RoutedTabs, NavTab } from 'react-router-tabs';
4-
3+
import { NavTab } from 'react-router-tabs';
54
import 'react-router-tabs/styles/react-router-tabs.scss';
65
import Stats from './Stats';
76
import AdminTable from './AdminTable';
87
import BulkChange from './BulkChange';
9-
import BulkCreate from './BulkCreate';
8+
import BulkCreate from './BulkCreate';
109
import BulkImportHacks from './BulkImportHacks';
1110
import HackTable from './HackTable';
1211
import JudgeTable from './JudgeTable';
1312
import JudgeLeaderboard from './JudgeLeaderboard';
13+
import Live from './LiveNotifications';
1414

1515
const Admin = ({ match }) => {
1616
return (
@@ -23,20 +23,32 @@ const Admin = ({ match }) => {
2323
<NavTab to={`${match.path}/bulkcreate`}>Bulk Create Users</NavTab>
2424
{/* <NavTab to={`${match.path}/bulk_import_hacks`}>Bulk Import Hacks</NavTab> */}
2525
<NavTab to={`${match.path}/stats`}>Application Stats</NavTab>
26+
<NavTab to={`${match.path}/live`}>Live Notifications</NavTab>
2627

2728
<Switch>
28-
<Route exact path={`${match.path}`} render={() => <Redirect replace to={`${match.path}/table`} />} />
29+
<Route
30+
exact
31+
path={`${match.path}`}
32+
render={() => <Redirect replace to={`${match.path}/table`} />}
33+
/>
2934
<Route path={`${match.path}/table`} component={AdminTable} />
3035
<Route path={`${match.path}/stats`} component={Stats} />
3136
<Route path={`${match.path}/bulkchange`} component={BulkChange} />
3237
<Route path={`${match.path}/bulkcreate`} component={BulkCreate} />
3338
<Route path={`${match.path}/hack_table`} component={HackTable} />
34-
<Route path={`${match.path}/bulk_import_hacks`} component={BulkImportHacks} />
39+
<Route
40+
path={`${match.path}/bulk_import_hacks`}
41+
component={BulkImportHacks}
42+
/>
3543
<Route path={`${match.path}/judge_table`} component={JudgeTable} />
36-
<Route path={`${match.path}/judge_leaderboard`} component={JudgeLeaderboard} />
44+
<Route
45+
path={`${match.path}/judge_leaderboard`}
46+
component={JudgeLeaderboard}
47+
/>
48+
<Route path={`${match.path}/live`} component={Live} />
3749
</Switch>
3850
</div>
3951
);
4052
};
41-
42-
export default Admin;
53+
54+
export default Admin;

src/Admin/LiveNotifications.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React from 'react';
2+
import { ILiveNotificationsProps } from './types';
3+
import { IAdminState } from '../store/admin/types';
4+
import Loading from '../Loading/Loading';
5+
import { connect } from 'react-redux';
6+
import {
7+
getLiveStats,
8+
sendLiveNotification,
9+
setLiveNotificationData,
10+
} from '../store/admin/actions';
11+
import ReactTable from 'react-table';
12+
13+
const columns = [
14+
{
15+
Header: 'Event Id',
16+
accessor: 'id',
17+
},
18+
{
19+
Header: 'Event Name',
20+
accessor: 'name',
21+
},
22+
{
23+
Header: 'Subscriptions',
24+
accessor: 'numSubscriptions',
25+
},
26+
];
27+
28+
const LiveNotifications = (props: ILiveNotificationsProps) => {
29+
return (
30+
<div>
31+
<div className="row">
32+
<div className="form-group col-12 col-sm-6">
33+
<form
34+
onSubmit={(e) => {
35+
e.preventDefault();
36+
props.sendNotification();
37+
}}
38+
>
39+
<textarea
40+
required
41+
className="form-control"
42+
placeholder="Notification Title"
43+
value={props.liveNotification.title}
44+
onChange={(e) =>
45+
props.setNotificationData({
46+
title: e.target.value,
47+
body: props.liveNotification.body,
48+
})
49+
}
50+
></textarea>
51+
<textarea
52+
required
53+
className="form-control"
54+
placeholder="Notification Body"
55+
value={props.liveNotification.body}
56+
onChange={(e) =>
57+
props.setNotificationData({
58+
title: props.liveNotification.title,
59+
body: e.target.value,
60+
})
61+
}
62+
></textarea>
63+
<input className="form-control" type="submit" />
64+
</form>
65+
</div>
66+
<div className="col-12 col-sm-6">
67+
<small>
68+
<div>
69+
The input on the left will send a mass notification to all devices
70+
subscribed to any event notification via TreeHacks Live, the event
71+
schedule app. This is a good way to notify all attendees of a
72+
last-minute change or important information.
73+
</div>
74+
<hr />
75+
<div>
76+
<b>Total Devices:</b> {props.liveStats.numDevices}
77+
<br />
78+
<b>Total Subscriptions:</b> {props.liveStats.numSubscriptions}
79+
</div>
80+
</small>
81+
</div>
82+
</div>
83+
<div className="col-12">
84+
<ReactTable
85+
filterable
86+
columns={columns}
87+
data={props.liveStats.events}
88+
minRows={0}
89+
/>
90+
</div>
91+
</div>
92+
);
93+
};
94+
95+
const mapStateToProps = (state) => ({
96+
...(state.admin as IAdminState),
97+
});
98+
99+
const mapDispatchToProps = (dispatch, ownProps) => ({
100+
getLiveStats: () => dispatch(getLiveStats()),
101+
setNotificationData: (data: { title: string; body: string }) =>
102+
dispatch(setLiveNotificationData(data)),
103+
sendNotification: () => dispatch(sendLiveNotification()),
104+
});
105+
106+
class LiveNotificationsWrapper extends React.Component<
107+
ILiveNotificationsProps,
108+
{}
109+
> {
110+
componentDidMount() {
111+
this.props.getLiveStats();
112+
}
113+
render() {
114+
if (!this.props.liveStats) {
115+
return <Loading />;
116+
}
117+
return <LiveNotifications {...this.props} />;
118+
}
119+
}
120+
121+
export default connect(
122+
mapStateToProps,
123+
mapDispatchToProps
124+
)(LiveNotificationsWrapper);

src/Admin/types.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { IFormState } from "../store/form/types";
2-
import { IAdminState } from "src/store/admin/types";
2+
import { IAdminState } from '../store/admin/types';
33
import { getApplicationList, getExportedApplications } from "src/store/admin/actions";
44
import { IBaseState } from "src/store/base/types";
55

@@ -50,6 +50,11 @@ export interface IBulkImportHacksProps extends IAdminState {
5050
setBulkImportHacksFloor: (e: number) => void
5151
}
5252

53+
export interface ILiveNotificationsProps extends IAdminState {
54+
getLiveStats: () => void,
55+
sendNotification: () => void,
56+
setNotificationData: (data: {title: string, body: string}) => void,
57+
}
5358
export interface IReactTableHeader {
5459
Header?: string,
5560
id?: string,

src/store/admin/actions.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ export const setApplicationStats = (applicationStats) => ({
3838
applicationStats,
3939
});
4040

41+
export const setLiveStats = (liveStats) => ({
42+
type: "SET_LIVE_STATS",
43+
liveStats,
44+
});
45+
46+
export const setLiveNotificationData = (data: { title: string, body: string }) => ({
47+
type: "SET_LIVE_NOTIFICATION_DATA",
48+
data,
49+
});
50+
4151
export const getApplicationList = (tableState: IReactTableState) => (
4252
dispatch,
4353
getState
@@ -392,6 +402,39 @@ export const getApplicationStats = () => (dispatch, getState) => {
392402
});
393403
};
394404

405+
export const getLiveStats = () => (dispatch, getState) => {
406+
dispatch(loadingStart());
407+
return API.get("treehacks", `/live/stats`, {})
408+
.then((e) => {
409+
dispatch(setLiveStats(e));
410+
dispatch(loadingEnd());
411+
})
412+
.catch((e) => {
413+
console.error(e);
414+
dispatch(loadingEnd());
415+
alert("Error getting live stats " + e);
416+
});
417+
};
418+
419+
export const sendLiveNotification = () => (dispatch, getState) => {
420+
dispatch(loadingStart());
421+
const { title, body } = (getState().admin as IAdminState).liveNotification;
422+
return API.post("treehacks", `/live/notifications`, {
423+
body: {
424+
title,
425+
body,
426+
},
427+
})
428+
.then((e) => {
429+
dispatch(loadingEnd());
430+
})
431+
.catch((e) => {
432+
console.error(e);
433+
dispatch(loadingEnd());
434+
alert("Error sending notification " + e);
435+
});
436+
};
437+
395438
export const setBulkChangeStatus = (status) => ({
396439
type: "SET_BULK_CHANGE_STATUS",
397440
status,

0 commit comments

Comments
 (0)