Skip to content

Commit e5f065b

Browse files
authored
Merge pull request #254 from ReflectionsProjections/new-stats-endpoints
New stats endpoints
2 parents 8a188fb + 5a0ae84 commit e5f065b

File tree

2 files changed

+242
-1
lines changed

2 files changed

+242
-1
lines changed

src/services/stats/stats-router.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,3 +845,147 @@ describe("GET /stats/dietary-restrictions", () => {
845845
);
846846
});
847847
});
848+
849+
describe("GET /stats/registrations", () => {
850+
beforeEach(async () => {
851+
await SupabaseDB.REGISTRATIONS.delete().neq("userId", "non-existent");
852+
await SupabaseDB.AUTH_INFO.delete().neq("userId", "non-existent");
853+
854+
await SupabaseDB.AUTH_INFO.insert([AUTH_INFO_RITAM, AUTH_INFO_NATHAN]);
855+
await SupabaseDB.REGISTRATIONS.insert([
856+
{
857+
userId: "a1",
858+
name: "Ritam",
859+
email: "ritam@test.com",
860+
school: "University of Illinois",
861+
educationLevel: "Computer Science",
862+
graduationYear: "2025",
863+
majors: ["Computer Science"],
864+
dietaryRestrictions: [],
865+
allergies: [],
866+
gender: "Prefer not to say",
867+
ethnicity: [],
868+
howDidYouHear: [],
869+
personalLinks: [],
870+
opportunities: [],
871+
isInterestedMechMania: false,
872+
isInterestedPuzzleBang: false,
873+
},
874+
{
875+
userId: "a2",
876+
name: "Nathan",
877+
email: "nathan@test.com",
878+
school: "University of Illinois",
879+
educationLevel: "Computer Science",
880+
graduationYear: "2024",
881+
majors: ["Computer Science"],
882+
dietaryRestrictions: [],
883+
allergies: [],
884+
gender: "Prefer not to say",
885+
ethnicity: [],
886+
howDidYouHear: [],
887+
personalLinks: [],
888+
opportunities: [],
889+
isInterestedMechMania: false,
890+
isInterestedPuzzleBang: false,
891+
},
892+
]);
893+
});
894+
895+
it("should return the correct count of total registrations", async () => {
896+
const response = await getAsStaff("/stats/registrations").expect(
897+
StatusCodes.OK
898+
);
899+
expect(response.body.count).toBe(2);
900+
});
901+
902+
it("should return 0 when no registrations exist", async () => {
903+
await SupabaseDB.REGISTRATIONS.delete().neq("userId", "non-existent");
904+
const response = await getAsStaff("/stats/registrations").expect(
905+
StatusCodes.OK
906+
);
907+
expect(response.body.count).toBe(0);
908+
});
909+
});
910+
911+
describe("GET /stats/event/:EVENT_ID/attendance", () => {
912+
beforeEach(async () => {
913+
await SupabaseDB.EVENTS.delete().neq("eventId", uuidv4());
914+
await SupabaseDB.EVENTS.insert(EVENT_1);
915+
});
916+
917+
it("should return the attendance count for a specific event", async () => {
918+
const response = await getAsStaff(
919+
`/stats/event/${EVENT_1.eventId}/attendance`
920+
).expect(StatusCodes.OK);
921+
expect(response.body.attendanceCount).toBe(EVENT_1.attendanceCount);
922+
});
923+
924+
it("should return 404 for a non-existent event", async () => {
925+
await getAsStaff(`/stats/event/${uuidv4()}/attendance`).expect(
926+
StatusCodes.NOT_FOUND
927+
);
928+
});
929+
930+
it("should return 400 for an invalid event ID format", async () => {
931+
await getAsStaff("/stats/event/not-a-uuid/attendance").expect(
932+
StatusCodes.BAD_REQUEST
933+
);
934+
});
935+
});
936+
937+
describe("GET /stats/tier-counts", () => {
938+
beforeEach(async () => {
939+
await SupabaseDB.ATTENDEES.delete().neq("userId", "non-existent");
940+
await SupabaseDB.AUTH_INFO.delete().neq("userId", "non-existent");
941+
942+
await SupabaseDB.AUTH_INFO.insert([
943+
AUTH_INFO_RITAM,
944+
AUTH_INFO_NATHAN,
945+
AUTH_INFO_TIMOTHY,
946+
]);
947+
// Setup attendees with different tiers
948+
await SupabaseDB.ATTENDEES.insert([
949+
{ ...ATTENDEE_RITAM, currentTier: Tiers.Enum.TIER1 },
950+
{ ...ATTENDEE_NATHAN, currentTier: Tiers.Enum.TIER2 },
951+
{ ...ATTENDEE_TIMOTHY, currentTier: Tiers.Enum.TIER1 },
952+
]);
953+
});
954+
955+
it("should return the count of attendees in each tier", async () => {
956+
const response = await getAsStaff("/stats/tier-counts").expect(
957+
StatusCodes.OK
958+
);
959+
expect(response.body).toEqual({
960+
TIER1: 2,
961+
TIER2: 1,
962+
TIER3: 0,
963+
TIER4: 0,
964+
});
965+
});
966+
});
967+
968+
describe("GET /stats/tag-counts", () => {
969+
beforeEach(async () => {
970+
await SupabaseDB.ATTENDEES.delete().neq("userId", "non-existent");
971+
await SupabaseDB.AUTH_INFO.delete().neq("userId", "non-existent");
972+
973+
await SupabaseDB.AUTH_INFO.insert([AUTH_INFO_RITAM, AUTH_INFO_NATHAN]);
974+
// Setup attendees with overlapping tags
975+
await SupabaseDB.ATTENDEES.insert([
976+
{ ...ATTENDEE_RITAM, tags: ["AI", "career_readiness"] },
977+
{ ...ATTENDEE_NATHAN, tags: ["AI", "networking"] },
978+
]);
979+
});
980+
981+
it("should return the count of each tag used by attendees", async () => {
982+
const response = await getAsStaff("/stats/tag-counts").expect(
983+
StatusCodes.OK
984+
);
985+
expect(response.body).toEqual({
986+
AI: 2,
987+
career_readiness: 1,
988+
networking: 1,
989+
});
990+
});
991+
});

