Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
62d66e5
fix: optimise select users by service
SebassNoob Dec 17, 2023
dc4e601
feat: add endpoint for getusersbyservice
SebassNoob Dec 17, 2023
2392042
feat: add bulk user service change to endpoints
SebassNoob Dec 18, 2023
8cbd029
feat: add service page
SebassNoob Dec 18, 2023
f650d41
fix: fix code smells
SebassNoob Dec 18, 2023
01bc7a9
feat: update navbar headers
SebassNoob Dec 18, 2023
ef7c177
feat: add create services gui
SebassNoob Dec 18, 2023
16a17f4
fix: clean up
SebassNoob Dec 18, 2023
1450d39
fix: hopefully fix build process
SebassNoob Dec 18, 2023
b9d4ceb
feat: add delete service action
SebassNoob Dec 20, 2023
2a35e82
fix: move to node to prevent segfaults
SebassNoob Dec 20, 2023
a4f3243
feat: finalise services page
SebassNoob Dec 20, 2023
53c438e
chore: clean up code smells
SebassNoob Dec 20, 2023
8f9f56a
chore: revert breaking changes, optimise further
SebassNoob Dec 20, 2023
d6c9b3d
feat: add minio
SebassNoob Dec 21, 2023
ee7473d
feat: add fullstack support for minio
SebassNoob Dec 21, 2023
ce5d3d9
fix: auto test failure
SebassNoob Dec 21, 2023
84bbe84
fix: fix incorrect placeholder image pathing
SebassNoob Dec 21, 2023
5a97ed0
fix: simplify initialisation
SebassNoob Dec 21, 2023
29da4c1
fix: revert accidental removal of build test
SebassNoob Dec 21, 2023
0474881
chore: update readme
SebassNoob Dec 21, 2023
424552f
chore: standardise import aliases
SebassNoob Dec 22, 2023
d919a73
fix: incorrect aliases
SebassNoob Dec 22, 2023
08cf8fa
fix: show error on invalid user permission combination
SebassNoob Dec 31, 2023
8516e61
feat: add endpoint for getting service sessions
SebassNoob Dec 20, 2023
c31278d
feat: allow optional specification of service_id when getting all
SebassNoob Dec 20, 2023
8098ffa
feat: tweak behaviour of /service/session/get_all
SebassNoob Dec 22, 2023
f1bd82f
feat: add service session display
SebassNoob Dec 22, 2023
b3ee3e4
chore: standardise import aliases for service sessions
SebassNoob Dec 22, 2023
8a1848b
fix: fix build
SebassNoob Dec 22, 2023
751516d
feat: allow for many roles to access same endpoint
SebassNoob Dec 22, 2023
97338ba
chore: clean up providers
SebassNoob Dec 22, 2023
dac04cd
feat: add delete service session use bulk endpoint
SebassNoob Dec 23, 2023
184b7b6
feat: add edit sessions capability
SebassNoob Dec 23, 2023
821432e
fix: fix smells
SebassNoob Dec 23, 2023
9e48aab
fix: end time before start time
SebassNoob Dec 23, 2023
6737d3a
fix: standardise naming
SebassNoob Dec 31, 2023
1ade48d
feat: add service sessions
SebassNoob Dec 31, 2023
66ebe6a
feat: delete action
SebassNoob Jan 1, 2024
08ca5fa
fix: misc fixes
SebassNoob Jan 1, 2024
bf2056e
chore: clean up code
SebassNoob Jan 1, 2024
17d7d8a
refactor: use resusable CRUDModal for add and delete service
SebassNoob Jan 1, 2024
1a61772
fix: prevent already selected ICs from being selected again
SebassNoob Jan 1, 2024
8a64fba
refactor: split types into file
SebassNoob Jan 1, 2024
09b41fc
fix: incorrect alignment for svc details
SebassNoob Jan 1, 2024
a6c290c
chore: allow update cascade
SebassNoob Jan 1, 2024
c74283d
chore: prettier backend
SebassNoob Jan 1, 2024
853151f
refactor: organise code
SebassNoob Jan 1, 2024
9f8a389
feat: add edit service
SebassNoob Jan 1, 2024
c5d0b25
fix: incorrect import of service type
SebassNoob Jan 1, 2024
e14947e
fix: restrict service ic options to valid options
SebassNoob Jan 1, 2024
cd28e62
fix: incorrect dumping of promotional_image in db
SebassNoob Jan 1, 2024
9fbdb69
fix: invalid image url caused by invalid file
SebassNoob Jan 1, 2024
3bf982f
refactor: remove stateful service in servicebox
SebassNoob Jan 1, 2024
1740382
fix: build errs
SebassNoob Jan 1, 2024
7ac63b7
fix: smells
SebassNoob Jan 1, 2024
3706226
fix: fix more code smells
SebassNoob Jan 1, 2024
59bee46
feat: add scheduler to dump hashes
SebassNoob Jan 1, 2024
15cf24e
feat: add get active service sessions
SebassNoob Jan 1, 2024
1d71f3d
fix: incorrect mapping for active sessions get
SebassNoob Jan 2, 2024
a5a7e69
fix: incorrect key type for attendance status
SebassNoob Jan 2, 2024
4daaf35
feat: UI improvements for service session users display
SebassNoob Jan 2, 2024
be30566
feat: add attendance page
SebassNoob Jan 2, 2024
7cebf7e
feat: add QR functionality
SebassNoob Jan 2, 2024
077cb1c
feat: add support for verify attendance
SebassNoob Jan 2, 2024
e32dd32
feat: add frontend verify attendance functionality
SebassNoob Jan 2, 2024
1a00c21
chore: backend prettier
SebassNoob Jan 2, 2024
9d888da
feat: finalise design
SebassNoob Jan 2, 2024
2bba4ec
fix: code smells
SebassNoob Jan 2, 2024
8c92cb3
fix: more adjustments
SebassNoob Jan 2, 2024
6350a27
feat: add support for specifying user when getting endpoint
SebassNoob Jan 3, 2024
7309a46
fix: misc cleanup and fixes
SebassNoob Jan 3, 2024
de3b387
fix: crash when user has no permissions
SebassNoob Jan 3, 2024
b8dd354
fix: check for corrupt user type onload
SebassNoob Jan 3, 2024
24aedc3
feat: add support for profile pictures
SebassNoob Jan 3, 2024
72af239
fix: security issues with endpoints
SebassNoob Jan 3, 2024
408ec79
chore: prettier
SebassNoob Jan 3, 2024
1707312
fix: crash in user integrity check
SebassNoob Jan 3, 2024
a5aa3cc
feat: overview page
SebassNoob Jan 3, 2024
ab35c18
feat: add support for updating profile picture
SebassNoob Jan 4, 2024
857f169
feat: Add getAllServiceSessionsByUser method to UserModel
SebassNoob Jan 4, 2024
9662611
fix: add additional metadata in query
SebassNoob Jan 4, 2024
b68a6b5
fix: build error
SebassNoob Jan 4, 2024
0e580a5
feat: add frontend support for service sessions tab
SebassNoob Jan 4, 2024
1e6a5a5
fix: fix code smell
SebassNoob Jan 4, 2024
aad974c
feat: happy new year!
SebassNoob Jan 4, 2024
76aa639
fix: non descriptive error when no active sessions found
SebassNoob Jan 4, 2024
1ef1a03
feat: design service sessionn card
SebassNoob Jan 4, 2024
a914e5c
fix: clean up code
SebassNoob Jan 4, 2024
84492b4
fix: sort in order
SebassNoob Jan 5, 2024
e547355
chore: service box styling change
SebassNoob Jan 5, 2024
4168817
feat: absence page
SebassNoob Jan 5, 2024
711f1ba
fix: update tests to reflect sort changes
SebassNoob Jan 5, 2024
c849f3b
feat: add support for querying for ad hoc sessions
SebassNoob Jan 5, 2024
c89b49b
fix: consistant api responses
SebassNoob Jan 5, 2024
df31aee
fix: crash when no one is IC
SebassNoob Jan 5, 2024
cf61313
feat: add service card to profile
SebassNoob Jan 5, 2024
d763eab
fix: code smells
SebassNoob Jan 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions interapp-backend/api/models/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class AuthModel {
user.email = email;
user.service_hours = 0;
user.verified = false;
user.profile_picture = null;

try {
user.password_hash = await Bun.password.hash(password);
Expand Down
141 changes: 129 additions & 12 deletions interapp-backend/api/models/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { HTTPError, HTTPErrorCode } from '@utils/errors';
import appDataSource from '@utils/init_datasource';
import minioClient from '@utils/init_minio';
import dataUrlToBuffer from '@utils/dataUrlToBuffer';
import { Service, ServiceSession, ServiceSessionUser } from '@db/entities';
import { AttendanceStatus, Service, ServiceSession, ServiceSessionUser } from '@db/entities';
import { UserModel } from './user';
import redisClient from '@utils/init_redis';

export class ServiceModel {
public static async createService(
Expand Down Expand Up @@ -79,19 +80,25 @@ export class ServiceModel {
const service_ic = await UserModel.getUser(service.service_ic_username);
if (!service.promotional_image) service.promotional_image = null;
else {
const convertedFile = dataUrlToBuffer(service.promotional_image);
if (!convertedFile) {
throw new HTTPError(
'Invalid promotional image',
'Promotional image is not a valid data URL',
HTTPErrorCode.BAD_REQUEST_ERROR,
// why we do this:
// if promotional_image is a URL, then it is not changed
// if promotional_image is a data:image/gif...., then it is changed to a URL and dumped into minio
// in either case, service.promotional_image points to the location of the image in minio
if (service.promotional_image.startsWith('data:')) {
const convertedFile = dataUrlToBuffer(service.promotional_image);
if (!convertedFile) {
throw new HTTPError(
'Invalid promotional image',
'Promotional image is not a valid data URL',
HTTPErrorCode.BAD_REQUEST_ERROR,
);
}
await minioClient.putObject(
process.env.MINIO_BUCKETNAME as string,
'service/' + service.name,
convertedFile.buffer,
);
}
await minioClient.putObject(
process.env.MINIO_BUCKETNAME as string,
'service/' + service.name,
convertedFile.buffer,
);
service.promotional_image = 'service/' + service.name;
}

Expand Down Expand Up @@ -288,4 +295,114 @@ export class ServiceModel {
public static async deleteServiceSessionUser(service_session_id: number, username: string) {
await appDataSource.manager.delete(ServiceSessionUser, { service_session_id, username });
}
public static async deleteServiceSessionUsers(service_session_id: number, usernames: string[]) {
await appDataSource.manager
.createQueryBuilder()
.delete()
.from(ServiceSessionUser)
.where('service_session_id = :service_session_id', { service_session_id })
.andWhere('username IN (:...usernames)', { usernames })
.execute();
}
public static async getAllServiceSessions(page?: number, perPage?: number, service_id?: number) {
const parseRes = (res: (Omit<ServiceSession, 'service'> & { service?: Service })[]) =>
res.map((session) => {
const service_name = session.service?.name;
delete session.service;
return { ...session, service_name };
});
const condition = service_id ? 'service_session.service_id = :service_id' : '1 = 1';

const total_entries = await appDataSource.manager
.createQueryBuilder()
.select('service_session')
.from(ServiceSession, 'service_session')
.where(condition, { service_id })
.getCount();

const res = await appDataSource.manager
.createQueryBuilder()
.select('service_session')
.from(ServiceSession, 'service_session')
.where(condition, { service_id })

.leftJoinAndSelect('service_session.service_session_users', 'service_session_users')
.leftJoin('service_session.service', 'service')
.addSelect('service.name')
.take(page && perPage ? perPage : undefined)
.skip(page && perPage ? (page - 1) * perPage : undefined)
.orderBy('service_session.start_time', 'DESC')
.getMany();

return { data: parseRes(res), total_entries, length_of_page: res.length };
}
public static async getActiveServiceSessions() {
const active = await redisClient.hGetAll('service_session');

if (Object.keys(active).length === 0) return [];

const ICs: {
username: string;
service_session_id: number;
}[] = await appDataSource.manager
.createQueryBuilder()
.select(['service_session_user.username', 'service_session_user.service_session_id'])
.from(ServiceSessionUser, 'service_session_user')
.where('service_session_id IN (:...service_session_ids)', {
service_session_ids: Object.values(active).map((v) => parseInt(v)),
})
.andWhere('service_session_user.is_ic = true')
.getMany();

// sort by service_session_id
const sortedICs = ICs.reduce(
(acc, cur) => {
acc[cur.service_session_id] = acc[cur.service_session_id] ?? [];
acc[cur.service_session_id].push(cur.username);
return acc;
},
{} as { [key: number]: string[] },
);

return Object.entries(active).map(([hash, id]) => ({
[hash]: {
service_session_id: parseInt(id),
ICs: sortedICs[parseInt(id)],
},
}));
}
public static async verifyAttendance(hash: string, username: string) {
const service_session_id = await redisClient.hGet('service_session', hash);
if (!service_session_id) {
throw new HTTPError(
'Invalid hash',
`Hash ${hash} is not a valid hash`,
HTTPErrorCode.BAD_REQUEST_ERROR,
);
}
const service_session_user = await this.getServiceSessionUser(
parseInt(service_session_id),
username,
);

if (service_session_user.attended === AttendanceStatus.Attended) {
throw new HTTPError(
'Already attended',
`User ${username} has already attended service session with service_session_id ${service_session_id}`,
HTTPErrorCode.CONFLICT_ERROR,
);
}
service_session_user.attended = AttendanceStatus.Attended;
await this.updateServiceSessionUser(service_session_user);
return service_session_user;
}
public static async getAdHocServiceSessions() {
const res = await appDataSource.manager
.createQueryBuilder()
.select('service_session')
.from(ServiceSession, 'service_session')
.where('service_session.ad_hoc_enabled = true')
.getMany();
return res;
}
}
175 changes: 173 additions & 2 deletions interapp-backend/api/models/user.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import appDataSource from '@utils/init_datasource';
import { User, UserPermission, UserService, Service } from '@db/entities';
import {
User,
UserPermission,
UserService,
Service,
ServiceSessionUser,
ServiceSession,
} from '@db/entities';
import { HTTPError, HTTPErrorCode } from '@utils/errors';
import { randomBytes } from 'crypto';
import redisClient from '@utils/init_redis';
import transporter from '@email_handler/index';
import Mail from 'nodemailer/lib/mailer';
import dataUrlToBuffer from '@utils/dataUrlToBuffer';
import minioClient from '@utils/init_minio';

interface EmailOptions extends Mail.Options {
template: string;
Expand All @@ -31,7 +40,9 @@ export class UserModel {
public static async deleteUser(username: string) {
await appDataSource.manager.delete(User, { username });
}
public static async getAllUsers() {
// the following function does not expose sensitive information
public static async getUserDetails(username?: string) {
const condition = username ? 'user.username = :username' : '1=1';
const users = await appDataSource.manager
.createQueryBuilder()
.select([
Expand All @@ -40,9 +51,48 @@ export class UserModel {
'user.verified',
'user.user_id',
'user.service_hours',
'user.profile_picture',
])
.where(condition, { username })
.from(User, 'user')
.getMany();

for (const user of users) {
if (user.profile_picture) {
const url = await minioClient.presignedGetObject(
process.env.MINIO_BUCKETNAME as string,
user.profile_picture,
);
user.profile_picture = url;
}
}

if (username) {
switch (users.length) {
case 0:
throw new HTTPError(
'User not found',
`The user with username ${username} was not found in the database`,
HTTPErrorCode.NOT_FOUND_ERROR,
);
case 1:
return users[0] as Omit<
User,
| 'password_hash'
| 'refresh_token'
| 'user_permissions'
| 'user_services'
| 'service_session_users'
>;
default:
throw new HTTPError(
'Multiple users found',
`Multiple users with username ${username} were found in the database`,
HTTPErrorCode.INTERNAL_SERVER_ERROR,
);
}
}

return users as Omit<
User,
| 'password_hash'
Expand Down Expand Up @@ -351,6 +401,74 @@ export class UserModel {
.where('service.service_id IN (:...services)', { services: service_ids })
.getMany();
}
public static async getAllServiceSessionsByUser(username: string) {
type getAllServiceSessionsByUserResult = Omit<ServiceSessionUser, 'service_session' | 'user'> &
{service_session: Pick<ServiceSession, 'start_time' | 'end_time' | 'service_id'> & {service: Pick<Service, 'name' | 'promotional_image'>}};

const serviceSessions = (await appDataSource.manager
.createQueryBuilder()
.select(['service_session_user'])
.from(ServiceSessionUser, 'service_session_user')
.where('service_session_user.username = :username', { username })
.leftJoin('service_session_user.service_session', 'service_session')
.addSelect([
'service_session.service_id',
'service_session.start_time',
'service_session.end_time',
])
.leftJoin('service_session.service', 'service')
.addSelect(['service.name', 'service.promotional_image'])
.orderBy('service_session.start_time', 'DESC')
.getMany()) as unknown as getAllServiceSessionsByUserResult[];

if (!serviceSessions) {
throw new HTTPError(
'User not found',
`The user with username ${username} has no service sessions`,
HTTPErrorCode.NOT_FOUND_ERROR,
);
}

for (const session of serviceSessions) {
if (session.service_session.service.promotional_image) {
const url = await minioClient.presignedGetObject(
process.env.MINIO_BUCKETNAME as string,
session.service_session.service.promotional_image,
);
session.service_session.service.promotional_image = url;
}
}
let parsed: {
service_id: number;
start_time: string;
end_time: string;
name: string;
promotional_image?: string | null;
service_session_id: number;
username: string;
ad_hoc: boolean;
attended: string;
is_ic: boolean;
service_session?: any;

}[] = serviceSessions.map((session) => ({
...session,
service_id: session.service_session.service_id,
start_time: session.service_session.start_time,
end_time: session.service_session.end_time,
name: session.service_session.service.name,
promotional_image: session.service_session.service.promotional_image,

}));

for (const sess of parsed) {
delete sess.service_session;
}



return parsed;
}
public static async getAllUsersByService(service_id: number) {
const service_users = await appDataSource
.createQueryBuilder()
Expand Down Expand Up @@ -493,4 +611,57 @@ export class UserModel {
user.service_hours = hours;
await appDataSource.manager.update(User, { username }, user);
}
public static async updateProfilePicture(username: string, profile_picture: string) {
const user = await appDataSource.manager
.createQueryBuilder()
.select(['user'])
.from(User, 'user')
.where('user.username = :username', { username })
.getOne();
if (!user)
throw new HTTPError(
'User not found',
`The user with username ${username} was not found in the database`,
HTTPErrorCode.NOT_FOUND_ERROR,
);
const converted = dataUrlToBuffer(profile_picture);

if (!converted)
throw new HTTPError(
'Invalid image',
'The image you provided is invalid',
HTTPErrorCode.BAD_REQUEST_ERROR,
);
await minioClient.putObject(
process.env.MINIO_BUCKETNAME as string,
`profile_pictures/${username}`,
converted.buffer,
{ 'Content-Type': converted.mimetype },
);
user.profile_picture = `profile_pictures/${username}`;

await appDataSource.manager.update(User, { username }, user);
}
public static async deleteProfilePicture(username: string) {
const user = await appDataSource.manager
.createQueryBuilder()
.select(['user'])
.from(User, 'user')
.where('user.username = :username', { username })
.getOne();
if (!user)
throw new HTTPError(
'User not found',
`The user with username ${username} was not found in the database`,
HTTPErrorCode.NOT_FOUND_ERROR,
);

await minioClient.removeObject(
process.env.MINIO_BUCKETNAME as string,
`profile_pictures/${username}`,
);
user.profile_picture = null;

await appDataSource.manager.update(User, { username }, user);
}
}
Loading