From 6d9248f9bcd50fc85b8872a0e3d7bb74a11e1ac0 Mon Sep 17 00:00:00 2001 From: Shubham Jain Date: Sun, 14 May 2023 20:22:25 +0100 Subject: [PATCH] Finish adding Libcal endpoints for app --- src/constants/apiRoutes.ts | 5 +- src/middleware/auth.ts | 1 + src/redis/keys.ts | 5 + src/redis/redis.ts | 20 ++- src/redis/ttl.ts | 3 + src/uclapi/app.ts | 104 +++++++++++++-- src/uclapi/libcal.ts | 266 +++++++++++++++++++++++++++++++++---- 7 files changed, 355 insertions(+), 49 deletions(-) diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts index 2d406174..8f7a83af 100644 --- a/src/constants/apiRoutes.ts +++ b/src/constants/apiRoutes.ts @@ -24,6 +24,9 @@ export default { ROOMBOOKINGS_FREEROOMS_URL: `${ROOMBOOKINGS_BASE_URL}/freerooms`, LIBCAL_LOCATIONS_URL: `${LIBCAL_BASE_URL}/space/locations`, LIBCAL_CATEGORIES_URL: `${LIBCAL_BASE_URL}/space/categories`, + LIBCAL_CATEGORY_URL: `${LIBCAL_BASE_URL}/space/category`, LIBCAL_SEATS_URL: `${LIBCAL_BASE_URL}/space/seats`, - LIBCAL_BOOKINGS_URL: `${LIBCAL_BASE_URL}/space/personal_bookings`, + LIBCAL_PERSONAL_BOOKINGS_URL: `${LIBCAL_BASE_URL}/space/personal_bookings`, + LIBCAL_RESERVE_URL: `${LIBCAL_BASE_URL}/space/reserve`, + LIBCAL_CANCEL_URL: `${LIBCAL_BASE_URL}/space/cancel`, } diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 9edaf9dc..1275236d 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -26,6 +26,7 @@ const jwtVerifyDev = async (ctx: Context, next: Next) => { export const genToken = user => jsonwebtoken.sign(user, Environment.SECRET) +// Note: You need the JWT for timetable & libcal, so comment out if needed const shouldBypassAuthentication = ( Environment.DEVELOPMENT_MODE || Environment.TEST_MODE diff --git a/src/redis/keys.ts b/src/redis/keys.ts index d4354faf..5b9253f1 100644 --- a/src/redis/keys.ts +++ b/src/redis/keys.ts @@ -3,6 +3,7 @@ const PEOPLE_PATH = `/people` const ROOMS_PATH = `/rooms` const TIMETABLE_PATH = `/timetable` const SITES_PATH = `/sites` +const LIBCAL_PATH = `/libcal` export default { TIMETABLE_PERSONAL_PATH: `${TIMETABLE_PATH}/personal`, TIMETABLE_MODULE_PATH: `${TIMETABLE_PATH}/module`, @@ -13,4 +14,8 @@ export default { PEOPLE_SEARCH_PATH: `${PEOPLE_PATH}/search`, ROOMS_SEARCH_PATH: `${ROOMS_PATH}/rooms`, SITES_SEARCH_PATH: `${SITES_PATH}/sites`, + LIBCAL_LOCATIONS_PATH: `${LIBCAL_PATH}/locations`, + LIBCAL_BOOKINGS_PATH: `${LIBCAL_PATH}/bookings`, + LIBCAL_CATEGORIES_PATH: `${LIBCAL_PATH}/categories`, + LIBCAL_CATEGORY_PATH: `${LIBCAL_PATH}/category`, } diff --git a/src/redis/redis.ts b/src/redis/redis.ts index eeafba75..122021ce 100644 --- a/src/redis/redis.ts +++ b/src/redis/redis.ts @@ -1,6 +1,17 @@ import { Context } from "koa" import Environment from "../lib/Environment" +const SKIP_CACHE = ( + Environment.TEST_MODE || Environment.DEVELOPMENT_MODE +) + +const load = async (ctx: Context, key: string) => { + if (SKIP_CACHE) return null + + const cacheData = await ctx.redisGet(key) + return JSON.parse(cacheData) +} + /** * Loads data from redis using the given key, or fetches * new data if there is a cache miss, populating the cache @@ -15,18 +26,14 @@ import Environment from "../lib/Environment" * @returns The new or cached data. */ const loadOrFetch = async (ctx: Context, key, fetchNewData, ttl) => { - - const skipCache = ( - Environment.TEST_MODE || Environment.DEVELOPMENT_MODE - ) - if (!skipCache) { + if (!SKIP_CACHE) { const cacheData = await ctx.redisGet(key) if (cacheData) { return JSON.parse(cacheData) } } const newData = await fetchNewData() - if (!skipCache) { + if (!SKIP_CACHE) { if (ttl) { await ctx.redisSet(key, JSON.stringify(newData), `EX`, ttl) } else { @@ -38,4 +45,5 @@ const loadOrFetch = async (ctx: Context, key, fetchNewData, ttl) => { export default { loadOrFetch, + load, } diff --git a/src/redis/ttl.ts b/src/redis/ttl.ts index bd763c8d..60bec4eb 100644 --- a/src/redis/ttl.ts +++ b/src/redis/ttl.ts @@ -13,4 +13,7 @@ export default { WORKSPACE_EQUIPMENT_TTL: DAY, PEOPLE_SEARCH_TTL: DAY, ROOMS_SEARCH_TTL: DAY, + LIBCAL_CATEGORIES_TTL: DAY, + LIBCAL_LOCATIONS_TTL: DAY, + LIBCAL_BOOKINGS_TTL: MINUTE, } diff --git a/src/uclapi/app.ts b/src/uclapi/app.ts index 7561f872..5b674f49 100644 --- a/src/uclapi/app.ts +++ b/src/uclapi/app.ts @@ -1,4 +1,4 @@ -import Koa from 'koa' +import Koa, { Context } from 'koa' import Router from 'koa-router' import { jwt } from '../middleware/auth' import redis from '../redis' @@ -12,6 +12,14 @@ import { } from './timetable' import { getUserData } from './user' import Workspaces from './workspaces' +import { + cancelBooking, + getGroupSpaces, + getLocations, + getPersonalBookingsSevenDays, + getSeats, + reserveSpace, +} from "./libcal" const app = new Koa() const router = new Router() @@ -50,7 +58,7 @@ router.get(`/timetable/week`, jwt, async ctx => { ctx.set(`Last-Modified`, lastModified) }) -router.get(`/timetable/:module`, jwt, async ctx => { +router.get(`/timetable/:module`, jwt, async (ctx: Context) => { ctx.assert(ctx.params.module, 400) const { module: timetableModule } = ctx.params const date = ctx.query.date || null @@ -70,7 +78,7 @@ router.get(`/timetable/:module`, jwt, async ctx => { ctx.set(`Last-Modified`, lastModified) }) -router.get(`/search/people`, jwt, async ctx => { +router.get(`/search/people`, jwt, async (ctx: Context) => { ctx.assert( (ctx.query.query || ``).length >= 3, 400, @@ -85,7 +93,7 @@ router.get(`/search/people`, jwt, async ctx => { ctx.body = data }) -router.get(`/search/rooms`, jwt, async ctx => { +router.get(`/search/rooms`, jwt, async (ctx: Context) => { ctx.assert( (ctx.query.query || ``).length >= 3, 400, @@ -110,21 +118,19 @@ router.get(`/sites`, jwt, async ctx => { ctx.body = rooms }) -router.get(`/equipment`, jwt, async ctx => { +router.get(`/equipment`, jwt, async (ctx: Context) => { ctx.assert(ctx.query.roomid, 400, `Must specify roomid`) ctx.assert(ctx.query.siteid, 400, `Must specify siteid`) const data = await redis.loadOrFetch( ctx, - `${ - redis.keys.WORKSPACE_EQUIPMENT_PATH - }/${ctx.query.roomid}/${ctx.query.siteid}`, + `${redis.keys.WORKSPACE_EQUIPMENT_PATH}/${ctx.query.roomid}/${ctx.query.siteid}`, async () => getEquipment(ctx.query.roomid, ctx.query.siteid), redis.ttl.WORKSPACE_EQUIPMENT_TTL, ) ctx.body = data }) -router.get(`/workspaces/getimage/:id.png`, jwt, async ctx => { +router.get(`/workspaces/getimage/:id.png`, jwt, async (ctx: Context) => { ctx.assert(ctx.params.id, 400, `Must specify id`) ctx.set({ "Content-Type": `image/png` }) ctx.state.jsonify = false @@ -132,7 +138,7 @@ router.get(`/workspaces/getimage/:id.png`, jwt, async ctx => { ctx.body = res }) -router.get(`/workspaces/getliveimage/map.svg`, jwt, async ctx => { +router.get(`/workspaces/getliveimage/map.svg`, jwt, async (ctx: Context) => { ctx.assert(ctx.query.survey_id) ctx.assert(ctx.query.map_id) ctx.set({ "Content-Type": `image/svg+xml` }) @@ -159,7 +165,7 @@ router.get(`/workspaces/summary`, jwt, async ctx => { ctx.set(`Last-Modified`, lastModified) }) -router.get(`/workspaces/historic`, jwt, async ctx => { +router.get(`/workspaces/historic`, jwt, async (ctx: Context) => { ctx.assert(ctx.query.id, 400, `Need to include a survey id.`) const data = await redis.loadOrFetch( ctx, @@ -170,7 +176,7 @@ router.get(`/workspaces/historic`, jwt, async ctx => { ctx.body = data }) -router.get(`/workspaces/:id/seatinfo`, jwt, async ctx => { +router.get(`/workspaces/:id/seatinfo`, jwt, async (ctx: Context) => { ctx.assert(ctx.params.id, 400) ctx.body = await Workspaces.getSeatingInfo(ctx.params.id) }) @@ -187,7 +193,7 @@ router.get(`/workspaces`, jwt, async ctx => { ) }) -router.get(`/roombookings`, jwt, async ctx => { +router.get(`/roombookings`, jwt, async (ctx: Context) => { ctx.assert(ctx.query.roomid, 400, `Please include a roomid`) ctx.assert(ctx.query.siteid, 400, `Please include a siteid`) ctx.assert(ctx.query.date, 400, `Please include a date`) @@ -206,6 +212,78 @@ router.get(`/freerooms`, jwt, async ctx => { ctx.body = await getFreeRooms(startDateTime, endDateTime) }) +router.get(`/libcal/locations`, jwt, async (ctx: Context) => { + ctx.body = await redis.loadOrFetch( + ctx, + redis.keys.LIBCAL_LOCATIONS_PATH, + async () => getLocations(ctx.state.user.apiToken), + redis.ttl.LIBCAL_LOCATIONS_TTL, + ) +}) + +// Note: don't cache this endpoint, because ?availability query param +// implies always needing fresh data +router.get(`/libcal/locations/:id/seats`, jwt, async (ctx: Context) => { + ctx.assert(ctx.params.id, 400) + ctx.assert(ctx.query.date, 400) + ctx.body = await getSeats( + ctx, + ctx.state.user.apiToken, + ctx.params.id, + ctx.query.date as string, + ) +}) + +// Note: there is more nuanced cache implementation within the implementation +// Do not cache the entire endpoint +router.get(`/libcal/locations/:id/groupSpaces`, jwt, async (ctx: Context) => { + ctx.assert(ctx.params.id, 400) + ctx.assert(ctx.query.date, 400) + ctx.body = await getGroupSpaces( + ctx, + ctx.state.user.apiToken, + ctx.params.id, + ctx.query.date as string, + ) +}) + +router.post(`/libcal/spaces/:id/reserve`, jwt, async (ctx: Context) => { + ctx.assert(ctx.params.id, 400) + + const body = ctx.request.body as { [key: string]: string | number } + ctx.assert(body.from, 400) + ctx.assert(body.to, 400) + + ctx.body = await reserveSpace( + ctx.state.user.apiToken, + parseInt(ctx.params.id, 10), + body.from as string, + body.to as string, + body.seat_id + ? parseInt(body.seat_id as string, 10) + : null, + ) +}) + +router.get(`/libcal/bookings`, jwt, async (ctx: Context) => { + ctx.body = await redis.loadOrFetch( + ctx, + redis.keys.LIBCAL_BOOKINGS_PATH, + async () => await getPersonalBookingsSevenDays(ctx.state.user.apiToken), + redis.ttl.LIBCAL_BOOKINGS_TTL, + ) +}) + +router.post(`/libcal/bookings/cancel`, jwt, async (ctx: Context) => { + const body = ctx.request.body as { [key: string]: string | number } + ctx.assert(body.booking_id, 400) + + ctx.body = await cancelBooking( + ctx.state.user.apiToken, + body.booking_id as string, + ) +}) + app.use(router.routes()) app.use(router.allowedMethods()) diff --git a/src/uclapi/libcal.ts b/src/uclapi/libcal.ts index 198b51f0..abfdf2db 100644 --- a/src/uclapi/libcal.ts +++ b/src/uclapi/libcal.ts @@ -1,19 +1,122 @@ -import axios from 'axios' -import ApiRoutes from '../constants/apiRoutes' -import Environment from '../lib/Environment' -import ErrorManager from '../lib/ErrorManager' -import moment from 'moment' +import axios from "axios" +import ApiRoutes from "../constants/apiRoutes" +import Environment from "../lib/Environment" +import ErrorManager from "../lib/ErrorManager" +import moment from "moment" +import redis from "../redis" +import { Context } from "koa" -export const getPersonalBookingsSevenDays = async (token: string) => { - const params = { - client_secret: Environment.CLIENT_SECRET, - token, - days: 7, - limit: 100, +const getCategories = async ( + ctx: Context, + token: string, + locationId: string, +) => { + const fetch = async () => { + const { data } = await axios.get(ApiRoutes.LIBCAL_CATEGORIES_URL, { + params: { + token, + client_secret: Environment.CLIENT_SECRET, + ids: locationId, + }, + }) + + return data.categories[0].categories + .filter((c) => !!c.public) + .map((c) => c.cid) + } + + return await redis.loadOrFetch( + ctx, + `${redis.keys.LIBCAL_CATEGORIES_PATH}/${locationId}`, + async () => fetch(), + redis.ttl.LIBCAL_CATEGORIES_TTL, + ) +} + +const fetchSpaces = async ( + ctx: Context, + categoryIdsToFetch: number[], + token: string, + date?: string, +) => { + const { data } = await axios.get(ApiRoutes.LIBCAL_CATEGORY_URL, { + params: { + token, + client_secret: Environment.CLIENT_SECRET, + ids: categoryIdsToFetch.join(`,`), + ...(date && { + availability: date || moment().format(`YYYY-MM-DD`), + }), + details: 1, + }, + }) + + const spaces = [] + for (const category of data.categories) { + if (!category.public) continue + const categorySpaces = category.items.filter( + (i) => i.capacity > 0 && (!date || i.availability.length > 0), + ) + spaces.push(...categorySpaces) + + // Only cache this data if it doesn't contain availability-specific info + if (!date) { + await ctx.redisSet( + `${redis.keys.LIBCAL_CATEGORY_PATH}/${category.cid}`, + JSON.stringify(categorySpaces), + `EX`, + redis.ttl.LIBCAL_CATEGORIES_TTL, + ) + } + } + + return spaces +} + +const getSpaces = async ( + ctx: Context, + token: string, + categories: number[], + date?: string, +) => { + + // Can't use cache if we need the (group) spaces specific availability + if (date) return await fetchSpaces(ctx, categories, token) + + // If we don't need specific availabiliy, we can use cache + const spaces = [] + const categoryIdsToFetch = [] + + for (const categoryId of categories) { + // Get the spaces we do have cached for this category ID + const cachedSpaces = await redis.load( + ctx, + `${redis.keys.LIBCAL_CATEGORY_PATH}/${categoryId}`, + ) + if (cachedSpaces) spaces.push(...cachedSpaces) + // And record the category IDs for which we have no cache + else categoryIdsToFetch.push(categoryId) + } + + if (categoryIdsToFetch.length > 0) { + // Finally, fetch (and cache) spaces for the remaining uncached category IDs + spaces.push(...(await fetchSpaces(ctx, categoryIdsToFetch, token, date))) } + return spaces +} + +export const getPersonalBookingsSevenDays = async (token: string) => { try { - const { data } = await axios.get(ApiRoutes.LIBCAL_BOOKINGS_URL, { params }) + const { data } = await axios.get(ApiRoutes.LIBCAL_PERSONAL_BOOKINGS_URL, { + params: { + client_secret: Environment.CLIENT_SECRET, + token, + days: 7, + limit: 100, + }, + }) + return data } catch (error) { ErrorManager.captureError(error) @@ -24,9 +127,13 @@ export const getPersonalBookingsSevenDays = async (token: string) => { export const getLocations = async (token: string) => { try { const { data } = await axios.get(ApiRoutes.LIBCAL_LOCATIONS_URL, { - params: { token }, + params: { + token, + client_secret: Environment.CLIENT_SECRET, + }, }) + data.locations = data.locations.filter((l) => l.public === 1) return data } catch (error) { ErrorManager.captureError(error) @@ -34,14 +141,111 @@ export const getLocations = async (token: string) => { } } -export const getCategories = async (token: string, locationId: string) => { +export const getSeats = async ( + ctx: Context, + token: string, + locationId: string, + date: string, +) => { try { - const { data } = await axios.get(ApiRoutes.LIBCAL_CATEGORIES_URL, { - params: { - token, - ids: locationId, + const categories = await getCategories(ctx, token, locationId) + const spaces = (await getSpaces(ctx, token, categories)) + .filter((s) => !s.is_bookable_as_whole) + + // To book a seat, we will need the space ID + // The API doesn't give us Space IDs for Seats using the /seats endpoint + // So, for each Space ID, fetch only its seats -- so we know the Space ID + // for each group of seats we fetch + // Potential improvements: + // - cache seat ID => space ID mappings, and add to returned object + // - only fetch space ID after in the /reserve endpoint + // - add internal cache to UCL API to augment responses with space IDs + const seats = [] + const promises = [] + spaces.forEach((space) => + promises.push( + axios.get(ApiRoutes.LIBCAL_SEATS_URL, { + params: { + token, + client_secret: Environment.CLIENT_SECRET, + ids: locationId, + space_id: space.id, + availability: date, + }, + }), + ), + ) + + await Promise.all(promises) + for (let i = 0; i < spaces.length; i++) { + const { data } = await promises[i] + data.seats = data.seats.filter((s) => s.availability.length > 0) + seats.push( + ...data.seats.map((s) => ({ + space_id: spaces[i].id, + ...s, + })), + ) + } + + return { + ok: true, + seats, + } + } catch (error) { + ErrorManager.captureError(error) + throw error + } +} + +export const getGroupSpaces = async ( + ctx: Context, + token: string, + locationId: string, + date: string, +) => { + try { + const categories = await getCategories(ctx, token, locationId) + const spaces = await getSpaces(ctx, token, categories, date) + + return { + ok: true, + spaces: spaces.filter((s) => s.is_bookable_as_whole), + } + } catch (error) { + ErrorManager.captureError(error) + throw error + } +} + +export const reserveSpace = async ( + token: string, + spaceId: number, + from: string, + to: string, + seatId: number, +) => { + try { + const { data } = await axios.post( + ApiRoutes.LIBCAL_RESERVE_URL, + { + start: from, + test: 0, + bookings: [ + { + id: spaceId, + to, + ...(!!seatId && { seat_id: seatId }), + }, + ], }, - }) + { + params: { + token, + client_secret: Environment.CLIENT_SECRET, + }, + }, + ) return data } catch (error) { @@ -50,16 +254,19 @@ export const getCategories = async (token: string, locationId: string) => { } } -export const getSeats = - async (token: string, locationId: string, date: string) => { +export const cancelBooking = async (token: string, bookingId: string) => { try { - const { data } = await axios.get(ApiRoutes.LIBCAL_SEATS_URL, { - params: { - token, - ids: locationId, - availabiliy: date || moment().format(`YYYY-MM-DD`), + const { data } = await axios.post( + ApiRoutes.LIBCAL_CANCEL_URL, + null, + { + params: { + ids: bookingId, + token, + client_secret: Environment.CLIENT_SECRET, + }, }, - }) + ) return data } catch (error) { @@ -70,7 +277,8 @@ export const getSeats = export default { getLocations, - getCategories, - getPersonalBookingsSevenDays, getSeats, + getGroupSpaces, + getPersonalBookingsSevenDays, + cancelBooking, }