Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ✨ add study room scraper and availability endpoint #143

Merged
merged 16 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
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 @@ -23,6 +23,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 { aggreagteStudyRooms } from "./lib";
import { Query, QuerySchema } from "./schema";

export const GET = createHandler(async (event, context, res) => {
const headers = event.headers;
const query = event.queryStringParameters;
const requestId = context.awsRequestId;
let parsedQuery: Query;
try {
parsedQuery = QuerySchema.parse(query);
if (!studyLocations[parsedQuery.location]) {
return res.createErrorResult(404, `Location ${parsedQuery.location} not found`, requestId);
}
return res.createOKResult(
await aggreagteStudyRooms(parsedQuery.location, parsedQuery.start, parsedQuery.end),
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);
cokwong marked this conversation as resolved.
Show resolved Hide resolved
}
});
60 changes: 60 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,60 @@
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",
};
if (!timeSlots[roomId]) {
timeSlots[roomId] = [timeSlot];
} else {
timeSlots[roomId].push(timeSlot);
}
});
cokwong marked this conversation as resolved.
Show resolved Hide resolved
return timeSlots;
}

/**
* Aggregate study rooms and their time slots into a StudyLocation object.
*/
export async function aggreagteStudyRooms(
cokwong marked this conversation as resolved.
Show resolved Hide resolved
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])
cokwong marked this conversation as resolved.
Show resolved Hide resolved
.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,
},
};
41 changes: 41 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,41 @@
import { createHandler } from "@libs/lambda";
import { studyLocations } from "libs/uc-irvine-lib/src/spaces";
import { ZodError } from "zod";

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

import { Query, 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 ?? {};
let parsedQuery: Query;
try {
switch (id) {
case null:
case undefined:
return res.createErrorResult(400, "Location not provided", requestId);
case "all":
parsedQuery = QuerySchema.parse(query);
cokwong marked this conversation as resolved.
Show resolved Hide resolved
return res.createOKResult(
await Promise.all(
Object.keys(studyLocations).map(async (locationId) => {
return aggreagteStudyRooms(locationId, parsedQuery.start, parsedQuery.end);
}),
),
headers,
requestId,
);
default:
cokwong marked this conversation as resolved.
Show resolved Hide resolved
return res.createErrorResult(400, "Invalid endpoint", 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
Loading