Skip to content

Commit

Permalink
feat: ✨ add study room scraper and availability endpoint (#143)
Browse files Browse the repository at this point in the history
Co-authored-by: Sanskar Mishra <[email protected]>
  • Loading branch information
cokwong and Sanskar Mishra authored May 20, 2024
1 parent 0b16a9c commit cf565b3
Show file tree
Hide file tree
Showing 23 changed files with 815 additions and 2 deletions.
13 changes: 12 additions & 1 deletion apps/api/bronya.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { App, Stack, Duration } from "aws-cdk-lib/core";
import { config } from "dotenv";
import type { BuildOptions } from "esbuild";

import { normalizeCourse } from "./src/lib/utils";
import { normalizeCourse, normalizeStudyRoom } from "./src/lib/utils";

const prisma = new PrismaClient();

Expand Down Expand Up @@ -121,6 +121,10 @@ export const esbuildOptions: BuildOptions = {
path: args.path,
namespace,
}));
build.onResolve({ filter: /virtual:studyRooms/ }, (args) => ({
path: args.path,
namespace,
}));
build.onLoad({ filter: /virtual:courses/, namespace }, async () => ({
contents: `export const courses = ${JSON.stringify(
Object.fromEntries(
Expand All @@ -133,6 +137,13 @@ export const esbuildOptions: BuildOptions = {
Object.fromEntries((await prisma.instructor.findMany()).map((x) => [x.ucinetid, x])),
)}`,
}));
build.onLoad({ filter: /virtual:studyRooms/, namespace }, async () => ({
contents: `export const studyRooms = ${JSON.stringify(
Object.fromEntries(
(await prisma.studyRoom.findMany()).map(normalizeStudyRoom).map((x) => [x.id, x]),
),
)}`,
}));
},
},
],
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ declare module "virtual:instructors" {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
declare const instructors: Record<string, import("@peterportal-api/types").Instructor>;
}

declare module "virtual:studyRooms" {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
declare const studyRooms: Record<string, import("@peterportal-api/types").StudyRoom>;
}
15 changes: 14 additions & 1 deletion apps/api/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Course as PrismaCourse } from "@libs/db";
import type { Course as PrismaCourse, StudyRoom as PrismaStudyRoom } from "@libs/db";
import type {
Course,
CourseLevel,
CoursePreview,
GECategory,
InstructorPreview,
PrerequisiteTree,
StudyRoom,
} from "@peterportal-api/types";

const days = ["Su", "M", "Tu", "W", "Th", "F", "Sa"];
Expand Down Expand Up @@ -82,3 +83,15 @@ export function normalizeCourse(course: PrismaCourse): Course {
terms: course.terms,
};
}

export function normalizeStudyRoom(room: PrismaStudyRoom): StudyRoom {
return {
id: room.id,
name: room.name,
capacity: room.capacity,
location: room.location,
description: room.description,
directions: room.directions,
techEnhanced: room.techEnhanced,
};
}
2 changes: 2 additions & 0 deletions apps/api/src/routes/v1/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const resolvers: ApolloServerOptions<BaseContext>["resolvers"] = {
instructors: proxyRestApi("/v1/rest/instructors"),
allInstructors: proxyRestApi("/v1/rest/instructors/all"),
larc: proxyRestApi("/v1/rest/larc"),
studyRooms: proxyRestApi("/v1/rest/studyrooms"),
allStudyRooms: proxyRestApi("/v1/rest/studyrooms/all"),
websoc: proxyRestApi("/v1/rest/websoc", { argsTransform: geTransform }),
depts: proxyRestApi("/v1/rest/websoc/depts"),
terms: proxyRestApi("/v1/rest/websoc/terms"),
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/routes/v1/graphql/schema/studyRooms.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
type TimeSlot {
"Date of the time slot (YYYY-MM-DD)."
date: String!
"Start time of the time slot (HH:MM)."
start: String!
"End time of the time slot (HH:MM)."
end: String!
"If the time slot is booked."
booked: Boolean!
}

type StudyRoom {
"ID of study room used by spaces.lib."
id: ID!
"Name of the study room and its room number."
name: String!
"Number of chairs in the study room."
capacity: Int!
"Name of study location."
location: String!
"Description of the study room."
description: String
"Directions to the study room."
directions: String
"Time slots for the study room."
timeSlots: [TimeSlot]!
"If the study room has TV or other tech enhancements."
techEnhanced: Boolean
}

type StudyLocation {
"ID of the study location using shortened name of the location."
id: ID!
"Location ID of the study location used by space.lib."
lid: String!
"Name of the study location."
name: String!
"Rooms in the study location."
rooms: [StudyRoom!]!
}

extend type Query {
"Fetch all study rooms."
allStudyRooms(start: String!, end: String!): [StudyLocation!]!
studyRooms(location: String!, start: String!, end: String!): StudyLocation!
}
30 changes: 30 additions & 0 deletions apps/api/src/routes/v1/rest/studyRooms/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createHandler } from "@libs/lambda";
import { studyLocations } from "libs/uc-irvine-lib/src/spaces";
import { ZodError } from "zod";

import { aggregateStudyRooms } from "./lib";
import { QuerySchema } from "./schema";

