diff --git a/src/routes/admin/apisearcher.ts b/src/routes/admin/apisearcher.ts index 600562b..06a07f4 100644 --- a/src/routes/admin/apisearcher.ts +++ b/src/routes/admin/apisearcher.ts @@ -1,12 +1,24 @@ import { Express } from 'express'; -import { IntraCoalitionUser, Prisma, PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; import { DefaultArgs } from '@prisma/client/runtime/library'; -import Fast42 from '@codam/fast42'; import { CAMPUS_ID, CURSUS_ID } from '../../env'; -import { getAPIClient, fetchSingleApiPage, parseTeamInAPISearcher, parseScaleTeamInAPISearcher } from '../../utils'; +import { getAPIClient, fetchSingleApiPage, parseTeamInAPISearcher, parseScaleTeamInAPISearcher, getPageNumber, getOffset } from '../../utils'; const EXAM_PROJECT_IDS = [1320, 1321, 1322, 1323, 1324]; +// Response interface +export interface APISearchResponse { + data: any[]; + meta: { + pagination: { + total: number; + pages: number; + page: number; + per_page: number; + }; + }; +}; + // Cache for the API searcher specifically import NodeCache from 'node-cache'; const apiSearcherCache = new NodeCache({ stdTTL: 60 * 5, checkperiod: 60 * 5 }); @@ -44,18 +56,34 @@ export const API_DEFAULT_FILTERS_EVENTS = { 'sort': '-begin_at', }; +const getPaginationMeta = function(headers: any): APISearchResponse['meta']['pagination'] { + return { + total: parseInt(headers['x-total']), + pages: Math.ceil(parseInt(headers['x-total']) / parseInt(headers['x-per-page'])), + page: parseInt(headers['x-page']), + per_page: parseInt(headers['x-per-page']), + }; +}; + export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient): void { // LOCATIONS // All locations app.get('/admin/apisearch/locations', async (req, res) => { try { + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const locations = await fetchSingleApiPage(api, `/campus/${CAMPUS_ID}/locations`, { ...API_DEFAULT_FILTERS_LOCATIONS, - 'page[size]': '50', + 'page[size]': itemsPerPage.toString(), + }, pageNum); + return res.json({ + data: locations.data, + meta: { + pagination: getPaginationMeta(locations.headers), + }, }); - return res.json(locations); } catch (err) { console.log(err); @@ -78,12 +106,19 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) if (user === null) { return res.status(404).json({ error: 'User not found' }); } + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const locations = await fetchSingleApiPage(api, `/users/${user.id}/locations`, { ...API_DEFAULT_FILTERS_LOCATIONS, - 'page[size]': '50', + 'page[size]': itemsPerPage.toString(), + }, pageNum); + return res.json({ + data: locations.data, + meta: { + pagination: getPaginationMeta(locations.headers), + }, }); - return res.json(locations); } catch (err) { console.log(err); @@ -99,7 +134,12 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) const location = await fetchSingleApiPage(api, '/locations/', { // use /locations with filter to make sure the response is in the same format as the other location endpoints 'filter[id]': locationId, }); - return res.json(location); + return res.json({ + data: location.data, + meta: { + pagination: getPaginationMeta(location.headers), + }, + }); } catch (err) { console.log(err); @@ -111,13 +151,20 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) // All projects (teams) app.get('/admin/apisearch/projects', async (req, res) => { try { + const itemsPerPage = 100; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const teams = await fetchSingleApiPage(api, `/teams`, { ...API_DEFAULT_FILTERS_PROJECTS, - 'page[size]': '100', + 'page[size]': itemsPerPage.toString(), + }, pageNum); + const modifiedTeams = await parseTeamInAPISearcher(prisma, teams.data); + return res.json({ + data: modifiedTeams, + meta: { + pagination: getPaginationMeta(teams.headers), + }, }); - const modifiedTeams = await parseTeamInAPISearcher(prisma, teams); - return res.json(modifiedTeams); } catch (err) { console.log(err); @@ -140,13 +187,20 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) if (user === null) { return res.status(404).json({ error: 'User not found' }); } + const itemsPerPage = 100; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const teams = await fetchSingleApiPage(api, `/users/${user.id}/teams`, { ...API_DEFAULT_FILTERS_PROJECTS, - 'page[size]': '100', + 'page[size]': itemsPerPage.toString(), + }, pageNum); + const modifiedTeams = await parseTeamInAPISearcher(prisma, teams.data); + return res.json({ + data: modifiedTeams, + meta: { + pagination: getPaginationMeta(teams.headers), + }, }); - const modifiedTeams = await parseTeamInAPISearcher(prisma, teams); - return res.json(modifiedTeams); } catch (err) { console.log(err); @@ -158,12 +212,20 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) app.get('/admin/apisearch/projects/id/:teamId', async (req, res) => { try { const teamId = req.params.teamId; + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const teams = await fetchSingleApiPage(api, '/teams/', { 'filter[id]': teamId, + 'page[size]': itemsPerPage.toString(), + }, pageNum); + const modifiedTeams = await parseTeamInAPISearcher(prisma, teams.data); + return res.json({ + data: modifiedTeams, + meta: { + pagination: getPaginationMeta(teams.headers), + }, }); - const modifiedTeams = await parseTeamInAPISearcher(prisma, teams); - return res.json(modifiedTeams); } catch (err) { console.log(err); @@ -176,12 +238,19 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) app.get('/admin/apisearch/exams', async (req, res) => { try { const api = await getAPIClient(); + const itemsPerPage = 100; + const pageNum = getPageNumber(req, NaN); const teams = await fetchSingleApiPage(api, `/teams`, { ...API_DEFAULT_FILTERS_EXAMS, - 'page[size]': '100', + 'page[size]': itemsPerPage.toString(), + }); + const modifiedTeams = await parseTeamInAPISearcher(prisma, teams.data); + return res.json({ + data: modifiedTeams, + meta: { + pagination: getPaginationMeta(teams.headers), + }, }); - const modifiedTeams = await parseTeamInAPISearcher(prisma, teams); - return res.json(modifiedTeams); } catch (err) { console.log(err); @@ -205,12 +274,19 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) return res.status(404).json({ error: 'User not found' }); } const api = await getAPIClient(); + const itemsPerPage = 100; + const pageNum = getPageNumber(req, NaN); const teams = await fetchSingleApiPage(api, `/users/${user.id}/teams`, { ...API_DEFAULT_FILTERS_EXAMS, - 'page[size]': '100', + 'page[size]': itemsPerPage.toString(), + }, pageNum); + const modifiedTeams = await parseTeamInAPISearcher(prisma, teams.data); + return res.json({ + data: modifiedTeams, + meta: { + pagination: getPaginationMeta(teams.headers), + }, }); - const modifiedTeams = await parseTeamInAPISearcher(prisma, teams); - return res.json(modifiedTeams); } catch (err) { console.log(err); @@ -222,13 +298,21 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) app.get('/admin/apisearch/exams/id/:teamId', async (req, res) => { try { const teamId = req.params.teamId; + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const teams = await fetchSingleApiPage(api, '/teams/', { 'filter[id]': teamId, 'filter[project_id]': EXAM_PROJECT_IDS.join(','), + 'page[size]': itemsPerPage.toString(), + }, pageNum); + const modifiedTeams = await parseTeamInAPISearcher(prisma, teams.data); + return res.json({ + data: modifiedTeams, + meta: { + pagination: getPaginationMeta(teams.headers), + }, }); - const modifiedTeams = await parseTeamInAPISearcher(prisma, teams); - return res.json(modifiedTeams); } catch (err) { console.log(err); @@ -240,13 +324,20 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) // All evaluations app.get('/admin/apisearch/evaluations', async (req, res) => { try { + const itemsPerPage = 10; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const evaluations = await fetchSingleApiPage(api, '/scale_teams', { ...API_DEFAULT_FILTERS_SCALE_TEAMS, - 'page[size]': '25', + 'page[size]': itemsPerPage.toString(), + }, pageNum); + const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations.data); + return res.json({ + data: modifiedScaleTeams, + meta: { + pagination: getPaginationMeta(evaluations.headers), + }, }); - const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations); - return res.json(modifiedScaleTeams); } catch (err) { console.log(err); @@ -269,14 +360,21 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) if (user === null) { return res.status(404).json({ error: 'User not found' }); } + const itemsPerPage = 10; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const evaluations = await fetchSingleApiPage(api, '/scale_teams', { ...API_DEFAULT_FILTERS_SCALE_TEAMS, - 'page[size]': '25', + 'page[size]': itemsPerPage.toString(), 'filter[user_id]': user.id.toString(), + }, pageNum); + const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations.data); + return res.json({ + data: modifiedScaleTeams, + meta: { + pagination: getPaginationMeta(evaluations.headers), + }, }); - const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations); - return res.json(modifiedScaleTeams); } catch (err) { console.log(err); @@ -288,15 +386,22 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) app.get('/admin/apisearch/evaluations/team/:teamId', async (req, res) => { try { const teamId = req.params.teamId; + const itemsPerPage = 10; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const evaluations = await fetchSingleApiPage(api, '/scale_teams', { 'filter[team_id]': teamId, 'filter[future]': 'false', - 'page[size]': '25', + 'page[size]': itemsPerPage.toString(), 'sort': '-filled_at' + }, pageNum); + const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations.data); + return res.json({ + data: modifiedScaleTeams, + meta: { + pagination: getPaginationMeta(evaluations.headers), + }, }); - const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations); - return res.json(modifiedScaleTeams); } catch (err) { console.log(err); @@ -308,15 +413,22 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) app.get('/admin/apisearch/evaluations/scale_team/:scaleTeamId', async (req, res) => { try { const scaleTeamId = req.params.scaleTeamId; + const itemsPerPage = 10; + const pageNum = getPageNumber(req, NaN); const api = await getAPIClient(); const evaluations = await fetchSingleApiPage(api, '/scale_teams', { 'filter[id]': scaleTeamId, 'filter[future]': 'false', - 'page[size]': '25', + 'page[size]': itemsPerPage.toString(), 'sort': '-filled_at' + }, pageNum); + const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations.data); + return res.json({ + data: modifiedScaleTeams, + meta: { + pagination: getPaginationMeta(evaluations.headers), + }, }); - const modifiedScaleTeams = await parseScaleTeamInAPISearcher(prisma, evaluations); - return res.json(modifiedScaleTeams); } catch (err) { console.log(err); @@ -367,8 +479,29 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) // All users app.get('/admin/apisearch/users', async (req, res) => { - const coalitionUsers = await prisma.intraCoalitionUser.findMany(USER_QUERY_DEFAULTS); - return res.json(coalitionUsers); + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + const offset = getOffset(pageNum, itemsPerPage); + // @ts-ignore + const totalCoalitionUsers = await prisma.intraCoalitionUser.count({ + where: USER_QUERY_DEFAULTS.where, + }); + const coalitionUsers = await prisma.intraCoalitionUser.findMany({ + ...USER_QUERY_DEFAULTS, + take: itemsPerPage, + skip: offset, + }); + return res.json({ + data: coalitionUsers, + meta: { + pagination: { + total: totalCoalitionUsers, + pages: Math.ceil(totalCoalitionUsers / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, + }, + }); }); // Users by login @@ -382,7 +515,17 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) }, }, }); - return res.json(coalitionUsers); + return res.json({ + data: coalitionUsers, + meta: { + pagination: { + total: coalitionUsers.length, + pages: 1, + page: 1, + per_page: coalitionUsers.length, + }, + }, + }); }); // Users by ID @@ -399,28 +542,47 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) }, }, }); - return res.json(coalitionUsers); + return res.json({ + data: coalitionUsers, + meta: { + pagination: { + total: coalitionUsers.length, + pages: 1, + page: 1, + per_page: coalitionUsers.length, + }, + }, + }); }); // Users by coalition name / slug app.get('/admin/apisearch/users/coalition/:name', async (req, res) => { const name = req.params.name; + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + const offset = getOffset(pageNum, itemsPerPage); if (name.toLowerCase() === 'none' || name.toLowerCase() === 'null') { console.log("Fetching users without coalition"); const nonExistingCoalitionUsers = []; - console.log(USER_QUERY_DEFAULTS); + const whereQuery = { + ...(USER_QUERY_DEFAULTS.where?.user), + coalition_users: { + none: {}, + }, + }; + + const totalUsersWithoutCoalition = await prisma.intraUser.count({ + where: whereQuery, + }); const usersWithoutCoalition = await prisma.intraUser.findMany({ // @ts-ignore select: (USER_QUERY_DEFAULTS.select?.user)?.select, - where: { - ...(USER_QUERY_DEFAULTS.where?.user), - coalition_users: { - none: {}, - }, - }, + where: whereQuery, orderBy: { created_at: 'desc', - } + }, + take: itemsPerPage, + skip: offset, }); for (const user of usersWithoutCoalition) { // Should be similar to an IntraCoalitionUser object, but then with null values. @@ -432,36 +594,58 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) coalition: null, }); } - return res.json(nonExistingCoalitionUsers); + return res.json({ + data: nonExistingCoalitionUsers, + meta: { + pagination: { + total: totalUsersWithoutCoalition, + pages: Math.ceil(totalUsersWithoutCoalition / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, + }, + }); } else { // Format name with first letter uppercase and rest lowercase (Intra coalitions are usually named like this) const stylizedName = req.params.name.charAt(0).toUpperCase() + req.params.name.slice(1).toLowerCase(); - const coalitionUsers = await prisma.intraCoalitionUser.findMany({ - ...USER_QUERY_DEFAULTS, - where: { - OR: [ - { - coalition: { - name: stylizedName, - }, + const whereQuery = { + OR: [ + { + coalition: { + name: stylizedName, }, - { - coalition: { - name: name, - }, + }, + { + coalition: { + name: name, }, - { - coalition: { - slug: { - contains: name, - } - }, + }, + { + coalition: { + slug: { + contains: name, + } }, - ], + }, + ], + }; + const totalCoalitionUsers = await prisma.intraCoalitionUser.count({ where: whereQuery }); + const coalitionUsers = await prisma.intraCoalitionUser.findMany({ + ...USER_QUERY_DEFAULTS, + where: whereQuery, + }); + return res.json({ + data: coalitionUsers, + meta: { + pagination: { + total: totalCoalitionUsers, + pages: Math.ceil(totalCoalitionUsers / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, }, }); - return res.json(coalitionUsers); } }); @@ -477,18 +661,43 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) id: coalitionUserId, }, }); - return res.json(coalitionUsers); + return res.json({ + data: coalitionUsers, + meta: { + pagination: { + total: coalitionUsers.length, + pages: 1, + page: 1, + per_page: coalitionUsers.length, + }, + }, + }); }); // WEBHOOKS // All webhooks app.get('/admin/apisearch/hooks', async (req, res) => { + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + const offset = getOffset(pageNum, itemsPerPage); const webhooks = await prisma.intraWebhook.findMany({ orderBy: { received_at: 'desc', }, + take: itemsPerPage, + skip: offset, + }); + return res.json({ + data: webhooks, + meta: { + pagination: { + total: await prisma.intraWebhook.count(), + pages: Math.ceil(await prisma.intraWebhook.count() / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, + }, }); - return res.json(webhooks); }); // Webhooks by delivery ID @@ -499,12 +708,30 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) delivery_id: deliveryId, }, }); - return res.json(webhooks); + return res.json({ + data: webhooks, + meta: { + pagination: { + total: webhooks.length, + pages: 1, + page: 1, + per_page: webhooks.length, + }, + }, + }); }); // Webhooks by status app.get('/admin/apisearch/hooks/status/:status', async (req, res) => { const status = req.params.status; + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + const offset = getOffset(pageNum, itemsPerPage); + const webhookCount = await prisma.intraWebhook.count({ + where: { + status: status, + }, + }); const webhooks = await prisma.intraWebhook.findMany({ where: { status: status, @@ -512,13 +739,33 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) orderBy: { received_at: 'desc', }, + take: itemsPerPage, + skip: offset, + }); + return res.json({ + data: webhooks, + meta: { + pagination: { + total: webhookCount, + pages: Math.ceil(webhookCount / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, + }, }); - return res.json(webhooks); }); // Webhooks by model type app.get('/admin/apisearch/hooks/model/:modelType', async (req, res) => { const modelType = req.params.modelType; + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + const offset = getOffset(pageNum, itemsPerPage); + const webhookCount = await prisma.intraWebhook.count({ + where: { + model: modelType, + }, + }); const webhooks = await prisma.intraWebhook.findMany({ where: { model: modelType, @@ -527,12 +774,30 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) received_at: 'desc', }, }); - return res.json(webhooks); + return res.json({ + data: webhooks, + meta: { + pagination: { + total: webhookCount, + pages: Math.ceil(webhookCount / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, + }, + }); }); // Webhooks by event type app.get('/admin/apisearch/hooks/event/:eventType', async (req, res) => { const eventType = req.params.eventType; + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + const offset = getOffset(pageNum, itemsPerPage); + const webhookCount = await prisma.intraWebhook.count({ + where: { + event: eventType, + }, + }); const webhooks = await prisma.intraWebhook.findMany({ where: { event: eventType, @@ -541,12 +806,32 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) received_at: 'desc', }, }); - return res.json(webhooks); + return res.json({ + data: webhooks, + meta: { + pagination: { + total: webhookCount, + pages: Math.ceil(webhookCount / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, + }, + }); }); // Webhooks by (part of) the body app.get('/admin/apisearch/hooks/body/:body', async (req, res) => { const body = req.params.body; + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + const offset = getOffset(pageNum, itemsPerPage); + const webhookCount = await prisma.intraWebhook.count({ + where: { + body: { + contains: body, + }, + }, + }); const webhooks = await prisma.intraWebhook.findMany({ where: { body: { @@ -557,7 +842,17 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) received_at: 'desc', }, }); - return res.json(webhooks); + return res.json({ + data: webhooks, + meta: { + pagination: { + total: webhookCount, + pages: Math.ceil(webhookCount / itemsPerPage), + page: pageNum, + per_page: itemsPerPage, + }, + }, + }); }); // EVENTS @@ -565,18 +860,31 @@ export const setupAPISearchRoutes = function(app: Express, prisma: PrismaClient) // Store them in a node-cache to speed up loading app.get('/admin/apisearch/events', async (req, res) => { try { - if (apiSearcherCache.has('recent_events')) { - return res.json(apiSearcherCache.get('recent_events')); + const itemsPerPage = 50; + const pageNum = getPageNumber(req, NaN); + if (apiSearcherCache.has(`recent_events_p${pageNum}`)) { + const cachedEvents = apiSearcherCache.get(`recent_events_p${pageNum}`) as { data: any, headers: any }; + return res.json({ + data: cachedEvents.data, + meta: { + pagination: getPaginationMeta(cachedEvents.headers), + }, + }); } const api = await getAPIClient(); const recentEvents = await fetchSingleApiPage(api, `/campus/${CAMPUS_ID}/events`, { ...API_DEFAULT_FILTERS_EVENTS, - 'page[size]': '100', + 'page[size]': itemsPerPage.toString(), 'filter[future]': 'false', }); - apiSearcherCache.set('recent_events', recentEvents, 60 * 60 * 3); // cache for 3 hours - return res.json(recentEvents); + apiSearcherCache.set(`recent_events_p${pageNum}`, recentEvents, 60 * 60 * 3); // cache for 3 hours + return res.json({ + data: recentEvents.data, + meta: { + pagination: getPaginationMeta(recentEvents.headers), + }, + }); } catch (err) { console.log(err); diff --git a/src/routes/admin/points.ts b/src/routes/admin/points.ts index ef18c3f..25c4990 100644 --- a/src/routes/admin/points.ts +++ b/src/routes/admin/points.ts @@ -1,7 +1,7 @@ import { Express } from 'express'; import { CodamCoalitionScore, PrismaClient } from '@prisma/client'; import fs from 'fs'; -import { fetchSingleApiPage, getAPIClient, getPageNav } from '../../utils'; +import { fetchSingleApiPage, getAPIClient, getOffset, getPageNav, getPageNumber } from '../../utils'; import { ExpressIntraUser } from '../../sync/oauth'; import { createScore, handleFixedPointScore, shiftScore } from '../../handlers/points'; @@ -12,11 +12,8 @@ export const setupAdminPointsRoutes = function(app: Express, prisma: PrismaClien // Calculate the total amount of pages const totalScores = await prisma.codamCoalitionScore.count(); const totalPages = Math.ceil(totalScores / SCORES_PER_PAGE); - const pageNum = (req.query.page ? parseInt(req.query.page as string) : 1); - if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) { - return res.status(404).send('Page not found'); - } - const offset = (pageNum - 1) * SCORES_PER_PAGE; + const pageNum = getPageNumber(req, totalPages); + const offset = getOffset(pageNum, SCORES_PER_PAGE); // Retrieve the scores to be displayed on the page const scores = await prisma.codamCoalitionScore.findMany({ @@ -389,7 +386,8 @@ export const setupAdminPointsRoutes = function(app: Express, prisma: PrismaClien // Verify the event exists on Intra const api = await getAPIClient(); - const intraEvent = await fetchSingleApiPage(api, `/events/${eventId}`); + const apires = await fetchSingleApiPage(api, `/events/${eventId}`); + const intraEvent = apires.data; if (!intraEvent) { return res.status(404).send('Intra event not found'); } diff --git a/src/routes/hooks/triggers.ts b/src/routes/hooks/triggers.ts index fa80603..a9cf9b5 100644 --- a/src/routes/hooks/triggers.ts +++ b/src/routes/hooks/triggers.ts @@ -11,7 +11,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl // ID belongs to a location ID in the intra system const api = await getAPIClient(); try { - const location: Location = await fetchSingleApiPage(api, `/locations/${req.params.id}`, {}) as Location; + const apires = await fetchSingleApiPage(api, `/locations/${req.params.id}`, {}); + const location: Location = apires.data as Location; if (location === null) { console.error(`Failed to find location ${req.params.id}, cannot trigger location close webhook`); return res.status(404).json({ error: 'Location not found' }); @@ -34,7 +35,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl // ID belongs to a team ID in the intra system const api = await getAPIClient(); try { - const team = await fetchSingleApiPage(api, `/teams/${req.params.id}`, {}); + const apires = await fetchSingleApiPage(api, `/teams/${req.params.id}`, {}); + const team = apires.data; if (team === null) { console.error(`Failed to find team ${req.params.id}, cannot trigger projectsUser update webhook for team users`); return res.status(404).json({ error: 'Team not found' }); @@ -46,7 +48,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl console.warn(`User ${user.id} in team ${team.id} has no projects_user ID, skipping projectsUser update webhook...`); } try { - const projectUser: ProjectUser = await fetchSingleApiPage(api, `/projects_users/${user.projects_user_id}`, {}) as ProjectUser; + const apires2 = await fetchSingleApiPage(api, `/projects_users/${user.projects_user_id}`, {}); + const projectUser: ProjectUser = apires2.data as ProjectUser; await handleProjectsUserUpdateWebhook(prisma, projectUser); } catch (err) { @@ -65,11 +68,8 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl // ID belongs to a projects_user ID in the intra system const api = await getAPIClient(); try { - const projectUser: ProjectUser = await fetchSingleApiPage(api, `/projects_users/${req.params.id}`, {}) as ProjectUser; - if (projectUser === null) { - console.error(`Failed to find projects_user ${req.params.id}, cannot trigger projectsUser update webhook`); - return res.status(404).json({ error: 'Project user not found' }); - } + const apires = await fetchSingleApiPage(api, `/projects_users/${req.params.id}`, {}); + const projectUser: ProjectUser = apires.data as ProjectUser; await handleProjectsUserUpdateWebhook(prisma, projectUser); return res.status(200).json({ status: 'ok' }); } @@ -98,11 +98,9 @@ export const setupWebhookTriggerRoutes = function(app: Express, prisma: PrismaCl // ID belongs to a scale_team ID in the intra system const api = await getAPIClient(); try { - const scaleTeam: ScaleTeam = await fetchSingleApiPage(api, `/scale_teams/${req.params.id}`, {}) as ScaleTeam; - if (scaleTeam === null) { - console.error(`Failed to find scale_team ${req.params.id}, cannot trigger scale_team update webhook`); - return res.status(404).json({ error: 'Scale team not found' }); - } + const apires = await fetchSingleApiPage(api, `/scale_teams/${req.params.id}`, {}); + const scaleTeam: ScaleTeam = apires.data as ScaleTeam; + await handleScaleTeamUpdateWebhook(prisma, scaleTeam); return res.status(200).json({ status: 'ok' }); } diff --git a/src/utils.ts b/src/utils.ts index 393dac8..0653427 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import Fast42 from "@codam/fast42"; import { api } from "./main"; import { CURSUS_ID } from "./env"; import NodeCache from "node-cache"; +import { Request } from "express"; export const getAPIClient = async function(): Promise { if (!api) { @@ -12,19 +13,38 @@ export const getAPIClient = async function(): Promise { return api; }; -export const fetchSingleApiPage = async function(api: Fast42, endpoint: string, params: Record = {}): Promise { - const job = await api.getPage(endpoint, "1", params); - try { - if (job.status !== 200) { - console.error(`Failed to fetch page ${endpoint} with status ${job.status}`); - return null; +export const fetchSingleApiPage = function(api: Fast42, endpoint: string, params: Record = {}, pageNum: number = 1): Promise<{headers: any, data: any}> { + return new Promise(async (resolve, reject) => { + try { + const job = await api.getPage(endpoint, pageNum.toString(), params); + if (job.status !== 200) { + console.error(`Failed to fetch page ${endpoint} with status ${job.status}`); + reject(`Failed to fetch page ${endpoint} with status ${job.status}`); + } + const headers = job.headers.raw(); + const data = await job.json(); + resolve({ headers, data }); + } + catch (err) { + console.error(`Failed to fetch page ${endpoint}`, err); + reject(err); } - return await job.json(); + }); +}; + +export const getPageNumber = function(req: Request, totalPages: number | null): number { + const pageNum = (req.query.page ? parseInt(req.query.page as string) : 1); + if (pageNum < 1 || isNaN(pageNum)) { + return 1; } - catch (err) { - console.error(`Failed to fetch page ${endpoint}`, err); - return null; + if (totalPages && !isNaN(totalPages) && pageNum > totalPages) { + return totalPages; } + return pageNum; +}; + +export const getOffset = function(pageNum: number, itemsPerPage: number): number { + return (pageNum - 1) * itemsPerPage; }; export const isStudentOrStaff = async function(prisma: PrismaClient, intraUser: ExpressIntraUser | IntraUser): Promise { diff --git a/static/js/apisearcher-inputlist.js b/static/js/apisearcher-inputlist.js index af456c6..39fdf87 100644 --- a/static/js/apisearcher-inputlist.js +++ b/static/js/apisearcher-inputlist.js @@ -68,12 +68,12 @@ const ApiSearcherInputList = function(options) { } }; - this.clearAndLoadResults = (data) => { + this.clearAndLoadResults = (results) => { this.clear(); // Load results into datalist const options = []; - for (const row of data) { + for (const row of results.data) { const option = document.createElement('option'); const optionValue = []; for (const dataKey of this.dataKeys) { diff --git a/static/js/apisearcher-table.js b/static/js/apisearcher-table.js index ad39a16..7e34cde 100644 --- a/static/js/apisearcher-table.js +++ b/static/js/apisearcher-table.js @@ -82,6 +82,14 @@ const ApiSearcherTable = function(options) { this.filterForm = document.querySelector(this.options['filterForm']); this.filterForm.addEventListener('submit', this.search); + // Page navigation setup + if (this.options['pageNav']) { + this.pageNav = document.querySelector(this.options['pageNav']); + } + else { + this.pageNav = null; + } + // Points calculator setup if (this.options['noPoints'] !== true) { if (!this.options['pointsCalculator']) { @@ -120,12 +128,94 @@ const ApiSearcherTable = function(options) { } }; + this.setupPagination = (results) => { + const pageNav = []; + const currentPageNum = results.meta.pagination.page; + const totalPages = results.meta.pagination.pages; + const maxPages = 5; + const halfMaxPages = Math.floor(maxPages / 2); + let startPage = Math.max(1, currentPageNum - halfMaxPages); + let endPage = Math.min(totalPages, startPage + maxPages - 1); + + if (endPage - startPage < maxPages - 1) { + startPage = Math.max(1, endPage - maxPages + 1); + } + if (endPage - startPage < maxPages - 1) { + endPage = Math.min(totalPages, startPage + maxPages - 1); + } + if (endPage - startPage < maxPages - 1) { + startPage = Math.max(1, endPage - maxPages + 1); + } + if (startPage > 1) { + pageNav.push({ + num: 1, + active: false, + text: 'First', + }); + pageNav.push({ + num: currentPageNum - 1, + active: false, + text: '<', + }); + } + for (let i = startPage; i <= endPage; i++) { + pageNav.push({ + num: i, + active: i === currentPageNum, + text: i.toString(), + }); + } + if (endPage < totalPages) { + pageNav.push({ + num: currentPageNum + 1, + active: false, + text: '>', + }); + pageNav.push({ + num: totalPages, + active: false, + text: 'Last', + }); + } + + const ul = document.createElement('ul'); + ul.classList.add('pagination'); + for (const page of pageNav) { + const li = document.createElement('li'); + li.classList.add('page-item'); + if (page.active) { + li.classList.add('active'); + li.setAttribute('aria-current', 'page'); + } + const a = document.createElement('a'); + a.classList.add('page-link'); + a.innerText = page.text; + a.href = '?page=' + page.num; + a.addEventListener('click', (e) => { + e.preventDefault(); + this.clearAndShowLoading(); + this.search(null, e.target.href.split('=')[1]); + // TODO: update page URL + }); + li.appendChild(a); + ul.appendChild(li); + } + this.pageNav.appendChild(ul); + }; + this.clear = () => { // Delete all tbody children const tbody = this.results.querySelector('tbody'); while (tbody.firstChild) { tbody.removeChild(tbody.firstChild); } + + // Clear pagination + if (this.pageNav) { + while (this.pageNav.firstChild) { + this.pageNav.removeChild(this.pageNav.firstChild); + } + } }; this.clearAndShowLoading = () => { @@ -149,13 +239,18 @@ const ApiSearcherTable = function(options) { } }; - this.clearAndLoadResults = (data) => { + this.clearAndLoadResults = (results) => { this.clear(); const tbody = this.results.querySelector('tbody'); + // Set up pagination + if (this.pageNav) { + this.setupPagination(results); + } + // Load results into table const trs = []; - for (const row of data) { + for (const row of results.data) { const tr = document.createElement('tr'); for (const header of this.headers) { const td = document.createElement('td'); @@ -289,19 +384,37 @@ const ApiSearcherTable = function(options) { tbody.append(...trs); }; - this.search = async (e) => { + this.abortController = null; + this.search = async (e, pageNum = 1) => { if (e) { e.preventDefault(); } + // Abort any ongoing fetch requests + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } // Check if a filter should be applied let filter = ''; if (this.filterSelector.value != 'none' && this.filterValue.value != '') { filter = this.filterSelector.value + '/' + this.filterValue.value; + // TODO: update page URL with filter } this.clearAndShowLoading(); - const req = await fetch(this.concatUrlPaths(this.url, filter)); - const results = await req.json(); - this.clearAndLoadResults(results); + try { + this.abortController = new AbortController(); + fetch(this.concatUrlPaths(this.url, filter) + '?page=' + pageNum, { signal: this.abortController.signal }) + .then(req => req.json()) + .then(results => this.clearAndLoadResults(results)) + } + catch (err) { + if (err.name === 'AbortError') { + console.log('Search aborted'); + } + else { + console.error('An error occurred while fetching data', err); + } + } }; this.init(); diff --git a/templates/admin/hooks/history.njk b/templates/admin/hooks/history.njk index 91c20a7..81e4af9 100644 --- a/templates/admin/hooks/history.njk +++ b/templates/admin/hooks/history.njk @@ -46,6 +46,8 @@ + +