src/services/stats/stats-router.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Router } from "express";
22
import { StatusCodes } from "http-status-codes";
3-
import { SupabaseDB } from "../../database";
3+
import { SupabaseDB, TierType } from "../../database";
44
import RoleChecker from "../../middleware/role-checker";
55
import { Role } from "../auth/auth-models";
66
import { getCurrentDay } from "../checkin/checkin-utils";
@@ -222,4 +222,101 @@ statsRouter.get(
222222
}
223223
);
224224

225+
// get the number of registrations
226+
statsRouter.get(
227+
"/registrations",
228+
RoleChecker([Role.enum.STAFF], false),
229+
async (req, res) => {
230+
const { count } = await SupabaseDB.REGISTRATIONS.select("*", {
231+
count: "exact",
232+
head: true,
233+
}).throwOnError();
234+
235+
return res.status(StatusCodes.OK).json({ count: count || 0 });
236+
}
237+
);
238+
239+
// event attendance at a specific event
240+
statsRouter.get(
241+
"/event/:EVENT_ID/attendance",
242+
RoleChecker([Role.enum.STAFF], false),
243+
async (req, res) => {
244+
const schema = z.object({
245+
EVENT_ID: z.string().uuid(),
246+
});
247+
248+
const result = schema.safeParse(req.params);
249+
if (!result.success) {
250+
return res.status(StatusCodes.BAD_REQUEST).json({
251+
error: result.error.errors[0].message,
252+
});
253+
}
254+
const eventId = result.data.EVENT_ID;
255+
256+
const { data: event } = await SupabaseDB.EVENTS.select(
257+
"attendanceCount"
258+
)
259+
.eq("eventId", eventId)
260+
.maybeSingle()
261+
.throwOnError();
262+
263+
if (!event) {
264+
return res
265+
.status(StatusCodes.NOT_FOUND)
266+
.json({ error: "Event not found" });
267+
}
268+
269+
return res
270+
.status(StatusCodes.OK)
271+
.json({ attendanceCount: event.attendanceCount });
272+
}
273+
);
274+
275+
// Number of people at each tier
276+
statsRouter.get(
277+
"/tier-counts",
278+
RoleChecker([Role.enum.STAFF], false),
279+
async (req, res) => {
280+
const { data } = await SupabaseDB.ATTENDEES.select("currentTier", {
281+
count: "exact",
282+
}).throwOnError();
283+
284+
// Aggregate counts for each tier
285+
const tierCounts: Record<TierType, number> = {
286+
TIER1: 0,
287+
TIER2: 0,
288+
TIER3: 0,
289+
TIER4: 0,
290+
};
291+
data?.forEach((attendee: { currentTier: TierType }) => {
292+
if (attendee.currentTier) {
293+
tierCounts[attendee.currentTier] =
294+
(tierCounts[attendee.currentTier] || 0) + 1;
295+
}
296+
});
297+
298+
return res.status(StatusCodes.OK).json(tierCounts);
299+
}
300+
);
301+
302+
// Number of people who marked each tag
303+
statsRouter.get(
304+
"/tag-counts",
305+
RoleChecker([Role.enum.STAFF], false),
306+
async (req, res) => {
307+
const { data } =
308+
await SupabaseDB.ATTENDEES.select("tags").throwOnError();
309+
310+
// Aggregate counts for each tag
311+
const tagCounts: Record<string, number> = {};
312+
data?.forEach((attendee: { tags: string[] }) => {
313+
attendee.tags?.forEach((tag: string) => {
314+
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
315+
});
316+
});
317+
318+
return res.status(StatusCodes.OK).json(tagCounts);
319+
}
320+
);
321+
225322
export default statsRouter;

0 commit comments

Comments
 (0)