Skip to content

Commit ad67c3e

Browse files
fix: potentially more fixes! (#52)
* fix: incorrect coersion for query fields * chore: remove useless console log in test * feat: gracefully clean up sigint * feat: update lockfiles * fix: block until minio is recreated * fix: remove properties out of expected tests * refactor: types in constants and comment some code * chore: rename e2e tests to api * feat: set up eslint * fix: remove lint from ci * fix: all eslint errors * fix: combine tests, add warning lint * chore: prettier * feat: add autofix ci * [autofix.ci] apply automated fixes * chore: bump checkout version * fix: attempt fix of eslint ci? * fix: again? * fix: again again? * fix: final fix? * feat: add servicehoursexportmodel * [autofix.ci] apply automated fixes * fix: revert replaced error message * refactor: validation of middleware function name in API routes * [autofix.ci] apply automated fixes * feat: improve verify attendance * [autofix.ci] apply automated fixes * chore: remove legacy userwithprofilepicture * fix: mapping for update profile picture * [autofix.ci] apply automated fixes * fix: delete profile picture from localstorage only on successful api call * [autofix.ci] apply automated fixes * fix: timeout during postgres client install * feat: add interface for all exports models to follow * [autofix.ci] apply automated fixes * fix: remove console log * feat: update tests * [autofix.ci] apply automated fixes * fix: attempt to get sonar to ignore error * refactor: parseErrorMessage -> parseServerError * fix: rename file * feat: add docs to remap asset url * feat: more descriptive client errors * [autofix.ci] apply automated fixes * fix: reformat err handling * fix: remove console log * [autofix.ci] apply automated fixes * fix: fail silently without causing crash on app update * fix: weird failed condition for profile page --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent aa857c6 commit ad67c3e

72 files changed

Lines changed: 1147 additions & 409 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/autofix.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: autofix.ci # needed to securely identify the workflow
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- '**'
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
autofix:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Setup bun
18+
uses: oven-sh/setup-bun@v1
19+
with:
20+
bun-version: 1.1.3
21+
22+
- name: Install dependencies (backend)
23+
run: cd interapp-backend && bun install
24+
25+
- name: Format code (backend)
26+
run: cd interapp-backend && bun run prettier
27+
28+
- name: Install dependencies (frontend)
29+
run: cd interapp-frontend && bun install
30+
31+
- name: Format code (frontend)
32+
run: cd interapp-frontend && bun run prettier
33+
34+
- uses: autofix-ci/action@d3e591514b99d0fca6779455ff8338516663f7cc

.github/workflows/pipeline.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ on:
88

99
jobs:
1010
test-backend:
11+
name: Test backend
1112
runs-on: ubuntu-latest
1213

1314
steps:
1415
- name: Checkout code
15-
uses: actions/checkout@v3
16+
uses: actions/checkout@v4
1617

1718
- name: Build test environment
1819
run: docker compose -f docker-compose.test.yml build --no-cache
@@ -27,18 +28,23 @@ jobs:
2728

2829
- name: Install dependencies
2930
run: cd interapp-backend && bun install
30-
- name: Test with bun
31+
32+
- name: Lint code
33+
run: cd interapp-backend && bun run lint
34+
35+
- name: Run unit and api tests
3136
run: cd interapp-backend && bun run test
3237

3338
- name: Tear down test environment
3439
run: docker compose -f docker-compose.test.yml down
3540

3641
build-application:
42+
name: Build application
3743
runs-on: ubuntu-latest
3844

3945
steps:
4046
- name: Checkout code
41-
uses: actions/checkout@v3
47+
uses: actions/checkout@v4
4248

4349
- name: Setup prod environment
4450
run: docker compose -f docker-compose.prod.yml up -d --build

interapp-backend/api/models/auth.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HTTPErrors } from '@utils/errors';
44
import { SignJWT, jwtVerify, JWTPayload, JWTVerifyResult } from 'jose';
55

66
import redisClient from '@utils/init_redis';
7+
import minioClient from '@utils/init_minio';
78

