From c1c90a025dbea8ca6e2e57ebfd72dea796e24d3c Mon Sep 17 00:00:00 2001 From: Rei <38581479+Rei-x@users.noreply.github.com> Date: Mon, 9 Sep 2024 18:13:51 +0200 Subject: [PATCH] feat: add api for courses (#44) * feat:add plan sharing * feat:add plan copying * fix: types * fix: fix scrollbars * fix: fix styling on share plan page * feat: add responsive share link dialog * fix: fix input props component error * fix: fix drawer styling * fix: maybe fix for olo * fix: umami can be undefined * feat: add link to helpform * feat: add api for courses * feat: inform about project deploy * fix: add padding to mobile ending quote * feat: add new darker background * feat: change design for plan view * fix: disable hover on mai nbutton --------- Co-authored-by: unewMe <56974648+unewMe@users.noreply.github.com> --- package-lock.json | 41 ++++++++++++++++-- package.json | 1 + public/copy.svg | 1 + src/app/api/data/[facultyId]/route.ts | 36 ++++++++++++++++ src/app/api/profile/route.ts | 10 ----- src/services/usos/getRegistrations.ts | 44 +++++++++++++++++++ src/services/usos/index.ts | 61 +++++++++++++++++++++++++-- src/services/usos/usosClient.ts | 19 ++++++++- 8 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 public/copy.svg create mode 100644 src/app/api/data/[facultyId]/route.ts delete mode 100644 src/app/api/profile/route.ts create mode 100644 src/services/usos/getRegistrations.ts diff --git a/package-lock.json b/package-lock.json index d70f0be..95dbfc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -22,6 +23,7 @@ "fetch-cookie": "^3.0.1", "framer-motion": "^11.3.28", "jotai": "^2.9.3", + "lru-cache": "^11.0.1", "lucide-react": "^0.426.0", "next": "^14.2.3", "next-sitemap": "^4.2.3", @@ -33,6 +35,7 @@ "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.2", + "vaul": "^0.9.2", "zod": "^3.23.8" }, "devDependencies": { @@ -3101,6 +3104,30 @@ } } }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", + "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", @@ -8385,12 +8412,12 @@ } }, "node_modules/lru-cache": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", - "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.1.tgz", + "integrity": "sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==", "license": "ISC", "engines": { - "node": "14 || >=16.14" + "node": "20 || >=22" } }, "node_modules/lucide-react": { @@ -9051,6 +9078,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", diff --git a/package.json b/package.json index d5833e2..ace0124 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "fetch-cookie": "^3.0.1", "framer-motion": "^11.3.28", "jotai": "^2.9.3", + "lru-cache": "^11.0.1", "lucide-react": "^0.426.0", "next": "^14.2.3", "next-sitemap": "^4.2.3", diff --git a/public/copy.svg b/public/copy.svg new file mode 100644 index 0000000..f3b629c --- /dev/null +++ b/public/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/data/[facultyId]/route.ts b/src/app/api/data/[facultyId]/route.ts new file mode 100644 index 0000000..8e66af3 --- /dev/null +++ b/src/app/api/data/[facultyId]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; + +import type { ApiResponse } from "@/lib/types"; +import { createUsosService } from "@/lib/usos"; + +export const revalidate = 3600; + +export async function GET( + _request: Request, + { params }: { params: { facultyId: string } }, +) { + const service = createUsosService(); + + return NextResponse.json( + { + registrations: await Promise.all( + (await service.getRegistrations(params.facultyId)).map(async (r) => ({ + registration: r, + courses: await Promise.all( + r.related_courses.flatMap(async (c) => ({ + course: await service.getCourse(c.course_id), + groups: await service.getGroups(c.course_id, c.term_id), + })), + ), + })), + ), + }, + { + headers: { + "Cache-Control": "public, max-age=3600", + }, + }, + ); +} + +export type ApiProfileGet = ApiResponse; diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts deleted file mode 100644 index f804ece..0000000 --- a/src/app/api/profile/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NextResponse } from "next/server"; - -import type { ApiResponse } from "@/lib/types"; -import { createUsosService } from "@/lib/usos"; - -export async function GET() { - return NextResponse.json(await createUsosService().getProfile()); -} - -export type ApiProfileGet = ApiResponse; diff --git a/src/services/usos/getRegistrations.ts b/src/services/usos/getRegistrations.ts new file mode 100644 index 0000000..9d9243e --- /dev/null +++ b/src/services/usos/getRegistrations.ts @@ -0,0 +1,44 @@ +export type GetRegistrations = Registration[]; +export interface Registration { + id: string; + description: Description; + message: Description; + type: Type; + status: RegistrationStatus; + is_linkage_required: boolean; + www_instance: WWWInstance; + related_courses: RelatedCourse[]; +} + +export interface Description { + pl: string; + en: string; +} + +export interface RelatedCourse { + course_id: string; + term_id: TermID; + status: RelatedCourseStatus; + limits: null; +} + +export enum RelatedCourseStatus { + RegisterAndUnregister = "register_and_unregister", +} + +export enum TermID { + The202425Z = "2024/25-Z", +} + +export enum RegistrationStatus { + Active = "active", + Prepared = "prepared", +} + +export enum Type { + NotToken = "not_token", +} + +export enum WWWInstance { + PwrWWW = "PWR_WWW", +} diff --git a/src/services/usos/index.ts b/src/services/usos/index.ts index d8a2d79..64d15d3 100644 --- a/src/services/usos/index.ts +++ b/src/services/usos/index.ts @@ -1,10 +1,12 @@ import * as cheerio from "cheerio"; import makeFetchCookie from "fetch-cookie"; +import { LRUCache } from "lru-cache"; import type { GetCoursesCart } from "./getCoursesCart"; import type { GetCoursesEditions } from "./getCoursesEditions"; import type { GetProfile } from "./getProfile"; import type { GetRegistrationRoundCourses } from "./getRegistrationRoundCourses"; +import type { GetRegistrations } from "./getRegistrations"; import { type GetTerms } from "./getTerms"; import type { GetUserRegistrations } from "./getUserRegistrations"; import { Day, Frequency, LessonType } from "./types"; @@ -40,6 +42,11 @@ const calculateDifference = (start: Time, end: Time) => { }; }; +const cache = new LRUCache({ + ttl: 60 * 1000, + ttlAutopurge: true, +}); + export const usosService = (usosClient: UsosClient) => { return { getProfile: async () => { @@ -57,7 +64,7 @@ export const usosService = (usosClient: UsosClient) => { getUserRegistrations: async () => { const data = await usosClient.get( - "registrations/user_registrations?fields=id|description|message|type|status|is_linkage_required|www_instance|faculty|rounds|related_courses", + "registrations/user_registrations?fields=id|description|message|type|status|is_linkage_required|www_instance|faculty|rounds|related_courses&active=false", ); return data; @@ -88,6 +95,16 @@ export const usosService = (usosClient: UsosClient) => { return data; }, + getRegistrations: async (facultyId: string) => { + return usosClient.get( + `registrations/faculty_registrations?faculty_id=${facultyId}&fields=id|description|message|type|status|is_linkage_required|www_instance|related_courses`, + ); + }, + getCourses: async (coursesIds: string[]) => { + return usosClient.get( + `courses/courses&course_ids=${coursesIds.join("|")}`, + ); + }, getClassGroupTimetable: async ( courseUnitId: string, groupNumber: string, @@ -111,6 +128,15 @@ export const usosService = (usosClient: UsosClient) => { return data; }, getCourse: async (courseId: string) => { + const cacheKey = `course-${courseId}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey) as Promise<{ + id: string; + name: string; + }>; + } + const data = await fetchWithCookie( `https://web.usos.pwr.edu.pl/kontroler.php?_action=katalog2/przedmioty/pokazPrzedmiot&prz_kod=${courseId}`, ); @@ -119,13 +145,40 @@ export const usosService = (usosClient: UsosClient) => { const name = $("h1").text(); - return { + cache.set(cacheKey, { id: courseId, name, - }; + }); + + return cache.get(cacheKey) as Promise<{ + id: string; + name: string; + }>; }, getGroups: async (courseId: string, term?: string) => { + const cacheKey = `groups-${courseId}-${term}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey) as Promise< + Array<{ + hourStartTime: Time; + hourEndTime: Time; + duration: Time; + person: string; + personLink: string; + groupLink: string; + day: Day; + courseId: string; + type: LessonType; + nameExtended: string; + groupNumber: number; + frequency: Frequency; + name: string; + }> + >; + } + const data = await fetchWithCookie( `https://web.usos.pwr.edu.pl/kontroler.php?_action=katalog2/przedmioty/pokazPlanZajecPrzedmiotu&prz_kod=${courseId}&plan_division=semester&plan_format=new-ui${ typeof term === "string" ? `&cdyd_kod=${term}` : "" @@ -239,6 +292,8 @@ export const usosService = (usosClient: UsosClient) => { }; }); + cache.set(cacheKey, groups); + return groups; }, }; diff --git a/src/services/usos/usosClient.ts b/src/services/usos/usosClient.ts index 992e316..9f02862 100644 --- a/src/services/usos/usosClient.ts +++ b/src/services/usos/usosClient.ts @@ -1,8 +1,16 @@ +import { LRUCache } from "lru-cache"; + import { USOS_APPS_URL } from "@/env.mjs"; import { oauth } from "@/lib/auth"; const baseUrl = `${USOS_APPS_URL}/services`; +const cache = new LRUCache({ + ttl: 60 * 60 * 1000, + ttlAutopurge: false, + max: 10000, +}); + export const createClient = ({ token, secret, @@ -19,6 +27,10 @@ export const createClient = ({ async get(endpoint: string): Promise { const url = `${baseUrl}/${endpoint}`; + if (cache.has(url)) { + return cache.get(url) as R; + } + const data = oauth.authorize( { url, @@ -46,7 +58,12 @@ export const createClient = ({ throw new Error("Unauthorized"); } - return response.json() as Promise; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const json: object = await response.json(); + + cache.set(url, json); + + return json as Promise; }, async post(endpoint: string, body: unknown): Promise { const url = `${baseUrl}/${endpoint}`;