diff --git a/interapp-backend/api/models/service.ts b/interapp-backend/api/models/service.ts index ee8b5fa1..2cbbeb14 100644 --- a/interapp-backend/api/models/service.ts +++ b/interapp-backend/api/models/service.ts @@ -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( @@ -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[]) => + public static async getAllServiceSessions(page?: number, perPage?: number, service_id?: number) { + const parseRes = (res: (Omit & { service?: Service })[]) => res.map((session) => { const service_name = session.service?.name; delete session.service; @@ -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; + } } diff --git a/interapp-backend/api/routes/endpoints/service.ts b/interapp-backend/api/routes/endpoints/service.ts index 87789494..8f63fd55 100644 --- a/interapp-backend/api/routes/endpoints/service.ts +++ b/interapp-backend/api/routes/endpoints/service.ts @@ -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( @@ -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; diff --git a/interapp-backend/api/routes/endpoints/user.ts b/interapp-backend/api/routes/endpoints/user.ts index 3f6b1fd9..79339890 100644 --- a/interapp-backend/api/routes/endpoints/user.ts +++ b/interapp-backend/api/routes/endpoints/user.ts @@ -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); diff --git a/interapp-backend/scheduler/scheduler.ts b/interapp-backend/scheduler/scheduler.ts index db0f9822..e2cc5ac7 100644 --- a/interapp-backend/scheduler/scheduler.ts +++ b/interapp-backend/scheduler/scheduler.ts @@ -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(); @@ -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 @@ -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))!, + ); + } + } +}); diff --git a/interapp-backend/tests/e2e/service_session.test.ts b/interapp-backend/tests/e2e/service_session.test.ts index 3fb07b60..57b1c252 100644 --- a/interapp-backend/tests/e2e/service_session.test.ts +++ b/interapp-backend/tests/e2e/service_session.test.ts @@ -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; @@ -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), + ).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(); }); }); diff --git a/interapp-backend/tests/unit/service.test.ts b/interapp-backend/tests/unit/service.test.ts index f1cd4940..17390b37 100644 --- a/interapp-backend/tests/unit/service.test.ts +++ b/interapp-backend/tests/unit/service.test.ts @@ -3,6 +3,9 @@ import { ServiceModel } from '@models/service'; import { describe, expect, test, afterAll, beforeAll } from 'bun:test'; import { recreateDB } from '../utils/recreate_db'; import { AttendanceStatus } from '@db/entities'; +import { randomBytes } from 'crypto'; +import redisClient from '@utils/init_redis'; +import { recreateRedis } from '../utils/recreate_redis'; describe('Unit (service)', () => { beforeAll(async () => { @@ -267,7 +270,86 @@ describe('Unit (service)', () => { const serviceSessions = await ServiceModel.getAllServiceSessions(1, 5, 1); expect(serviceSessions.data).toBeArrayOfSize(1); }); + + test('create more service sessions starting now', async () => { + const now = new Date(); + const inOneHour = new Date(); + inOneHour.setHours(now.getHours() + 1); + await ServiceModel.createServiceSession({ + service_id: 1, + start_time: now.toISOString(), + end_time: inOneHour.toISOString(), + ad_hoc_enabled: false, + }); + await ServiceModel.createServiceSession({ + service_id: 1, + start_time: now.toISOString(), + end_time: inOneHour.toISOString(), + ad_hoc_enabled: false, + }); + await ServiceModel.createServiceSession({ + service_id: 1, + start_time: now.toISOString(), + end_time: inOneHour.toISOString(), + ad_hoc_enabled: false, + }); + expect((await ServiceModel.getAllServiceSessions(1, 5)).data).toBeArrayOfSize(4); + }); + + 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(4); + }); + + test('get active service sessions', async () => { + const activeServiceSessions = await ServiceModel.getActiveServiceSessions(); + expect(Object.entries(activeServiceSessions)).toBeArrayOfSize(4); + }); + + test('add user to active service session and verify attendance', async () => { + await ServiceModel.createServiceSessionUser({ + service_session_id: 3, + username: 'testuser', + is_ic: false, + attended: AttendanceStatus.Absent, + ad_hoc: false, + }); + + const activeServiceSessions = await ServiceModel.getActiveServiceSessions(); + expect(Object.entries(activeServiceSessions)).toBeArrayOfSize(4); + + // find the service session hash that the user is in (id 3) + const serviceSessionHash = activeServiceSessions.find((v) => + Object.values(v).find((v) => v.service_session_id === 3), + ); + expect(serviceSessionHash).toBeDefined(); + + // get the hash + const hash = Object.keys(serviceSessionHash!)[0]; + + await ServiceModel.verifyAttendance(hash, 'testuser'); + + expect((await ServiceModel.getServiceSessionUser(3, 'testuser')).attended).toBe( + AttendanceStatus.Attended, + ); + }); + afterAll(async () => { await recreateDB(); + await recreateRedis(); }); }); diff --git a/interapp-backend/tests/utils/recreate_redis.ts b/interapp-backend/tests/utils/recreate_redis.ts new file mode 100644 index 00000000..99b8ce22 --- /dev/null +++ b/interapp-backend/tests/utils/recreate_redis.ts @@ -0,0 +1,5 @@ +import redisClient from '@utils/init_redis'; + +export const recreateRedis = async () => { + await redisClient.FLUSHALL(); +}; diff --git a/interapp-frontend/.env.development b/interapp-frontend/.env.development index 84c8ef9f..0adf9de9 100644 --- a/interapp-frontend/.env.development +++ b/interapp-frontend/.env.development @@ -7,4 +7,6 @@ NEXT_PUBLIC_AXIOS_BASE_URL=http://localhost:3000/api NEXT_PUBLIC_SCHOOL_EMAIL_REGEX="^[A-Za-z0-9]+@student\.ri\.edu\.sg|[A-Za-z0-9]+@rafflesgirlssch.edu.sg$" MINIO_HOST=interapp-minio -MINIO_PORT=9000 \ No newline at end of file +MINIO_PORT=9000 + +NEXT_PUBLIC_WEBSITE_URL=http://localhost:3000 \ No newline at end of file diff --git a/interapp-frontend/.env.production b/interapp-frontend/.env.production index 84c8ef9f..0adf9de9 100644 --- a/interapp-frontend/.env.production +++ b/interapp-frontend/.env.production @@ -7,4 +7,6 @@ NEXT_PUBLIC_AXIOS_BASE_URL=http://localhost:3000/api NEXT_PUBLIC_SCHOOL_EMAIL_REGEX="^[A-Za-z0-9]+@student\.ri\.edu\.sg|[A-Za-z0-9]+@rafflesgirlssch.edu.sg$" MINIO_HOST=interapp-minio -MINIO_PORT=9000 \ No newline at end of file +MINIO_PORT=9000 + +NEXT_PUBLIC_WEBSITE_URL=http://localhost:3000 \ No newline at end of file diff --git a/interapp-frontend/bun.lockb b/interapp-frontend/bun.lockb index 513ac5bc..eddc968c 100644 Binary files a/interapp-frontend/bun.lockb and b/interapp-frontend/bun.lockb differ diff --git a/interapp-frontend/package.json b/interapp-frontend/package.json index 45ff49e4..737a5552 100644 --- a/interapp-frontend/package.json +++ b/interapp-frontend/package.json @@ -18,11 +18,13 @@ "@tabler/icons-react": "^2.44.0", "axios": "^1.6.2", "next": "^14.0.4", + "qrcode": "^1.5.3", "react": "^18", "react-dom": "^18" }, "devDependencies": { "@types/node": "^20", + "@types/qrcode": "^1.5.5", "@types/react": "^18", "@types/react-dom": "^18", "bun-types": "^1.0.11", diff --git a/interapp-frontend/src/app/announcements/page.tsx b/interapp-frontend/src/app/announcements/page.tsx new file mode 100644 index 00000000..da1f49f6 --- /dev/null +++ b/interapp-frontend/src/app/announcements/page.tsx @@ -0,0 +1,5 @@ +import UnderConstruction from '@components/UnderConstruction/UnderContruction'; + +export default function AnnouncementsPage() { + return ; +} diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx new file mode 100644 index 00000000..7dbf74f8 --- /dev/null +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenu.tsx @@ -0,0 +1,93 @@ +'use client'; +import APIClient from '@/api/api_client'; +import { useState, useEffect, useContext } from 'react'; +import { Stack, Text, Title } from '@mantine/core'; +import { AuthContext } from '@providers/AuthProvider/AuthProvider'; +import AttendanceMenuEntry from './AttendanceMenuEntry/AttendanceMenuEntry'; +import QRPage from './QRPage/QRPage'; + +interface AttendanceMenuProps { + id?: number; +} + +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'); + + const data: { + [hash: string]: { + service_session_id: number; + ICs: string[]; + }; + }[] = response.data; + return data; +}; + +export type fetchActiveServiceSessionsType = Awaited>; + +const AttendanceMenu = ({ id }: AttendanceMenuProps) => { + const [activeSessions, setActiveSessions] = useState([]); + const { user } = useContext(AuthContext); + + useEffect(() => { + fetchActiveServiceSessions().then((data) => { + setActiveSessions(data); + }); + }, []); + if (id === undefined) { + if (Object.keys(activeSessions).length === 0) { + return No active sessions!; + } + + const destructuredActiveSessions = activeSessions.map((session) => { + const hash = Object.keys(session)[0]; + const { service_session_id, ICs } = session[hash]; + return { hash, service_session_id, ICs }; + }); + + const visibleActiveSessions = destructuredActiveSessions.filter(({ ICs }) => { + return ICs.includes(user?.username ?? ''); + }); + + return ( + <> + + Verify Attendance + + This page shows all ongoing service sessions. Click on a session to view the QR code. + + + + {visibleActiveSessions.map(({ hash, service_session_id }) => { + return ; + })} + + + ); + } else { + const activeSession = activeSessions.find((session) => { + const hash = Object.keys(session)[0]; + const { service_session_id } = session[hash]; + return service_session_id === id; + }); + + if (activeSession === undefined) { + return Session not found!; + } + + const hash = Object.keys(activeSession)[0]; + + return ( + <> + + Verify Attendance + This page shows the QR code for the selected service session. + + + + ); + } +}; + +export default AttendanceMenu; diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx new file mode 100644 index 00000000..a018f990 --- /dev/null +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/AttendanceMenuEntry.tsx @@ -0,0 +1,134 @@ +'use client'; + +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 '@/api/utils'; +import { IconFlag } from '@tabler/icons-react'; +import { useRouter } from 'next/navigation'; +import './styles.css'; + +interface AttendanceMenuEntryProps { + service_session_id: number; +} + +export const fetchAttendanceDetails = async (service_session_id: number) => { + const apiClient = new APIClient({ useClient: false }).instance; + const res = await apiClient.get('/service/session', { + params: { service_session_id: service_session_id }, + }); + if (res.status !== 200) throw new Error('Failed to fetch attendance details'); + + 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'); + + let res3: AxiosResponse | null = (await apiClient.get('/service', { + params: { service_id: res.data.service_id }, + })) satisfies AxiosResponse; + if (res3.status !== 200) res3 = null; + + const promo: string | null = res3 ? res3.data.promotional_image : null; + const serviceTitle: string | null = res3 ? res3.data.name : null; + + const sessionDetails: { + service_id: number; + start_time: string; + end_time: string; + ad_hoc_enabled: boolean; + service_session_id: number; + } = res.data; + const sessionUserDetails: { + service_session_id: number; + username: string; + ad_hoc: boolean; + attended: 'Absent' | 'Attended' | 'Valid Reason'; + is_ic: boolean; + }[] = res2.data; + + return { + ...sessionDetails, + start_time: new Date(sessionDetails.start_time), + end_time: new Date(sessionDetails.end_time), + user_details: sessionUserDetails, + promotional_image: promo ? remapAssetUrl(promo) : null, + service_title: serviceTitle, + }; +}; + +export type fetchAttendanceDetailsType = Awaited>; + +const AttendanceMenuEntry = ({ service_session_id }: AttendanceMenuEntryProps) => { + const router = useRouter(); + const [detail, setDetail] = useState( + {} as fetchAttendanceDetailsType, + ); + + useEffect(() => { + fetchAttendanceDetails(service_session_id).then((data) => { + setDetail(data); + }); + }, []); + + if (Object.keys(detail).length === 0) { + return ; + } + + return ( + router.push('/attendance?id=' + detail.service_session_id)} + > +
+
+ promotional-img +
+ +
+ + {detail.service_title} (id: {detail.service_session_id}) + + + {detail.start_time.toLocaleString()} - {detail.end_time.toLocaleString()} + + + {detail.user_details.filter((user) => user.attended === 'Attended').length} /{' '} + {detail.user_details.length} attended + +
+ {detail.user_details.map((user) => { + return ( + : null} + key={user.username} + > + {user.username} + + ); + })} +
+
+
+
+ ); +}; + +export default memo(AttendanceMenuEntry); diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/styles.css b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/styles.css new file mode 100644 index 00000000..db8bee9d --- /dev/null +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/AttendanceMenuEntry/styles.css @@ -0,0 +1,52 @@ +.entry-skeleton { + width: 90%; + height: 10rem; + border-radius: 7px; +} + +.entry { + border-radius: 7px; + padding: 1rem; + display: flex; + gap: 1rem; + cursor: pointer; +} + +.entry-image { + width: 7rem; + height: 7rem; + + object-fit: scale-down; +} + +.entry-image-container { + padding: 0.7rem; + border: 1px solid var(--mantine-color-dark-9); + background-color: var(--mantine-color-white); + border-radius: 7px; +} + +.entry-text { + display: flex; + flex-direction: column; + width: 100%; + gap: 0.7rem; +} + +.entry-users { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(3rem, 1fr)); + row-gap: 0.5rem; + column-gap: 0.3rem; + justify-items: center; +} + +.entry-users > * { + width: 100%; +} + +@media (max-width: 768px) { + .entry-users { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx new file mode 100644 index 00000000..79fd13a5 --- /dev/null +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/QRPage.tsx @@ -0,0 +1,149 @@ +'use client'; +import { + fetchAttendanceDetails, + type fetchAttendanceDetailsType, +} from '../AttendanceMenuEntry/AttendanceMenuEntry'; +import APIClient from '@/api/api_client'; +import { useState, useEffect, memo, useRef, Suspense } from 'react'; +import { Title, Text, Badge, Skeleton } from '@mantine/core'; +import { useInterval } from '@mantine/hooks'; +import QRCode from 'qrcode'; +import { IconFlag, IconExternalLink } from '@tabler/icons-react'; +import './styles.css'; +import Link from 'next/link'; + +interface QRPageProps { + id: number; + hash: string; +} +const refreshAttendance = async (id: number) => { + const apiClient = new APIClient({ useClient: false }).instance; + 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'); + + const sessionUserDetails: { + service_session_id: number; + username: string; + ad_hoc: boolean; + attended: 'Absent' | 'Attended' | 'Valid Reason'; + is_ic: boolean; + }[] = res.data; + + return sessionUserDetails; +}; + +const QRPage = ({ id, hash }: QRPageProps) => { + const [detail, setDetail] = useState( + {} as fetchAttendanceDetailsType, + ); + + const redirectLink = useRef( + process.env.NEXT_PUBLIC_WEBSITE_URL + '/attendance/verify?hash=' + hash + '&id=' + id, + ); + + const canvasRef = useRef(null); + + const [timer, setTimer] = useState(5); + const timerInterval = useInterval(() => { + setTimer((prev) => prev - 1); + }, 1000); + + useEffect(() => { + fetchAttendanceDetails(id).then((data) => { + setDetail(data); + }); + }, []); + + useEffect(() => { + if (canvasRef.current) { + QRCode.toCanvas( + canvasRef.current, + redirectLink.current, + { + width: 300, + }, + function (error) { + if (error) { + canvasRef.current?.after(error.message); + canvasRef.current?.remove(); + } + }, + ); + } + }, [canvasRef]); + + useEffect(() => { + if (detail.service_session_id) { + timerInterval.start(); + } + return () => { + timerInterval.stop(); + }; + }, [detail.service_session_id]); + + useEffect(() => { + if (timer === 0) { + timerInterval.stop(); + refreshAttendance(id).then((data) => { + setDetail((prev) => { + return { ...prev, user_details: data }; + }); + setTimer(5); + timerInterval.start(); + }); + } + }, [timer]); + + return ( + <> +
+ +
+ + + Verify Attendance + +
+
+ }> +
+ + {detail.service_title} (id: {detail.service_session_id}) + + + {detail.start_time?.toLocaleString()} - {detail.end_time?.toLocaleString()} + + + + {detail.user_details?.filter((user) => user.attended === 'Attended').length} /{' '} + {detail.user_details?.length} attended + +
+ {detail.user_details?.map((user) => { + return ( + : null} + > + {user.username} + + ); + })} +
+ Refreshes in {timer} seconds! +
+
+ + ); +}; + +export default memo(QRPage); diff --git a/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/styles.css b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/styles.css new file mode 100644 index 00000000..d2ca4ef7 --- /dev/null +++ b/interapp-frontend/src/app/attendance/AttendanceMenu/QRPage/styles.css @@ -0,0 +1,45 @@ +.QRCode-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; +} + +.QRCode { + aspect-ratio: 1/1; + border: 0.7rem solid var(--mantine-color-red-5); + border-radius: 1rem; + + object-fit: scale-down; +} + +.QRCode-redirect { + margin: 1rem 0; + display: flex; + justify-content: center; + + gap: 1rem; + width: 100%; +} + +.QRCode-details { + margin: 1rem 0; + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + align-items: center; +} + +.QRCode-users { + display: grid; + grid-template-columns: repeat(3, 1fr); + row-gap: 0.5rem; + column-gap: 0.3rem; + justify-items: center; +} + +.QRCode-users > * { + width: 90%; +} diff --git a/interapp-frontend/src/app/attendance/error-styles.css b/interapp-frontend/src/app/attendance/error-styles.css new file mode 100644 index 00000000..7c91b092 --- /dev/null +++ b/interapp-frontend/src/app/attendance/error-styles.css @@ -0,0 +1,11 @@ +.error-container { + display: flex; + flex-direction: column; + justify-content: center; + gap: 1rem; + align-items: center; + height: 100%; + width: 100%; + margin: auto; + padding: 10%; +} diff --git a/interapp-frontend/src/app/attendance/page.tsx b/interapp-frontend/src/app/attendance/page.tsx new file mode 100644 index 00000000..348ac37e --- /dev/null +++ b/interapp-frontend/src/app/attendance/page.tsx @@ -0,0 +1,20 @@ +import AttendanceMenu from './AttendanceMenu/AttendanceMenu'; +import { Text } from '@mantine/core'; +import GoHomeButton from '@/components/GoHomeButton/GoHomeButton'; +import './error-styles.css'; + +export default async function AttendancePage({ + searchParams, +}: Readonly<{ + searchParams: { [key: string]: string | string[] | undefined }; +}>) { + // if there is an id, it must be a number + if (searchParams.id instanceof Array || (searchParams.id && !/^\d+$/.test(searchParams.id))) + return ( +
+ Invalid id + +
+ ); + return ; +} diff --git a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx new file mode 100644 index 00000000..27b59188 --- /dev/null +++ b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/VerifyAttendance.tsx @@ -0,0 +1,134 @@ +'use client'; + +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 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({ useClient: false }).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'); + + const sessionDetails: { + service_id: number; + start_time: string; + end_time: string; + ad_hoc_enabled: boolean; + service_session_id: number; + } = res.data; + + const diff = + new Date(sessionDetails.end_time).getTime() - new Date(sessionDetails.start_time).getTime(); + + const diffHours = diff / (1000 * 60 * 60); + + const rounded = parseFloat(diffHours.toFixed(1)); + + return rounded; +}; + +const verifyAttendanceUser = async ( + hash: string, + username: string, +): Promise<{ status: 'Success' | 'Error'; message: string }> => { + const apiClient = new APIClient().instance; + const res = await apiClient.post('/service/verify_attendance', { + hash, + username, + }); + switch (res.status) { + case 204: + return { + status: 'Success', + message: 'Attendance verified successfully!', + }; + case 400: + return { + status: 'Error', + message: 'Invalid attendance hash.', + }; + case 409: + return { + status: 'Error', + message: 'Attendance already verified.', + }; + case 401: + return { + status: 'Error', + message: 'You must be logged in to verify attendance.', + }; + default: + return { + status: 'Error', + message: 'An unknown error occurred.', + }; + } +}; + +const updateServiceHours = async (username: string, newHours: number) => { + const apiClient = new APIClient().instance; + const res = await apiClient.patch('/user/service_hours', { + username, + hours: newHours, + }); + if (res.status !== 204) throw new Error('Failed to update service hours'); +}; + +const VerifyAttendance = ({ id, hash }: VerifyAttendanceProps) => { + const { user, updateUser, loading } = useContext(AuthContext); + + const [message, setMessage] = useState(''); + const [status, setStatus] = useState<'Success' | 'Error'>(); + const [gainedHours, setGainedHours] = useState(0); + + const handleVerify = (user: User) => { + verifyAttendanceUser(hash, user.username).then(({ status, message }) => { + setMessage(message); + setStatus(status); + + if (status === 'Success') { + fetchDuration(id).then((data) => { + updateUser({ ...user, service_hours: user.service_hours + data }); + updateServiceHours(user.username, user.service_hours + data); + + setGainedHours(data); + }); + } + }); + }; + + useEffect(() => { + if (loading) return; + if (!user) throw new Error('User not logged in'); + + handleVerify(user); + }, [loading]); + + return ( +
+ Verify Attendance + {message} + {status === 'Success' && You have gained {gainedHours} service hours!} + {status === 'Success' ? ( + + ) : ( + + )} +
+ ); +}; + +export default VerifyAttendance; diff --git a/interapp-frontend/src/app/attendance/verify/VerifyAttendance/styles.css b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/styles.css new file mode 100644 index 00000000..ecbdf837 --- /dev/null +++ b/interapp-frontend/src/app/attendance/verify/VerifyAttendance/styles.css @@ -0,0 +1,11 @@ +.verify-attendance { + display: flex; + flex-direction: column; + justify-content: center; + gap: 1rem; + align-items: center; + height: 100%; + width: 100%; + margin: auto; + padding: 10%; +} diff --git a/interapp-frontend/src/app/attendance/verify/page.tsx b/interapp-frontend/src/app/attendance/verify/page.tsx new file mode 100644 index 00000000..9242657f --- /dev/null +++ b/interapp-frontend/src/app/attendance/verify/page.tsx @@ -0,0 +1,28 @@ +import VerifyAttendance from './VerifyAttendance/VerifyAttendance'; +import { Text } from '@mantine/core'; +import GoHomeButton from '@/components/GoHomeButton/GoHomeButton'; +import './../error-styles.css'; + +export default function AttendanceVerifyPage({ + searchParams, +}: Readonly<{ + searchParams: { [key: string]: string | string[] | undefined }; +}>) { + if (searchParams.hash instanceof Array || searchParams.hash === undefined) + return ( +
+ Invalid hash + +
+ ); + + if (searchParams.id instanceof Array || searchParams.id === undefined) + return ( +
+ Invalid hash + +
+ ); + + return ; +} diff --git a/interapp-frontend/src/app/route_permissions.ts b/interapp-frontend/src/app/route_permissions.ts index cc31d5d1..c348521a 100644 --- a/interapp-frontend/src/app/route_permissions.ts +++ b/interapp-frontend/src/app/route_permissions.ts @@ -18,9 +18,9 @@ export const noLoginRequiredRoutes = [ export const RoutePermissions = { [Permissions.VISTOR]: ['/', '/auth/verify_email', '/settings'], - [Permissions.CLUB_MEMBER]: ['/', '/announcements', '/services', '/profile'], - [Permissions.SERVICE_IC]: ['/', '/service_sessions'], - [Permissions.MENTORSHIP_IC]: ['/', '/service_sessions'], + [Permissions.CLUB_MEMBER]: ['/', '/announcements', '/services', '/profile', '/attendance/verify'], + [Permissions.SERVICE_IC]: ['/', '/service_sessions', '/attendance'], + [Permissions.MENTORSHIP_IC]: ['/', '/service_sessions', '/attendance'], [Permissions.EXCO]: ['/'], [Permissions.ATTENDANCE_MANAGER]: ['/'], [Permissions.ADMIN]: ['/', '/admin'], 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 abd83ffd..e1975a40 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/EditAction/EditAction.tsx +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/EditAction/EditAction.tsx @@ -79,19 +79,22 @@ function EditAction({ } const addedAttendees = values.attendees.filter((attendee) => !attendees.includes(attendee)); const removedAttendees = attendees.filter((attendee) => !values.attendees.includes(attendee)); - - const res1 = await apiClient.delete(`/service/session_user_bulk`, { - data: { + let res1 = null; + if (removedAttendees.length > 0) + res1 = await apiClient.delete(`/service/session_user_bulk`, { + data: { + service_session_id, + usernames: removedAttendees.map((attendee) => attendee.username), + }, + }); + let res2 = null; + if (addedAttendees.length > 0) + res2 = await apiClient.post(`/service/session_user_bulk`, { service_session_id, - usernames: removedAttendees.map((attendee) => attendee.username), - }, - }); - const res2 = await apiClient.post(`/service/session_user_bulk`, { - service_session_id, - users: addedAttendees, - }); + users: addedAttendees, + }); - if (res1.status >= 400 || res2.status >= 400) { + if ((res1 && res1.status >= 400) || (res2 && res2.status >= 400)) { notifications.show({ title: 'Error', message: diff --git a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx index 4a16be76..b5334b3e 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/ServiceSessionRow.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { Table, Pill, Text, Group } from '@mantine/core'; +import { Table, Badge, Text, Group } from '@mantine/core'; import { ServiceSession } from '../../types'; import EditAction from '../EditAction/EditAction'; import './styles.css'; @@ -16,7 +16,9 @@ const parseDateAndTime = (startTime: string, endTime: string): readonly [string, startDate.getFullYear() === endDate.getFullYear(); return [ - `${startDate.getHours()}:${startDate.getMinutes()} - ${endDate.getHours()}:${endDate.getMinutes()}`, + `${startDate.getHours()}:${ + (startDate.getMinutes() < 10 ? '0' : '') + startDate.getMinutes() + } - ${endDate.getHours()}:${(endDate.getMinutes() < 10 ? '0' : '') + endDate.getMinutes()}`, isSameDay ? `${startDate.getDate()}/${startDate.getMonth() + 1}/${startDate.getFullYear()}` : `${startDate.getDate()}/${ @@ -65,7 +67,18 @@ const ServiceSessionRow = ({
{service_session_users.map((user) => ( - {user.username} + + {user.username} + ))}
diff --git a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css index 2693d5cc..53d51df6 100644 --- a/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css +++ b/interapp-frontend/src/app/service_sessions/ServiceSessionContent/ServiceSessionRow/styles.css @@ -8,6 +8,11 @@ display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.2rem; + justify-items: center; +} + +.service-session-users > * { + width: 90%; } @media (max-width: 768px) { diff --git a/interapp-frontend/src/components/Navbar/NavbarButton/NavbarButton.tsx b/interapp-frontend/src/components/Navbar/NavbarButton/NavbarButton.tsx index 8d43ade9..873dc27a 100644 --- a/interapp-frontend/src/components/Navbar/NavbarButton/NavbarButton.tsx +++ b/interapp-frontend/src/components/Navbar/NavbarButton/NavbarButton.tsx @@ -1,5 +1,5 @@ 'use client'; -import { memo, useContext, useState, useMemo } from 'react'; +import { memo, useContext, useState, useMemo, useCallback } from 'react'; import { IconHome, IconLogin, @@ -13,6 +13,7 @@ import { IconMail, IconHeart, type TablerIconsProps, + IconCheck, } from '@tabler/icons-react'; import { AuthContext } from '@providers/AuthProvider/AuthProvider'; import { User } from '@providers/AuthProvider/types'; @@ -175,6 +176,16 @@ const generateNavbarTabs: (user: User | null, actions: NavbarActions) => NavbarT user.permissions.includes(Permissions.MENTORSHIP_IC)), category: 'Administration', }, + { + name: 'Attendance', + callback: () => goTo('/attendance'), + icon: IconCheck, + show: + !!user && + (user.permissions.includes(Permissions.SERVICE_IC) || + user.permissions.includes(Permissions.MENTORSHIP_IC)), + category: 'Administration', + }, ]; const catagoriseTabs = (tabs: NavbarTab[]) => { @@ -202,9 +213,8 @@ const NavbarButton = () => { const { user, logout } = useContext(AuthContext); const apiClient = new APIClient().instance; - const resendVerificationEmail = useMemo( - () => async () => - (await apiClient.post('/user/verify_email', { username: user?.username })).status, + const resendVerificationEmail = useCallback( + async () => (await apiClient.post('/user/verify_email', { username: user?.username })).status, [user], ); diff --git a/interapp-frontend/src/components/Navbar/NavbarTitle/NavbarTitle.tsx b/interapp-frontend/src/components/Navbar/NavbarTitle/NavbarTitle.tsx index f0ac7172..a70fb55b 100644 --- a/interapp-frontend/src/components/Navbar/NavbarTitle/NavbarTitle.tsx +++ b/interapp-frontend/src/components/Navbar/NavbarTitle/NavbarTitle.tsx @@ -66,6 +66,31 @@ export const getNavbarTitle = (pathname: string) => { title: 'Services', Icon: IconHeart, }; + case '/service_sessions': + return { + title: 'Service Sessions', + Icon: IconPlaylistAdd, + }; + case '/attendance': + return { + title: 'Attendance', + Icon: IconCheck, + }; + case '/attendance/verify': + return { + title: 'Verify Attendance', + Icon: IconCheck, + }; + case '/announcements': + return { + title: 'Announcements', + Icon: IconSpeakerphone, + }; + case '/profile': + return { + title: 'Profile', + Icon: IconUserSquare, + }; default: return { title: 'Page Not Found',