export const GET = createHandler(async (event, context, res) => {
const headers = event.headers;
const query = event.queryStringParameters;
const requestId = context.awsRequestId;
try {
const parsedQuery = QuerySchema.parse(query);
if (!studyLocations[parsedQuery.location]) {
return res.createErrorResult(404, `Location ${parsedQuery.location} not found`, requestId);
}
const studyRooms = await aggregateStudyRooms(
parsedQuery.location,
parsedQuery.start,
parsedQuery.end,
);
return res.createOKResult(studyRooms, headers, requestId);
} catch (e) {
if (e instanceof ZodError) {
const messages = e.issues.map((issue) => issue.message);
return res.createErrorResult(400, messages.join("; "), requestId);
}
return res.createErrorResult(400, e, requestId);
}
});
57 changes: 57 additions & 0 deletions apps/api/src/routes/v1/rest/studyRooms/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { TimeSlot, StudyLocation } from "@peterportal-api/types";
import { studyLocations } from "libs/uc-irvine-lib/src/spaces";
import { getStudySpaces } from "libs/uc-irvine-lib/src/spaces";
import { studyRooms } from "virtual:studyRooms";

/**
* Data structure of time slots returned by libs.spaces.
*/
type Slot = {
start: string;
end: string;
itemId: number;
checkSum: string;
className: string;
};

/**
* Map time slots to a more readable format.
*/
export function parseTimeSlots(slots: Slot[]): { [id: string]: TimeSlot[] } {
const timeSlots: { [id: string]: TimeSlot[] } = {};
slots.forEach((slot) => {
const roomId = slot.itemId.toString();
const [date, start] = slot.start.split(" ");
const [_, end] = slot.end.split(" ");
const timeSlot: TimeSlot = {
date,
start,
end,
booked: !!slot.className && slot.className === "s-lc-eq-checkout",
};
timeSlots[roomId] ??= []
timeSlots[roomId].push(timeSlot)
});
return timeSlots;
}

/**
* Aggregate study rooms and their time slots into a StudyLocation object.
*/
export async function aggregateStudyRooms(
locationId: string,
start: string,
end: string,
): Promise<StudyLocation> {
const spaces = await getStudySpaces(studyLocations[locationId].lid, start, end);
const timeSlotsMap = parseTimeSlots(spaces.slots);
return {
id: locationId,
...studyLocations[locationId],
rooms: Object.entries(timeSlotsMap)
.filter(([id, _]) => studyRooms[id] != null)
.map(([id, timeSlots]) => {
return { ...studyRooms[id], timeSlots };
}),
};
}
13 changes: 13 additions & 0 deletions apps/api/src/routes/v1/rest/studyRooms/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from "zod";

export const QuerySchema = z.object({
location: z.string({ required_error: 'Parameter "location" not provided' }),
start: z
.string({ required_error: 'Parameter "start" not provided' })
.regex(/^\d{4}-\d{2}-\d{2}$/, { message: "Start date must be in YYYY-MM-DD format" }),
end: z
.string({ required_error: 'Parameter "end" not provided' })
.regex(/^\d{4}-\d{2}-\d{2}$/, { message: "End date must be in YYYY-MM-DD format" }),
});

export type Query = z.infer<typeof QuerySchema>;
11 changes: 11 additions & 0 deletions apps/api/src/routes/v1/rest/studyRooms/{id}/+config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ApiPropsOverride } from "@bronya.js/api-construct";

import { esbuildOptions, constructs } from "../../../../../../bronya.config";

export const overrides: ApiPropsOverride = {
esbuild: esbuildOptions,
constructs: {
functionPlugin: constructs.functionPlugin,
restApiProps: constructs.restApiProps,
},
};
47 changes: 47 additions & 0 deletions apps/api/src/routes/v1/rest/studyRooms/{id}/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createHandler } from "@libs/lambda";
import { studyLocations } from "libs/uc-irvine-lib/src/spaces";
import { ZodError } from "zod";

import { aggregateStudyRooms } from "../lib";

import { QuerySchema } from "./schema";

export const GET = createHandler(async (event, context, res) => {
const headers = event.headers;
const query = event.queryStringParameters;
const requestId = context.awsRequestId;
const { id } = event.pathParameters ?? {};
try {
switch (id) {
case null:
case undefined:
return res.createErrorResult(400, "Location not provided", requestId);
case "all": {
const parsedQuery = QuerySchema.parse(query);
return res.createOKResult(
await Promise.all(
Object.keys(studyLocations).map(async (locationId) => {
return aggregateStudyRooms(locationId, parsedQuery.start, parsedQuery.end);
}),
),
headers,
requestId,
);
}
default: {
if (studyLocations[id]) {
const parsedQuery = QuerySchema.parse(query);
const studyRooms = await aggregateStudyRooms(id, parsedQuery.start, parsedQuery.end);
return res.createOKResult(studyRooms, headers, requestId);
}
return res.createErrorResult(400, `Location ${id} not found`, requestId);
}
}
} catch (e) {
if (e instanceof ZodError) {
const messages = e.issues.map((issue) => issue.message);
return res.createErrorResult(400, messages.join("; "), requestId);
}
return res.createErrorResult(400, e, requestId);
}
});
12 changes: 12 additions & 0 deletions apps/api/src/routes/v1/rest/studyRooms/{id}/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from "zod";

export const QuerySchema = z.object({
start: z
.string({ required_error: 'Parameter "start" not provided' })
.regex(/^\d{4}-\d{2}-\d{2}$/, { message: "Start date must be in YYYY-MM-DD format" }),
end: z
.string({ required_error: 'Parameter "end" not provided' })
.regex(/^\d{4}-\d{2}-\d{2}$/, { message: "End date must be in YYYY-MM-DD format" }),
});

export type Query = z.infer<typeof QuerySchema>;
Loading

0 comments on commit cf565b3

Please sign in to comment.