From 8e4e3ee9cc03f68938ee5d01c6e081b42425fc0c Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 20 Apr 2024 00:37:32 +0800 Subject: [PATCH 01/49] fix: incorrect coersion for query fields --- interapp-backend/api/models/user.ts | 10 ++++----- .../api/routes/endpoints/exports/exports.ts | 22 ++++++++++++------- .../api/routes/endpoints/service/service.ts | 6 +++-- .../routes/endpoints/service/validation.ts | 9 ++++++++ 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/interapp-backend/api/models/user.ts b/interapp-backend/api/models/user.ts index 49206f05..1a353356 100644 --- a/interapp-backend/api/models/user.ts +++ b/interapp-backend/api/models/user.ts @@ -505,10 +505,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 +518,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) { diff --git a/interapp-backend/api/routes/endpoints/exports/exports.ts b/interapp-backend/api/routes/endpoints/exports/exports.ts index d4fb80f0..97afccef 100644 --- a/interapp-backend/api/routes/endpoints/exports/exports.ts +++ b/interapp-backend/api/routes/endpoints/exports/exports.ts @@ -9,13 +9,19 @@ 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( + '/', + validateRequiredFieldsV2(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 ExportsModel.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..3081023b 100644 --- a/interapp-backend/api/routes/endpoints/service/service.ts +++ b/interapp-backend/api/routes/endpoints/service/service.ts @@ -15,6 +15,7 @@ import { DeleteBulkServiceSessionUserFields, VerifyAttendanceFields, ServiceSessionIdFields, + FindServiceSessionUserFields, } from './validation'; import { HTTPError, HTTPErrorCode } from '@utils/errors'; import { Permissions } from '@utils/permissions'; @@ -193,9 +194,10 @@ serviceRouter.post( serviceRouter.get( '/session_user', - validateRequiredFieldsV2(ServiceSessionUserBulkFields), + validateRequiredFieldsV2(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), 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), From 02a31793e9a8d87724ed05700baff1b3e4fe1f67 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 14:23:23 +0800 Subject: [PATCH 02/49] chore: remove useless console log in test --- interapp-backend/tests/e2e/service_session_user.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/interapp-backend/tests/e2e/service_session_user.test.ts b/interapp-backend/tests/e2e/service_session_user.test.ts index 8ede9135..a672d4e7 100644 --- a/interapp-backend/tests/e2e/service_session_user.test.ts +++ b/interapp-backend/tests/e2e/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); }); From 4a3333ba19dca751fb6507207aecb5e41d1ca0af Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 14:23:39 +0800 Subject: [PATCH 03/49] feat: gracefully clean up sigint --- interapp-backend/tests/constants.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/interapp-backend/tests/constants.test.ts b/interapp-backend/tests/constants.test.ts index 81efb329..5855308b 100644 --- a/interapp-backend/tests/constants.test.ts +++ b/interapp-backend/tests/constants.test.ts @@ -1,5 +1,6 @@ import { ServiceModel, AuthModel, AnnouncementModel, UserModel, ExportsModel } from '../api/models'; import { expect, test, describe } from 'bun:test'; +import { recreateDB, recreateMinio, recreateRedis } from './utils'; interface Test { name: string; @@ -56,6 +57,13 @@ export const testSuites = Object.entries(testableMethods).reduce( }, ); +process.on('SIGINT', async (s) => { + console.warn('SIGINT received, aborting...'); + await Promise.all([recreateDB(), recreateMinio(), recreateRedis()]); + + process.exit(0); +}); + test('test suites are of correct shape', () => { for (const obj of objs) { expect(testSuites).toHaveProperty(obj.name); From f8c8d792a0049f4e4fe2e76236046cb027e5122c Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 14:24:01 +0800 Subject: [PATCH 04/49] feat: update lockfiles --- interapp-backend/bun.lockb | Bin 109109 -> 109109 bytes interapp-frontend/bun.lockb | Bin 206174 -> 206174 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/interapp-backend/bun.lockb b/interapp-backend/bun.lockb index 8ba299797558a90689d593e9af01579708e0b537..704c6ad743c2247af07672e40453826026cc6bd3 100644 GIT binary patch delta 26 icmdmbg>CBCBpnaVL)An@|%o}(Bu$u{Y From db35a7f613a63c6d780610b2546dc33442c4445a Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 14:48:32 +0800 Subject: [PATCH 05/49] fix: block until minio is recreated --- .../tests/utils/recreate_minio.ts | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) 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); From d5a8d2966db4b57885b933bf21fa299c5f935f1b Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 17:26:35 +0800 Subject: [PATCH 06/49] fix: remove properties out of expected tests --- interapp-backend/tests/constants.test.ts | 36 ++++++++++++------- interapp-backend/tests/unit/AuthModel.test.ts | 2 -- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/interapp-backend/tests/constants.test.ts b/interapp-backend/tests/constants.test.ts index 5855308b..cf6d5cd4 100644 --- a/interapp-backend/tests/constants.test.ts +++ b/interapp-backend/tests/constants.test.ts @@ -26,13 +26,14 @@ const models = objs.reduce( // get all methods of a default class const defaultClassMethods = Object.getOwnPropertyNames(class {}); +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 @@ -57,7 +58,7 @@ export const testSuites = Object.entries(testableMethods).reduce( }, ); -process.on('SIGINT', async (s) => { +process.on('SIGINT', async () => { console.warn('SIGINT received, aborting...'); await Promise.all([recreateDB(), recreateMinio(), recreateRedis()]); @@ -68,11 +69,9 @@ test('test suites are of correct shape', () => { for (const obj of objs) { expect(testSuites).toHaveProperty(obj.name); 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(); - } + for (const method of filterObjectProperties(obj)) { + expect(testSuites[obj.name]).toHaveProperty(method); + expect(testSuites[obj.name][method]).toBeArray(); } } }); @@ -94,8 +93,21 @@ export const runSuite = async (name: string, suite: TestSuite) => { } test('make sure suite is exhaustive', () => { Object.values(suite).forEach((tests) => { - expect(tests).toBeArray(); - expect(tests).not.toBeEmpty(); + try { + expect(tests).toBeArray(); + expect(tests).not.toBeEmpty(); + } catch (e) { + const failed = Object.entries(suite) + .filter(([, tests]) => tests.length === 0) + .reduce((acc, [name]) => { + acc.push(name); + return acc; + }, [] as string[]); + + console.error(`The following methods have no test coverage: ${failed.join(', ')}`); + + throw e; + } }); }); }); 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 = [ { From 1627dff756f40fdeaa719d384b7de4ed9e68a819 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 17:46:22 +0800 Subject: [PATCH 07/49] refactor: types in constants and comment some code --- interapp-backend/tests/constants.test.ts | 105 ++++++++--------------- 1 file changed, 35 insertions(+), 70 deletions(-) diff --git a/interapp-backend/tests/constants.test.ts b/interapp-backend/tests/constants.test.ts index cf6d5cd4..75185493 100644 --- a/interapp-backend/tests/constants.test.ts +++ b/interapp-backend/tests/constants.test.ts @@ -17,15 +17,14 @@ const objs = [ServiceModel, AuthModel, AnnouncementModel, UserModel, ExportsMode // 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') @@ -39,27 +38,28 @@ const testableMethods = Object.fromEntries( // 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...'); + 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); @@ -67,8 +67,12 @@ process.on('SIGINT', async () => { 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(); + + // 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(); @@ -76,36 +80,51 @@ test('test suites are of correct shape', () => { } }); +// 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) => { 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; } }); @@ -119,67 +138,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: [], - }, -} + ... */ From 4004f3e8c8f244d5b823049758d3426aef6a7259 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 19:21:43 +0800 Subject: [PATCH 08/49] chore: rename e2e tests to api --- interapp-backend/tests/{e2e => api}/account.test.ts | 0 interapp-backend/tests/{e2e => api}/announcement.test.ts | 0 interapp-backend/tests/{e2e => api}/auth.test.ts | 0 interapp-backend/tests/{e2e => api}/service.test.ts | 0 interapp-backend/tests/{e2e => api}/service_session.test.ts | 0 interapp-backend/tests/{e2e => api}/service_session_user.test.ts | 0 interapp-backend/tests/{e2e => api}/service_user.test.ts | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename interapp-backend/tests/{e2e => api}/account.test.ts (100%) rename interapp-backend/tests/{e2e => api}/announcement.test.ts (100%) rename interapp-backend/tests/{e2e => api}/auth.test.ts (100%) rename interapp-backend/tests/{e2e => api}/service.test.ts (100%) rename interapp-backend/tests/{e2e => api}/service_session.test.ts (100%) rename interapp-backend/tests/{e2e => api}/service_session_user.test.ts (100%) rename interapp-backend/tests/{e2e => api}/service_user.test.ts (100%) diff --git a/interapp-backend/tests/e2e/account.test.ts b/interapp-backend/tests/api/account.test.ts similarity index 100% rename from interapp-backend/tests/e2e/account.test.ts rename to interapp-backend/tests/api/account.test.ts 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 100% rename from interapp-backend/tests/e2e/service_session.test.ts rename to interapp-backend/tests/api/service_session.test.ts diff --git a/interapp-backend/tests/e2e/service_session_user.test.ts b/interapp-backend/tests/api/service_session_user.test.ts similarity index 100% rename from interapp-backend/tests/e2e/service_session_user.test.ts rename to interapp-backend/tests/api/service_session_user.test.ts 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 From abbe26cb32a86947c81e7eb6c06976c538092f2a Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 19:22:20 +0800 Subject: [PATCH 09/49] feat: set up eslint --- .github/workflows/pipeline.yml | 13 +++++++++++-- interapp-backend/bun.lockb | Bin 109109 -> 148053 bytes interapp-backend/eslint.config.js | 12 ++++++++++++ interapp-backend/package.json | 10 ++++++++-- 4 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 interapp-backend/eslint.config.js diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 4fc5d4c3..32b8530d 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -8,6 +8,7 @@ on: jobs: test-backend: + name: Test backend runs-on: ubuntu-latest steps: @@ -27,13 +28,21 @@ jobs: - name: Install dependencies run: cd interapp-backend && bun install - - name: Test with bun - run: cd interapp-backend && bun run test + + - name: lint backend + run: cd interapp-backend && bun run lint + + - name: Run unit tests + run: cd interapp-backend && bun run test:unit + + - name: Run API tests + run: cd interapp-backend && bun run test:api - name: Tear down test environment run: docker compose -f docker-compose.test.yml down build-application: + name: Build application runs-on: ubuntu-latest steps: diff --git a/interapp-backend/bun.lockb b/interapp-backend/bun.lockb index 704c6ad743c2247af07672e40453826026cc6bd3..868fada5572f254b9e860f2cc66570475c7d4bf2 100644 GIT binary patch delta 39854 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
  • Rm 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 diff --git a/interapp-backend/eslint.config.js b/interapp-backend/eslint.config.js new file mode 100644 index 00000000..878af3de --- /dev/null +++ b/interapp-backend/eslint.config.js @@ -0,0 +1,12 @@ +// @ts-check + +import * as eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ['node_modules/', 'pgdata/', 'minio-data/', 'tests/','scripts/'], + } +); \ No newline at end of file diff --git a/interapp-backend/package.json b/interapp-backend/package.json index 60e75b76..488c089c 100644 --- a/interapp-backend/package.json +++ b/interapp-backend/package.json @@ -5,7 +5,10 @@ "type": "module", "scripts": { "prettier": "prettier --write . '!./pgdata'", - "test": "NODE_ENV=test bun test --env-file tests/config/.env.test --coverage --timeout 10000", + "lint": "eslint .", + "test": "bun run test:unit && bun run test:api", + "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 +18,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 +29,10 @@ "@types/nodemailer": "^6.4.14", "@types/nodemailer-express-handlebars": "^4.0.5", "@types/swagger-ui-express": "^4.1.6", + "eslint": "^9.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" From a6c558b50dbac2e6f8a0fda8358b7faa59012020 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 19:25:58 +0800 Subject: [PATCH 10/49] fix: remove lint from ci --- .github/workflows/pipeline.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 32b8530d..6f5aa362 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -28,9 +28,6 @@ jobs: - name: Install dependencies run: cd interapp-backend && bun install - - - name: lint backend - run: cd interapp-backend && bun run lint - name: Run unit tests run: cd interapp-backend && bun run test:unit From 4f71e421a991b8f37cbc01548e7369fe3432a5ca Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 19:31:12 +0800 Subject: [PATCH 11/49] fix: all eslint errors --- interapp-backend/api/models/user.ts | 8 ++++---- .../api/routes/endpoints/service/service.ts | 4 ++-- interapp-backend/api/utils/errors.ts | 14 +++++++------- interapp-backend/scheduler/scheduler.ts | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/interapp-backend/api/models/user.ts b/interapp-backend/api/models/user.ts index 1a353356..17926589 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, @@ -468,10 +468,10 @@ export class UserModel { }; const toAdd = data - .filter(({ action, username }) => action === 'add') + .filter(({ action }) => action === 'add') .map((data) => data.username); const toRemove = data - .filter(({ action, username }) => action === 'remove') + .filter(({ action }) => action === 'remove') .map((data) => data.username); if (toAdd.length !== 0) diff --git a/interapp-backend/api/routes/endpoints/service/service.ts b/interapp-backend/api/routes/endpoints/service/service.ts index 3081023b..c14c8c25 100644 --- a/interapp-backend/api/routes/endpoints/service/service.ts +++ b/interapp-backend/api/routes/endpoints/service/service.ts @@ -213,10 +213,10 @@ serviceRouter.get( 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), ); diff --git a/interapp-backend/api/utils/errors.ts b/interapp-backend/api/utils/errors.ts index bc0ce39e..f6273c9d 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; @@ -126,12 +126,12 @@ export const HTTPErrors = { ), NO_SERVICES_FOUND: new HTTPError( 'NoServicesFound', - 'This user is not part of any service', + 'This user is not part of unknown service', HTTPErrorCode.NOT_FOUND_ERROR, ), NO_SERVICE_SESSION_FOUND: new HTTPError( 'NoServiceSessionFound', - 'This user is not part of any service session', + 'This user is not part of unknown service session', HTTPErrorCode.NOT_FOUND_ERROR, ), SERVICE_NO_USER_FOUND: new HTTPError( diff --git a/interapp-backend/scheduler/scheduler.ts b/interapp-backend/scheduler/scheduler.ts index f1d8fadc..af2257b0 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)); From 61cc8c9c1bcbe7e594b6453e7aeb482f1d24c82c Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 19:53:34 +0800 Subject: [PATCH 12/49] fix: combine tests, add warning lint --- .github/workflows/pipeline.yml | 10 +++++----- interapp-backend/bun.lockb | Bin 148053 -> 148458 bytes interapp-backend/eslint.config.js | 11 ++++------- interapp-backend/package.json | 5 ++++- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6f5aa362..27e18f1e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -28,12 +28,12 @@ jobs: - name: Install dependencies run: cd interapp-backend && bun install - - - name: Run unit tests - run: cd interapp-backend && bun run test:unit - - name: Run API tests - run: cd interapp-backend && bun run test:api + - 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 diff --git a/interapp-backend/bun.lockb b/interapp-backend/bun.lockb index 868fada5572f254b9e860f2cc66570475c7d4bf2..da6b25de057d54fc6110dbdd8f67315b1161b87f 100644 GIT binary patch delta 20503 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 delta 20323 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 diff --git a/interapp-backend/eslint.config.js b/interapp-backend/eslint.config.js index 878af3de..07907e9f 100644 --- a/interapp-backend/eslint.config.js +++ b/interapp-backend/eslint.config.js @@ -2,11 +2,8 @@ import * as eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; +import 'eslint-plugin-only-warn'; -export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - { - ignores: ['node_modules/', 'pgdata/', 'minio-data/', 'tests/','scripts/'], - } -); \ No newline at end of file +export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, { + ignores: ['node_modules/', 'pgdata/', 'minio-data/', 'tests/', 'scripts/'], +}); diff --git a/interapp-backend/package.json b/interapp-backend/package.json index 488c089c..f0c5a268 100644 --- a/interapp-backend/package.json +++ b/interapp-backend/package.json @@ -6,7 +6,9 @@ "scripts": { "prettier": "prettier --write . '!./pgdata'", "lint": "eslint .", - "test": "bun run test:unit && bun run test:api", + "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", @@ -30,6 +32,7 @@ "@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", "typescript-eslint": "^7.7.0" From 6aeedbb66029beec9e5873cf13d80622c80b795b Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 19:54:06 +0800 Subject: [PATCH 13/49] chore: prettier --- interapp-backend/api/models/user.ts | 8 ++------ interapp-backend/scheduler/scheduler.ts | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/interapp-backend/api/models/user.ts b/interapp-backend/api/models/user.ts index 17926589..b6a43ec2 100644 --- a/interapp-backend/api/models/user.ts +++ b/interapp-backend/api/models/user.ts @@ -467,12 +467,8 @@ export class UserModel { .getMany(); }; - const toAdd = data - .filter(({ action }) => action === 'add') - .map((data) => data.username); - const toRemove = data - .filter(({ action }) => 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( diff --git a/interapp-backend/scheduler/scheduler.ts b/interapp-backend/scheduler/scheduler.ts index af2257b0..057ef3f9 100644 --- a/interapp-backend/scheduler/scheduler.ts +++ b/interapp-backend/scheduler/scheduler.ts @@ -138,7 +138,7 @@ schedule('0 */1 * * * *', async () => { // 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)); + toDelete.push(...ghost.map(([k]) => k)); // remove them all if (toDelete.length > 0) { const operations = toDelete.map((k) => redisClient.hDel('service_session', k)); From 11e9fa779965e1407040cf8cd12af1c89cd7473d Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 19:54:20 +0800 Subject: [PATCH 14/49] feat: add autofix ci --- .github/workflows/autofix.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/autofix.yml 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 From 5ef8a77332c6e5d2ad836942a88760da0ceb2ed5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 22 Apr 2024 11:55:09 +0000 Subject: [PATCH 15/49] [autofix.ci] apply automated fixes --- interapp-backend/bun.lockb | Bin 148458 -> 148706 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/interapp-backend/bun.lockb b/interapp-backend/bun.lockb index da6b25de057d54fc6110dbdd8f67315b1161b87f..2e231a4203259a8e946dcc784764a7049ccb86e3 100644 GIT binary patch delta 265 zcmaFW&iSa5bAlgdkP8z72((QMmuGs{xUs^3hod5=xB?`$c?FLK563h|`R~Uky_&fB zf`mijWC3@F$qyu0Hj7lL@GyllZRV=d<6#X4nXx%`((YC+Muva<{}>qae?f#R-SK$ZbFqd^l;JrHQLP7k=qC^DVDkx^s2 zO(WwMCa#E9h}4Si?N%*}Wo%qGx*-B4Q@8KxVr=5%%9sifu)DO~Z3<&ABRgZ9p`M|h m!S-8I8FQ?;Ixaz^UtHb}GI3z3UfwRFD{ElMzC=PEz^HAGTJaQ zY&U3P{K3SP&;k+P(6!yDm9dPC>p>Spz-G$!P2G%5oLmJ{AOda|x7$r+>}6z!xQk)? yrD=>g)?5=VLZm-j+U{|QaRH;a!exj+5Yz))Hl-GJ3Wn1IrZP%w7r(-Ix&Q!k?Ln0Q From e300f77731bbccba0ec468abc10259a9abf3598e Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 20:12:56 +0800 Subject: [PATCH 16/49] chore: bump checkout version --- .github/workflows/pipeline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 27e18f1e..8a75b98d 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -13,7 +13,7 @@ jobs: 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 @@ -44,7 +44,7 @@ jobs: 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 From 68aca3547bba897fc26e9bb9e9a4bb420ead2998 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 20:13:11 +0800 Subject: [PATCH 17/49] fix: attempt fix of eslint ci? --- interapp-backend/eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interapp-backend/eslint.config.js b/interapp-backend/eslint.config.js index 07907e9f..b8c6f70b 100644 --- a/interapp-backend/eslint.config.js +++ b/interapp-backend/eslint.config.js @@ -1,8 +1,8 @@ // @ts-check +import 'eslint-plugin-only-warn'; import * as eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; -import 'eslint-plugin-only-warn'; export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, { ignores: ['node_modules/', 'pgdata/', 'minio-data/', 'tests/', 'scripts/'], From 68a4eeec49baa68fde7ad7ed8346beaf25f4ad4e Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 20:16:01 +0800 Subject: [PATCH 18/49] fix: again? --- interapp-backend/eslint.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/interapp-backend/eslint.config.js b/interapp-backend/eslint.config.js index b8c6f70b..59cee030 100644 --- a/interapp-backend/eslint.config.js +++ b/interapp-backend/eslint.config.js @@ -1,5 +1,3 @@ -// @ts-check - import 'eslint-plugin-only-warn'; import * as eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; From 4e3b49a52b815fcdb9b919bf58ed9f9eec139338 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 20:29:29 +0800 Subject: [PATCH 19/49] fix: again again? --- interapp-backend/eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interapp-backend/eslint.config.js b/interapp-backend/eslint.config.js index 59cee030..ea87b500 100644 --- a/interapp-backend/eslint.config.js +++ b/interapp-backend/eslint.config.js @@ -2,6 +2,6 @@ import 'eslint-plugin-only-warn'; import * as eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; -export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, { +export default tseslint.config(...tseslint.configs.recommended, { ignores: ['node_modules/', 'pgdata/', 'minio-data/', 'tests/', 'scripts/'], }); From c02fab5009b529ddd8fb21d1daaccd87f05424a7 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 20:40:18 +0800 Subject: [PATCH 20/49] fix: final fix? --- interapp-backend/eslint.config.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/interapp-backend/eslint.config.js b/interapp-backend/eslint.config.js index ea87b500..e0cd8639 100644 --- a/interapp-backend/eslint.config.js +++ b/interapp-backend/eslint.config.js @@ -1,7 +1,6 @@ import 'eslint-plugin-only-warn'; -import * as eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; export default tseslint.config(...tseslint.configs.recommended, { - ignores: ['node_modules/', 'pgdata/', 'minio-data/', 'tests/', 'scripts/'], + ignores: ['node_modules/', 'pgdata/', 'minio-data/'], }); From 19246e63e1e573fe63a4c762551c3225154c8a69 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Mon, 22 Apr 2024 22:26:16 +0800 Subject: [PATCH 21/49] feat: add servicehoursexportmodel --- .../exports_attendance.ts} | 252 ++++++++---------- .../api/models/exports/exports_base.ts | 8 + .../models/exports/exports_service_hours.ts | 10 + interapp-backend/api/models/exports/index.ts | 2 + interapp-backend/api/models/exports/types.ts | 33 +++ .../api/routes/endpoints/exports/exports.ts | 4 +- interapp-backend/tests/constants.test.ts | 4 +- ...test.ts => AttendanceExportsModel.test.ts} | 4 +- .../unit/ServiceHoursExportsModel.test.ts | 11 + 9 files changed, 179 insertions(+), 149 deletions(-) rename interapp-backend/api/models/{exports.ts => exports/exports_attendance.ts} (67%) create mode 100644 interapp-backend/api/models/exports/exports_base.ts create mode 100644 interapp-backend/api/models/exports/exports_service_hours.ts create mode 100644 interapp-backend/api/models/exports/index.ts create mode 100644 interapp-backend/api/models/exports/types.ts rename interapp-backend/tests/unit/{ExportsModel.test.ts => AttendanceExportsModel.test.ts} (70%) create mode 100644 interapp-backend/tests/unit/ServiceHoursExportsModel.test.ts diff --git a/interapp-backend/api/models/exports.ts b/interapp-backend/api/models/exports/exports_attendance.ts similarity index 67% rename from interapp-backend/api/models/exports.ts rename to interapp-backend/api/models/exports/exports_attendance.ts index bb265e65..a1fc8cce 100644 --- a/interapp-backend/api/models/exports.ts +++ b/interapp-backend/api/models/exports/exports_attendance.ts @@ -1,143 +1,109 @@ -import appDataSource from '@utils/init_datasource'; -import { ServiceSession, AttendanceStatus } from '@db/entities'; -import xlsx, { WorkSheet } from 'node-xlsx'; -import { HTTPErrors } from '@utils/errors'; - -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[]; - if (start_date === undefined || end_date === undefined) { - res = await appDataSource.manager - .createQueryBuilder() - .select([ - 'service_session.service_session_id', - 'service_session.start_time', - 'service_session.end_time', - 'service.name', - 'service.service_id', - ]) - .from(ServiceSession, 'service_session') - .leftJoin('service_session.service', 'service') - .where('service.service_id = :id', { id }) - .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') - .orderBy('service_session.start_time', 'ASC') - .getMany(); - } else { - res = await appDataSource.manager - .createQueryBuilder() - .select([ - 'service_session.service_session_id', - 'service_session.start_time', - 'service_session.end_time', - 'service.name', - 'service.service_id', - ]) - .from(ServiceSession, 'service_session') - .leftJoin('service_session.service', 'service') - .where('service.service_id = :id', { id }) - .andWhere('service_session.start_time >= :start_date', { start_date }) - .andWhere('service_session.end_time <= :end_date', { end_date }) - .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') - .orderBy('service_session.start_time', 'ASC') - .getMany(); - } - - return res; - } - - public static async formatXLSX(conds: QueryExportsConditions) { - 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( - ret.map(({ start_time }) => start_time), - ) as ExportsXLSX[0]; - - // output needs to be in the form: - // [username, [attendance status]] - - // get all unique usernames - const usernames = new Set(); - ret.forEach(({ service_session_users }) => { - service_session_users.forEach(({ username }) => { - usernames.add(username); - }); - }); - - // transform set to {[username]: [attendance status]} - const usernameMap: Record = {}; - usernames.forEach((username) => { - usernameMap[username] = []; - }); - - // create body - ret.forEach(({ service_session_users }) => { - usernames.forEach((username) => { - const user = service_session_users.find((user) => user.username === username); - usernameMap[username].push(user ? user.attended : null); - }); - }); - - const body: ExportsXLSX[1][] = Object.entries(usernameMap).map(([username, attendance]) => [ - username, - ...attendance, - ]); - - const out: ExportsXLSX = [headers, ...body]; - - const sheetOptions = this.getSheetOptions(out); - console.log(sheetOptions); - - return { name: ret[0].service.name, data: out, options: sheetOptions }; - } - - public static async packXLSX(ids: number[], start_date?: string, end_date?: string) { - const data: WorkSheet[] = await Promise.all( - ids.map((id) => { - if (start_date === undefined || end_date === undefined) return this.formatXLSX({ id }); - return this.formatXLSX({ id, start_date, end_date }); - }), - ); - return this.constructXLSX(...data); - } -} +import { AttendanceExportsResult, AttendanceExportsXLSX, AttendanceQueryExportsConditions } 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"; + + +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() + .select([ + 'service_session.service_session_id', + 'service_session.start_time', + 'service_session.end_time', + 'service.name', + 'service.service_id', + ]) + .from(ServiceSession, 'service_session') + .leftJoin('service_session.service', 'service') + .where('service.service_id = :id', { id }) + .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') + .orderBy('service_session.start_time', 'ASC') + .getMany(); + } else { + res = await appDataSource.manager + .createQueryBuilder() + .select([ + 'service_session.service_session_id', + 'service_session.start_time', + 'service_session.end_time', + 'service.name', + 'service.service_id', + ]) + .from(ServiceSession, 'service_session') + .leftJoin('service_session.service', 'service') + .where('service.service_id = :id', { id }) + .andWhere('service_session.start_time >= :start_date', { start_date }) + .andWhere('service_session.end_time <= :end_date', { end_date }) + .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') + .orderBy('service_session.start_time', 'ASC') + .getMany(); + } + + return res; + } + + 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 = (['username'] as AttendanceExportsXLSX[0]).concat( + ret.map(({ start_time }) => start_time), + ) as AttendanceExportsXLSX[0]; + + // output needs to be in the form: + // [username, [attendance status]] + + // get all unique usernames + const usernames = new Set(); + ret.forEach(({ service_session_users }) => { + service_session_users.forEach(({ username }) => { + usernames.add(username); + }); + }); + + // transform set to {[username]: [attendance status]} + const usernameMap: Record = {}; + usernames.forEach((username) => { + usernameMap[username] = []; + }); + + // create body + ret.forEach(({ service_session_users }) => { + usernames.forEach((username) => { + const user = service_session_users.find((user) => user.username === username); + usernameMap[username].push(user ? user.attended : null); + }); + }); + + const body: AttendanceExportsXLSX[1][] = Object.entries(usernameMap).map(([username, attendance]) => [ + username, + ...attendance, + ]); + + const out: AttendanceExportsXLSX = [headers, ...body]; + + const sheetOptions = this.getSheetOptions(out); + console.log(sheetOptions); + + return { name: ret[0].service.name, data: out, options: sheetOptions }; + } + + public static async packXLSX(ids: number[], start_date?: string, end_date?: string) { + const data: WorkSheet[] = await Promise.all( + ids.map((id) => { + if (start_date === undefined || end_date === undefined) return this.formatXLSX({ id }); + return this.formatXLSX({ id, start_date, end_date }); + }), + ); + return this.constructXLSX(...data); + } +} \ No newline at end of file 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..0e1969b1 --- /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); +} \ No newline at end of file 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..ac24da39 --- /dev/null +++ b/interapp-backend/api/models/exports/exports_service_hours.ts @@ -0,0 +1,10 @@ +import { AttendanceExportsResult, AttendanceExportsXLSX, AttendanceQueryExportsConditions } 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"; + +export class ServiceHoursExportsModel extends BaseExportsModel { + +} \ No newline at end of file diff --git a/interapp-backend/api/models/exports/index.ts b/interapp-backend/api/models/exports/index.ts new file mode 100644 index 00000000..f577b341 --- /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'; \ No newline at end of file diff --git a/interapp-backend/api/models/exports/types.ts b/interapp-backend/api/models/exports/types.ts new file mode 100644 index 00000000..2bd7ee4d --- /dev/null +++ b/interapp-backend/api/models/exports/types.ts @@ -0,0 +1,33 @@ +import { AttendanceStatus } from '@db/entities'; + +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; + } +); \ No newline at end of file diff --git a/interapp-backend/api/routes/endpoints/exports/exports.ts b/interapp-backend/api/routes/endpoints/exports/exports.ts index 97afccef..3cf0266a 100644 --- a/interapp-backend/api/routes/endpoints/exports/exports.ts +++ b/interapp-backend/api/routes/endpoints/exports/exports.ts @@ -1,4 +1,4 @@ -import { ExportsModel } from '@models/exports'; +import { AttendanceExportsModel } from '@models/exports'; import { z } from 'zod'; import { validateRequiredFieldsV2, verifyJWT, verifyRequiredPermission } from '@routes/middleware'; import { ExportsFields } from './validation'; @@ -17,7 +17,7 @@ exportsRouter.get( 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'); diff --git a/interapp-backend/tests/constants.test.ts b/interapp-backend/tests/constants.test.ts index 75185493..beca3277 100644 --- a/interapp-backend/tests/constants.test.ts +++ b/interapp-backend/tests/constants.test.ts @@ -1,4 +1,4 @@ -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'; @@ -13,7 +13,7 @@ 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( diff --git a/interapp-backend/tests/unit/ExportsModel.test.ts b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts similarity index 70% rename from interapp-backend/tests/unit/ExportsModel.test.ts rename to interapp-backend/tests/unit/AttendanceExportsModel.test.ts index c3bfa798..98c3a480 100644 --- a/interapp-backend/tests/unit/ExportsModel.test.ts +++ b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts @@ -1,9 +1,9 @@ import { runSuite, testSuites } from '../constants.test'; -import { ExportsModel } from '@models/.'; +import { AttendanceExportsModel } from '@models/.'; import { User } from '@db/entities'; import { describe, test, expect } from 'bun:test'; -const SUITE_NAME = 'ExportsModel'; +const SUITE_NAME = 'AttendanceExportsModel'; const suite = testSuites[SUITE_NAME]; console.log(suite); diff --git a/interapp-backend/tests/unit/ServiceHoursExportsModel.test.ts b/interapp-backend/tests/unit/ServiceHoursExportsModel.test.ts new file mode 100644 index 00000000..aa1406c6 --- /dev/null +++ b/interapp-backend/tests/unit/ServiceHoursExportsModel.test.ts @@ -0,0 +1,11 @@ +import { runSuite, testSuites } from '../constants.test'; +import { ServiceHoursExportsModel } from '@models/.'; +import { User } from '@db/entities'; +import { describe, test, expect } from 'bun:test'; + +const SUITE_NAME = 'ServiceHoursExportsModel'; +const suite = testSuites[SUITE_NAME]; + +console.log(suite); + +runSuite(SUITE_NAME, suite); From 93fed866a2efbfb50d53d0735ac346587da07083 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 22 Apr 2024 14:26:50 +0000 Subject: [PATCH 22/49] [autofix.ci] apply automated fixes --- .../api/models/exports/exports_attendance.ts | 220 +++++++++--------- .../api/models/exports/exports_base.ts | 16 +- .../models/exports/exports_service_hours.ts | 22 +- interapp-backend/api/models/exports/index.ts | 4 +- interapp-backend/api/models/exports/types.ts | 69 +++--- .../api/routes/endpoints/exports/exports.ts | 6 +- interapp-backend/tests/constants.test.ts | 18 +- 7 files changed, 190 insertions(+), 165 deletions(-) diff --git a/interapp-backend/api/models/exports/exports_attendance.ts b/interapp-backend/api/models/exports/exports_attendance.ts index a1fc8cce..e0da7c03 100644 --- a/interapp-backend/api/models/exports/exports_attendance.ts +++ b/interapp-backend/api/models/exports/exports_attendance.ts @@ -1,109 +1,111 @@ -import { AttendanceExportsResult, AttendanceExportsXLSX, AttendanceQueryExportsConditions } 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"; - - -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() - .select([ - 'service_session.service_session_id', - 'service_session.start_time', - 'service_session.end_time', - 'service.name', - 'service.service_id', - ]) - .from(ServiceSession, 'service_session') - .leftJoin('service_session.service', 'service') - .where('service.service_id = :id', { id }) - .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') - .orderBy('service_session.start_time', 'ASC') - .getMany(); - } else { - res = await appDataSource.manager - .createQueryBuilder() - .select([ - 'service_session.service_session_id', - 'service_session.start_time', - 'service_session.end_time', - 'service.name', - 'service.service_id', - ]) - .from(ServiceSession, 'service_session') - .leftJoin('service_session.service', 'service') - .where('service.service_id = :id', { id }) - .andWhere('service_session.start_time >= :start_date', { start_date }) - .andWhere('service_session.end_time <= :end_date', { end_date }) - .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') - .orderBy('service_session.start_time', 'ASC') - .getMany(); - } - - return res; - } - - 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 = (['username'] as AttendanceExportsXLSX[0]).concat( - ret.map(({ start_time }) => start_time), - ) as AttendanceExportsXLSX[0]; - - // output needs to be in the form: - // [username, [attendance status]] - - // get all unique usernames - const usernames = new Set(); - ret.forEach(({ service_session_users }) => { - service_session_users.forEach(({ username }) => { - usernames.add(username); - }); - }); - - // transform set to {[username]: [attendance status]} - const usernameMap: Record = {}; - usernames.forEach((username) => { - usernameMap[username] = []; - }); - - // create body - ret.forEach(({ service_session_users }) => { - usernames.forEach((username) => { - const user = service_session_users.find((user) => user.username === username); - usernameMap[username].push(user ? user.attended : null); - }); - }); - - const body: AttendanceExportsXLSX[1][] = Object.entries(usernameMap).map(([username, attendance]) => [ - username, - ...attendance, - ]); - - const out: AttendanceExportsXLSX = [headers, ...body]; - - const sheetOptions = this.getSheetOptions(out); - console.log(sheetOptions); - - return { name: ret[0].service.name, data: out, options: sheetOptions }; - } - - public static async packXLSX(ids: number[], start_date?: string, end_date?: string) { - const data: WorkSheet[] = await Promise.all( - ids.map((id) => { - if (start_date === undefined || end_date === undefined) return this.formatXLSX({ id }); - return this.formatXLSX({ id, start_date, end_date }); - }), - ); - return this.constructXLSX(...data); - } -} \ No newline at end of file +import { + AttendanceExportsResult, + AttendanceExportsXLSX, + AttendanceQueryExportsConditions, +} 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'; + +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() + .select([ + 'service_session.service_session_id', + 'service_session.start_time', + 'service_session.end_time', + 'service.name', + 'service.service_id', + ]) + .from(ServiceSession, 'service_session') + .leftJoin('service_session.service', 'service') + .where('service.service_id = :id', { id }) + .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') + .orderBy('service_session.start_time', 'ASC') + .getMany(); + } else { + res = await appDataSource.manager + .createQueryBuilder() + .select([ + 'service_session.service_session_id', + 'service_session.start_time', + 'service_session.end_time', + 'service.name', + 'service.service_id', + ]) + .from(ServiceSession, 'service_session') + .leftJoin('service_session.service', 'service') + .where('service.service_id = :id', { id }) + .andWhere('service_session.start_time >= :start_date', { start_date }) + .andWhere('service_session.end_time <= :end_date', { end_date }) + .leftJoinAndSelect('service_session.service_session_users', 'service_session_users') + .orderBy('service_session.start_time', 'ASC') + .getMany(); + } + + return res; + } + + 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 = (['username'] as AttendanceExportsXLSX[0]).concat( + ret.map(({ start_time }) => start_time), + ) as AttendanceExportsXLSX[0]; + + // output needs to be in the form: + // [username, [attendance status]] + + // get all unique usernames + const usernames = new Set(); + ret.forEach(({ service_session_users }) => { + service_session_users.forEach(({ username }) => { + usernames.add(username); + }); + }); + + // transform set to {[username]: [attendance status]} + const usernameMap: Record = {}; + usernames.forEach((username) => { + usernameMap[username] = []; + }); + + // create body + ret.forEach(({ service_session_users }) => { + usernames.forEach((username) => { + const user = service_session_users.find((user) => user.username === username); + usernameMap[username].push(user ? user.attended : null); + }); + }); + + const body: AttendanceExportsXLSX[1][] = Object.entries(usernameMap).map( + ([username, attendance]) => [username, ...attendance], + ); + + const out: AttendanceExportsXLSX = [headers, ...body]; + + const sheetOptions = this.getSheetOptions(out); + console.log(sheetOptions); + + return { name: ret[0].service.name, data: out, options: sheetOptions }; + } + + public static async packXLSX(ids: number[], start_date?: string, end_date?: string) { + const data: WorkSheet[] = await Promise.all( + ids.map((id) => { + if (start_date === undefined || end_date === undefined) return this.formatXLSX({ id }); + return this.formatXLSX({ id, start_date, end_date }); + }), + ); + return this.constructXLSX(...data); + } +} diff --git a/interapp-backend/api/models/exports/exports_base.ts b/interapp-backend/api/models/exports/exports_base.ts index 0e1969b1..2450b883 100644 --- a/interapp-backend/api/models/exports/exports_base.ts +++ b/interapp-backend/api/models/exports/exports_base.ts @@ -1,8 +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); -} \ No newline at end of file +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 index ac24da39..f347015a 100644 --- a/interapp-backend/api/models/exports/exports_service_hours.ts +++ b/interapp-backend/api/models/exports/exports_service_hours.ts @@ -1,10 +1,12 @@ -import { AttendanceExportsResult, AttendanceExportsXLSX, AttendanceQueryExportsConditions } 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"; - -export class ServiceHoursExportsModel extends BaseExportsModel { - -} \ No newline at end of file +import { + AttendanceExportsResult, + AttendanceExportsXLSX, + AttendanceQueryExportsConditions, +} 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'; + +export class ServiceHoursExportsModel extends BaseExportsModel {} diff --git a/interapp-backend/api/models/exports/index.ts b/interapp-backend/api/models/exports/index.ts index f577b341..eaa34eca 100644 --- a/interapp-backend/api/models/exports/index.ts +++ b/interapp-backend/api/models/exports/index.ts @@ -1,2 +1,2 @@ -export { AttendanceExportsModel } from './exports_attendance'; -export { ServiceHoursExportsModel } from './exports_service_hours'; \ No newline at end of file +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 index 2bd7ee4d..0f9f38ce 100644 --- a/interapp-backend/api/models/exports/types.ts +++ b/interapp-backend/api/models/exports/types.ts @@ -1,33 +1,36 @@ -import { AttendanceStatus } from '@db/entities'; - -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; - } -); \ No newline at end of file +import { AttendanceStatus } from '@db/entities'; + +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/routes/endpoints/exports/exports.ts b/interapp-backend/api/routes/endpoints/exports/exports.ts index 3cf0266a..c66cd712 100644 --- a/interapp-backend/api/routes/endpoints/exports/exports.ts +++ b/interapp-backend/api/routes/endpoints/exports/exports.ts @@ -17,7 +17,11 @@ exportsRouter.get( async (req, res) => { const query = req.query as unknown as z.infer; - const exports = await AttendanceExportsModel.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'); diff --git a/interapp-backend/tests/constants.test.ts b/interapp-backend/tests/constants.test.ts index beca3277..43e46e11 100644 --- a/interapp-backend/tests/constants.test.ts +++ b/interapp-backend/tests/constants.test.ts @@ -1,4 +1,11 @@ -import { ServiceModel, AuthModel, AnnouncementModel, UserModel, AttendanceExportsModel, ServiceHoursExportsModel } 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'; @@ -13,7 +20,14 @@ type TestSuite = { }; // get all models in an array -const objs = [ServiceModel, AuthModel, AnnouncementModel, UserModel, AttendanceExportsModel, ServiceHoursExportsModel] 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( From 352a98957b3a94293ebf4156693ae61bd69ba654 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Tue, 23 Apr 2024 00:08:56 +0800 Subject: [PATCH 23/49] fix: revert replaced error message --- interapp-backend/api/utils/errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interapp-backend/api/utils/errors.ts b/interapp-backend/api/utils/errors.ts index f6273c9d..37399a16 100644 --- a/interapp-backend/api/utils/errors.ts +++ b/interapp-backend/api/utils/errors.ts @@ -126,12 +126,12 @@ export const HTTPErrors = { ), NO_SERVICES_FOUND: new HTTPError( 'NoServicesFound', - 'This user is not part of unknown service', + 'This user is not part of any service', HTTPErrorCode.NOT_FOUND_ERROR, ), NO_SERVICE_SESSION_FOUND: new HTTPError( 'NoServiceSessionFound', - 'This user is not part of unknown service session', + 'This user is not part of any service session', HTTPErrorCode.NOT_FOUND_ERROR, ), SERVICE_NO_USER_FOUND: new HTTPError( From 65db8b90c09c92432d54e00074cbaabbc00a9cf9 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Wed, 24 Apr 2024 02:36:50 +0800 Subject: [PATCH 24/49] refactor: validation of middleware function name in API routes --- .../endpoints/announcement/announcement.ts | 16 ++++---- .../api/routes/endpoints/auth/auth.ts | 6 +-- .../api/routes/endpoints/exports/exports.ts | 4 +- .../api/routes/endpoints/service/service.ts | 40 +++++++++---------- .../api/routes/endpoints/user/user.ts | 34 ++++++++-------- interapp-backend/api/routes/middleware.ts | 2 +- 6 files changed, 51 insertions(+), 51 deletions(-) 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 c66cd712..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 { 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'; @@ -11,7 +11,7 @@ const xlsxMime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sh exportsRouter.get( '/', - validateRequiredFieldsV2(ExportsFields), + validateRequiredFields(ExportsFields), verifyJWT, verifyRequiredPermission(Permissions.ATTENDANCE_MANAGER), async (req, res) => { diff --git a/interapp-backend/api/routes/endpoints/service/service.ts b/interapp-backend/api/routes/endpoints/service/service.ts index c14c8c25..8a7112ac 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, @@ -27,7 +27,7 @@ const serviceRouter = Router(); serviceRouter.post( '/', - validateRequiredFieldsV2(CreateServiceFields), + validateRequiredFields(CreateServiceFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -40,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)); @@ -49,7 +49,7 @@ serviceRouter.get('/', validateRequiredFieldsV2(ServiceIdFields), async (req, re serviceRouter.patch( '/', - validateRequiredFieldsV2(UpdateServiceFields), + validateRequiredFields(UpdateServiceFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -63,7 +63,7 @@ serviceRouter.patch( serviceRouter.delete( '/', - validateRequiredFieldsV2(ServiceIdFields), + validateRequiredFields(ServiceIdFields), verifyJWT, verifyRequiredPermission(Permissions.EXCO), async (req, res) => { @@ -81,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) => { @@ -94,7 +94,7 @@ serviceRouter.get( serviceRouter.post( '/session', - validateRequiredFieldsV2(CreateServiceSessionFields), + validateRequiredFields(CreateServiceSessionFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -108,7 +108,7 @@ serviceRouter.post( serviceRouter.get( '/session', - validateRequiredFieldsV2(ServiceSessionIdFields), + validateRequiredFields(ServiceSessionIdFields), async (req, res) => { const query = req.query as unknown as z.infer; const session = await ServiceModel.getServiceSession(Number(query.service_session_id)); @@ -118,7 +118,7 @@ serviceRouter.get( serviceRouter.patch( '/session', - validateRequiredFieldsV2(UpdateServiceSessionFields), + validateRequiredFields(UpdateServiceSessionFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -131,7 +131,7 @@ serviceRouter.patch( serviceRouter.delete( '/session', - validateRequiredFieldsV2(ServiceSessionIdFields), + validateRequiredFields(ServiceSessionIdFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -143,7 +143,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; @@ -165,7 +165,7 @@ serviceRouter.get( serviceRouter.post( '/session_user', - validateRequiredFieldsV2(CreateServiceSessionUserFields), + validateRequiredFields(CreateServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -178,7 +178,7 @@ serviceRouter.post( serviceRouter.post( '/session_user_bulk', - validateRequiredFieldsV2(CreateBulkServiceSessionUserFields), + validateRequiredFields(CreateBulkServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -194,7 +194,7 @@ serviceRouter.post( serviceRouter.get( '/session_user', - validateRequiredFieldsV2(FindServiceSessionUserFields), + validateRequiredFields(FindServiceSessionUserFields), async (req, res) => { const query = req.query as unknown as z.infer; @@ -209,7 +209,7 @@ 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; @@ -228,7 +228,7 @@ serviceRouter.get( serviceRouter.patch( '/session_user', - validateRequiredFieldsV2(UpdateServiceSessionUserFields), + validateRequiredFields(UpdateServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -245,7 +245,7 @@ serviceRouter.patch( serviceRouter.patch( '/absence', - validateRequiredFieldsV2(ServiceSessionUserIdFields), + validateRequiredFields(ServiceSessionUserIdFields), verifyJWT, verifyRequiredPermission(Permissions.CLUB_MEMBER), async (req, res) => { @@ -264,7 +264,7 @@ serviceRouter.patch( serviceRouter.delete( '/session_user', - validateRequiredFieldsV2(ServiceSessionUserIdFields), + validateRequiredFields(ServiceSessionUserIdFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -279,7 +279,7 @@ serviceRouter.delete( serviceRouter.delete( '/session_user_bulk', - validateRequiredFieldsV2(DeleteBulkServiceSessionUserFields), + validateRequiredFields(DeleteBulkServiceSessionUserFields), verifyJWT, verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC), async (req, res) => { @@ -306,7 +306,7 @@ 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; diff --git a/interapp-backend/api/routes/endpoints/user/user.ts b/interapp-backend/api/routes/endpoints/user/user.ts index c5430e18..bfb1aebc 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,7 +249,7 @@ 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); 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); From d086e199bf66539a9e7f960325c282a0601263e0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Apr 2024 18:37:26 +0000 Subject: [PATCH 25/49] [autofix.ci] apply automated fixes --- .../api/routes/endpoints/service/service.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/interapp-backend/api/routes/endpoints/service/service.ts b/interapp-backend/api/routes/endpoints/service/service.ts index 8a7112ac..519e557a 100644 --- a/interapp-backend/api/routes/endpoints/service/service.ts +++ b/interapp-backend/api/routes/endpoints/service/service.ts @@ -106,15 +106,11 @@ serviceRouter.post( }, ); -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.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', From b3a1bbae0f885fe1cbf7958bbf1d10c8df23eb6c Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Wed, 24 Apr 2024 00:13:00 +0800 Subject: [PATCH 26/49] feat: improve verify attendance --- interapp-backend/api/models/service.ts | 47 ++++++-- .../api/routes/endpoints/service/service.ts | 4 +- .../AttendanceMenu/QRPage/QRPage.tsx | 2 +- .../VerifyAttendance/VerifyAttendance.tsx | 100 +++++++++++------- .../src/app/attendance/verify/page.tsx | 10 +- 5 files changed, 106 insertions(+), 57 deletions(-) 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/routes/endpoints/service/service.ts b/interapp-backend/api/routes/endpoints/service/service.ts index 519e557a..ebc22382 100644 --- a/interapp-backend/api/routes/endpoints/service/service.ts +++ b/interapp-backend/api/routes/endpoints/service/service.ts @@ -306,8 +306,8 @@ serviceRouter.post( 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-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx index 6c7a83f0..fdf47ad1 100644 --- a/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx @@ -40,7 +40,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/verify/VerifyAttendance/VerifyAttendance.tsx b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx index f144315a..43b290ea 100644 --- a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx +++ b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx @@ -3,54 +3,55 @@ 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 './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 +63,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', @@ -78,27 +84,38 @@ const updateServiceHours = async (newHours: number) => { if (res.status !== 204) throw new Error('Failed to update CCA hours'); }; -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 +133,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 ; } From 6285484d1b33e1e82df5b647459cb40c7fe95f91 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Apr 2024 16:13:56 +0000 Subject: [PATCH 27/49] [autofix.ci] apply automated fixes --- .../app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx index 43b290ea..f518b8d2 100644 --- a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx +++ b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx @@ -145,7 +145,7 @@ const VerifyAttendance = ({ hash }: VerifyAttendanceProps) => { return (
    Verify Attendance - + {message} {status === 'Success' ? ( From 985d20e301cab35e5795addbb99e8d5cef6ccaf5 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Wed, 24 Apr 2024 02:25:33 +0800 Subject: [PATCH 28/49] chore: remove legacy userwithprofilepicture --- interapp-backend/api/models/auth.ts | 7 +++++ interapp-backend/api/models/user.ts | 30 +++++++++++-------- .../api/routes/endpoints/user/user.ts | 7 +++-- interapp-backend/tests/api/account.test.ts | 5 +++- .../tests/api/service_session.test.ts | 9 +++++- .../src/app/profile/Overview/Overview.tsx | 9 +++--- .../ServiceBoxUsers/ServiceBoxUsers.tsx | 6 ++-- .../ChangeProfilePicture.tsx | 15 +++++++--- .../providers/AuthProvider/AuthProvider.tsx | 7 +++-- .../src/providers/AuthProvider/types.ts | 4 +-- .../src/utils/getAllUsernames.ts | 4 +-- 11 files changed, 67 insertions(+), 36 deletions(-) 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/user.ts b/interapp-backend/api/models/user.ts index b6a43ec2..a49931cd 100644 --- a/interapp-backend/api/models/user.ts +++ b/interapp-backend/api/models/user.ts @@ -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; } @@ -553,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/user/user.ts b/interapp-backend/api/routes/endpoints/user/user.ts index bfb1aebc..28b3c286 100644 --- a/interapp-backend/api/routes/endpoints/user/user.ts +++ b/interapp-backend/api/routes/endpoints/user/user.ts @@ -252,8 +252,11 @@ userRouter.patch( 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/tests/api/account.test.ts b/interapp-backend/tests/api/account.test.ts index e47ec8f6..1cd0ba28 100644 --- a/interapp-backend/tests/api/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/api/service_session.test.ts b/interapp-backend/tests/api/service_session.test.ts index aa019f90..49ca4262 100644 --- a/interapp-backend/tests/api/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-frontend/src/app/profile/Overview/Overview.tsx b/interapp-frontend/src/app/profile/Overview/Overview.tsx index 744addff..68d44c5c 100644 --- a/interapp-frontend/src/app/profile/Overview/Overview.tsx +++ b/interapp-frontend/src/app/profile/Overview/Overview.tsx @@ -1,7 +1,7 @@ '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 { Text, Title, Group, Stack, Badge, ActionIcon, Paper, Button } from '@mantine/core'; @@ -18,7 +18,7 @@ const fetchUserDetails = async (username: string) => { if (response.status !== 200) throw new Error('Failed to fetch user info'); - const data: UserWithProfilePicture = response.data; + const data: User = response.data; if (data.profile_picture) data.profile_picture = remapAssetUrl(data.profile_picture); @@ -38,7 +38,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 +50,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/services/ServiceBoxUsers/ServiceBoxUsers.tsx b/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx index 97a00952..727eeaa6 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'; @@ -17,7 +17,7 @@ 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 = []; @@ -27,7 +27,7 @@ const handleGetUsers = async (service_id: number) => { 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; + 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/settings/ChangeProfilePicture/ChangeProfilePicture.tsx b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx index 4922e3a1..4d39a92c 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -5,7 +5,7 @@ import APIClient from '@api/api_client'; import { 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'; @@ -14,14 +14,14 @@ const fetchUserProfilePicture = async (username: string) => { const response = await apiClient.get('/user?username=' + username); if (response.status !== 200) throw new Error('Failed to fetch profile picture'); - 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,9 +32,12 @@ 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) => { + updateUser({ ...user, profile_picture: null }); if (response.status !== 204) { notifications.show({ title: 'Failed to delete profile picture', @@ -54,13 +57,15 @@ const ChangeProfilePicture = () => { convertToBase64(file) .then((base64) => { apiClient.patch('/user/profile_picture', { profile_picture: base64 }).then((response) => { - if (response.status !== 204) { + + if (response.status !== 200) { notifications.show({ title: 'Failed to update profile picture', message: 'Please try again later.', color: 'red', }); } else { + updateUser({ ...user, profile_picture: (response.data as { url: string }).url}); notifications.show({ title: 'Profile picture updated', message: 'Your profile picture has been updated.', @@ -80,6 +85,8 @@ const ChangeProfilePicture = () => { } }; + + return ( ({ user: null, @@ -130,7 +130,10 @@ 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..f82683dc 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,7 @@ 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; } From 4aaca4327deec7e0921c7611f3b1dcaf0e9174fb Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Wed, 24 Apr 2024 02:25:59 +0800 Subject: [PATCH 29/49] fix: mapping for update profile picture --- .../settings/ChangeProfilePicture/ChangeProfilePicture.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx index 4d39a92c..2168fc9b 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -57,6 +57,8 @@ const ChangeProfilePicture = () => { convertToBase64(file) .then((base64) => { apiClient.patch('/user/profile_picture', { profile_picture: base64 }).then((response) => { + const url = (response.data as { url: string }).url; + const mappedURL = url ? remapAssetUrl(url) : null; if (response.status !== 200) { notifications.show({ @@ -65,7 +67,7 @@ const ChangeProfilePicture = () => { color: 'red', }); } else { - updateUser({ ...user, profile_picture: (response.data as { url: string }).url}); + updateUser({ ...user, profile_picture: mappedURL }); notifications.show({ title: 'Profile picture updated', message: 'Your profile picture has been updated.', From 56e11b3c431ed16de01bd2f79df369bc8e62aa58 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Apr 2024 18:26:30 +0000 Subject: [PATCH 30/49] [autofix.ci] apply automated fixes --- .../ChangeProfilePicture/ChangeProfilePicture.tsx | 4 +--- .../src/providers/AuthProvider/AuthProvider.tsx | 11 +++++++---- interapp-frontend/src/providers/AuthProvider/types.ts | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx index 2168fc9b..e46af7dc 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -59,7 +59,7 @@ const ChangeProfilePicture = () => { apiClient.patch('/user/profile_picture', { profile_picture: base64 }).then((response) => { 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', @@ -87,8 +87,6 @@ const ChangeProfilePicture = () => { } }; - - return ( { if (status === 200) { localStorage.setItem('access_token_expire', expire.toString()); localStorage.setItem('access_token', access_token); - localStorage.setItem('user', JSON.stringify({ - ...user, - profile_picture: user.profile_picture ? remapAssetUrl(user.profile_picture) : null, - })); + 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 f82683dc..a80dda90 100644 --- a/interapp-frontend/src/providers/AuthProvider/types.ts +++ b/interapp-frontend/src/providers/AuthProvider/types.ts @@ -53,7 +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'), + user.profile_picture !== undefined && + (user.profile_picture === null || typeof user.profile_picture === 'string'), ]; if (conditions.every((condition) => condition)) return true; From b82ebc87cf57e381eb72c2e6327bbf07c4ff4fec Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Wed, 24 Apr 2024 02:31:31 +0800 Subject: [PATCH 31/49] fix: delete profile picture from localstorage only on successful api call --- .../app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx index e46af7dc..69f8b674 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -37,7 +37,7 @@ const ChangeProfilePicture = () => { const handleUpdate = (imageURL: string, file: File | null) => { if (file === null) { apiClient.delete('/user/profile_picture').then((response) => { - updateUser({ ...user, profile_picture: null }); + if (response.status !== 204) { notifications.show({ title: 'Failed to delete profile picture', @@ -45,6 +45,7 @@ const ChangeProfilePicture = () => { color: 'red', }); } else { + updateUser({ ...user, profile_picture: null }); notifications.show({ title: 'Profile picture deleted', message: 'Your profile picture has been deleted.', From a663dba28fb17db794cefb932d1fd83f28c07904 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Apr 2024 18:32:00 +0000 Subject: [PATCH 32/49] [autofix.ci] apply automated fixes --- .../app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx index 69f8b674..6f2871f1 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -37,7 +37,6 @@ const ChangeProfilePicture = () => { const handleUpdate = (imageURL: string, file: File | null) => { if (file === null) { apiClient.delete('/user/profile_picture').then((response) => { - if (response.status !== 204) { notifications.show({ title: 'Failed to delete profile picture', From a5f640e280d603af8eaea200806636d33ee71d3f Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Wed, 24 Apr 2024 21:47:37 +0800 Subject: [PATCH 33/49] fix: timeout during postgres client install --- interapp-backend/scheduler/dev.Dockerfile | 2 +- interapp-backend/scheduler/prod.Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From ddb4475cba82ae647f2b050f247a47fe21e8b3c7 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Wed, 24 Apr 2024 21:47:59 +0800 Subject: [PATCH 34/49] feat: add interface for all exports models to follow --- .../api/models/exports/exports_attendance.ts | 3 +++ .../api/models/exports/exports_service_hours.ts | 6 +++++- interapp-backend/api/models/exports/types.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/interapp-backend/api/models/exports/exports_attendance.ts b/interapp-backend/api/models/exports/exports_attendance.ts index e0da7c03..448cfb48 100644 --- a/interapp-backend/api/models/exports/exports_attendance.ts +++ b/interapp-backend/api/models/exports/exports_attendance.ts @@ -2,6 +2,8 @@ import { AttendanceExportsResult, AttendanceExportsXLSX, AttendanceQueryExportsConditions, + ExportsModelImpl, + staticImplements, } from './types'; import { BaseExportsModel } from './exports_base'; import { ServiceSession, type AttendanceStatus } from '@db/entities'; @@ -9,6 +11,7 @@ import { HTTPErrors } from '@utils/errors'; import { WorkSheet } from 'node-xlsx'; import appDataSource from '@utils/init_datasource'; +@staticImplements() export class AttendanceExportsModel extends BaseExportsModel { public static async queryExports({ id, start_date, end_date }: AttendanceQueryExportsConditions) { let res: AttendanceExportsResult[]; diff --git a/interapp-backend/api/models/exports/exports_service_hours.ts b/interapp-backend/api/models/exports/exports_service_hours.ts index f347015a..b1f21e8b 100644 --- a/interapp-backend/api/models/exports/exports_service_hours.ts +++ b/interapp-backend/api/models/exports/exports_service_hours.ts @@ -2,6 +2,8 @@ import { AttendanceExportsResult, AttendanceExportsXLSX, AttendanceQueryExportsConditions, + ExportsModelImpl, + staticImplements, } from './types'; import { BaseExportsModel } from './exports_base'; import { ServiceSession, type AttendanceStatus } from '@db/entities'; @@ -9,4 +11,6 @@ import { HTTPErrors } from '@utils/errors'; import { WorkSheet } from 'node-xlsx'; import appDataSource from '@utils/init_datasource'; -export class ServiceHoursExportsModel extends BaseExportsModel {} +// @staticImplements() +export class ServiceHoursExportsModel extends BaseExportsModel { +} diff --git a/interapp-backend/api/models/exports/types.ts b/interapp-backend/api/models/exports/types.ts index 0f9f38ce..97629a1a 100644 --- a/interapp-backend/api/models/exports/types.ts +++ b/interapp-backend/api/models/exports/types.ts @@ -1,5 +1,19 @@ 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}; +} + + + export type AttendanceExportsResult = { service_session_id: number; start_time: string; From dd544a7320563a267d17135291204535ed969e6d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Apr 2024 13:49:00 +0000 Subject: [PATCH 35/49] [autofix.ci] apply automated fixes --- .../api/models/exports/exports_service_hours.ts | 3 +-- interapp-backend/api/models/exports/types.ts | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/interapp-backend/api/models/exports/exports_service_hours.ts b/interapp-backend/api/models/exports/exports_service_hours.ts index b1f21e8b..9efb8ec9 100644 --- a/interapp-backend/api/models/exports/exports_service_hours.ts +++ b/interapp-backend/api/models/exports/exports_service_hours.ts @@ -12,5 +12,4 @@ import { WorkSheet } from 'node-xlsx'; import appDataSource from '@utils/init_datasource'; // @staticImplements() -export class ServiceHoursExportsModel extends BaseExportsModel { -} +export class ServiceHoursExportsModel extends BaseExportsModel {} diff --git a/interapp-backend/api/models/exports/types.ts b/interapp-backend/api/models/exports/types.ts index 97629a1a..4e9f90e4 100644 --- a/interapp-backend/api/models/exports/types.ts +++ b/interapp-backend/api/models/exports/types.ts @@ -9,10 +9,10 @@ export interface ExportsModelImpl { // class decorator that asserts that a class implements an interface statically // https://stackoverflow.com/a/43674389 export function staticImplements() { - return (constructor: U) => {constructor}; -} - - + return (constructor: U) => { + constructor; + }; +} export type AttendanceExportsResult = { service_session_id: number; From 31f5dcbde2d131e1d34123b27130de7d5843fd33 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 27 Apr 2024 14:14:42 +0800 Subject: [PATCH 36/49] fix: remove console log --- interapp-backend/api/models/exports/exports_attendance.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/interapp-backend/api/models/exports/exports_attendance.ts b/interapp-backend/api/models/exports/exports_attendance.ts index 448cfb48..dc2b0237 100644 --- a/interapp-backend/api/models/exports/exports_attendance.ts +++ b/interapp-backend/api/models/exports/exports_attendance.ts @@ -97,7 +97,6 @@ export class AttendanceExportsModel extends BaseExportsModel { const out: AttendanceExportsXLSX = [headers, ...body]; const sheetOptions = this.getSheetOptions(out); - console.log(sheetOptions); return { name: ret[0].service.name, data: out, options: sheetOptions }; } From bbb6e898c2b08851e0af3dfcb5e505b595e08723 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 27 Apr 2024 14:14:50 +0800 Subject: [PATCH 37/49] feat: update tests --- .../tests/unit/AttendanceExportsModel.test.ts | 233 +++++++++++++++++- interapp-backend/tests/unit/UserModel.test.ts | 53 ++++ 2 files changed, 283 insertions(+), 3 deletions(-) diff --git a/interapp-backend/tests/unit/AttendanceExportsModel.test.ts b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts index 98c3a480..c5f27592 100644 --- a/interapp-backend/tests/unit/AttendanceExportsModel.test.ts +++ b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts @@ -1,11 +1,238 @@ import { runSuite, testSuites } from '../constants.test'; -import { AttendanceExportsModel } from '@models/.'; -import { User } from '@db/entities'; -import { describe, test, expect } from 'bun: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/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); From 27d09e27f6f4f19d94f24bd880f5a91d08cbca62 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 06:15:25 +0000 Subject: [PATCH 38/49] [autofix.ci] apply automated fixes --- .../tests/unit/AttendanceExportsModel.test.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/interapp-backend/tests/unit/AttendanceExportsModel.test.ts b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts index c5f27592..eadc71a1 100644 --- a/interapp-backend/tests/unit/AttendanceExportsModel.test.ts +++ b/interapp-backend/tests/unit/AttendanceExportsModel.test.ts @@ -49,10 +49,8 @@ const populateDb = async () => { 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++) { @@ -65,7 +63,6 @@ const populateDb = async () => { await createSessions(id, now, end); } - // add all users to all sessions const users = []; @@ -149,7 +146,7 @@ suite.queryExports = [ expect(AttendanceExportsModel.queryExports({ id: 2 })).resolves.toBeArrayOfSize(0); }, cleanup: async () => await recreateDB(), - } + }, ]; suite.formatXLSX = [ @@ -174,7 +171,7 @@ suite.formatXLSX = [ name: 'a', data: expect.any(Array), options: expect.any(Object), - }) + }); }, cleanup: async () => await recreateDB(), }, @@ -186,15 +183,22 @@ suite.formatXLSX = [ 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(); + expect( + AttendanceExportsModel.formatXLSX({ + id: 1, + start_date: new Date().toISOString(), + end_date: new Date().toISOString(), + }), + ).rejects.toThrow(); }, cleanup: async () => await recreateDB(), - } -] + }, +]; suite.packXLSX = [ { @@ -208,7 +212,11 @@ suite.packXLSX = [ 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()); + const ret = await AttendanceExportsModel.packXLSX( + [1], + start_date.toISOString(), + end_date.toISOString(), + ); expect(ret).toBeInstanceOf(Buffer); }, @@ -227,10 +235,12 @@ suite.packXLSX = [ name: 'should throw if no exports found', cb: async () => { await populateDb(); - expect(AttendanceExportsModel.packXLSX([1], new Date().toISOString(), new Date().toISOString())).rejects.toThrow(); + expect( + AttendanceExportsModel.packXLSX([1], new Date().toISOString(), new Date().toISOString()), + ).rejects.toThrow(); }, cleanup: async () => await recreateDB(), - } + }, ]; console.log(suite); From 6a5fef8758047fb846b16058b2f751fe3eb4d4ec Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 27 Apr 2024 22:21:37 +0800 Subject: [PATCH 39/49] fix: attempt to get sonar to ignore error --- interapp-backend/api/models/exports/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interapp-backend/api/models/exports/types.ts b/interapp-backend/api/models/exports/types.ts index 4e9f90e4..fb3c0e43 100644 --- a/interapp-backend/api/models/exports/types.ts +++ b/interapp-backend/api/models/exports/types.ts @@ -10,7 +10,7 @@ export interface ExportsModelImpl { // https://stackoverflow.com/a/43674389 export function staticImplements() { return (constructor: U) => { - constructor; + constructor; // NOSONAR }; } From 0a99fa0be00f3bfa6b99e2e21663624a65e3e45b Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 27 Apr 2024 22:21:03 +0800 Subject: [PATCH 40/49] refactor: parseErrorMessage -> parseServerError --- .../src/app/announcements/[id]/edit/EditForm.tsx | 4 ++-- .../src/app/exports/ExportsForm/ExportsForm.tsx | 4 ++-- .../ServiceSessionContent/AddAction/AddAction.tsx | 4 ++-- .../ServiceSessionContent/EditAction/EditAction.tsx | 10 +++++----- .../src/app/services/AddService/AddService.tsx | 4 ++-- .../src/app/services/EditService/EditService.tsx | 4 ++-- interapp-frontend/src/utils/parseErrorMessage.ts | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) 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/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/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx index 39d669f2..2db0ff77 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 { getAllUsernames, parseServerError } from '@utils/.'; import { ServiceSessionUser } from '../../types'; import { IconPlus } from '@tabler/icons-react'; import { Service } from '@/app/services/types'; @@ -85,7 +85,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/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/utils/parseErrorMessage.ts b/interapp-frontend/src/utils/parseErrorMessage.ts index cd9cc4ea..981c5901 100644 --- a/interapp-frontend/src/utils/parseErrorMessage.ts +++ b/interapp-frontend/src/utils/parseErrorMessage.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'; From 9b3b7910edf5375a1ffa49245f27e5337a8d5454 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 27 Apr 2024 22:23:59 +0800 Subject: [PATCH 41/49] fix: rename file --- interapp-frontend/src/utils/index.ts | 2 +- .../src/utils/{parseErrorMessage.ts => parseServerError.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename interapp-frontend/src/utils/{parseErrorMessage.ts => parseServerError.ts} (100%) diff --git a/interapp-frontend/src/utils/index.ts b/interapp-frontend/src/utils/index.ts index 538add11..0109a88e 100644 --- a/interapp-frontend/src/utils/index.ts +++ b/interapp-frontend/src/utils/index.ts @@ -1,4 +1,4 @@ -export * from './parseErrorMessage'; +export * from './parseServerError'; export * from './getAllUsernames'; export * from './remapAssetUrl'; export * from './wildcardMatcher'; diff --git a/interapp-frontend/src/utils/parseErrorMessage.ts b/interapp-frontend/src/utils/parseServerError.ts similarity index 100% rename from interapp-frontend/src/utils/parseErrorMessage.ts rename to interapp-frontend/src/utils/parseServerError.ts From 847120bf11ee30f41fa24dce996b01fb3920e756 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 27 Apr 2024 23:07:13 +0800 Subject: [PATCH 42/49] feat: add docs to remap asset url --- interapp-frontend/src/utils/remapAssetUrl.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/interapp-frontend/src/utils/remapAssetUrl.ts b/interapp-frontend/src/utils/remapAssetUrl.ts index 1f04eebb..4c334cc6 100644 --- a/interapp-frontend/src/utils/remapAssetUrl.ts +++ b/interapp-frontend/src/utils/remapAssetUrl.ts @@ -1,7 +1,22 @@ +/** + * 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(/\/$/, ''); const minioURL = new URL(url); const path = minioURL.pathname.split('/').slice(2).join('/'); return `${websiteURL}/assets/${path}`; -} +} \ No newline at end of file From 49bf6426d48c17f62c70f6a1fc537d9c5b11ed31 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sat, 27 Apr 2024 23:07:26 +0800 Subject: [PATCH 43/49] feat: more descriptive client errors --- .../LatestAnnouncement/LatestAnnouncement.tsx | 4 ++-- .../src/app/announcements/[id]/page.tsx | 12 ++++++---- .../src/app/announcements/page.tsx | 4 ++-- .../AttendanceMenu/AttendanceMenu.tsx | 3 ++- .../AttendanceMenuEntry.tsx | 6 ++--- .../AttendanceMenu/QRPage/QRPage.tsx | 3 ++- .../absence/AbsenceForm/AbsenceForm.tsx | 3 ++- .../VerifyAttendance/VerifyAttendance.tsx | 3 ++- interapp-frontend/src/app/exports/page.tsx | 4 ++-- interapp-frontend/src/app/page.tsx | 4 ++-- .../src/app/profile/Overview/Overview.tsx | 8 +++---- .../ServiceCardDisplay/ServiceCardDisplay.tsx | 12 +++++----- .../ServiceSessionsPage.tsx | 4 ++-- .../AddAction/AddAction.tsx | 4 ++-- .../src/app/service_sessions/page.tsx | 8 +++---- .../ServiceBoxUsers/ServiceBoxUsers.tsx | 5 ++-- interapp-frontend/src/app/services/page.tsx | 8 ++----- .../ChangeProfilePicture.tsx | 4 ++-- .../NavbarNotifications.tsx | 3 ++- .../providers/AuthProvider/AuthProvider.tsx | 4 ++-- interapp-frontend/src/utils/index.ts | 1 + .../src/utils/parseClientError.ts | 24 +++++++++++++++++++ 22 files changed, 81 insertions(+), 50 deletions(-) create mode 100644 interapp-frontend/src/utils/parseClientError.ts diff --git a/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx b/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx index 6169e5d8..37fac0c5 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,7 @@ 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]/page.tsx b/interapp-frontend/src/app/announcements/[id]/page.tsx index cdca0755..afffbe0e 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,7 @@ 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 +58,7 @@ 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..e9443ad1 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,7 @@ 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..46e55483 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,7 @@ 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..5938d71e 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,12 @@ 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 fdf47ad1..2a50b63b 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,7 @@ 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; diff --git a/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx b/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx index 1f2d8c42..80473c9f 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,7 @@ 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 f518b8d2..4ad4ae8d 100644 --- a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx +++ b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx @@ -6,6 +6,7 @@ import APIClient from '@/api/api_client'; 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 { @@ -81,7 +82,7 @@ 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 = ({ hash }: VerifyAttendanceProps) => { diff --git a/interapp-frontend/src/app/exports/page.tsx b/interapp-frontend/src/app/exports/page.tsx index 555ce2d2..8b1413be 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,7 @@ 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..c5389834 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,7 @@ 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 68d44c5c..42dde050 100644 --- a/interapp-frontend/src/app/profile/Overview/Overview.tsx +++ b/interapp-frontend/src/app/profile/Overview/Overview.tsx @@ -3,7 +3,7 @@ import APIClient from '@api/api_client'; import { useState, useEffect } from 'react'; 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,18 @@ 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: 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; }; diff --git a/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx b/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx index d0ce6921..2ce3c97a 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,15 @@ 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 +78,7 @@ 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 +88,7 @@ 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..24109396 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,7 @@ 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/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx index 2db0ff77..1570f44d 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, parseServerError } 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,7 @@ 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 })); }; diff --git a/interapp-frontend/src/app/service_sessions/page.tsx b/interapp-frontend/src/app/service_sessions/page.tsx index a20898bf..7d1e86b2 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,10 @@ 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 +47,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/ServiceBoxUsers/ServiceBoxUsers.tsx b/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx index 727eeaa6..434a8cd1 100644 --- a/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx +++ b/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx @@ -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; @@ -23,10 +24,10 @@ const handleGetUsers = async (service_id: number) => { 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'); + 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) : []; diff --git a/interapp-frontend/src/app/services/page.tsx b/interapp-frontend/src/app/services/page.tsx index efaa0ec8..811e817b 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,9 @@ 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 6f2871f1..ae964239 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -2,7 +2,7 @@ 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 { User } from '@providers/AuthProvider/types'; @@ -12,7 +12,7 @@ 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: User = response.data; if (data.profile_picture) data.profile_picture = remapAssetUrl(data.profile_picture); diff --git a/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx b/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx index 731f4e91..65708447 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,7 @@ 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 24ee4a0d..f6b29f40 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 { remapAssetUrl, wildcardMatcher } from '@utils/.'; +import { ClientError, remapAssetUrl, wildcardMatcher } from '@utils/.'; export const AuthContext = createContext({ user: null, @@ -55,7 +55,7 @@ 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)); + throw new ClientError({ message: 'Invalid user type in local storage' + JSON.stringify(user) }); } if (allowedRoutes.some((route) => memoWildcardMatcher(pathname, route))) { diff --git a/interapp-frontend/src/utils/index.ts b/interapp-frontend/src/utils/index.ts index 0109a88e..023ddcdb 100644 --- a/interapp-frontend/src/utils/index.ts +++ b/interapp-frontend/src/utils/index.ts @@ -1,4 +1,5 @@ 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..08081232 --- /dev/null +++ b/interapp-frontend/src/utils/parseClientError.ts @@ -0,0 +1,24 @@ +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, { cause }); + } + + static formatCause({ + responseBody, + responseStatus, + }: Pick): string { + if (!responseStatus && !responseBody) return ''; + return ` + \n + Response status: ${responseStatus}\n + Response body: ${String(responseBody)} + `; + } +} From 972ee4685b6bb02ca16fbfabe2d9f8b3f8d1fc1d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 15:08:05 +0000 Subject: [PATCH 44/49] [autofix.ci] apply automated fixes --- .../LatestAnnouncement/LatestAnnouncement.tsx | 7 +++- .../src/app/announcements/[id]/page.tsx | 13 +++++-- .../src/app/announcements/page.tsx | 7 +++- .../AttendanceMenu/AttendanceMenu.tsx | 7 +++- .../AttendanceMenuEntry.tsx | 14 ++++++-- .../AttendanceMenu/QRPage/QRPage.tsx | 7 +++- .../absence/AbsenceForm/AbsenceForm.tsx | 7 +++- .../VerifyAttendance/VerifyAttendance.tsx | 7 +++- interapp-frontend/src/app/exports/page.tsx | 7 +++- interapp-frontend/src/app/page.tsx | 7 +++- .../src/app/profile/Overview/Overview.tsx | 21 +++++++++-- .../ServiceCardDisplay/ServiceCardDisplay.tsx | 35 ++++++++++++++++--- .../ServiceSessionsPage.tsx | 7 +++- .../AddAction/AddAction.tsx | 7 +++- .../src/app/service_sessions/page.tsx | 16 +++++++-- .../ServiceBoxUsers/ServiceBoxUsers.tsx | 14 ++++++-- interapp-frontend/src/app/services/page.tsx | 7 +++- .../ChangeProfilePicture.tsx | 7 +++- .../NavbarNotifications.tsx | 7 +++- .../providers/AuthProvider/AuthProvider.tsx | 4 ++- interapp-frontend/src/utils/index.ts | 2 +- interapp-frontend/src/utils/remapAssetUrl.ts | 2 +- 22 files changed, 179 insertions(+), 33 deletions(-) diff --git a/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx b/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx index 37fac0c5..094ccd4b 100644 --- a/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx +++ b/interapp-frontend/src/app/_homepage/LatestAnnouncement/LatestAnnouncement.tsx @@ -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 ClientError({ message: 'Failed to fetch announcement', responseStatus: res.status, responseBody: res.data }); + 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]/page.tsx b/interapp-frontend/src/app/announcements/[id]/page.tsx index afffbe0e..e02780f3 100644 --- a/interapp-frontend/src/app/announcements/[id]/page.tsx +++ b/interapp-frontend/src/app/announcements/[id]/page.tsx @@ -45,7 +45,12 @@ const handleRead = async (id: number) => { completed: true, }); - if (res.status !== 204) throw new ClientError({ message: 'Failed to mark announcement as read', responseStatus: res.status, responseBody: res.data }); + 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) => { @@ -58,7 +63,11 @@ const handleDelete = async (id: number, handleEnd: () => void) => { message: 'Announcement could not be deleted', color: 'red', }); - throw new ClientError({ message: 'Failed to delete announcement', responseStatus: res.status, responseBody: res.data }); + 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 e9443ad1..586ef24a 100644 --- a/interapp-frontend/src/app/announcements/page.tsx +++ b/interapp-frontend/src/app/announcements/page.tsx @@ -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 ClientError({ message: 'Failed to fetch announcements', responseStatus: res.status, responseBody: res.data }); + 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 46e55483..e2f9f6bb 100644 --- a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx @@ -14,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 ClientError({ message: 'Failed to fetch active service sessions', responseStatus: response.status, responseBody: response.data }); + 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 5938d71e..af2c32fb 100644 --- a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx @@ -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 ClientError({ message: 'Failed to fetch service session', responseStatus: res.status, responseBody: res.data }); + 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 ClientError({ message: 'Failed to fetch service session users', responseStatus: res2.status, responseBody: res2.data }); + 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 2a50b63b..65e9a4c7 100644 --- a/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx @@ -23,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 ClientError({ message: 'Failed to fetch service session users', responseStatus: res.status, responseBody: res.data }); + 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; diff --git a/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx b/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx index 80473c9f..c94962d6 100644 --- a/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx +++ b/interapp-frontend/src/app/attendance/absence/AbsenceForm/AbsenceForm.tsx @@ -15,7 +15,12 @@ const handleSetValidReason = async (id: number, username: string) => { username: username, }); - if (res.status !== 204) throw new ClientError({ message: 'Failed to set valid reason', responseStatus: res.status, responseBody: res.data }); + 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 4ad4ae8d..313f1aca 100644 --- a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx +++ b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx @@ -82,7 +82,12 @@ const updateServiceHours = async (newHours: number) => { const res = await apiClient.patch('/user/service_hours', { hours: newHours, }); - if (res.status !== 204) throw new ClientError({ message: 'Failed to update service hours', responseStatus: res.status, responseBody: res.data }); + if (res.status !== 204) + throw new ClientError({ + message: 'Failed to update service hours', + responseStatus: res.status, + responseBody: res.data, + }); }; const VerifyAttendance = ({ hash }: VerifyAttendanceProps) => { diff --git a/interapp-frontend/src/app/exports/page.tsx b/interapp-frontend/src/app/exports/page.tsx index 8b1413be..aa7ef491 100644 --- a/interapp-frontend/src/app/exports/page.tsx +++ b/interapp-frontend/src/app/exports/page.tsx @@ -18,7 +18,12 @@ const fetchServices = async (apiClient: AxiosInstance) => { service_id: service.service_id, })) as Pick[]; - if (res.status !== 200) throw new ClientError({ message: 'Failed to fetch services', responseStatus: res.status, responseBody: res.data }); + 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 c5389834..f3b81d71 100644 --- a/interapp-frontend/src/app/page.tsx +++ b/interapp-frontend/src/app/page.tsx @@ -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 ClientError({ message: 'Failed to fetch attendance', responseStatus: response.status, responseBody: response.data }); + 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 42dde050..9c0996d5 100644 --- a/interapp-frontend/src/app/profile/Overview/Overview.tsx +++ b/interapp-frontend/src/app/profile/Overview/Overview.tsx @@ -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 ClientError({ message: 'Failed to fetch user details', responseStatus: response.status, responseBody: response.data }); + if (response.status !== 200) + throw new ClientError({ + message: 'Failed to fetch user details', + responseStatus: response.status, + responseBody: 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 ClientError({ message: 'Failed to fetch user permissions', responseStatus: response2.status, responseBody: response2.data }); + 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 ClientError({ message: 'Invalid user data', responseStatus: response.status, responseBody: response.data }); + if (!validateUserType(data)) + throw new ClientError({ + message: 'Invalid user data', + responseStatus: response.status, + responseBody: response.data, + }); return data; }; diff --git a/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx b/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx index 2ce3c97a..277290f9 100644 --- a/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx +++ b/interapp-frontend/src/app/profile/ServiceCardDisplay/ServiceCardDisplay.tsx @@ -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 ClientError({ message: 'Failed to fetch services', responseStatus: res.status, responseBody: res.data }); + 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 ClientError({ message: 'Failed to fetch ad hoc sessions', responseStatus: res2.status, responseBody: res2.data }); + 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 ClientError({ message: 'Failed to fetch user services', responseStatus: res3.status, responseBody: res3.data }); + 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 ClientError({ message: 'User has already joined session', responseStatus: check.status, responseBody: check.data }); + 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 ClientError({ message: 'Failed to join session', responseStatus: res.status, responseBody: res.data }); + 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 24109396..0da4f40e 100644 --- a/interapp-frontend/src/app/profile/ServiceSessionsPage/ServiceSessionsPage.tsx +++ b/interapp-frontend/src/app/profile/ServiceSessionsPage/ServiceSessionsPage.tsx @@ -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 ClientError({ message: 'Failed to fetch service sessions', responseStatus: response.status, responseBody: response.data }); + 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/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx index 1570f44d..c1959dc0 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/AddAction/AddAction.tsx @@ -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 ClientError({ message: 'Failed to fetch services', responseStatus: response.status, responseBody: response.data }); + 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 })); }; diff --git a/interapp-frontend/src/app/service_sessions/page.tsx b/interapp-frontend/src/app/service_sessions/page.tsx index 7d1e86b2..20b49913 100644 --- a/interapp-frontend/src/app/service_sessions/page.tsx +++ b/interapp-frontend/src/app/service_sessions/page.tsx @@ -23,10 +23,20 @@ const handleFetchServiceSessionsData = async ( const res = await apiClient.get('/service/session/all', { params: params, }); - if (res.status !== 200) throw new ClientError({ message: 'Failed to fetch service sessions', responseStatus: res.status, responseBody: res.data}); + 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) throw new ClientError({ message: 'Failed to fetch services', responseStatus: res2.status, responseBody: res2.data }); + 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); - + return result; }; diff --git a/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx b/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx index 434a8cd1..06b93d08 100644 --- a/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx +++ b/interapp-frontend/src/app/services/ServiceBoxUsers/ServiceBoxUsers.tsx @@ -24,10 +24,20 @@ const handleGetUsers = async (service_id: number) => { serviceUsers = []; } else if (get_users_by_service.status === 200) { serviceUsers = users.map((user) => user.username); - } else throw new ClientError({ message: 'Could not get users by service', responseStatus: get_users_by_service.status, responseBody: get_users_by_service.data }) + } 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 ClientError({ message: 'Could not get all users', responseStatus: get_all_users.status, responseBody: 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) : []; diff --git a/interapp-frontend/src/app/services/page.tsx b/interapp-frontend/src/app/services/page.tsx index 811e817b..f6ce18e1 100644 --- a/interapp-frontend/src/app/services/page.tsx +++ b/interapp-frontend/src/app/services/page.tsx @@ -14,7 +14,12 @@ const fetchAllServices = async () => { try { const res = await apiClient.get('/service/all'); - if (res.status !== 200) throw new ClientError({ message: 'Failed to fetch services', responseStatus: res.status, responseBody: 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; diff --git a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx index ae964239..5605de7a 100644 --- a/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx +++ b/interapp-frontend/src/app/settings/ChangeProfilePicture/ChangeProfilePicture.tsx @@ -12,7 +12,12 @@ 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 ClientError({ message: 'Could not get user', responseStatus: response.status, responseBody: response.data }); + if (response.status !== 200) + throw new ClientError({ + message: 'Could not get user', + responseStatus: response.status, + responseBody: response.data, + }); const data: User = response.data; if (data.profile_picture) data.profile_picture = remapAssetUrl(data.profile_picture); diff --git a/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx b/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx index 65708447..4daf343d 100644 --- a/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx +++ b/interapp-frontend/src/components/Navbar/NavbarNotifications/NavbarNotifications.tsx @@ -29,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 ClientError({ message: 'Could not get notifications', responseStatus: res.status, responseBody: res.data }); + 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 f6b29f40..ecc42c1e 100644 --- a/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx +++ b/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx @@ -55,7 +55,9 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const validUser = validateUserType(user); if (!validUser) { logout(); - throw new ClientError({ message: 'Invalid user type in local storage' + JSON.stringify(user) }); + throw new ClientError({ + message: 'Invalid user type in local storage' + JSON.stringify(user), + }); } if (allowedRoutes.some((route) => memoWildcardMatcher(pathname, route))) { diff --git a/interapp-frontend/src/utils/index.ts b/interapp-frontend/src/utils/index.ts index 023ddcdb..42c7e81c 100644 --- a/interapp-frontend/src/utils/index.ts +++ b/interapp-frontend/src/utils/index.ts @@ -1,5 +1,5 @@ export * from './parseServerError'; -export * from './parseClientError' +export * from './parseClientError'; export * from './getAllUsernames'; export * from './remapAssetUrl'; export * from './wildcardMatcher'; diff --git a/interapp-frontend/src/utils/remapAssetUrl.ts b/interapp-frontend/src/utils/remapAssetUrl.ts index 4c334cc6..36e20623 100644 --- a/interapp-frontend/src/utils/remapAssetUrl.ts +++ b/interapp-frontend/src/utils/remapAssetUrl.ts @@ -19,4 +19,4 @@ export function remapAssetUrl(url: string) { const minioURL = new URL(url); const path = minioURL.pathname.split('/').slice(2).join('/'); return `${websiteURL}/assets/${path}`; -} \ No newline at end of file +} From 70848cffe9205df049614687d08d381c130b5438 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sun, 28 Apr 2024 00:09:57 +0800 Subject: [PATCH 45/49] fix: reformat err handling --- interapp-frontend/src/utils/parseClientError.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/interapp-frontend/src/utils/parseClientError.ts b/interapp-frontend/src/utils/parseClientError.ts index 08081232..f6ead1fa 100644 --- a/interapp-frontend/src/utils/parseClientError.ts +++ b/interapp-frontend/src/utils/parseClientError.ts @@ -7,7 +7,7 @@ interface ClientErrorParams { export class ClientError extends Error { constructor({ message, responseBody, responseStatus }: ClientErrorParams) { const cause = ClientError.formatCause({ responseBody, responseStatus }); - super(message, { cause }); + super(message + '\n' + cause, { cause }); } static formatCause({ @@ -15,10 +15,11 @@ export class ClientError extends Error { responseStatus, }: Pick): string { if (!responseStatus && !responseBody) return ''; - return ` - \n - Response status: ${responseStatus}\n - Response body: ${String(responseBody)} - `; + console.log(responseBody); + return `Response status: ${responseStatus}\nResponse body: \n${JSON.stringify( + responseBody, + null, + 2, + )}`; } } From 536d687fff1133d1a03ac8d2937e1cc1edf02db9 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sun, 28 Apr 2024 15:25:23 +0800 Subject: [PATCH 46/49] fix: remove console log --- interapp-frontend/src/utils/parseClientError.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interapp-frontend/src/utils/parseClientError.ts b/interapp-frontend/src/utils/parseClientError.ts index f6ead1fa..89ee5645 100644 --- a/interapp-frontend/src/utils/parseClientError.ts +++ b/interapp-frontend/src/utils/parseClientError.ts @@ -15,7 +15,7 @@ export class ClientError extends Error { responseStatus, }: Pick): string { if (!responseStatus && !responseBody) return ''; - console.log(responseBody); + return `Response status: ${responseStatus}\nResponse body: \n${JSON.stringify( responseBody, null, From d86df518db92d69b82d791a1bf455703fd83172a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 28 Apr 2024 07:25:51 +0000 Subject: [PATCH 47/49] [autofix.ci] apply automated fixes --- interapp-frontend/src/utils/parseClientError.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interapp-frontend/src/utils/parseClientError.ts b/interapp-frontend/src/utils/parseClientError.ts index 89ee5645..42df7bed 100644 --- a/interapp-frontend/src/utils/parseClientError.ts +++ b/interapp-frontend/src/utils/parseClientError.ts @@ -15,7 +15,7 @@ export class ClientError extends Error { responseStatus, }: Pick): string { if (!responseStatus && !responseBody) return ''; - + return `Response status: ${responseStatus}\nResponse body: \n${JSON.stringify( responseBody, null, From 48387aca59d617b2e6151eba0931090f35eb24f2 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sun, 28 Apr 2024 19:03:18 +0800 Subject: [PATCH 48/49] fix: fail silently without causing crash on app update --- .../src/providers/AuthProvider/AuthProvider.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx b/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx index ecc42c1e..8b5f78e4 100644 --- a/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx +++ b/interapp-frontend/src/providers/AuthProvider/AuthProvider.tsx @@ -55,9 +55,16 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const validUser = validateUserType(user); if (!validUser) { logout(); + 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))) { From 8e9226e875c04b58ebe6e8a665398336b9bb52b9 Mon Sep 17 00:00:00 2001 From: SebassNoob Date: Sun, 28 Apr 2024 20:02:40 +0800 Subject: [PATCH 49/49] fix: weird failed condition for profile page --- interapp-frontend/src/app/profile/page.tsx | 25 ++++++++++++++++---- interapp-frontend/src/app/profile/styles.css | 8 +++++++ 2 files changed, 29 insertions(+), 4 deletions(-) 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; +}