Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: attendance support #22

Merged
merged 13 commits into from
Jan 2, 2024
71 changes: 66 additions & 5 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 @@ -303,8 +304,8 @@ export class ServiceModel {
.andWhere('username IN (:...usernames)', { usernames })
.execute();
}
public static async getAllServiceSessions(page: number, perPage: number, service_id?: number) {
const parseRes = (res: Partial<ServiceSession>[]) =>
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;
Expand All @@ -328,11 +329,71 @@ export class ServiceModel {
.leftJoinAndSelect('service_session.service_session_users', 'service_session_users')
.leftJoin('service_session.service', 'service')
.addSelect('service.name')
.take(perPage)
.skip((page - 1) * perPage)
.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;
}
}
23 changes: 22 additions & 1 deletion interapp-backend/api/routes/endpoints/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ serviceRouter.post(
'ad_hoc' in user &&
'attended' in user &&
'is_ic' in user &&
user.attended in AttendanceStatus
Object.values(AttendanceStatus).some((status) => status === user.attended)
)
) {
throw new HTTPError(
Expand Down Expand Up @@ -362,4 +362,25 @@ serviceRouter.delete(
res.status(204).send();
},
);

serviceRouter.get(
'/active_sessions',
verifyJWT,
verifyRequiredPermission(Permissions.SERVICE_IC, Permissions.MENTORSHIP_IC),
async (req, res) => {
const sessions = await ServiceModel.getActiveServiceSessions();
res.status(200).send(sessions);
},
);

serviceRouter.post(
'/verify_attendance',
validateRequiredFields(['username', 'hash']),
verifyJWT,
async (req, res) => {
await ServiceModel.verifyAttendance(req.body.hash, req.body.username);
res.status(204).send();
},
);

export default serviceRouter;
1 change: 0 additions & 1 deletion interapp-backend/api/routes/endpoints/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,6 @@ userRouter.patch(
userRouter.patch(
'/service_hours',
verifyJWT,
verifyRequiredPermission(Permissions.ADMIN),
validateRequiredFields(['username', 'hours']),
async (req, res) => {
await UserModel.updateServiceHours(req.body.username, req.body.hours);
Expand Down
48 changes: 48 additions & 0 deletions interapp-backend/scheduler/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { ServiceModel } from '@models/service';
import { UserModel } from '@models/user';
import { User } from '@db/entities/user';
import { AttendanceStatus } from '@db/entities/service_session_user';
import redisClient from '@utils/init_redis';
import { randomBytes } from 'crypto';

function constructDate(day_of_week: number, time: string) {
const d = new Date();
Expand All @@ -12,6 +14,10 @@ function constructDate(day_of_week: number, time: string) {
return d;
}

function addHours(date: Date, hours: number) {
date.setHours(date.getHours() + hours);
return date;
}
async function getCurrentServices() {
// get current date and time
// get service days of week and start and end time
Expand Down Expand Up @@ -76,3 +82,45 @@ schedule(
timezone: 'Asia/Singapore',
},
);

schedule('0 */1 * * * *', async () => {
// get all service sessions
const service_sessions = (await ServiceModel.getAllServiceSessions()).data;

// check if current time is within start_time and end_time - offset 10 mins
const current_time = new Date();
const timezone_offset_hours = current_time.getTimezoneOffset() / 60;
const local_time = addHours(current_time, timezone_offset_hours);

// get all hashes from redis and check if service session id is in redis else add it
const hashes = await redisClient.hGetAll('service_session');

console.log(hashes);
for (const session of service_sessions) {
const start_time = new Date(session.start_time);
const end_time = new Date(session.end_time);
const offset = new Date(start_time.getTime() - 10 * 60000);

const withinInterval = local_time <= end_time && local_time >= offset;

if (withinInterval) {
// if yes, service session is active

if (!Object.values(hashes).find((v) => v === String(session.service_session_id))) {
// if service session id is not in redis, generate a hash as key and service session id as value

const newHash = randomBytes(128).toString('hex');

await redisClient.hSet('service_session', newHash, session.service_session_id);
}
}
// setting expiry is not possible with hset, so we need to check if the hash is expired
// if yes, remove it from redis
else if (Object.values(hashes).find((k) => k === String(session.service_session_id))) {
await redisClient.hDel(
'service_session',
Object.keys(hashes).find((k) => hashes[k] === String(session.service_session_id))!,
);
}
}
});
108 changes: 108 additions & 0 deletions interapp-backend/tests/e2e/service_session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { test, expect, describe, afterAll, beforeAll } from 'bun:test';
import { recreateDB } from '../utils/recreate_db';
import appDataSource from '@utils/init_datasource';
import { User, UserPermission } from '@db/entities';
import { ServiceModel } from '@models/service';
import { randomBytes } from 'crypto';
import redisClient from '@utils/init_redis';
import { recreateRedis } from '../utils/recreate_redis';

const API_URL = process.env.API_URL;

Expand Down Expand Up @@ -567,7 +571,111 @@ describe('API (service session)', async () => {
});
});

test('create more service sessions', async () => {
const res = await fetch(`${API_URL}/service/session`, {
method: 'POST',
body: JSON.stringify({
service_id: 1,
start_time: '2023-11-28T16:42Z',
end_time: '2023-11-28T17:42Z',
ad_hoc_enabled: true,
}),
headers: { 'Content-type': 'application/json', Authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);

const res2 = await fetch(`${API_URL}/service/session`, {
method: 'POST',
body: JSON.stringify({
service_id: 1,
start_time: '2023-11-29T16:42Z',
end_time: '2023-11-29T17:42Z',
ad_hoc_enabled: true,
}),
headers: { 'Content-type': 'application/json', Authorization: `Bearer ${accessToken}` },
});
expect(res2.status).toBe(200);

const res3 = await fetch(`${API_URL}/service/session`, {
method: 'POST',
body: JSON.stringify({
service_id: 1,
start_time: '2023-11-30T16:42Z',
end_time: '2023-11-30T17:42Z',
ad_hoc_enabled: true,
}),
headers: { 'Content-type': 'application/json', Authorization: `Bearer ${accessToken}` },
});
expect(res3.status).toBe(200);
});

test('dump keys into redis', async () => {
// get all service sessions
const service_sessions = (await ServiceModel.getAllServiceSessions()).data;
let hashes = await redisClient.hGetAll('service_session');
for (const session of service_sessions) {
// check if service session id is in redis

if (!Object.values(hashes).find((v) => v === String(session.service_session_id))) {
// if service session id is not in redis, generate a hash as key and service session id as value

const newHash = randomBytes(128).toString('hex');

await redisClient.hSet('service_session', newHash, session.service_session_id);
}
}
hashes = await redisClient.hGetAll('service_session');
expect(Object.entries(hashes)).toBeArrayOfSize(10);
});

test('get active service sessions', async () => {
const res = await fetch(`${API_URL}/service/active_sessions`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(200);
const res_json = await res.json();
expect(
Object.entries(res_json as Record<string, { service_session_id: number; ICs: string[] }>),
).toBeArrayOfSize(10);
});

test('add user to active service session and verify attendance', async () => {
// add user to service session
const res = await fetch(`${API_URL}/service/session_user`, {
method: 'POST',
body: JSON.stringify({
service_session_id: 1,
username: 'testuser3',
ad_hoc: false,
attended: 'Absent',
is_ic: false,
}),
headers: { 'Content-type': 'application/json', Authorization: `Bearer ${accessToken}` },
});
expect(res.status).toBe(201);

// find hash of service session
const hashes = await redisClient.hGetAll('service_session');
const hashPair = Object.entries(hashes).find(([k, v]) => v === '1');

expect(hashPair).toBeDefined();
const hash = hashPair![0];

// verify attendance
const res2 = await fetch(`${API_URL}/service/verify_attendance`, {
method: 'POST',
body: JSON.stringify({
username: 'testuser3',
hash: hash,
}),
headers: { 'Content-type': 'application/json', Authorization: `Bearer ${accessToken}` },
});
expect(res2.status).toBe(204);
});

afterAll(async () => {
await recreateDB();
await recreateRedis();
});
});
Loading