diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 00000000..f5b9b312 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,34 @@ +name: autofix.ci # needed to securely identify the workflow + +on: + pull_request: + push: + branches: + - '**' +permissions: + contents: read + +jobs: + autofix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.1.3 + + - name: Install dependencies (backend) + run: cd interapp-backend && bun install + + - name: Format code (backend) + run: cd interapp-backend && bun run prettier + + - name: Install dependencies (frontend) + run: cd interapp-frontend && bun install + + - name: Format code (frontend) + run: cd interapp-frontend && bun run prettier + + - uses: autofix-ci/action@d3e591514b99d0fca6779455ff8338516663f7cc \ No newline at end of file diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 4fc5d4c3..8a75b98d 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -8,11 +8,12 @@ on: jobs: test-backend: + name: Test backend runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build test environment run: docker compose -f docker-compose.test.yml build --no-cache @@ -27,18 +28,23 @@ jobs: - name: Install dependencies run: cd interapp-backend && bun install - - name: Test with bun + + - name: Lint code + run: cd interapp-backend && bun run lint + + - name: Run unit and api tests run: cd interapp-backend && bun run test - name: Tear down test environment run: docker compose -f docker-compose.test.yml down build-application: + name: Build application runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup prod environment run: docker compose -f docker-compose.prod.yml up -d --build diff --git a/interapp-backend/api/models/auth.ts b/interapp-backend/api/models/auth.ts index 8ae65660..f11c0e4e 100644 --- a/interapp-backend/api/models/auth.ts +++ b/interapp-backend/api/models/auth.ts @@ -4,6 +4,7 @@ import { HTTPErrors } from '@utils/errors'; import { SignJWT, jwtVerify, JWTPayload, JWTVerifyResult } from 'jose'; import redisClient from '@utils/init_redis'; +import minioClient from '@utils/init_minio'; export interface UserJWT { user_id: number; @@ -12,6 +13,8 @@ export interface UserJWT { type JWTtype = 'access' | 'refresh'; +const MINIO_BUCKETNAME = process.env.MINIO_BUCKETNAME as string; + export class AuthModel { private static readonly accessSecret = new TextEncoder().encode( process.env.JWT_ACCESS_SECRET as string, @@ -79,6 +82,7 @@ export class AuthModel { 'user.email', 'user.verified', 'user.service_hours', + 'user.profile_picture', ]) .from(User, 'user') .leftJoinAndSelect('user.user_permissions', 'user_permissions') @@ -109,6 +113,9 @@ export class AuthModel { email: user.email, verified: user.verified, service_hours: user.service_hours, + profile_picture: user.profile_picture + ? await minioClient.presignedGetObject(MINIO_BUCKETNAME, user.profile_picture) + : null, permissions: user.user_permissions.map((perm) => perm.permission_id), }; diff --git a/interapp-backend/api/models/exports.ts b/interapp-backend/api/models/exports/exports_attendance.ts similarity index 68% rename from interapp-backend/api/models/exports.ts rename to interapp-backend/api/models/exports/exports_attendance.ts index bb265e65..dc2b0237 100644 --- a/interapp-backend/api/models/exports.ts +++ b/interapp-backend/api/models/exports/exports_attendance.ts @@ -1,48 +1,20 @@ -import appDataSource from '@utils/init_datasource'; -import { ServiceSession, AttendanceStatus } from '@db/entities'; -import xlsx, { WorkSheet } from 'node-xlsx'; +import { + AttendanceExportsResult, + AttendanceExportsXLSX, + AttendanceQueryExportsConditions, + ExportsModelImpl, + staticImplements, +} from './types'; +import { BaseExportsModel } from './exports_base'; +import { ServiceSession, type AttendanceStatus } from '@db/entities'; import { HTTPErrors } from '@utils/errors'; +import { WorkSheet } from 'node-xlsx'; +import appDataSource from '@utils/init_datasource'; -type ExportsResult = { - service_session_id: number; - start_time: string; - end_time: string; - service: { - name: string; - service_id: number; - }; - service_session_users: { - service_session_id: number; - username: string; - ad_hoc: boolean; - attended: AttendanceStatus; - is_ic: boolean; - }[]; -}; - -type ExportsXLSX = [['username', ...string[]], ...[string, ...(AttendanceStatus | null)[]][]]; - -type QueryExportsConditions = { - id: number; -} & ( - | { - start_date: string; // ISO strings, we have already validated this - end_date: string; - } - | { - start_date?: never; - end_date?: never; - } -); - -export class ExportsModel { - private static getSheetOptions = (ret: ExportsXLSX) => ({ - '!cols': [{ wch: 24 }, ...Array(ret.length).fill({ wch: 16 })], - }); - private static constructXLSX = (...data: Parameters[0]) => xlsx.build(data); - - public static async queryExports({ id, start_date, end_date }: QueryExportsConditions) { - let res: ExportsResult[]; +@staticImplements() +export class AttendanceExportsModel extends BaseExportsModel { + public static async queryExports({ id, start_date, end_date }: AttendanceQueryExportsConditions) { + let res: AttendanceExportsResult[]; if (start_date === undefined || end_date === undefined) { res = await appDataSource.manager .createQueryBuilder() @@ -82,16 +54,16 @@ export class ExportsModel { return res; } - public static async formatXLSX(conds: QueryExportsConditions) { + public static async formatXLSX(conds: AttendanceQueryExportsConditions) { const ret = await this.queryExports(conds); if (ret.length === 0) throw HTTPErrors.RESOURCE_NOT_FOUND; // create headers // start_time is in ascending order - const headers: ExportsXLSX[0] = (['username'] as ExportsXLSX[0]).concat( + const headers = (['username'] as AttendanceExportsXLSX[0]).concat( ret.map(({ start_time }) => start_time), - ) as ExportsXLSX[0]; + ) as AttendanceExportsXLSX[0]; // output needs to be in the form: // [username, [attendance status]] @@ -118,15 +90,13 @@ export class ExportsModel { }); }); - const body: ExportsXLSX[1][] = Object.entries(usernameMap).map(([username, attendance]) => [ - username, - ...attendance, - ]); + const body: AttendanceExportsXLSX[1][] = Object.entries(usernameMap).map( + ([username, attendance]) => [username, ...attendance], + ); - const out: ExportsXLSX = [headers, ...body]; + const out: AttendanceExportsXLSX = [headers, ...body]; const sheetOptions = this.getSheetOptions(out); - console.log(sheetOptions); return { name: ret[0].service.name, data: out, options: sheetOptions }; } diff --git a/interapp-backend/api/models/exports/exports_base.ts b/interapp-backend/api/models/exports/exports_base.ts new file mode 100644 index 00000000..2450b883 --- /dev/null +++ b/interapp-backend/api/models/exports/exports_base.ts @@ -0,0 +1,8 @@ +import xlsx from 'node-xlsx'; + +export class BaseExportsModel { + protected static getSheetOptions = (ret: T) => ({ + '!cols': [{ wch: 24 }, ...Array(ret.length).fill({ wch: 16 })], + }); + protected static constructXLSX = (...data: Parameters[0]) => xlsx.build(data); +} diff --git a/interapp-backend/api/models/exports/exports_service_hours.ts b/interapp-backend/api/models/exports/exports_service_hours.ts new file mode 100644 index 00000000..9efb8ec9 --- /dev/null +++ b/interapp-backend/api/models/exports/exports_service_hours.ts @@ -0,0 +1,15 @@ +import { + AttendanceExportsResult, + AttendanceExportsXLSX, + AttendanceQueryExportsConditions, + ExportsModelImpl, + staticImplements, +} from './types'; +import { BaseExportsModel } from './exports_base'; +import { ServiceSession, type AttendanceStatus } from '@db/entities'; +import { HTTPErrors } from '@utils/errors'; +import { WorkSheet } from 'node-xlsx'; +import appDataSource from '@utils/init_datasource'; + +// @staticImplements() +export class ServiceHoursExportsModel extends BaseExportsModel {} diff --git a/interapp-backend/api/models/exports/index.ts b/interapp-backend/api/models/exports/index.ts new file mode 100644 index 00000000..eaa34eca --- /dev/null +++ b/interapp-backend/api/models/exports/index.ts @@ -0,0 +1,2 @@ +export { AttendanceExportsModel } from './exports_attendance'; +export { ServiceHoursExportsModel } from './exports_service_hours'; diff --git a/interapp-backend/api/models/exports/types.ts b/interapp-backend/api/models/exports/types.ts new file mode 100644 index 00000000..fb3c0e43 --- /dev/null +++ b/interapp-backend/api/models/exports/types.ts @@ -0,0 +1,50 @@ +import { AttendanceStatus } from '@db/entities'; + +export interface ExportsModelImpl { + queryExports(conds: unknown): Promise; + formatXLSX(conds: unknown): Promise; + packXLSX(ids: number[], start_date?: string, end_date?: string): Promise; +} + +// class decorator that asserts that a class implements an interface statically +// https://stackoverflow.com/a/43674389 +export function staticImplements() { + return (constructor: U) => { + constructor; // NOSONAR + }; +} + +export type AttendanceExportsResult = { + service_session_id: number; + start_time: string; + end_time: string; + service: { + name: string; + service_id: number; + }; + service_session_users: { + service_session_id: number; + username: string; + ad_hoc: boolean; + attended: AttendanceStatus; + is_ic: boolean; + }[]; +}; + +export type AttendanceExportsXLSX = [ + ['username', ...string[]], + ...[string, ...(AttendanceStatus | null)[]][], +]; + +export type AttendanceQueryExportsConditions = { + id: number; +} & ( + | { + start_date: string; // ISO strings, we have already validated this + end_date: string; + } + | { + start_date?: never; + end_date?: never; + } +); diff --git a/interapp-backend/api/models/service.ts b/interapp-backend/api/models/service.ts index 1ed81238..8f27a6df 100644 --- a/interapp-backend/api/models/service.ts +++ b/interapp-backend/api/models/service.ts @@ -347,21 +347,54 @@ export class ServiceModel { })); } public static async verifyAttendance(hash: string, username: string) { - const service_session_id = await redisClient.hGet('service_session', hash); - if (!service_session_id) { + const id = await redisClient.hGet('service_session', hash); + if (!id) { throw HTTPErrors.INVALID_HASH; } - const service_session_user = await this.getServiceSessionUser( - parseInt(service_session_id), - username, - ); + const service_session_id = parseInt(id); + + const service_session_user = await this.getServiceSessionUser(service_session_id, username); if (service_session_user.attended === AttendanceStatus.Attended) { throw HTTPErrors.ALREADY_ATTENDED; } service_session_user.attended = AttendanceStatus.Attended; await this.updateServiceSessionUser(service_session_user); - return service_session_user; + + // get some metadata and return it to the user + + type _Return = { + start_time: string; + end_time: string; + service_hours: number; + name: string; + ad_hoc: boolean; + }; + const res = await appDataSource.manager + .createQueryBuilder() + .select([ + 'service_session.start_time', + 'service_session.end_time', + 'service_session.service_hours', + 'service.name', + ]) + .from(ServiceSession, 'service_session') + .leftJoin('service_session.service', 'service') + .where('service_session_id = :id', { id: service_session_id }) + .getOne(); + + // literally impossible for this to be null + if (!res) { + throw HTTPErrors.RESOURCE_NOT_FOUND; + } + + return { + start_time: res.start_time, + end_time: res.end_time, + service_hours: res.service_hours, + name: res.service.name, + ad_hoc: service_session_user.ad_hoc, + } as _Return; } public static async getAdHocServiceSessions() { const res = await appDataSource.manager diff --git a/interapp-backend/api/models/user.ts b/interapp-backend/api/models/user.ts index 49206f05..a49931cd 100644 --- a/interapp-backend/api/models/user.ts +++ b/interapp-backend/api/models/user.ts @@ -364,7 +364,7 @@ export class UserModel { session.service_session.service.promotional_image = url; } } - let parsed: { + const parsed: { service_id: number; start_time: string; end_time: string; @@ -375,7 +375,7 @@ export class UserModel { ad_hoc: boolean; attended: string; is_ic: boolean; - service_session?: any; + service_session?: unknown; }[] = serviceSessions.map((session) => ({ ...session, service_id: session.service_session.service_id, @@ -405,19 +405,19 @@ export class UserModel { if (usernames.length === 0) { throw HTTPErrors.SERVICE_NO_USER_FOUND; } - const users: Pick[] = - await appDataSource.manager - .createQueryBuilder() - .select([ - 'user.username', - 'user.user_id', - 'user.email', - 'user.verified', - 'user.service_hours', - ]) - .from(User, 'user') - .where('user.username IN (:...usernames)', { usernames }) - .getMany(); + const users: UserWithoutSensitiveFields[] = await appDataSource.manager + .createQueryBuilder() + .select([ + 'user.username', + 'user.user_id', + 'user.email', + 'user.verified', + 'user.service_hours', + 'user.profile_picture', + ]) + .from(User, 'user') + .where('user.username IN (:...usernames)', { usernames }) + .getMany(); return users; } @@ -467,12 +467,8 @@ export class UserModel { .getMany(); }; - const toAdd = data - .filter(({ action, username }) => action === 'add') - .map((data) => data.username); - const toRemove = data - .filter(({ action, username }) => action === 'remove') - .map((data) => data.username); + const toAdd = data.filter(({ action }) => action === 'add').map((data) => data.username); + const toRemove = data.filter(({ action }) => action === 'remove').map((data) => data.username); if (toAdd.length !== 0) await appDataSource.manager.insert( @@ -505,10 +501,10 @@ export class UserModel { // it ADDS a certain number of hours to the user's service hours, and does not set it to a specific value like the previous one public static async updateServiceHoursBulk(data: { username: string; hours: number }[]) { const queryRunner = appDataSource.createQueryRunner(); - + // start a new transaction await queryRunner.startTransaction(); - + try { await Promise.all( data.map(async ({ username, hours }) => { @@ -518,14 +514,14 @@ export class UserModel { .from(User, 'user') .where('user.username = :username', { username }) .getOne(); - + if (!user) throw HTTPErrors.RESOURCE_NOT_FOUND; - + user.service_hours += hours; await queryRunner.manager.update(User, { username }, user); }), ); - + // commit the transaction if no errors were thrown await queryRunner.commitTransaction(); } catch (error) { @@ -557,6 +553,10 @@ export class UserModel { user.profile_picture = `profile_pictures/${username}`; await appDataSource.manager.update(User, { username }, user); + // sign and return url + return { + url: await minioClient.presignedGetObject(MINIO_BUCKETNAME, `profile_pictures/${username}`), + }; } public static async deleteProfilePicture(username: string) { const user = await appDataSource.manager diff --git a/interapp-backend/api/routes/endpoints/announcement/announcement.ts b/interapp-backend/api/routes/endpoints/announcement/announcement.ts index dec35b61..d8331b1a 100644 --- a/interapp-backend/api/routes/endpoints/announcement/announcement.ts +++ b/interapp-backend/api/routes/endpoints/announcement/announcement.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { validateRequiredFieldsV2, verifyJWT, verifyRequiredPermission } from '../../middleware'; +import { validateRequiredFields, verifyJWT, verifyRequiredPermission } from '../../middleware'; import { AnnouncementIdFields, CreateAnnouncementFields, @@ -19,7 +19,7 @@ const announcementRouter = Router(); announcementRouter.post( '/', upload.array('attachments', 10), - validateRequiredFieldsV2(CreateAnnouncementFields), + validateRequiredFields(CreateAnnouncementFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -38,7 +38,7 @@ announcementRouter.post( announcementRouter.get( '/', - validateRequiredFieldsV2(AnnouncementIdFields), + validateRequiredFields(AnnouncementIdFields), verifyJWT, async (req, res) => { const query = req.query as unknown as z.infer; @@ -49,7 +49,7 @@ announcementRouter.get( announcementRouter.get( '/all', - validateRequiredFieldsV2(PaginationFields), + validateRequiredFields(PaginationFields), verifyJWT, async (req, res) => { const query = req.query as unknown as z.infer; @@ -64,7 +64,7 @@ announcementRouter.get( announcementRouter.patch( '/', upload.array('attachments', 10), - validateRequiredFieldsV2(UpdateAnnouncementFields), + validateRequiredFields(UpdateAnnouncementFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -78,7 +78,7 @@ announcementRouter.patch( announcementRouter.delete( '/', - validateRequiredFieldsV2(AnnouncementIdFields), + validateRequiredFields(AnnouncementIdFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -90,7 +90,7 @@ announcementRouter.delete( announcementRouter.get( '/completion', - validateRequiredFieldsV2(AnnouncementIdFields), + validateRequiredFields(AnnouncementIdFields), verifyJWT, async (req, res) => { const completions = await AnnouncementModel.getAnnouncementCompletions( @@ -103,7 +103,7 @@ announcementRouter.get( announcementRouter.patch( '/completion', - validateRequiredFieldsV2(AnnouncementCompletionFields), + validateRequiredFields(AnnouncementCompletionFields), verifyJWT, async (req, res) => { const body: z.infer = req.body; diff --git a/interapp-backend/api/routes/endpoints/auth/auth.ts b/interapp-backend/api/routes/endpoints/auth/auth.ts index 6020b999..ba9acad7 100644 --- a/interapp-backend/api/routes/endpoints/auth/auth.ts +++ b/interapp-backend/api/routes/endpoints/auth/auth.ts @@ -1,19 +1,19 @@ import { Router } from 'express'; -import { validateRequiredFieldsV2, verifyJWT } from '../../middleware'; +import { validateRequiredFields, verifyJWT } from '../../middleware'; import { SignupFields, SigninFields } from './validation'; import { AuthModel } from '@models/.'; import { z } from 'zod'; const authRouter = Router(); -authRouter.post('/signup', validateRequiredFieldsV2(SignupFields), async (req, res) => { +authRouter.post('/signup', validateRequiredFields(SignupFields), async (req, res) => { const body: z.infer = req.body; await AuthModel.signUp(body.user_id, body.username, body.email, body.password); res.status(201).send(); }); -authRouter.post('/signin', validateRequiredFieldsV2(SigninFields), async (req, res) => { +authRouter.post('/signin', validateRequiredFields(SigninFields), async (req, res) => { const body: z.infer = req.body; const { token, refresh, user, expire } = await AuthModel.signIn(body.username, body.password); res.cookie('refresh', refresh, { diff --git a/interapp-backend/api/routes/endpoints/exports/exports.ts b/interapp-backend/api/routes/endpoints/exports/exports.ts index d4fb80f0..c8a6db6c 100644 --- a/interapp-backend/api/routes/endpoints/exports/exports.ts +++ b/interapp-backend/api/routes/endpoints/exports/exports.ts @@ -1,6 +1,6 @@ -import { ExportsModel } from '@models/exports'; +import { AttendanceExportsModel } from '@models/exports'; import { z } from 'zod'; -import { validateRequiredFieldsV2, verifyJWT, verifyRequiredPermission } from '@routes/middleware'; +import { validateRequiredFields, verifyJWT, verifyRequiredPermission } from '@routes/middleware'; import { ExportsFields } from './validation'; import { Router } from 'express'; import { Permissions } from '@utils/permissions'; @@ -9,13 +9,23 @@ export const exportsRouter = Router(); const xlsxMime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; -exportsRouter.get('/', validateRequiredFieldsV2(ExportsFields), verifyJWT, verifyRequiredPermission(Permissions.ATTENDANCE_MANAGER), async (req, res) => { - const query = req.query as unknown as z.infer; +exportsRouter.get( + '/', + validateRequiredFields(ExportsFields), + verifyJWT, + verifyRequiredPermission(Permissions.ATTENDANCE_MANAGER), + async (req, res) => { + const query = req.query as unknown as z.infer; - const exports = await ExportsModel.packXLSX(query.id, query.start_date, query.end_date); + const exports = await AttendanceExportsModel.packXLSX( + query.id, + query.start_date, + query.end_date, + ); - res.setHeader('Content-Type', xlsxMime); - res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); - res.type(xlsxMime); - res.status(200).send(exports); -}); + res.setHeader('Content-Type', xlsxMime); + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.type(xlsxMime); + res.status(200).send(exports); + }, +); diff --git a/interapp-backend/api/routes/endpoints/service/service.ts b/interapp-backend/api/routes/endpoints/service/service.ts index eebdc24c..ebc22382 100644 --- a/interapp-backend/api/routes/endpoints/service/service.ts +++ b/interapp-backend/api/routes/endpoints/service/service.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { validateRequiredFieldsV2, verifyJWT, verifyRequiredPermission } from '../../middleware'; +import { validateRequiredFields, verifyJWT, verifyRequiredPermission } from '../../middleware'; import { ServiceIdFields, UpdateServiceFields, @@ -15,6 +15,7 @@ import { DeleteBulkServiceSessionUserFields, VerifyAttendanceFields, ServiceSessionIdFields, + FindServiceSessionUserFields, } from './validation'; import { HTTPError, HTTPErrorCode } from '@utils/errors'; import { Permissions } from '@utils/permissions'; @@ -26,7 +27,7 @@ const serviceRouter = Router(); serviceRouter.post( '/', - validateRequiredFieldsV2(CreateServiceFields), + validateRequiredFields(CreateServiceFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -39,7 +40,7 @@ serviceRouter.post( }, ); -serviceRouter.get('/', validateRequiredFieldsV2(ServiceIdFields), async (req, res) => { +serviceRouter.get('/', validateRequiredFields(ServiceIdFields), async (req, res) => { const query = req.query as unknown as z.infer; const service = await ServiceModel.getService(Number(query.service_id)); @@ -48,7 +49,7 @@ serviceRouter.get('/', validateRequiredFieldsV2(ServiceIdFields), async (req, re serviceRouter.patch( '/', - validateRequiredFieldsV2(UpdateServiceFields), + validateRequiredFields(UpdateServiceFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -62,7 +63,7 @@ serviceRouter.patch( serviceRouter.delete( '/', - validateRequiredFieldsV2(ServiceIdFields), + validateRequiredFields(ServiceIdFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -80,7 +81,7 @@ serviceRouter.get('/all', async (req, res) => { serviceRouter.get( '/get_users_by_service', - validateRequiredFieldsV2(ServiceIdFields), + validateRequiredFields(ServiceIdFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -93,7 +94,7 @@ serviceRouter.get( serviceRouter.post( '/session', - validateRequiredFieldsV2(CreateServiceSessionFields), + validateRequiredFields(CreateServiceSessionFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -105,19 +106,15 @@ serviceRouter.post( }, ); -serviceRouter.get( - '/session', - validateRequiredFieldsV2(ServiceSessionIdFields), - async (req, res) => { - const query = req.query as unknown as z.infer; - const session = await ServiceModel.getServiceSession(Number(query.service_session_id)); - res.status(200).send(session); - }, -); +serviceRouter.get('/session', validateRequiredFields(ServiceSessionIdFields), async (req, res) => { + const query = req.query as unknown as z.infer; + const session = await ServiceModel.getServiceSession(Number(query.service_session_id)); + res.status(200).send(session); +}); serviceRouter.patch( '/session', - validateRequiredFieldsV2(UpdateServiceSessionFields), + validateRequiredFields(UpdateServiceSessionFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -130,7 +127,7 @@ serviceRouter.patch( serviceRouter.delete( '/session', - validateRequiredFieldsV2(ServiceSessionIdFields), + validateRequiredFields(ServiceSessionIdFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -142,7 +139,7 @@ serviceRouter.delete( serviceRouter.get( '/session/all', - validateRequiredFieldsV2(AllServiceSessionsFields), + validateRequiredFields(AllServiceSessionsFields), async (req, res) => { const query = req.query as unknown as z.infer; let sessions; @@ -164,7 +161,7 @@ serviceRouter.get( serviceRouter.post( '/session_user', - validateRequiredFieldsV2(CreateServiceSessionUserFields), + validateRequiredFields(CreateServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -177,7 +174,7 @@ serviceRouter.post( serviceRouter.post( '/session_user_bulk', - validateRequiredFieldsV2(CreateBulkServiceSessionUserFields), + validateRequiredFields(CreateBulkServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -193,9 +190,10 @@ serviceRouter.post( serviceRouter.get( '/session_user', - validateRequiredFieldsV2(ServiceSessionUserBulkFields), + validateRequiredFields(FindServiceSessionUserFields), async (req, res) => { - const query = req.query as unknown as z.infer; + const query = req.query as unknown as z.infer; + const session_user = await ServiceModel.getServiceSessionUser( Number(query.service_session_id), String(query.username), @@ -207,14 +205,14 @@ serviceRouter.get( // gets service session user by service_session_id or by username serviceRouter.get( '/session_user_bulk', - validateRequiredFieldsV2(ServiceSessionUserBulkFields), + validateRequiredFields(ServiceSessionUserBulkFields), async (req, res) => { const query = req.query as unknown as z.infer; - if (query.hasOwnProperty('username')) { + if (Object.prototype.hasOwnProperty.call(query, 'username')) { const session_users = await UserModel.getAllServiceSessionsByUser(String(req.query.username)); res.status(200).send(session_users); - } else if (query.hasOwnProperty('service_session_id')) { + } else if (Object.prototype.hasOwnProperty.call(query, 'service_session_id')) { const session_users = await ServiceModel.getServiceSessionUsers( Number(req.query.service_session_id), ); @@ -226,7 +224,7 @@ serviceRouter.get( serviceRouter.patch( '/session_user', - validateRequiredFieldsV2(UpdateServiceSessionUserFields), + validateRequiredFields(UpdateServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -243,7 +241,7 @@ serviceRouter.patch( serviceRouter.patch( '/absence', - validateRequiredFieldsV2(ServiceSessionUserIdFields), + validateRequiredFields(ServiceSessionUserIdFields), verifyJWT, verifyRequiredPermission(Permissions.CLUB_MEMBER), async (req, res) => { @@ -262,7 +260,7 @@ serviceRouter.patch( serviceRouter.delete( '/session_user', - validateRequiredFieldsV2(ServiceSessionUserIdFields), + validateRequiredFields(ServiceSessionUserIdFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -277,7 +275,7 @@ serviceRouter.delete( serviceRouter.delete( '/session_user_bulk', - validateRequiredFieldsV2(DeleteBulkServiceSessionUserFields), + validateRequiredFields(DeleteBulkServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -304,12 +302,12 @@ serviceRouter.get('/ad_hoc_sessions', verifyJWT, async (req, res) => { serviceRouter.post( '/verify_attendance', - validateRequiredFieldsV2(VerifyAttendanceFields), + validateRequiredFields(VerifyAttendanceFields), verifyJWT, async (req, res) => { const body: z.infer = req.body; - await ServiceModel.verifyAttendance(body.hash, req.headers.username as string); - res.status(204).send(); + const meta = await ServiceModel.verifyAttendance(body.hash, req.headers.username as string); + res.status(200).send(meta); }, ); diff --git a/interapp-backend/api/routes/endpoints/service/validation.ts b/interapp-backend/api/routes/endpoints/service/validation.ts index bd9e64fe..0336add5 100644 --- a/interapp-backend/api/routes/endpoints/service/validation.ts +++ b/interapp-backend/api/routes/endpoints/service/validation.ts @@ -115,6 +115,15 @@ export const ServiceSessionUserBulkFields = z.union([ }), ]); +export const FindServiceSessionUserFields = z.object({ + service_session_id: z.coerce + .number() + .int() + .nonnegative() + .max(2 ** 32 - 1), + username: z.string(), +}); + const _ServiceSessionUserFields = z.object({ ad_hoc: z.boolean(), attended: z.nativeEnum(AttendanceStatus), diff --git a/interapp-backend/api/routes/endpoints/user/user.ts b/interapp-backend/api/routes/endpoints/user/user.ts index c5430e18..28b3c286 100644 --- a/interapp-backend/api/routes/endpoints/user/user.ts +++ b/interapp-backend/api/routes/endpoints/user/user.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { validateRequiredFieldsV2, verifyJWT, verifyRequiredPermission } from '../../middleware'; +import { validateRequiredFields, verifyJWT, verifyRequiredPermission } from '../../middleware'; import { OptionalUsername, RequiredUsername, @@ -20,7 +20,7 @@ import { Permissions } from '@utils/permissions'; const userRouter = Router(); -userRouter.get('/', validateRequiredFieldsV2(OptionalUsername), verifyJWT, async (req, res) => { +userRouter.get('/', validateRequiredFields(OptionalUsername), verifyJWT, async (req, res) => { const query: z.infer = req.query; const username = query.username; @@ -55,7 +55,7 @@ userRouter.get('/', validateRequiredFieldsV2(OptionalUsername), verifyJWT, async userRouter.delete( '/', - validateRequiredFieldsV2(RequiredUsername), + validateRequiredFields(RequiredUsername), verifyJWT, verifyRequiredPermission(Permissions.ADMIN), async (req, res) => { @@ -67,7 +67,7 @@ userRouter.delete( userRouter.patch( '/password/change', - validateRequiredFieldsV2(ChangePasswordFields), + validateRequiredFields(ChangePasswordFields), verifyJWT, async (req, res) => { const body: z.infer = req.body; @@ -82,7 +82,7 @@ userRouter.patch( userRouter.post( '/password/reset_email', - validateRequiredFieldsV2(RequiredUsername), + validateRequiredFields(RequiredUsername), async (req, res) => { const body: z.infer = req.body; await UserModel.sendResetPasswordEmail(body.username); @@ -90,7 +90,7 @@ userRouter.post( }, ); -userRouter.patch('/password/reset', validateRequiredFieldsV2(TokenFields), async (req, res) => { +userRouter.patch('/password/reset', validateRequiredFields(TokenFields), async (req, res) => { const body: z.infer = req.body; const newPw = await UserModel.resetPassword(body.token); res.clearCookie('refresh', { path: '/api/auth/refresh' }); @@ -101,7 +101,7 @@ userRouter.patch('/password/reset', validateRequiredFieldsV2(TokenFields), async userRouter.patch( '/change_email', - validateRequiredFieldsV2(ChangeEmailFields), + validateRequiredFields(ChangeEmailFields), verifyJWT, async (req, res) => { const body: z.infer = req.body; @@ -129,7 +129,7 @@ userRouter.post('/verify_email', verifyJWT, async (req, res) => { res.status(204).send(); }); -userRouter.patch('/verify', validateRequiredFieldsV2(TokenFields), verifyJWT, async (req, res) => { +userRouter.patch('/verify', validateRequiredFields(TokenFields), verifyJWT, async (req, res) => { const body: z.infer = req.body; await UserModel.verifyEmail(body.token); res.status(204).send(); @@ -137,7 +137,7 @@ userRouter.patch('/verify', validateRequiredFieldsV2(TokenFields), verifyJWT, as userRouter.patch( '/permissions', - validateRequiredFieldsV2(PermissionsFields), + validateRequiredFields(PermissionsFields), verifyJWT, verifyRequiredPermission(Permissions.ADMIN), async (req, res) => { @@ -150,7 +150,7 @@ userRouter.patch( userRouter.get( '/permissions', - validateRequiredFieldsV2(OptionalUsername), + validateRequiredFields(OptionalUsername), verifyJWT, async (req, res) => { const query: z.infer = req.query; @@ -163,7 +163,7 @@ userRouter.get( userRouter.get( '/userservices', verifyJWT, - validateRequiredFieldsV2(RequiredUsername), + validateRequiredFields(RequiredUsername), async (req, res) => { const query = req.query as unknown as z.infer; const services = await UserModel.getAllServicesByUser(query.username as string); @@ -175,7 +175,7 @@ userRouter.post( '/userservices', verifyJWT, verifyRequiredPermission(Permissions.EXCO), - validateRequiredFieldsV2(ServiceIdFieldsNumeric), + validateRequiredFields(ServiceIdFieldsNumeric), async (req, res) => { const body: z.infer = req.body; await UserModel.addServiceUser(body.service_id, body.username); @@ -187,7 +187,7 @@ userRouter.delete( '/userservices', verifyJWT, verifyRequiredPermission(Permissions.EXCO), - validateRequiredFieldsV2(ServiceIdFieldsNumeric), + validateRequiredFields(ServiceIdFieldsNumeric), async (req, res) => { const body: z.infer = req.body; await UserModel.removeServiceUser(body.service_id, body.username); @@ -199,7 +199,7 @@ userRouter.patch( '/userservices', verifyJWT, verifyRequiredPermission(Permissions.EXCO), - validateRequiredFieldsV2(UpdateUserServicesFields), + validateRequiredFields(UpdateUserServicesFields), async (req, res) => { const body: z.infer = req.body; @@ -211,7 +211,7 @@ userRouter.patch( userRouter.patch( '/service_hours', verifyJWT, - validateRequiredFieldsV2(ServiceHoursFields), + validateRequiredFields(ServiceHoursFields), async (req, res) => { const body: z.infer = req.body; if (body.username) { @@ -236,7 +236,7 @@ userRouter.patch( userRouter.patch( '/service_hours_bulk', - validateRequiredFieldsV2(ServiceHoursBulkFields), + validateRequiredFields(ServiceHoursBulkFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -249,11 +249,14 @@ userRouter.patch( userRouter.patch( '/profile_picture', verifyJWT, - validateRequiredFieldsV2(ProfilePictureFields), + validateRequiredFields(ProfilePictureFields), async (req, res) => { const body: z.infer = req.body; - await UserModel.updateProfilePicture(req.headers.username as string, body.profile_picture); - res.status(204).send(); + const presigned = await UserModel.updateProfilePicture( + req.headers.username as string, + body.profile_picture, + ); + res.status(200).send(presigned); }, ); diff --git a/interapp-backend/api/routes/middleware.ts b/interapp-backend/api/routes/middleware.ts index 5f4b23ad..13a11c4a 100644 --- a/interapp-backend/api/routes/middleware.ts +++ b/interapp-backend/api/routes/middleware.ts @@ -9,7 +9,7 @@ type ReqBody = Partial<{ [key: string]: ReqBody }> | ReqBody[] | string | number type ReqQuery = { [key: string]: string | string[] | undefined }; -export function validateRequiredFieldsV2>(schema: T) { +export function validateRequiredFields>(schema: T) { return (req: Request, res: Response, next: NextFunction) => { const content: unknown = req.method === 'GET' ? req.query : req.body; const validationResult = schema.safeParse(content); diff --git a/interapp-backend/api/utils/errors.ts b/interapp-backend/api/utils/errors.ts index bc0ce39e..37399a16 100644 --- a/interapp-backend/api/utils/errors.ts +++ b/interapp-backend/api/utils/errors.ts @@ -1,13 +1,13 @@ export interface AppError extends Error { name: string; message: string; - data?: Record | Array; + data?: Record | Array; } export class HTTPError extends Error implements AppError { public readonly name: string; public readonly message: string; - public readonly data?: Record | Array; + public readonly data?: Record | Array; public readonly status: HTTPErrorCode; public readonly headers?: Record; @@ -15,7 +15,7 @@ export class HTTPError extends Error implements AppError { name: string, message: string, status: HTTPErrorCode, - data?: Record | Array, + data?: Record | Array, headers?: Record, ) { super(message); @@ -30,9 +30,9 @@ export class HTTPError extends Error implements AppError { export class TestError extends Error implements AppError { public readonly name: string; public readonly message: string; - public readonly data?: Record | Array; + public readonly data?: Record | Array; - constructor(name: string, message: string, data?: Record | Array) { + constructor(name: string, message: string, data?: Record | Array) { super(message); this.name = name; this.message = message; diff --git a/interapp-backend/bun.lockb b/interapp-backend/bun.lockb index 8ba29979..2e231a42 100644 Binary files a/interapp-backend/bun.lockb and b/interapp-backend/bun.lockb differ diff --git a/interapp-backend/eslint.config.js b/interapp-backend/eslint.config.js new file mode 100644 index 00000000..e0cd8639 --- /dev/null +++ b/interapp-backend/eslint.config.js @@ -0,0 +1,6 @@ +import 'eslint-plugin-only-warn'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config(...tseslint.configs.recommended, { + ignores: ['node_modules/', 'pgdata/', 'minio-data/'], +}); diff --git a/interapp-backend/package.json b/interapp-backend/package.json index 60e75b76..f0c5a268 100644 --- a/interapp-backend/package.json +++ b/interapp-backend/package.json @@ -5,7 +5,12 @@ "type": "module", "scripts": { "prettier": "prettier --write . '!./pgdata'", - "test": "NODE_ENV=test bun test --env-file tests/config/.env.test --coverage --timeout 10000", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:strict": "eslint . --max-warnings 0", + "test": "NODE_ENV=test bun test tests/* --env-file tests/config/.env.test --timeout 10000", + "test:api": "NODE_ENV=test bun test tests/api/* --env-file tests/config/.env.test --timeout 10000", + "test:unit": "NODE_ENV=test bun test tests/constants.test.ts tests/unit/* --env-file tests/config/.env.test --coverage --timeout 5000", "typeorm": "typeorm-ts-node-esm", "typeorm:generate": "sh ./scripts/typeorm_generate.sh", "typeorm:run": "typeorm-ts-node-esm migration:run -d db/data_source.ts", @@ -15,6 +20,7 @@ "typeorm:sync": "typeorm-ts-node-esm schema:sync -d db/data_source.ts" }, "devDependencies": { + "@eslint/js": "^9.1.1", "@types/bun": "^1.0.8", "@types/cookie-parser": "^1.4.6", "@types/cors": "^2.8.16", @@ -25,8 +31,11 @@ "@types/nodemailer": "^6.4.14", "@types/nodemailer-express-handlebars": "^4.0.5", "@types/swagger-ui-express": "^4.1.6", + "eslint": "^9.1.0", + "eslint-plugin-only-warn": "^1.1.0", "prettier": "^3.0.3", - "ts-node": "^10.9.1" + "ts-node": "^10.9.1", + "typescript-eslint": "^7.7.0" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/interapp-backend/scheduler/dev.Dockerfile b/interapp-backend/scheduler/dev.Dockerfile index 08bdd6dc..3785c337 100644 --- a/interapp-backend/scheduler/dev.Dockerfile +++ b/interapp-backend/scheduler/dev.Dockerfile @@ -13,7 +13,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list # update and install postgresql-client-16, tzdata -RUN apt-get update && apt-get install -y postgresql-client-16 tzdata && apt-get clean +RUN apt-get update && apt-get install -y --fix-missing postgresql-client-16 tzdata && apt-get clean ENV TZ=Asia/Singapore RUN ln -snf /usr/share/zoneinfo/"$TZ" /etc/localtime && echo "$TZ" > /etc/timezone diff --git a/interapp-backend/scheduler/prod.Dockerfile b/interapp-backend/scheduler/prod.Dockerfile index dfabb266..ce249c53 100644 --- a/interapp-backend/scheduler/prod.Dockerfile +++ b/interapp-backend/scheduler/prod.Dockerfile @@ -13,7 +13,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list # update and install postgresql-client-16, tzdata -RUN apt-get update && apt-get install -y postgresql-client-16 tzdata && apt-get clean +RUN apt-get update && apt-get install -y --fix-missing postgresql-client-16 tzdata && apt-get clean ENV TZ=Asia/Singapore RUN ln -snf /usr/share/zoneinfo/"$TZ" /etc/localtime && echo "$TZ" > /etc/timezone diff --git a/interapp-backend/scheduler/scheduler.ts b/interapp-backend/scheduler/scheduler.ts index f1d8fadc..057ef3f9 100644 --- a/interapp-backend/scheduler/scheduler.ts +++ b/interapp-backend/scheduler/scheduler.ts @@ -42,13 +42,13 @@ async function scheduleSessions() { if (to_be_scheduled.length === 0) return; // schedule services for the week - let created_services: { [id: number]: Record }[] = + const created_services: { [id: number]: Record }[] = []; for (const service of to_be_scheduled) { // create service session // add service session id to created_ids - let detail = { + const detail = { service_id: service.service_id, start_time: constructDate(service.day_of_week, service.start_time).toISOString(), end_time: constructDate(service.day_of_week, service.end_time).toISOString(), @@ -127,7 +127,7 @@ schedule('0 */1 * * * *', async () => { // if yes, remove it from redis else { const hash = Object.entries(hashes).find( - ([k, v]) => v === String(session.service_session_id), + ([, v]) => v === String(session.service_session_id), )?.[0]; if (hash) toDelete.push(hash); @@ -137,8 +137,8 @@ schedule('0 */1 * * * *', async () => { // this is to prevent memory leak // filter out all values that are not found in service_sessions const serviceSessionIds = new Set(service_sessions.map((s) => s.service_session_id)); - const ghost = Object.entries(hashes).filter(([_, v]) => !serviceSessionIds.has(Number(v))); - toDelete.push(...ghost.map(([k, _]) => k)); + const ghost = Object.entries(hashes).filter(([, v]) => !serviceSessionIds.has(Number(v))); + toDelete.push(...ghost.map(([k]) => k)); // remove them all if (toDelete.length > 0) { const operations = toDelete.map((k) => redisClient.hDel('service_session', k)); diff --git a/interapp-backend/tests/e2e/account.test.ts b/interapp-backend/tests/api/account.test.ts similarity index 99% rename from interapp-backend/tests/e2e/account.test.ts rename to interapp-backend/tests/api/account.test.ts index e47ec8f6..1cd0ba28 100644 --- a/interapp-backend/tests/e2e/account.test.ts +++ b/interapp-backend/tests/api/account.test.ts @@ -297,7 +297,10 @@ describe('API (account)', async () => { }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, }); - expect(res.status).toBe(204); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + url: expect.any(String), + }); // check if profile picture is updated const res2 = await fetch(`${API_URL}/user?username=testuser`, { diff --git a/interapp-backend/tests/e2e/announcement.test.ts b/interapp-backend/tests/api/announcement.test.ts similarity index 100% rename from interapp-backend/tests/e2e/announcement.test.ts rename to interapp-backend/tests/api/announcement.test.ts diff --git a/interapp-backend/tests/e2e/auth.test.ts b/interapp-backend/tests/api/auth.test.ts similarity index 100% rename from interapp-backend/tests/e2e/auth.test.ts rename to interapp-backend/tests/api/auth.test.ts diff --git a/interapp-backend/tests/e2e/service.test.ts b/interapp-backend/tests/api/service.test.ts similarity index 100% rename from interapp-backend/tests/e2e/service.test.ts rename to interapp-backend/tests/api/service.test.ts diff --git a/interapp-backend/tests/e2e/service_session.test.ts b/interapp-backend/tests/api/service_session.test.ts similarity index 98% rename from interapp-backend/tests/e2e/service_session.test.ts rename to interapp-backend/tests/api/service_session.test.ts index aa019f90..49ca4262 100644 --- a/interapp-backend/tests/e2e/service_session.test.ts +++ b/interapp-backend/tests/api/service_session.test.ts @@ -694,7 +694,14 @@ describe('API (service session)', async () => { }), headers: { 'Content-type': 'application/json', Authorization: `Bearer ${accessToken}` }, }); - expect(res2.status).toBe(204); + expect(res2.status).toBe(200); + expect(await res2.json()).toMatchObject({ + start_time: expect.any(String), + end_time: expect.any(String), + service_hours: expect.any(Number), + name: expect.any(String), + ad_hoc: expect.any(Boolean), + }); }); test('get ad hoc service sessions', async () => { diff --git a/interapp-backend/tests/e2e/service_session_user.test.ts b/interapp-backend/tests/api/service_session_user.test.ts similarity index 99% rename from interapp-backend/tests/e2e/service_session_user.test.ts rename to interapp-backend/tests/api/service_session_user.test.ts index 8ede9135..a672d4e7 100644 --- a/interapp-backend/tests/e2e/service_session_user.test.ts +++ b/interapp-backend/tests/api/service_session_user.test.ts @@ -294,7 +294,6 @@ describe('API (service session user)', async () => { }), headers: { 'Content-type': 'application/json', Authorization: `Bearer ${accessToken}` }, }); - console.log(await res.json()); expect(res.status).toBe(204); }); diff --git a/interapp-backend/tests/e2e/service_user.test.ts b/interapp-backend/tests/api/service_user.test.ts similarity index 100% rename from interapp-backend/tests/e2e/service_user.test.ts rename to interapp-backend/tests/api/service_user.test.ts diff --git a/interapp-backend/tests/constants.test.ts b/interapp-backend/tests/constants.test.ts index 81efb329..43e46e11 100644 --- a/interapp-backend/tests/constants.test.ts +++ b/interapp-backend/tests/constants.test.ts @@ -1,5 +1,13 @@ -import { ServiceModel, AuthModel, AnnouncementModel, UserModel, ExportsModel } from '../api/models'; +import { + ServiceModel, + AuthModel, + AnnouncementModel, + UserModel, + AttendanceExportsModel, + ServiceHoursExportsModel, +} from '../api/models'; import { expect, test, describe } from 'bun:test'; +import { recreateDB, recreateMinio, recreateRedis } from './utils'; interface Test { name: string; @@ -12,82 +20,127 @@ type TestSuite = { }; // get all models in an array -const objs = [ServiceModel, AuthModel, AnnouncementModel, UserModel, ExportsModel] as const; +const objs = [ + ServiceModel, + AuthModel, + AnnouncementModel, + UserModel, + AttendanceExportsModel, + ServiceHoursExportsModel, +] as const; // map all models to an object with the name as key const models = objs.reduce( - (acc, obj) => { - acc[obj.name] = obj; - return acc; - }, + (acc, obj) => ({ ...acc, [obj.name]: obj }), {} as Record, ); // get all methods of a default class const defaultClassMethods = Object.getOwnPropertyNames(class {}); +// filter out all non-function properties of a model and exclude the default class methods +const filterObjectProperties = (model: (typeof objs)[number]) => + Object.getOwnPropertyNames(model) + .filter((method) => typeof (model as any)[method] === 'function') + .filter((method) => !defaultClassMethods.includes(method)); + // get all testable methods of a model, excluding the default class methods const testableMethods = Object.fromEntries( - Object.entries(models).map(([name, model]) => [ - name, - Object.getOwnPropertyNames(model).filter((method) => !defaultClassMethods.includes(method)), - ]), + Object.entries(models).map(([name, model]) => [name, filterObjectProperties(model)]), ) as Record; // map all testable methods to a test suite export const testSuites = Object.entries(testableMethods).reduce( (acc, [model, methods]) => { + // create an empty array for each method const tests = methods.reduce( - (acc, method) => { - acc[method] = [] as Test[]; - return acc; - }, - {} as Record, + (acc, method) => ({ + ...acc, + [method]: [], + }), + {} as TestSuite, ); + // assign the testsuite to the model return { ...acc, [model]: tests, }; }, {} as { - [model: string]: { - [method: string]: Test[]; - }; + [model: string]: TestSuite; }, ); +process.on('SIGINT', async () => { + console.warn('SIGINT received, aborting.'); + // recreate the database, minio and redis to prevent side effects on the next test run + await Promise.all([recreateDB(), recreateMinio(), recreateRedis()]); + + process.exit(0); +}); + test('test suites are of correct shape', () => { for (const obj of objs) { + // check if the model is in the test suites expect(testSuites).toHaveProperty(obj.name); + // check if the test suite is an object expect(testSuites[obj.name]).toBeObject(); - for (const method of Object.getOwnPropertyNames(obj)) { - if (!defaultClassMethods.includes(method)) { - expect(testSuites[obj.name]).toHaveProperty(method); - expect(testSuites[obj.name][method]).toBeArray(); - } + + // loop through all methods of the model and check if they are in the test suite + for (const method of filterObjectProperties(obj)) { + expect(testSuites[obj.name]).toHaveProperty(method); + expect(testSuites[obj.name][method]).toBeArray(); } } }); +// Runs a suite of tests. export const runSuite = async (name: string, suite: TestSuite) => { + // The outermost describe block groups all tests for a specific model. describe(name, () => { + // Iterate over each method in the suite. for (const [method, tests] of Object.entries(suite)) { + // Create a describe block for each method. describe(method, async () => { + // Iterate over each test for the method. for (const { name, cb, cleanup } of tests) { + // Define the test. test(name, async () => { try { + // Run the test callback. await cb(); } finally { + // If a cleanup function is provided, run it after the test. if (cleanup) await cleanup(); } }); } }); } + // Add a test to make sure that the test suite is exhaustive. test('make sure suite is exhaustive', () => { + // Iterate over each method in the suite. Object.values(suite).forEach((tests) => { - expect(tests).toBeArray(); - expect(tests).not.toBeEmpty(); + try { + // Assert that the tests array is not empty. + expect(tests).toBeArray(); + expect(tests).not.toBeEmpty(); + } catch (e) { + // If the tests array is empty, find all methods with no tests. + const failed = Object.entries(suite) + .filter(([, tests]) => tests.length === 0) + .reduce((acc, [name]) => { + // Add the method name to the failed array. + acc.push(name); + return acc; + }, [] as string[]); + + // Log the methods with no tests. + console.error(`The following methods have no test coverage: ${failed.join(', ')}`); + + // Re-throw the error to fail the test. + throw e; + } }); }); }); @@ -99,67 +152,13 @@ export const runSuite = async (name: string, suite: TestSuite) => { ServiceModel: { createService: [], getService: [], - updateService: [], - deleteService: [], - getAllServices: [], - createServiceSession: [], - getServiceSession: [], - updateServiceSession: [], - deleteServiceSession: [], - createServiceSessionUser: [], - createServiceSessionUsers: [], - getServiceSessionUser: [], - getServiceSessionUsers: [], - updateServiceSessionUser: [], - deleteServiceSessionUser: [], - deleteServiceSessionUsers: [], - getAllServiceSessions: [], - getActiveServiceSessions: [], - verifyAttendance: [], + ... getAdHocServiceSessions: [], }, AuthModel: { signJWT: [], signUp: [], - signIn: [], - signOut: [], - getNewAccessToken: [], - verify: [], - accessSecret: [], - refreshSecret: [], + ... }, - AnnouncementModel: { - createAnnouncement: [], - getAnnouncement: [], - getAnnouncements: [], - updateAnnouncement: [], - deleteAnnouncement: [], - getAnnouncementCompletions: [], - updateAnnouncementCompletion: [], - }, - UserModel: { - getUser: [], - deleteUser: [], - getUserDetails: [], - changeEmail: [], - changePassword: [], - resetPassword: [], - sendResetPasswordEmail: [], - verifyEmail: [], - sendVerifyEmail: [], - checkPermissions: [], - updatePermissions: [], - getPermissions: [], - getAllServicesByUser: [], - getAllServiceSessionsByUser: [], - getAllUsersByService: [], - addServiceUser: [], - removeServiceUser: [], - updateServiceUserBulk: [], - updateServiceHours: [], - updateProfilePicture: [], - deleteProfilePicture: [], - getNotifications: [], - }, -} + ... */ diff --git a/interapp-backend/tests/unit/AttendanceExportsModel.test.ts b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts new file mode 100644 index 00000000..eadc71a1 --- /dev/null +++ b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts @@ -0,0 +1,248 @@ +import { runSuite, testSuites } from '../constants.test'; +import { AttendanceExportsModel, AuthModel, ServiceModel, UserModel } from '@models/.'; +import { recreateDB } from '../utils'; +import { AttendanceStatus, User } from '@db/entities'; +import { expect } from 'bun:test'; + +const SUITE_NAME = 'AttendanceExportsModel'; +const suite = testSuites[SUITE_NAME]; + +const populateDb = async () => { + const signUp = async (id: number, username: string) => + await AuthModel.signUp(id, username, 'test@email.com', 'pass'); + + const createService = async (name?: string, service_ic_username?: string) => + await ServiceModel.createService({ + name: name ?? 'test service', + description: 'test description', + contact_email: 'fkjsf@fjsdakfjsa', + day_of_week: 1, + start_time: '10:00', + end_time: '11:00', + service_ic_username: service_ic_username ?? 'user', + service_hours: 1, + enable_scheduled: true, + }); + + const createSessions = async (service_id: number, start_time: Date, end_time: Date) => + await ServiceModel.createServiceSession({ + service_id, + start_time: start_time.toISOString(), + end_time: end_time.toISOString(), + ad_hoc_enabled: false, + service_hours: 0, + }); + + interface _ServiceSessionUserParams { + service_session_id: number; + username: string; + attended: AttendanceStatus; + } + + const createSessionUsers = async (users: _ServiceSessionUserParams[]) => + await ServiceModel.createServiceSessionUsers( + users.map((user) => ({ ...user, ad_hoc: false, is_ic: true })), + ); + + // create users + for (let i = 0; i < 10; i++) { + await signUp(i, `user${i}`); + } + + // create service with id 1 + const id = await createService('a', 'user0'); + + // create sessions with id 1-10 + for (let i = 0; i < 10; i++) { + // create sessions with start time now + i days + const now = new Date(); + now.setDate(now.getDate() + i); + + const end = new Date(now); + end.setHours(now.getHours() + i); + + await createSessions(id, now, end); + } + + // add all users to all sessions + const users = []; + for (let i = 0; i < 10; i++) { + // service_session_id is 1-indexed (1-10) + const serviceSessionId = i + 1; + for (let j = 0; j < 10; j++) { + users.push({ + service_session_id: serviceSessionId, + username: `user${j}`, + attended: AttendanceStatus.Absent, + }); + } + } + + await createSessionUsers(users); +}; + +suite.queryExports = [ + { + name: 'should query all exports', + cb: async () => { + await populateDb(); + + const ret = await AttendanceExportsModel.queryExports({ id: 1 }); + + expect(ret.length).toBe(10); + expect(ret[0]).toMatchObject({ + service_session_id: 1, + start_time: expect.any(Date), + end_time: expect.any(Date), + service: { + name: 'a', + service_id: 1, + }, + }); + expect(ret[0].service_session_users.length).toBe(10); + + expect(ret[0].service_session_users[0]).toMatchObject({ + service_session_id: 1, + username: 'user0', + ad_hoc: false, + attended: AttendanceStatus.Absent, + is_ic: true, + }); + + for (let sess of ret) { + for (let [idx, user] of sess.service_session_users.entries()) { + expect(user.username).toBe(`user${idx}`); + expect(user.attended).toBe(AttendanceStatus.Absent); + } + } + }, + cleanup: async () => await recreateDB(), + }, + { + name: 'should query exports with date range', + cb: async () => { + await populateDb(); + + const start_date = new Date(); + start_date.setDate(start_date.getDate() + 5); + + const end_date = new Date(start_date); + end_date.setDate(end_date.getDate() + 5); + const ret = await AttendanceExportsModel.queryExports({ + id: 1, + start_date: start_date.toISOString(), + end_date: end_date.toISOString(), + }); + + expect(ret.length).toBe(4); + }, + cleanup: async () => await recreateDB(), + }, + { + name: 'should return length 0 if no exports found', + cb: async () => { + await populateDb(); + + expect(AttendanceExportsModel.queryExports({ id: 2 })).resolves.toBeArrayOfSize(0); + }, + cleanup: async () => await recreateDB(), + }, +]; + +suite.formatXLSX = [ + { + name: 'should format xlsx', + cb: async () => { + await populateDb(); + + const start_date = new Date(); + start_date.setDate(start_date.getDate() + 5); + + const end_date = new Date(start_date); + end_date.setDate(end_date.getDate() + 5); + + const ret = await AttendanceExportsModel.formatXLSX({ + id: 1, + start_date: start_date.toISOString(), + end_date: end_date.toISOString(), + }); + + expect(ret).toMatchObject({ + name: 'a', + data: expect.any(Array), + options: expect.any(Object), + }); + }, + cleanup: async () => await recreateDB(), + }, + { + name: 'should throw if no service not found', + cb: async () => { + await populateDb(); + + expect(AttendanceExportsModel.formatXLSX({ id: 2 })).rejects.toThrow(); + }, + cleanup: async () => await recreateDB(), + }, + { + name: 'should throw if no exports found', + cb: async () => { + await populateDb(); + expect( + AttendanceExportsModel.formatXLSX({ + id: 1, + start_date: new Date().toISOString(), + end_date: new Date().toISOString(), + }), + ).rejects.toThrow(); + }, + cleanup: async () => await recreateDB(), + }, +]; + +suite.packXLSX = [ + { + name: 'should pack xlsx', + cb: async () => { + await populateDb(); + + const start_date = new Date(); + start_date.setDate(start_date.getDate() + 5); + + const end_date = new Date(start_date); + end_date.setDate(end_date.getDate() + 5); + + const ret = await AttendanceExportsModel.packXLSX( + [1], + start_date.toISOString(), + end_date.toISOString(), + ); + + expect(ret).toBeInstanceOf(Buffer); + }, + cleanup: async () => await recreateDB(), + }, + { + name: 'should throw if no service not found', + cb: async () => { + await populateDb(); + + expect(AttendanceExportsModel.packXLSX([2])).rejects.toThrow(); + }, + cleanup: async () => await recreateDB(), + }, + { + name: 'should throw if no exports found', + cb: async () => { + await populateDb(); + expect( + AttendanceExportsModel.packXLSX([1], new Date().toISOString(), new Date().toISOString()), + ).rejects.toThrow(); + }, + cleanup: async () => await recreateDB(), + }, +]; + +console.log(suite); + +runSuite(SUITE_NAME, suite); diff --git a/interapp-backend/tests/unit/AuthModel.test.ts b/interapp-backend/tests/unit/AuthModel.test.ts index 6a7bb948..21773e57 100644 --- a/interapp-backend/tests/unit/AuthModel.test.ts +++ b/interapp-backend/tests/unit/AuthModel.test.ts @@ -12,8 +12,6 @@ const signUpUser = async (id: number, name: string) => // these are private internal methods delete suite.signJWT; -delete suite.accessSecret; -delete suite.refreshSecret; suite.signUp = [ { diff --git a/interapp-backend/tests/unit/ExportsModel.test.ts b/interapp-backend/tests/unit/ServiceHoursExportsModel.test.ts similarity index 70% rename from interapp-backend/tests/unit/ExportsModel.test.ts rename to interapp-backend/tests/unit/ServiceHoursExportsModel.test.ts index c3bfa798..aa1406c6 100644 --- a/interapp-backend/tests/unit/ExportsModel.test.ts +++ b/interapp-backend/tests/unit/ServiceHoursExportsModel.test.ts @@ -1,9 +1,9 @@ import { runSuite, testSuites } from '../constants.test'; -import { ExportsModel } from '@models/.'; +import { ServiceHoursExportsModel } from '@models/.'; import { User } from '@db/entities'; import { describe, test, expect } from 'bun:test'; -const SUITE_NAME = 'ExportsModel'; +const SUITE_NAME = 'ServiceHoursExportsModel'; const suite = testSuites[SUITE_NAME]; console.log(suite); diff --git a/interapp-backend/tests/unit/UserModel.test.ts b/interapp-backend/tests/unit/UserModel.test.ts index 3e13bb50..6907de8a 100644 --- a/interapp-backend/tests/unit/UserModel.test.ts +++ b/interapp-backend/tests/unit/UserModel.test.ts @@ -915,4 +915,57 @@ suite.getNotifications = [ }, ]; +suite.updateServiceHoursBulk = [ + { + name: 'should update service user bulk', + cb: async () => { + for (let i = 0; i < 50; i++) { + await signUp(i, `user${i}`); + } + const id = await createService('test service', 'user0'); + expect(id).toBe(1); + const data = Array.from({ length: 50 }, (_, i) => ({ + username: `user${i}`, + hours: i, + })); + + await UserModel.updateServiceHoursBulk(data); + for (let i = 0; i < 50; i++) { + const result = await UserModel.getUserDetails(`user${i}`); + expect(result.service_hours).toBe(i); + } + }, + cleanup: async () => await recreateDB(), + }, + { + name: 'should throw when user does not exist', + cb: async () => { + const data = [{ username: 'user', hours: 1 }]; + expect(UserModel.updateServiceHoursBulk(data)).rejects.toThrow(); + }, + }, + { + name: 'should use delta hours', + cb: async () => { + await signUp(1, 'user'); + const id = await createService('test service', 'user'); + expect(id).toBe(1); + await UserModel.updateServiceHours('user', 10); + + // add 1 hour + const data = [{ username: 'user', hours: 1 }]; + await UserModel.updateServiceHoursBulk(data); + const result = await UserModel.getUserDetails('user'); + expect(result.service_hours).toBe(11); + + // remove 999 hours + const data2 = [{ username: 'user', hours: -999 }]; + await UserModel.updateServiceHoursBulk(data2); + const result2 = await UserModel.getUserDetails('user'); + expect(result2.service_hours).toBe(-988); + }, + cleanup: async () => await recreateDB(), + }, +]; + runSuite(SUITE_NAME, suite); diff --git a/interapp-backend/tests/utils/recreate_minio.ts b/interapp-backend/tests/utils/recreate_minio.ts index 54dceeb6..dadebc4d 100644 --- a/interapp-backend/tests/utils/recreate_minio.ts +++ b/interapp-backend/tests/utils/recreate_minio.ts @@ -7,14 +7,33 @@ export const recreateMinio = async () => { undefined, true, ); - stream.on('data', async (obj) => { - if (obj.name) - await minioClient.removeObject(process.env.MINIO_BUCKETNAME as string, obj.name); - }); - stream.on('error', (e) => console.error(e)); - stream.on('end', async () => { - await minioClient.removeBucket(process.env.MINIO_BUCKETNAME as string); - await createBucket(); + + await new Promise((resolve, reject) => { + const deletePromises: Promise[] = []; + + stream.on('data', (obj) => { + if (obj.name) { + const deletePromise = minioClient.removeObject( + process.env.MINIO_BUCKETNAME as string, + obj.name, + ); + deletePromises.push(deletePromise); + } + }); + stream.on('error', (e) => { + console.error(e); + reject(e); + }); + stream.on('end', async () => { + try { + await Promise.all(deletePromises); + await minioClient.removeBucket(process.env.MINIO_BUCKETNAME as string); + await createBucket(); + resolve(); + } catch (e) { + reject(e); + } + }); }); } catch (e) { console.error(e); diff --git a/interapp-frontend/bun.lockb b/interapp-frontend/bun.lockb index 6a545f6d..060f8416 100644 Binary files a/interapp-frontend/bun.lockb and b/interapp-frontend/bun.lockb differ diff --git a/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx b/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx index 6169e5d8..094ccd4b 100644 --- a/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx +++ b/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx @@ -1,7 +1,7 @@ 'use client'; import './styles.css'; import APIClient from '@api/api_client'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { AnnouncementWithMeta } from '@/app/announcements/types'; import { Card, ActionIcon, Text, Title, Image, Skeleton, Stack } from '@mantine/core'; import { IconExternalLink, IconClock, IconUser } from '@tabler/icons-react'; @@ -12,7 +12,12 @@ const handleFetch = async () => { const apiClient = new APIClient().instance; const res = await apiClient.get('/announcement/all', { params: { page: 1, page_size: 1 } }); // get the very latest announcement - if (res.status !== 200) throw new Error('Failed to fetch announcements'); + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch announcement', + responseStatus: res.status, + responseBody: res.data, + }); // size is 1 because we only want the latest announcement const resData: { diff --git a/interapp-frontend/src/app/announcements/[id]/edit/EditForm.tsx b/interapp-frontend/src/app/announcements/[id]/edit/EditForm.tsx index a1946777..b2bc9cfe 100644 --- a/interapp-frontend/src/app/announcements/[id]/edit/EditForm.tsx +++ b/interapp-frontend/src/app/announcements/[id]/edit/EditForm.tsx @@ -10,7 +10,7 @@ import { useContext, useEffect, useState, useMemo, useCallback } from 'react'; import { AuthContext } from '@providers/AuthProvider/AuthProvider'; import { type AnnouncementWithMeta, type AnnouncementForm } from '../../types'; import { useParams, useRouter } from 'next/navigation'; -import { parseErrorMessage, remapAssetUrl } from '@utils/.'; +import { parseServerError, remapAssetUrl } from '@utils/.'; import { notifications } from '@mantine/notifications'; import { Button, Group, TextInput, Title, Text, Stack } from '@mantine/core'; import { IconClock, IconUser } from '@tabler/icons-react'; @@ -86,7 +86,7 @@ function EditForm() { default: notifications.show({ title: 'Error', - message: parseErrorMessage(data), + message: parseServerError(data), color: 'red', }); } diff --git a/interapp-frontend/src/app/announcements/[id]/page.tsx b/interapp-frontend/src/app/announcements/[id]/page.tsx index cdca0755..e02780f3 100644 --- a/interapp-frontend/src/app/announcements/[id]/page.tsx +++ b/interapp-frontend/src/app/announcements/[id]/page.tsx @@ -4,7 +4,7 @@ import { AnnouncementWithMeta } from './../types'; import { useState, useEffect, useContext } from 'react'; import { AuthContext } from '@providers/AuthProvider/AuthProvider'; import GoBackButton from '@components/GoBackButton/GoBackButton'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { Title, Text, Group, Stack, ActionIcon, Button } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconClock, IconUser, IconPencil, IconTrash } from '@tabler/icons-react'; @@ -30,7 +30,11 @@ const handleFetch = async (id: number) => { } else if (res.status === 404) { return null; } else { - throw new Error('Failed to fetch announcements'); + throw new ClientError({ + message: 'Failed to fetch announcement', + responseStatus: res.status, + responseBody: res.data, + }); } }; @@ -41,7 +45,12 @@ const handleRead = async (id: number) => { completed: true, }); - if (res.status !== 204) throw new Error('Failed to update announcement completion status'); + if (res.status !== 204) + throw new ClientError({ + message: 'Failed to mark announcement as read', + responseStatus: res.status, + responseBody: res.data, + }); }; const handleDelete = async (id: number, handleEnd: () => void) => { @@ -54,7 +63,11 @@ const handleDelete = async (id: number, handleEnd: () => void) => { message: 'Announcement could not be deleted', color: 'red', }); - throw new Error('Failed to delete announcement'); + throw new ClientError({ + message: 'Failed to delete announcement', + responseStatus: res.status, + responseBody: res.data, + }); } else notifications.show({ title: 'Success', diff --git a/interapp-frontend/src/app/announcements/page.tsx b/interapp-frontend/src/app/announcements/page.tsx index a0fb9f8e..586ef24a 100644 --- a/interapp-frontend/src/app/announcements/page.tsx +++ b/interapp-frontend/src/app/announcements/page.tsx @@ -5,7 +5,7 @@ import PageController from '@components/PageController/PageController'; import { AnnouncementWithMeta } from './types'; import { useState, useEffect, useContext } from 'react'; import { AuthContext } from '@providers/AuthProvider/AuthProvider'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { Title, Text, Group, ActionIcon } from '@mantine/core'; import { useDebouncedState } from '@mantine/hooks'; import { useRouter } from 'next/navigation'; @@ -16,7 +16,12 @@ import { Permissions } from '../route_permissions'; const handleFetch = async (page: number) => { const apiClient = new APIClient().instance; const res = await apiClient.get('/announcement/all', { params: { page: page, page_size: 8 } }); - if (res.status !== 200) throw new Error('Failed to fetch announcements'); + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch announcements', + responseStatus: res.status, + responseBody: res.data, + }); const resData: { data: AnnouncementWithMeta[]; diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx index f5b0b32f..e2f9f6bb 100644 --- a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx @@ -5,6 +5,7 @@ import { Stack, Text, Title } from '@mantine/core'; import { AuthContext } from '@providers/AuthProvider/AuthProvider'; import AttendanceMenuEntry from './AttendanceMenuEntry/AttendanceMenuEntry'; import QRPage from './QRPage/QRPage'; +import { ClientError } from '@/utils'; interface AttendanceMenuProps { id?: number; @@ -13,7 +14,12 @@ interface AttendanceMenuProps { export const fetchActiveServiceSessions = async () => { const apiClient = new APIClient().instance; const response = await apiClient.get('/service/active_sessions'); - if (response.status !== 200) throw new Error('Failed to fetch active service sessions'); + if (response.status !== 200) + throw new ClientError({ + message: 'Failed to fetch active service sessions', + responseStatus: response.status, + responseBody: response.data, + }); const data: { [hash: string]: { diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx index 5b79f893..af2c32fb 100644 --- a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx @@ -4,7 +4,7 @@ import APIClient from '@/api/api_client'; import { useState, useEffect, memo } from 'react'; import { Text, Skeleton, Paper, Title, Badge } from '@mantine/core'; import { AxiosResponse } from 'axios'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { IconFlag } from '@tabler/icons-react'; import { useRouter } from 'next/navigation'; import './styles.css'; @@ -20,12 +20,22 @@ export const fetchAttendanceDetails = async (service_session_id: number) => { }); // if the session does not exist, return null if (res.status === 404) return null; - if (res.status !== 200) throw new Error('Failed to fetch attendance details'); + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch service session', + responseStatus: res.status, + responseBody: res.data, + }); const res2 = await apiClient.get('/service/session_user_bulk', { params: { service_session_id: service_session_id }, }); - if (res2.status !== 200) throw new Error('Failed to fetch user attendance details'); + if (res2.status !== 200) + throw new ClientError({ + message: 'Failed to fetch service session users', + responseStatus: res2.status, + responseBody: res2.data, + }); let res3: AxiosResponse | null = (await apiClient.get('/service', { params: { service_id: res.data.service_id }, diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx index 6c7a83f0..65e9a4c7 100644 --- a/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx @@ -12,6 +12,7 @@ import { IconFlag, IconExternalLink } from '@tabler/icons-react'; import './styles.css'; import Link from 'next/link'; import PageSkeleton from '@/components/PageSkeleton/PageSkeleton'; +import { ClientError } from '@utils/.'; interface QRPageProps { id: number; @@ -22,7 +23,12 @@ const refreshAttendance = async (id: number) => { const res = await apiClient.get('/service/session_user_bulk', { params: { service_session_id: id }, }); - if (res.status !== 200) throw new Error('Failed to fetch user attendance details'); + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch service session users', + responseStatus: res.status, + responseBody: res.data, + }); const sessionUserDetails: { service_session_id: number; @@ -40,7 +46,7 @@ const QRPage = ({ id, hash }: QRPageProps) => { {} as fetchAttendanceDetailsType, ); const redirectLink = useRef( - process.env.NEXT_PUBLIC_WEBSITE_URL + '/attendance/verify?hash=' + hash + '&id=' + id, + process.env.NEXT_PUBLIC_WEBSITE_URL + '/attendance/verify?hash=' + hash, ); const canvasRef = useRef(null); diff --git a/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx b/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx index 1f2d8c42..c94962d6 100644 --- a/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx +++ b/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx @@ -6,6 +6,7 @@ import { Text, Button } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import Link from 'next/link'; import PageSkeleton from '@components/PageSkeleton/PageSkeleton'; +import { ClientError } from '@/utils'; const handleSetValidReason = async (id: number, username: string) => { const apiClient = new APIClient().instance; @@ -14,7 +15,12 @@ const handleSetValidReason = async (id: number, username: string) => { username: username, }); - if (res.status !== 204) throw new Error(res.data.message); + if (res.status !== 204) + throw new ClientError({ + message: 'Failed to set valid reason', + responseStatus: res.status, + responseBody: res.data, + }); }; interface AbsenceFormProps { diff --git a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx index f144315a..313f1aca 100644 --- a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx +++ b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx @@ -3,54 +3,56 @@ import { AuthContext } from '@/providers/AuthProvider/AuthProvider'; import { useContext, useEffect, useState } from 'react'; import APIClient from '@/api/api_client'; -import { Title, Text, Button } from '@mantine/core'; +import { Title, Text, Button, Loader } from '@mantine/core'; import GoHomeButton from '@/components/GoHomeButton/GoHomeButton'; import { User } from '@/providers/AuthProvider/types'; +import { ClientError } from '@utils/.'; import './styles.css'; interface VerifyAttendanceProps { - id: number; hash: string; } -const fetchDuration = async (id: number) => { - const apiClient = new APIClient().instance; - const res = await apiClient.get('/service/session', { - params: { service_session_id: id }, - }); - if (res.status !== 200) throw new Error('Failed to fetch session details'); +interface ErrorResponse { + status: 'Error'; + message: string; +} - const sessionDetails: { - service_id: number; +interface SuccessResponse { + status: 'Success'; + data: { start_time: string; end_time: string; - ad_hoc_enabled: boolean; - service_session_id: number; service_hours: number; - } = res.data; - - const rounded = parseFloat(sessionDetails.service_hours.toFixed(1)); + name: string; + ad_hoc: boolean; + }; +} - return rounded; -}; +type VerifyResponse = ErrorResponse | SuccessResponse; -const verifyAttendanceUser = async ( - hash: string, -): Promise<{ status: 'Success' | 'Error'; message: string }> => { +const verifyAttendanceUser = async (hash: string): Promise => { const apiClient = new APIClient().instance; const res = await apiClient.post('/service/verify_attendance', { hash, }); + switch (res.status) { - case 204: + case 200: return { status: 'Success', - message: '', + data: res.data satisfies { + start_time: string; + end_time: string; + service_hours: number; + name: string; + ad_hoc: boolean; + }, }; case 400: return { status: 'Error', - message: 'Invalid attendance hash.', + message: 'Invalid attendance hash. QR code likely has expired.', }; case 409: return { @@ -62,6 +64,11 @@ const verifyAttendanceUser = async ( status: 'Error', message: 'You must be logged in to verify attendance.', }; + case 404: + return { + status: 'Error', + message: 'Hash does not match any service session that you are in.', + }; default: return { status: 'Error', @@ -75,30 +82,46 @@ const updateServiceHours = async (newHours: number) => { const res = await apiClient.patch('/user/service_hours', { hours: newHours, }); - if (res.status !== 204) throw new Error('Failed to update CCA hours'); + if (res.status !== 204) + throw new ClientError({ + message: 'Failed to update service hours', + responseStatus: res.status, + responseBody: res.data, + }); }; -const VerifyAttendance = ({ id, hash }: VerifyAttendanceProps) => { +const VerifyAttendance = ({ hash }: VerifyAttendanceProps) => { const { user, updateUser, loading } = useContext(AuthContext); const [message, setMessage] = useState(''); const [status, setStatus] = useState<'Success' | 'Error'>(); - const [gainedHours, setGainedHours] = useState(0); + const [fetching, setFetching] = useState(true); const handleVerify = (user: User) => { - verifyAttendanceUser(hash).then(({ status, message }) => { - setMessage(message); - setStatus(status); - - if (status === 'Success') { - fetchDuration(id).then((data) => { - updateUser({ ...user, service_hours: user.service_hours + data }); - updateServiceHours(user.service_hours + data); + verifyAttendanceUser(hash).then((res) => { + // error + setStatus(res.status); - setGainedHours(data); - }); + if (res.status === 'Error') { + setMessage(res.message); + return; } + + // success + const { data } = res; + const message = + `Checked in for ${data.name} from ${new Date(data.start_time).toLocaleString( + 'en-GB', + )} to ${new Date(data.end_time).toLocaleString('en-GB')}. Gained ${ + data.service_hours + } CCA hours.` + (data.ad_hoc ? ' (Ad-hoc)' : ''); + + updateServiceHours(user.service_hours + data.service_hours); + updateUser({ ...user, service_hours: user.service_hours + data.service_hours }); + + setMessage(message); }); + setFetching(false); }; useEffect(() => { @@ -116,13 +139,20 @@ const VerifyAttendance = ({ id, hash }: VerifyAttendanceProps) => { ); } + if (fetching) { + return ( +
+ Verify Attendance + +
+ ); + } + return (
Verify Attendance + {message} - {status === 'Success' && ( - Checked in successfully. Added {gainedHours} CCA hours to your account. - )} {status === 'Success' ? ( ) : ( diff --git a/interapp-frontend/src/app/attendance/verify/page.tsx b/interapp-frontend/src/app/attendance/verify/page.tsx index a963ce22..b8cce806 100644 --- a/interapp-frontend/src/app/attendance/verify/page.tsx +++ b/interapp-frontend/src/app/attendance/verify/page.tsx @@ -16,13 +16,5 @@ export default function AttendanceVerifyPage({
); - if (searchParams.id instanceof Array || searchParams.id === undefined) - return ( -
- Invalid ID - -
- ); - - return ; + return ; } diff --git a/interapp-frontend/src/app/exports/ExportsForm/ExportsForm.tsx b/interapp-frontend/src/app/exports/ExportsForm/ExportsForm.tsx index 5c97b2de..53084f94 100644 --- a/interapp-frontend/src/app/exports/ExportsForm/ExportsForm.tsx +++ b/interapp-frontend/src/app/exports/ExportsForm/ExportsForm.tsx @@ -6,7 +6,7 @@ import { notifications } from '@mantine/notifications'; import { APIClient } from '@api/api_client'; import { Service } from '@/app/services/types'; import { use, useMemo, useState } from 'react'; -import { parseErrorMessage } from '@utils/.'; +import { parseServerError } from '@utils/.'; import './styles.css'; type ExportsProps = { @@ -91,7 +91,7 @@ export function ExportsForm({ allServices }: ExportsFormProps) { case 400: notifications.show({ title: 'Error', - message: parseErrorMessage(response.data), + message: parseServerError(response.data), color: 'red', }); return; diff --git a/interapp-frontend/src/app/exports/page.tsx b/interapp-frontend/src/app/exports/page.tsx index 555ce2d2..aa7ef491 100644 --- a/interapp-frontend/src/app/exports/page.tsx +++ b/interapp-frontend/src/app/exports/page.tsx @@ -8,6 +8,7 @@ import { type Service } from '@/app/services/types'; import { AxiosInstance } from 'axios'; import './styles.css'; import { ExportsForm } from './ExportsForm/ExportsForm'; +import { ClientError } from '@utils/.'; const fetchServices = async (apiClient: AxiosInstance) => { const res = await apiClient.get('/service/all'); @@ -17,8 +18,12 @@ const fetchServices = async (apiClient: AxiosInstance) => { service_id: service.service_id, })) as Pick[]; - if (res.status !== 200) throw new Error('Could not fetch services'); - + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch services', + responseStatus: res.status, + responseBody: res.data, + }); return data; }; diff --git a/interapp-frontend/src/app/page.tsx b/interapp-frontend/src/app/page.tsx index bfc93ef2..f3b81d71 100644 --- a/interapp-frontend/src/app/page.tsx +++ b/interapp-frontend/src/app/page.tsx @@ -10,7 +10,7 @@ import AttendanceList, { import NextAttendance from '@/app/_homepage/NextAttendance/NextAttendance'; import APIClient from '@api/api_client'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import Link from 'next/link'; import { Stack, Title, Text, SimpleGrid, Image, Group } from '@mantine/core'; import PageSkeleton from '@components/PageSkeleton/PageSkeleton'; @@ -19,7 +19,12 @@ import './styles.css'; const fetchAttendance = async (username: string, sessionCount: number) => { const apiClient = new APIClient().instance; const response = await apiClient.get('/service/session_user_bulk?username=' + username); - if (response.status !== 200) throw new Error('Failed to fetch service sessions'); + if (response.status !== 200) + throw new ClientError({ + message: 'Failed to fetch attendance', + responseStatus: response.status, + responseBody: response.data, + }); const now = new Date(); diff --git a/interapp-frontend/src/app/profile/Overview/Overview.tsx b/interapp-frontend/src/app/profile/Overview/Overview.tsx index 744addff..9c0996d5 100644 --- a/interapp-frontend/src/app/profile/Overview/Overview.tsx +++ b/interapp-frontend/src/app/profile/Overview/Overview.tsx @@ -1,9 +1,9 @@ 'use client'; import APIClient from '@api/api_client'; import { useState, useEffect } from 'react'; -import { User, UserWithProfilePicture, validateUserType } from '@providers/AuthProvider/types'; +import { User, validateUserType } from '@providers/AuthProvider/types'; import { Permissions } from '../../route_permissions'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { Text, Title, Group, Stack, Badge, ActionIcon, Paper, Button } from '@mantine/core'; import './styles.css'; import { permissionsMap } from '@/app/admin/AdminTable/PermissionsInput/PermissionsInput'; @@ -16,18 +16,33 @@ const fetchUserDetails = async (username: string) => { const apiClient = new APIClient().instance; const response = await apiClient.get('/user?username=' + username); - if (response.status !== 200) throw new Error('Failed to fetch user info'); + if (response.status !== 200) + throw new ClientError({ + message: 'Failed to fetch user details', + responseStatus: response.status, + responseBody: response.data, + }); - const data: UserWithProfilePicture = response.data; + const data: User = response.data; if (data.profile_picture) data.profile_picture = remapAssetUrl(data.profile_picture); const response2 = await apiClient.get('/user/permissions?username=' + username); - if (response2.status !== 200) throw new Error('Failed to fetch user permissions'); + if (response2.status !== 200) + throw new ClientError({ + message: 'Failed to fetch user permissions', + responseStatus: response2.status, + responseBody: response2.data, + }); data.permissions = response2.data[username] satisfies Permissions[]; - if (!validateUserType(data)) throw new Error('Invalid user data'); + if (!validateUserType(data)) + throw new ClientError({ + message: 'Invalid user data', + responseStatus: response.status, + responseBody: response.data, + }); return data; }; @@ -38,7 +53,7 @@ interface OverviewProps { const Overview = ({ username, updateUser }: OverviewProps) => { const router = useRouter(); - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); useEffect(() => { fetchUserDetails(username).then((data) => { @@ -50,8 +65,7 @@ const Overview = ({ username, updateUser }: OverviewProps) => { if (!user) return; fetchUserDetails(username).then((data) => { setUser(data); - const { profile_picture, ...rest } = data; - updateUser(rest); + updateUser(data); notifications.show({ title: 'Profile updated', message: 'Your profile has been updated successfully.', diff --git a/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx b/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx index d0ce6921..277290f9 100644 --- a/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx +++ b/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx @@ -2,7 +2,7 @@ import APIClient from '@api/api_client'; import { Service } from '@/app/services/types'; import { ServiceSession } from '@/app/service_sessions/types'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import ServiceCard from './ServiceCard/ServiceCard'; import { useState, useEffect } from 'react'; import { Title } from '@mantine/core'; @@ -15,15 +15,30 @@ const fetchServices = async (username: string) => { const apiClient = new APIClient().instance; const res = await apiClient.get('/service/all'); - if (res.status !== 200) throw new Error('Could not fetch services'); + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch services', + responseStatus: res.status, + responseBody: res.data, + }); const res2 = await apiClient.get('/service/ad_hoc_sessions'); - if (res2.status !== 200) throw new Error('Could not fetch ad hoc sessions'); + if (res2.status !== 200) + throw new ClientError({ + message: 'Failed to fetch ad hoc sessions', + responseStatus: res2.status, + responseBody: res2.data, + }); const res3 = await apiClient.get('/user/userservices?username=' + username); - if (res3.status !== 200) throw new Error('Could not fetch user services'); + if (res3.status !== 200) + throw new ClientError({ + message: 'Failed to fetch user services', + responseStatus: res3.status, + responseBody: res3.data, + }); const services: Service[] = res.data; const adHocSessions: Omit[] = res2.data; @@ -78,7 +93,12 @@ const handleJoinAdHocSession = async (serviceSessionId: number, username: string }); // if the user has already joined the session, throw an error (404 means they haven't joined) - if (check.status !== 404) throw new Error('You have already joined this session'); + if (check.status !== 404) + throw new ClientError({ + message: 'User has already joined session', + responseStatus: check.status, + responseBody: check.data, + }); const res = await apiClient.post('/service/session_user', { service_session_id: serviceSessionId, @@ -88,7 +108,12 @@ const handleJoinAdHocSession = async (serviceSessionId: number, username: string is_ic: false, }); - if (res.status !== 201) throw new Error('Could not join ad hoc session'); + if (res.status !== 201) + throw new ClientError({ + message: 'Failed to join session', + responseStatus: res.status, + responseBody: res.data, + }); }; const generateSessionsInFuture = (service: FetchServicesResponse[number]) => { diff --git a/interapp-frontend/src/app/profile/ServiceSessionsPage/ServiceSessionsPage.tsx b/interapp-frontend/src/app/profile/ServiceSessionsPage/ServiceSessionsPage.tsx index a15ceaa3..0da4f40e 100644 --- a/interapp-frontend/src/app/profile/ServiceSessionsPage/ServiceSessionsPage.tsx +++ b/interapp-frontend/src/app/profile/ServiceSessionsPage/ServiceSessionsPage.tsx @@ -1,6 +1,6 @@ 'use client'; import APIClient from '@api/api_client'; -import { remapAssetUrl } from '@utils/.'; +import { remapAssetUrl, ClientError } from '@utils/.'; import { useEffect, useState } from 'react'; import ServiceSessionCard from './ServiceSessionCard/ServiceSessionCard'; import { Text } from '@mantine/core'; @@ -10,7 +10,12 @@ import PageSkeleton from '@/components/PageSkeleton/PageSkeleton'; const fetchUserServiceSessions = async (username: string) => { const apiClient = new APIClient().instance; const response = await apiClient.get('/service/session_user_bulk?username=' + username); - if (response.status !== 200) throw new Error('Failed to fetch service sessions'); + if (response.status !== 200) + throw new ClientError({ + message: 'Failed to fetch service sessions', + responseStatus: response.status, + responseBody: response.data, + }); const data: { service_id: number; diff --git a/interapp-frontend/src/app/profile/page.tsx b/interapp-frontend/src/app/profile/page.tsx index 1f3dbdd4..b705f098 100644 --- a/interapp-frontend/src/app/profile/page.tsx +++ b/interapp-frontend/src/app/profile/page.tsx @@ -6,18 +6,35 @@ import { ActiveTabContext } from './utils'; import Overview from './Overview/Overview'; import ServiceSessionsPage from './ServiceSessionsPage/ServiceSessionsPage'; import ServiceCardDisplay from './ServiceCardDisplay/ServiceCardDisplay'; +import PageSkeleton from '@components/PageSkeleton/PageSkeleton'; +import { Text } from '@mantine/core'; +import GoHomeButton from '@/components/GoHomeButton/GoHomeButton'; +import './styles.css'; export default function Profile() { const activeTab = useContext(ActiveTabContext); - const { user, updateUser } = useContext(AuthContext); + const { user, updateUser, loading } = useContext(AuthContext); + + if (loading) { + return ; + } + + if (!user) { + return ( +
+ User not found. Please log in again. + +
+ ); + } switch (activeTab) { case 'Overview': - return ; + return ; case 'Services': - return ; + return ; case 'Service Sessions': - return ; + return ; } } diff --git a/interapp-frontend/src/app/profile/styles.css b/interapp-frontend/src/app/profile/styles.css index e69de29b..9ff303b8 100644 --- a/interapp-frontend/src/app/profile/styles.css +++ b/interapp-frontend/src/app/profile/styles.css @@ -0,0 +1,8 @@ +.profile-notfound-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 2rem; +} diff --git a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx index 39d669f2..c1959dc0 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx @@ -10,7 +10,7 @@ import { memo, useContext, useEffect, useState } from 'react'; import APIClient from '@api/api_client'; import { Permissions } from '@/app/route_permissions'; import CRUDModal from '@components/CRUDModal/CRUDModal'; -import { getAllUsernames, parseErrorMessage } from '@utils/.'; +import { ClientError, getAllUsernames, parseServerError } from '@utils/.'; import { ServiceSessionUser } from '../../types'; import { IconPlus } from '@tabler/icons-react'; import { Service } from '@/app/services/types'; @@ -23,7 +23,12 @@ export interface AddActionProps { const getAllServices = async () => { const apiClient = new APIClient().instance; const response = await apiClient.get('/service/all'); - if (response.status !== 200) throw new Error('Could not fetch services'); + if (response.status !== 200) + throw new ClientError({ + message: 'Failed to fetch services', + responseStatus: response.status, + responseBody: response.data, + }); const services: Service[] = response.data; return services.map((service) => ({ service_id: service.service_id, name: service.name })); }; @@ -85,7 +90,7 @@ function AddAction({ refreshData }: Readonly) { if (res.status !== 200) { notifications.show({ title: 'Error', - message: parseErrorMessage(res.data), + message: parseServerError(res.data), color: 'red', }); setLoading(false); diff --git a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/EditAction/EditAction.tsx b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/EditAction/EditAction.tsx index 920e0bdb..8b286cfb 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/EditAction/EditAction.tsx +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/EditAction/EditAction.tsx @@ -13,7 +13,7 @@ import { Permissions } from '@/app/route_permissions'; import CRUDModal from '@components/CRUDModal/CRUDModal'; import './styles.css'; import { ServiceSessionUser } from '../../types'; -import { getAllUsernames, parseErrorMessage } from '@utils/.'; +import { getAllUsernames, parseServerError } from '@utils/.'; import { type AxiosInstance } from 'axios'; const calculateInterval = (start: Date, end: Date) => { @@ -187,7 +187,7 @@ function EditAction({ if (updatedServiceSessionResponse.status !== 200) { notifications.show({ title: 'Error', - message: parseErrorMessage(updatedServiceSessionResponse.data), + message: parseServerError(updatedServiceSessionResponse.data), color: 'red', }); @@ -207,9 +207,9 @@ function EditAction({ title: 'Error', message: 'Failed to update attendees. Changes may have been partially applied.\n' + - parseErrorMessage(deletedServiceSessionUsersResponse) + + parseServerError(deletedServiceSessionUsersResponse) + '\n' + - parseErrorMessage(addedServiceSessionUsersResponse), + parseServerError(addedServiceSessionUsersResponse), color: 'red', }); @@ -230,7 +230,7 @@ function EditAction({ title: 'Error', message: 'Failed to update service hours. Changes may have been partially applied.\n' + - parseErrorMessage(updateHoursResponse), + parseServerError(updateHoursResponse), color: 'red', }); diff --git a/interapp-frontend/src/app/service_sessions/page.tsx b/interapp-frontend/src/app/service_sessions/page.tsx index a20898bf..20b49913 100644 --- a/interapp-frontend/src/app/service_sessions/page.tsx +++ b/interapp-frontend/src/app/service_sessions/page.tsx @@ -3,7 +3,7 @@ export const dynamic = 'force-dynamic'; // nextjs needs this to build properly import APIClient from '@api/api_client'; import { Service } from '../services/types'; import ServiceSessionContent from './ServiceSessionContent/ServiceSessionContent'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { ServiceSessionsWithMeta, ServiceMeta } from './types'; import { Title, Text } from '@mantine/core'; import './styles.css'; @@ -23,10 +23,20 @@ const handleFetchServiceSessionsData = async ( const res = await apiClient.get('/service/session/all', { params: params, }); - if (res.status !== 200) return null; + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch service sessions', + responseStatus: res.status, + responseBody: res.data, + }); // then we get the services for searching const res2 = await apiClient.get('/service/all'); - if (res2.status !== 200) return null; + if (res2.status !== 200) + throw new ClientError({ + message: 'Failed to fetch services', + responseStatus: res2.status, + responseBody: res2.data, + }); // we return the data and map the services to the format that the select component expects const parsed = [ res.data as ServiceSessionsWithMeta, @@ -47,7 +57,7 @@ export default async function ServiceSessionPage() { const refreshServiceSessions = async (page: number, service_id?: number) => { 'use server'; const result = await handleFetchServiceSessionsData(page, perPage, service_id); - if (result === null) throw new Error('Error fetching service sessions'); + return result; }; diff --git a/interapp-frontend/src/app/services/AddService/AddService.tsx b/interapp-frontend/src/app/services/AddService/AddService.tsx index 67f915b2..17df16bc 100644 --- a/interapp-frontend/src/app/services/AddService/AddService.tsx +++ b/interapp-frontend/src/app/services/AddService/AddService.tsx @@ -22,7 +22,7 @@ import SearchableSelect from '@components/SearchableSelect/SearchableSelect'; import UploadImage, { convertToBase64, allowedFormats } from '@components/UploadImage/UploadImage'; import './styles.css'; import { Permissions } from '@/app/route_permissions'; -import { getAllUsernames, parseErrorMessage } from '@utils/.'; +import { getAllUsernames, parseServerError } from '@utils/.'; import { useRouter } from 'next/navigation'; import { CreateServiceWithUsers } from '../types'; @@ -125,7 +125,7 @@ const AddService = () => { case 400: notifications.show({ title: 'Error', - message: parseErrorMessage(res.data), + message: parseServerError(res.data), color: 'red', }); setLoading(false); diff --git a/interapp-frontend/src/app/services/EditService/EditService.tsx b/interapp-frontend/src/app/services/EditService/EditService.tsx index 023e8711..16106936 100644 --- a/interapp-frontend/src/app/services/EditService/EditService.tsx +++ b/interapp-frontend/src/app/services/EditService/EditService.tsx @@ -8,7 +8,7 @@ import { useForm } from '@mantine/form'; import { TextInput, Textarea, NumberInput, Button, Group, Checkbox } from '@mantine/core'; import SearchableSelect from '@components/SearchableSelect/SearchableSelect'; import { daysOfWeek } from '../ServiceBox/ServiceBox'; -import { parseErrorMessage } from '@utils/.'; +import { parseServerError } from '@utils/.'; import { TimeInput } from '@mantine/dates'; import { useState, useContext, memo, useEffect } from 'react'; import { useRouter } from 'next/navigation'; @@ -114,7 +114,7 @@ const EditService = ({ if (res.status !== 200) { notifications.show({ title: 'Error', - message: parseErrorMessage(res.data), + message: parseServerError(res.data), color: 'red', }); setLoading(false); diff --git a/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx b/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx index 97a00952..06b93d08 100644 --- a/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx +++ b/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx @@ -1,7 +1,7 @@ 'use client'; import APIClient from '@api/api_client'; import SearchableSelect from '@components/SearchableSelect/SearchableSelect'; -import { UserWithProfilePicture } from '@providers/AuthProvider/types'; +import { User } from '@providers/AuthProvider/types'; import { useDisclosure } from '@mantine/hooks'; import { useForm } from '@mantine/form'; import { useState, useEffect, useContext } from 'react'; @@ -10,6 +10,7 @@ import { AuthContext } from '@providers/AuthProvider/AuthProvider'; import './styles.css'; import { Permissions } from '@/app/route_permissions'; +import { ClientError } from '@/utils'; const handleGetUsers = async (service_id: number) => { const apiClient = new APIClient().instance; @@ -17,17 +18,27 @@ const handleGetUsers = async (service_id: number) => { const get_users_by_service = await apiClient.get( `/service/get_users_by_service?service_id=${service_id}`, ); - const users: Omit[] = get_users_by_service.data; + const users: Omit[] = get_users_by_service.data; let serviceUsers: string[] = []; if (get_users_by_service.status === 404) { serviceUsers = []; } else if (get_users_by_service.status === 200) { serviceUsers = users.map((user) => user.username); - } else throw new Error('Could not get users by service'); + } else + throw new ClientError({ + message: 'Could not get users by service', + responseStatus: get_users_by_service.status, + responseBody: get_users_by_service.data, + }); const get_all_users = await apiClient.get('/user'); - if (get_all_users.status !== 200) throw new Error('Could not get all users'); - const all_users: Omit[] = get_all_users.data; + if (get_all_users.status !== 200) + throw new ClientError({ + message: 'Could not get all users', + responseStatus: get_all_users.status, + responseBody: get_all_users.data, + }); + const all_users: Omit[] = get_all_users.data; const allUsernames = all_users !== undefined ? all_users.map((user) => user.username) : []; return [serviceUsers, allUsernames] as const; diff --git a/interapp-frontend/src/app/services/page.tsx b/interapp-frontend/src/app/services/page.tsx index efaa0ec8..f6ce18e1 100644 --- a/interapp-frontend/src/app/services/page.tsx +++ b/interapp-frontend/src/app/services/page.tsx @@ -5,7 +5,7 @@ import APIClient from '@api/api_client'; const ServiceBox = lazy(() => import('./ServiceBox/ServiceBox')); import AddService from './AddService/AddService'; import { Title, Skeleton, Text } from '@mantine/core'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { Service } from './types'; import './styles.css'; @@ -14,13 +14,14 @@ const fetchAllServices = async () => { try { const res = await apiClient.get('/service/all'); - if (res.status !== 200) throw new Error(res.data); + if (res.status !== 200) + throw new ClientError({ + message: 'Failed to fetch services', + responseStatus: res.status, + responseBody: res.data, + }); const allServices: Service[] = res.data; - // promotional image url will look like this: - // http://interapp-minio:9000/interapp-minio/service/yes677?X-Amz-Algorithm=... - // we need to remove the bit before the 'service' part - // and remap it to localhost:3000/assets/service/yes677?.... allServices.forEach((service) => { if (service.promotional_image) { diff --git a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx index 4922e3a1..5605de7a 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -2,26 +2,31 @@ import './styles.css'; import UploadImage, { convertToBase64, allowedFormats } from '@components/UploadImage/UploadImage'; import APIClient from '@api/api_client'; -import { remapAssetUrl } from '@utils/.'; +import { ClientError, remapAssetUrl } from '@utils/.'; import { useContext, useState, useEffect, memo } from 'react'; import { AuthContext } from '@providers/AuthProvider/AuthProvider'; -import { UserWithProfilePicture } from '@providers/AuthProvider/types'; +import { User } from '@providers/AuthProvider/types'; import { notifications } from '@mantine/notifications'; import { Group, Title, Text } from '@mantine/core'; const fetchUserProfilePicture = async (username: string) => { const apiClient = new APIClient().instance; const response = await apiClient.get('/user?username=' + username); - if (response.status !== 200) throw new Error('Failed to fetch profile picture'); + if (response.status !== 200) + throw new ClientError({ + message: 'Could not get user', + responseStatus: response.status, + responseBody: response.data, + }); - const data: UserWithProfilePicture = response.data; + const data: User = response.data; if (data.profile_picture) data.profile_picture = remapAssetUrl(data.profile_picture); return data.profile_picture; }; const ChangeProfilePicture = () => { const apiClient = new APIClient().instance; - const { user, loading } = useContext(AuthContext); + const { user, loading, updateUser } = useContext(AuthContext); const username = user?.username ?? ''; const [imageURL, setImageURL] = useState(null); @@ -32,6 +37,8 @@ const ChangeProfilePicture = () => { }); }, [loading]); + if (loading || !user) return null; + const handleUpdate = (imageURL: string, file: File | null) => { if (file === null) { apiClient.delete('/user/profile_picture').then((response) => { @@ -42,6 +49,7 @@ const ChangeProfilePicture = () => { color: 'red', }); } else { + updateUser({ ...user, profile_picture: null }); notifications.show({ title: 'Profile picture deleted', message: 'Your profile picture has been deleted.', @@ -54,13 +62,17 @@ const ChangeProfilePicture = () => { convertToBase64(file) .then((base64) => { apiClient.patch('/user/profile_picture', { profile_picture: base64 }).then((response) => { - if (response.status !== 204) { + const url = (response.data as { url: string }).url; + const mappedURL = url ? remapAssetUrl(url) : null; + + if (response.status !== 200) { notifications.show({ title: 'Failed to update profile picture', message: 'Please try again later.', color: 'red', }); } else { + updateUser({ ...user, profile_picture: mappedURL }); notifications.show({ title: 'Profile picture updated', message: 'Your profile picture has been updated.', diff --git a/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx b/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx index 731f4e91..4daf343d 100644 --- a/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx +++ b/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx @@ -15,6 +15,7 @@ import { useRouter } from 'next/navigation'; import { notifications } from '@mantine/notifications'; import './styles.css'; import { Permissions } from '@/app/route_permissions'; +import { ClientError } from '@/utils'; const NavbarNotifications = () => { const apiClient = new APIClient().instance; @@ -28,7 +29,12 @@ const NavbarNotifications = () => { const getNotifications = useCallback(async () => { if (!user) return; const res = await apiClient.get('/user/notifications'); - if (res.status !== 200) throw new Error('Error getting notifications'); + if (res.status !== 200) + throw new ClientError({ + message: 'Could not get notifications', + responseStatus: res.status, + responseBody: res.data, + }); const data: { unread_announcements: { diff --git a/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx b/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx index 00bbecbe..8b5f78e4 100644 --- a/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx +++ b/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx @@ -12,7 +12,7 @@ import APIClient from '@api/api_client'; import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { routePermissions, noLoginRequiredRoutes } from '@/app/route_permissions'; import { notifications } from '@mantine/notifications'; -import { wildcardMatcher } from '@utils/.'; +import { ClientError, remapAssetUrl, wildcardMatcher } from '@utils/.'; export const AuthContext = createContext({ user: null, @@ -55,7 +55,16 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const validUser = validateUserType(user); if (!validUser) { logout(); - throw new Error('Invalid user type in local storage\n' + JSON.stringify(user)); + notifications.show({ + title: 'Error', + message: 'Invalid user type in local storage', + color: 'red', + }); + /* + throw new ClientError({ + message: 'Invalid user type in local storage' + JSON.stringify(user), + }); + */ } if (allowedRoutes.some((route) => memoWildcardMatcher(pathname, route))) { @@ -130,7 +139,13 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { if (status === 200) { localStorage.setItem('access_token_expire', expire.toString()); localStorage.setItem('access_token', access_token); - localStorage.setItem('user', JSON.stringify(user)); + localStorage.setItem( + 'user', + JSON.stringify({ + ...user, + profile_picture: user.profile_picture ? remapAssetUrl(user.profile_picture) : null, + }), + ); setUser(user); setJustLoggedIn(true); router.refresh(); // invalidate browser cache diff --git a/interapp-frontend/src/providers/AuthProvider/types.ts b/interapp-frontend/src/providers/AuthProvider/types.ts index 890c8d31..a80dda90 100644 --- a/interapp-frontend/src/providers/AuthProvider/types.ts +++ b/interapp-frontend/src/providers/AuthProvider/types.ts @@ -27,9 +27,6 @@ export interface User { permissions: Permissions[]; verified: boolean; service_hours: number; -} - -export interface UserWithProfilePicture extends User { profile_picture: string | null; } @@ -56,6 +53,8 @@ export function validateUserType(user: User | null): boolean { user.permissions.every((permission) => Object.values(Permissions).includes(permission)), user.verified !== undefined && typeof user.verified === 'boolean', user.service_hours !== undefined && typeof user.service_hours === 'number', + user.profile_picture !== undefined && + (user.profile_picture === null || typeof user.profile_picture === 'string'), ]; if (conditions.every((condition) => condition)) return true; diff --git a/interapp-frontend/src/utils/getAllUsernames.ts b/interapp-frontend/src/utils/getAllUsernames.ts index 597e6f3e..1e9d483a 100644 --- a/interapp-frontend/src/utils/getAllUsernames.ts +++ b/interapp-frontend/src/utils/getAllUsernames.ts @@ -1,11 +1,11 @@ import { APIClient } from '@api/api_client'; -import { UserWithProfilePicture } from '@providers/AuthProvider/types'; +import { User } from '@providers/AuthProvider/types'; export async function getAllUsernames() { const apiClient = new APIClient().instance; const get_all_users = await apiClient.get('/user'); - const all_users: Omit[] = get_all_users.data; + const all_users: Omit[] = get_all_users.data; const allUsersNames = all_users !== undefined ? all_users.map((user) => user.username) : []; return allUsersNames; } diff --git a/interapp-frontend/src/utils/index.ts b/interapp-frontend/src/utils/index.ts index 538add11..42c7e81c 100644 --- a/interapp-frontend/src/utils/index.ts +++ b/interapp-frontend/src/utils/index.ts @@ -1,4 +1,5 @@ -export * from './parseErrorMessage'; +export * from './parseServerError'; +export * from './parseClientError'; export * from './getAllUsernames'; export * from './remapAssetUrl'; export * from './wildcardMatcher'; diff --git a/interapp-frontend/src/utils/parseClientError.ts b/interapp-frontend/src/utils/parseClientError.ts new file mode 100644 index 00000000..42df7bed --- /dev/null +++ b/interapp-frontend/src/utils/parseClientError.ts @@ -0,0 +1,25 @@ +interface ClientErrorParams { + message: string; + responseBody?: unknown; + responseStatus?: number; +} + +export class ClientError extends Error { + constructor({ message, responseBody, responseStatus }: ClientErrorParams) { + const cause = ClientError.formatCause({ responseBody, responseStatus }); + super(message + '\n' + cause, { cause }); + } + + static formatCause({ + responseBody, + responseStatus, + }: Pick): string { + if (!responseStatus && !responseBody) return ''; + + return `Response status: ${responseStatus}\nResponse body: \n${JSON.stringify( + responseBody, + null, + 2, + )}`; + } +} diff --git a/interapp-frontend/src/utils/parseErrorMessage.ts b/interapp-frontend/src/utils/parseServerError.ts similarity index 92% rename from interapp-frontend/src/utils/parseErrorMessage.ts rename to interapp-frontend/src/utils/parseServerError.ts index cd9cc4ea..981c5901 100644 --- a/interapp-frontend/src/utils/parseErrorMessage.ts +++ b/interapp-frontend/src/utils/parseServerError.ts @@ -3,7 +3,7 @@ interface ZodFieldErrors { [key: string]: Array; }; } -export function parseErrorMessage(resBody: unknown) { +export function parseServerError(resBody: unknown) { // check if 'data' exists in the response body if (!resBody || typeof resBody !== 'object' || !('data' in resBody)) { return 'An unknown error occurred'; diff --git a/interapp-frontend/src/utils/remapAssetUrl.ts b/interapp-frontend/src/utils/remapAssetUrl.ts index 1f04eebb..36e20623 100644 --- a/interapp-frontend/src/utils/remapAssetUrl.ts +++ b/interapp-frontend/src/utils/remapAssetUrl.ts @@ -1,3 +1,18 @@ +/** + * Remaps a given asset URL from a Minio server to a local asset URL. + * + * The function takes a URL of an asset stored on a Minio server, removes the server part of the URL, + * and prepends the local server address to the asset path, effectively remapping the asset URL to point + * to a local server. + * + * @param {string} url - The URL of the asset on the Minio server. + * @returns {string} The remapped URL pointing to the local server. + * + * @example + * // Original Minio URL: http://interapp-minio:9000/interapp-minio/service/yes677?X-Amz-Algorithm=... + * // Remapped URL: http://localhost:3000/assets/service/yes677?X-Amz-Algorithm=... + * const remappedUrl = remapAssetUrl('http://interapp-minio:9000/interapp-minio/service/yes677?X-Amz-Algorithm=...'); + */ export function remapAssetUrl(url: string) { // get the website URL from the environment variables, remove trailing slashes const websiteURL = (process.env.NEXT_PUBLIC_WEBSITE_URL as string).replace(/\/$/, '');