89
export interface UserJWT {
910
user_id: number;
@@ -12,6 +13,8 @@ export interface UserJWT {
1213

1314
type JWTtype = 'access' | 'refresh';
1415

16+
const MINIO_BUCKETNAME = process.env.MINIO_BUCKETNAME as string;
17+
1518
export class AuthModel {
1619
private static readonly accessSecret = new TextEncoder().encode(
1720
process.env.JWT_ACCESS_SECRET as string,
@@ -79,6 +82,7 @@ export class AuthModel {
7982
'user.email',
8083
'user.verified',
8184
'user.service_hours',
85+
'user.profile_picture',
8286
])
8387
.from(User, 'user')
8488
.leftJoinAndSelect('user.user_permissions', 'user_permissions')
@@ -109,6 +113,9 @@ export class AuthModel {
109113
email: user.email,
110114
verified: user.verified,
111115
service_hours: user.service_hours,
116+
profile_picture: user.profile_picture
117+
? await minioClient.presignedGetObject(MINIO_BUCKETNAME, user.profile_picture)
118+
: null,
112119
permissions: user.user_permissions.map((perm) => perm.permission_id),
113120
};
114121

interapp-backend/api/models/exports.ts renamed to interapp-backend/api/models/exports/exports_attendance.ts

Lines changed: 22 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,20 @@
1-
import appDataSource from '@utils/init_datasource';
2-
import { ServiceSession, AttendanceStatus } from '@db/entities';
3-
import xlsx, { WorkSheet } from 'node-xlsx';
1+
import {
2+
AttendanceExportsResult,
3+
AttendanceExportsXLSX,
4+
AttendanceQueryExportsConditions,
5+
ExportsModelImpl,
6+
staticImplements,
7+
} from './types';
8+
import { BaseExportsModel } from './exports_base';
9+
import { ServiceSession, type AttendanceStatus } from '@db/entities';
410
import { HTTPErrors } from '@utils/errors';
11+
import { WorkSheet } from 'node-xlsx';
12+
import appDataSource from '@utils/init_datasource';
513

6-
type ExportsResult = {
7-
service_session_id: number;
8-
start_time: string;
9-
end_time: string;
10-
service: {
11-
name: string;
12-
service_id: number;
13-
};
14-
service_session_users: {
15-
service_session_id: number;
16-
username: string;
17-
ad_hoc: boolean;
18-
attended: AttendanceStatus;
19-
is_ic: boolean;
20-
}[];
21-
};
22-
23-
type ExportsXLSX = [['username', ...string[]], ...[string, ...(AttendanceStatus | null)[]][]];
24-
25-
type QueryExportsConditions = {
26-
id: number;
27-
} & (
28-
| {
29-
start_date: string; // ISO strings, we have already validated this
30-
end_date: string;
31-
}
32-
| {
33-
start_date?: never;
34-
end_date?: never;
35-
}
36-
);
37-
38-
export class ExportsModel {
39-
private static getSheetOptions = (ret: ExportsXLSX) => ({
40-
'!cols': [{ wch: 24 }, ...Array(ret.length).fill({ wch: 16 })],
41-
});
42-
private static constructXLSX = (...data: Parameters<typeof xlsx.build>[0]) => xlsx.build(data);
43-
44-
public static async queryExports({ id, start_date, end_date }: QueryExportsConditions) {
45-
let res: ExportsResult[];
14+
@staticImplements<ExportsModelImpl>()
15+
export class AttendanceExportsModel extends BaseExportsModel {
16+
public static async queryExports({ id, start_date, end_date }: AttendanceQueryExportsConditions) {
17+
let res: AttendanceExportsResult[];
4618
if (start_date === undefined || end_date === undefined) {
4719
res = await appDataSource.manager
4820
.createQueryBuilder()
@@ -82,16 +54,16 @@ export class ExportsModel {
8254
return res;
8355
}
8456

85-
public static async formatXLSX(conds: QueryExportsConditions) {
57+
public static async formatXLSX(conds: AttendanceQueryExportsConditions) {
8658
const ret = await this.queryExports(conds);
8759

8860
if (ret.length === 0) throw HTTPErrors.RESOURCE_NOT_FOUND;
8961

9062
// create headers
9163
// start_time is in ascending order
92-
const headers: ExportsXLSX[0] = (['username'] as ExportsXLSX[0]).concat(
64+
const headers = (['username'] as AttendanceExportsXLSX[0]).concat(
9365
ret.map(({ start_time }) => start_time),
94-
) as ExportsXLSX[0];
66+
) as AttendanceExportsXLSX[0];
9567

9668
// output needs to be in the form:
9769
// [username, [attendance status]]
@@ -118,15 +90,13 @@ export class ExportsModel {
11890
});
11991
});
12092

121-
const body: ExportsXLSX[1][] = Object.entries(usernameMap).map(([username, attendance]) => [
122-
username,
123-
...attendance,
124-
]);
93+
const body: AttendanceExportsXLSX[1][] = Object.entries(usernameMap).map(
94+
([username, attendance]) => [username, ...attendance],
95+
);
12596

126-
const out: ExportsXLSX = [headers, ...body];
97+
const out: AttendanceExportsXLSX = [headers, ...body];
12798

12899
const sheetOptions = this.getSheetOptions(out);
129-
console.log(sheetOptions);
130100

131101
return { name: ret[0].service.name, data: out, options: sheetOptions };
132102
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import xlsx from 'node-xlsx';
2+
3+
export class BaseExportsModel {
4+
protected static getSheetOptions = <T extends unknown[]>(ret: T) => ({
5+
'!cols': [{ wch: 24 }, ...Array(ret.length).fill({ wch: 16 })],
6+
});
7+
protected static constructXLSX = (...data: Parameters<typeof xlsx.build>[0]) => xlsx.build(data);
8+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {
2+
AttendanceExportsResult,
3+
AttendanceExportsXLSX,
4+
AttendanceQueryExportsConditions,
5+
ExportsModelImpl,
6+
staticImplements,
7+
} from './types';
8+
import { BaseExportsModel } from './exports_base';
9+
import { ServiceSession, type AttendanceStatus } from '@db/entities';
10+
import { HTTPErrors } from '@utils/errors';
11+
import { WorkSheet } from 'node-xlsx';
12+
import appDataSource from '@utils/init_datasource';
13+
14+
// @staticImplements<ExportsModelImpl>()
15+
export class ServiceHoursExportsModel extends BaseExportsModel {}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { AttendanceExportsModel } from './exports_attendance';
2+
export { ServiceHoursExportsModel } from './exports_service_hours';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { AttendanceStatus } from '@db/entities';
2+
3+
export interface ExportsModelImpl {
4+
queryExports(conds: unknown): Promise<unknown[]>;
5+
formatXLSX(conds: unknown): Promise<unknown>;
6+
packXLSX(ids: number[], start_date?: string, end_date?: string): Promise<Buffer>;
7+
}
8+
9+
// class decorator that asserts that a class implements an interface statically
10+
// https://stackoverflow.com/a/43674389
11+
export function staticImplements<T>() {
12+
return <U extends T>(constructor: U) => {
13+
constructor; // NOSONAR
14+
};
15+
}
16+
17+
export type AttendanceExportsResult = {
18+
service_session_id: number;
19+
start_time: string;
20+
end_time: string;
21+
service: {
22+
name: string;
23+
service_id: number;
24+
};
25+
service_session_users: {
26+
service_session_id: number;
27+
username: string;
28+
ad_hoc: boolean;
29+
attended: AttendanceStatus;
30+
is_ic: boolean;
31+
}[];
32+
};
33+
34+
export type AttendanceExportsXLSX = [
35+
['username', ...string[]],
36+
...[string, ...(AttendanceStatus | null)[]][],
37+
];
38+
39+
export type AttendanceQueryExportsConditions = {
40+
id: number;
41+
} & (
42+
| {
43+
start_date: string; // ISO strings, we have already validated this
44+
end_date: string;
45+
}
46+
| {
47+
start_date?: never;
48+
end_date?: never;
49+
}
50+
);

interapp-backend/api/models/service.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -347,21 +347,54 @@ export class ServiceModel {
347347
}));
348348
}
349349
public static async verifyAttendance(hash: string, username: string) {
350-
const service_session_id = await redisClient.hGet('service_session', hash);
351-
if (!service_session_id) {
350+
const id = await redisClient.hGet('service_session', hash);
351+
if (!id) {
352352
throw HTTPErrors.INVALID_HASH;
353353
}
354-
const service_session_user = await this.getServiceSessionUser(
355-
parseInt(service_session_id),
356-
username,
357-
);
354+
const service_session_id = parseInt(id);
355+
356+
const service_session_user = await this.getServiceSessionUser(service_session_id, username);
358357

359358
if (service_session_user.attended === AttendanceStatus.Attended) {
360359
throw HTTPErrors.ALREADY_ATTENDED;
361360
}
362361
service_session_user.attended = AttendanceStatus.Attended;
363362
await this.updateServiceSessionUser(service_session_user);
364-
return service_session_user;
363+
364+
// get some metadata and return it to the user
365+
366+
type _Return = {
367+
start_time: string;
368+
end_time: string;
369+
service_hours: number;
370+
name: string;
371+
ad_hoc: boolean;
372+
};
373+
const res = await appDataSource.manager
374+
.createQueryBuilder()
375+
.select([
376+
'service_session.start_time',
377+
'service_session.end_time',
378+
'service_session.service_hours',
379+
'service.name',
380+
])
381+
.from(ServiceSession, 'service_session')
382+
.leftJoin('service_session.service', 'service')
383+
.where('service_session_id = :id', { id: service_session_id })
384+
.getOne();
385+
386+
// literally impossible for this to be null
387+
if (!res) {
388+
throw HTTPErrors.RESOURCE_NOT_FOUND;
389+
}
390+
391+
return {
392+
start_time: res.start_time,
393+
end_time: res.end_time,
394+
service_hours: res.service_hours,
395+
name: res.service.name,
396+
ad_hoc: service_session_user.ad_hoc,
397+
} as _Return;
365398
}
366399
public static async getAdHocServiceSessions() {
367400
const res = await appDataSource.manager

0 commit comments

Comments
 (0)