diff --git a/backend/.env-old b/backend/.env-old new file mode 100644 index 00000000..bedfb0b7 --- /dev/null +++ b/backend/.env-old @@ -0,0 +1,21 @@ +PORT=3000 +DWENGO_DB_HOST=db +DWENGO_DB_PORT=5432 +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres +DWENGO_DB_UPDATE=false + +DWENGO_AUTH_STUDENT_URL=http://localhost/idp/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs +DWENGO_AUTH_TEACHER_URL=http://localhost/idp/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs + +# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! +#DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/,127.0.0.1:80,http://127.0.0.1,http://localhost:80,http://127.0.0.1:80,localhost +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080 + +# Logging and monitoring + +LOKI_HOST=http://logging:3102 diff --git a/backend/Dockerfile b/backend/Dockerfile index bb3464c3..1d82a484 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,8 +6,9 @@ WORKDIR /app/dwengo COPY package*.json ./ COPY backend/package.json ./backend/ -# Backend depends on common +# Backend depends on common and docs COPY common/package.json ./common/ +COPY docs/package.json ./docs/ RUN npm install --silent @@ -34,6 +35,7 @@ COPY ./backend/i18n ./i18n COPY --from=build-stage /app/dwengo/common/dist ./common/dist COPY --from=build-stage /app/dwengo/backend/dist ./backend/dist +COPY --from=build-stage /app/dwengo/docs/api/swagger.json ./docs/api/swagger.json COPY package*.json ./ COPY backend/package.json ./backend/ @@ -42,7 +44,6 @@ COPY common/package.json ./common/ RUN npm install --silent --only=production -COPY ./docs ./docs COPY ./backend/i18n ./backend/i18n EXPOSE 3000 diff --git a/backend/src/controllers/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts new file mode 100644 index 00000000..4956f3e2 --- /dev/null +++ b/backend/src/controllers/teacher-invitations.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import { requireFields } from './error-helper'; +import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations'; +import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; + +export async function getAllInvitationsHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const by = req.query.sent === 'true'; + requireFields({ username }); + + const invitations = await getAllInvitations(username, by); + + res.json({ invitations }); +} + +export async function getInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const invitation = await getInvitation(sender, receiver, classId); + + res.json({ invitation }); +} + +export async function createInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await createInvitation(data); + + res.json({ invitation }); +} + +export async function updateInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + req.body.accepted = req.body.accepted !== 'false'; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await updateInvitation(data); + + res.json({ invitation }); +} + +export async function deleteInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const data: TeacherInvitationData = { + sender, + receiver, + class: classId, + }; + const invitation = await deleteInvitation(data); + + res.json({ invitation }); +} diff --git a/backend/src/data/classes/class-join-request-repository.ts b/backend/src/data/classes/class-join-request-repository.ts index 0d9ab6e1..8bd0f81e 100644 --- a/backend/src/data/classes/class-join-request-repository.ts +++ b/backend/src/data/classes/class-join-request-repository.ts @@ -2,14 +2,14 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { ClassJoinRequest } from '../../entities/classes/class-join-request.entity.js'; import { Student } from '../../entities/users/student.entity.js'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export class ClassJoinRequestRepository extends DwengoEntityRepository { public async findAllRequestsBy(requester: Student): Promise { return this.findAll({ where: { requester: requester } }); } public async findAllOpenRequestsTo(clazz: Class): Promise { - return this.findAll({ where: { class: clazz, status: ClassJoinRequestStatus.Open } }); // TODO check if works like this + return this.findAll({ where: { class: clazz, status: ClassStatus.Open } }); // TODO check if works like this } public async findByStudentAndClass(requester: Student, clazz: Class): Promise { return this.findOne({ requester, class: clazz }); diff --git a/backend/src/data/classes/teacher-invitation-repository.ts b/backend/src/data/classes/teacher-invitation-repository.ts index ce059ca8..c9442e29 100644 --- a/backend/src/data/classes/teacher-invitation-repository.ts +++ b/backend/src/data/classes/teacher-invitation-repository.ts @@ -2,6 +2,7 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { TeacherInvitation } from '../../entities/classes/teacher-invitation.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export class TeacherInvitationRepository extends DwengoEntityRepository { public async findAllInvitationsForClass(clazz: Class): Promise { @@ -11,7 +12,7 @@ export class TeacherInvitationRepository extends DwengoEntityRepository { - return this.findAll({ where: { receiver: receiver } }); + return this.findAll({ where: { receiver: receiver, status: ClassStatus.Open } }); } public async deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { return this.deleteWhere({ @@ -20,4 +21,11 @@ export class TeacherInvitationRepository extends DwengoEntityRepository { + return this.findOne({ + sender: sender, + receiver: receiver, + class: clazz, + }); + } } diff --git a/backend/src/entities/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts index 907c0199..548968a6 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -2,7 +2,7 @@ import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { Class } from './class.entity.js'; import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; @Entity({ repository: () => ClassJoinRequestRepository, @@ -20,6 +20,6 @@ export class ClassJoinRequest { }) class!: Class; - @Enum(() => ClassJoinRequestStatus) - status!: ClassJoinRequestStatus; + @Enum(() => ClassStatus) + status!: ClassStatus; } diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts index 668a0a1c..6059f155 100644 --- a/backend/src/entities/classes/teacher-invitation.entity.ts +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -1,7 +1,8 @@ -import { Entity, ManyToOne } from '@mikro-orm/core'; +import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { Class } from './class.entity.js'; import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; /** * Invitation of a teacher into a class (in order to teach it). @@ -25,4 +26,7 @@ export class TeacherInvitation { primary: true, }) class!: Class; + + @Enum(() => ClassStatus) + status!: ClassStatus; } diff --git a/backend/src/interfaces/student-request.ts b/backend/src/interfaces/student-request.ts index d97f5eb5..a4d3b31b 100644 --- a/backend/src/interfaces/student-request.ts +++ b/backend/src/interfaces/student-request.ts @@ -4,7 +4,7 @@ import { getClassJoinRequestRepository } from '../data/repositories.js'; import { Student } from '../entities/users/student.entity.js'; import { Class } from '../entities/classes/class.entity.js'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function mapToStudentRequestDTO(request: ClassJoinRequest): ClassJoinRequestDTO { return { @@ -18,6 +18,6 @@ export function mapToStudentRequest(student: Student, cls: Class): ClassJoinRequ return getClassJoinRequestRepository().create({ requester: student, class: cls, - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); } diff --git a/backend/src/interfaces/teacher-invitation.ts b/backend/src/interfaces/teacher-invitation.ts index d9cb9915..8fef17af 100644 --- a/backend/src/interfaces/teacher-invitation.ts +++ b/backend/src/interfaces/teacher-invitation.ts @@ -1,13 +1,17 @@ import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; -import { mapToClassDTO } from './class.js'; import { mapToUserDTO } from './user.js'; import { TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { getTeacherInvitationRepository } from '../data/repositories'; +import { Teacher } from '../entities/users/teacher.entity'; +import { Class } from '../entities/classes/class.entity'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function mapToTeacherInvitationDTO(invitation: TeacherInvitation): TeacherInvitationDTO { return { sender: mapToUserDTO(invitation.sender), receiver: mapToUserDTO(invitation.receiver), - class: mapToClassDTO(invitation.class), + classId: invitation.class.classId!, + status: invitation.status, }; } @@ -15,6 +19,16 @@ export function mapToTeacherInvitationDTOIds(invitation: TeacherInvitation): Tea return { sender: invitation.sender.username, receiver: invitation.receiver.username, - class: invitation.class.classId!, + classId: invitation.class.classId!, + status: invitation.status, }; } + +export function mapToInvitation(sender: Teacher, receiver: Teacher, cls: Class): TeacherInvitation { + return getTeacherInvitationRepository().create({ + sender, + receiver, + class: cls, + status: ClassStatus.Open, + }); +} diff --git a/backend/src/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts new file mode 100644 index 00000000..772b1351 --- /dev/null +++ b/backend/src/routes/teacher-invitations.ts @@ -0,0 +1,22 @@ +import express from 'express'; +import { + createInvitationHandler, + deleteInvitationHandler, + getAllInvitationsHandler, + getInvitationHandler, + updateInvitationHandler, +} from '../controllers/teacher-invitations'; + +const router = express.Router({ mergeParams: true }); + +router.get('/:username', getAllInvitationsHandler); + +router.get('/:sender/:receiver/:classId', getInvitationHandler); + +router.post('/', createInvitationHandler); + +router.put('/', updateInvitationHandler); + +router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); + +export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index a6106a80..801eaee8 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -10,6 +10,8 @@ import { getTeacherStudentHandler, updateStudentJoinRequestHandler, } from '../controllers/teachers.js'; +import invitationRouter from './teacher-invitations.js'; + const router = express.Router(); // Root endpoint used to search objects @@ -32,10 +34,6 @@ router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); // Invitations to other classes a teacher received -router.get('/:id/invitations', (_req, res) => { - res.json({ - invitations: ['0'], - }); -}); +router.get('/invitations', invitationRouter); export default router; diff --git a/backend/src/services/teacher-invitations.ts b/backend/src/services/teacher-invitations.ts new file mode 100644 index 00000000..07f61bae --- /dev/null +++ b/backend/src/services/teacher-invitations.ts @@ -0,0 +1,87 @@ +import { fetchTeacher } from './teachers'; +import { getTeacherInvitationRepository } from '../data/repositories'; +import { mapToInvitation, mapToTeacherInvitationDTO } from '../interfaces/teacher-invitation'; +import { addClassTeacher, fetchClass } from './classes'; +import { TeacherInvitationData, TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { ConflictException } from '../exceptions/conflict-exception'; +import { NotFoundException } from '../exceptions/not-found-exception'; +import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; + +export async function getAllInvitations(username: string, sent: boolean): Promise { + const teacher = await fetchTeacher(username); + const teacherInvitationRepository = getTeacherInvitationRepository(); + + let invitations; + if (sent) { + invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher); + } else { + invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher); + } + return invitations.map(mapToTeacherInvitationDTO); +} + +export async function createInvitation(data: TeacherInvitationData): Promise { + const teacherInvitationRepository = getTeacherInvitationRepository(); + const sender = await fetchTeacher(data.sender); + const receiver = await fetchTeacher(data.receiver); + + const cls = await fetchClass(data.class); + + if (!cls.teachers.contains(sender)) { + throw new ConflictException('The teacher sending the invite is not part of the class'); + } + + const newInvitation = mapToInvitation(sender, receiver, cls); + await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); + + return mapToTeacherInvitationDTO(newInvitation); +} + +async function fetchInvitation(usernameSender: string, usernameReceiver: string, classId: string): Promise { + const sender = await fetchTeacher(usernameSender); + const receiver = await fetchTeacher(usernameReceiver); + const cls = await fetchClass(classId); + + const teacherInvitationRepository = getTeacherInvitationRepository(); + const invite = await teacherInvitationRepository.findBy(cls, sender, receiver); + + if (!invite) { + throw new NotFoundException('Teacher invite not found'); + } + + return invite; +} + +export async function getInvitation(sender: string, receiver: string, classId: string): Promise { + const invitation = await fetchInvitation(sender, receiver, classId); + return mapToTeacherInvitationDTO(invitation); +} + +export async function updateInvitation(data: TeacherInvitationData): Promise { + const invitation = await fetchInvitation(data.sender, data.receiver, data.class); + invitation.status = ClassStatus.Declined; + + if (data.accepted) { + invitation.status = ClassStatus.Accepted; + await addClassTeacher(data.class, data.receiver); + } + + const teacherInvitationRepository = getTeacherInvitationRepository(); + await teacherInvitationRepository.save(invitation); + + return mapToTeacherInvitationDTO(invitation); +} + +export async function deleteInvitation(data: TeacherInvitationData): Promise { + const invitation = await fetchInvitation(data.sender, data.receiver, data.class); + + const sender = await fetchTeacher(data.sender); + const receiver = await fetchTeacher(data.receiver); + const cls = await fetchClass(data.class); + + const teacherInvitationRepository = getTeacherInvitationRepository(); + await teacherInvitationRepository.deleteBy(cls, sender, receiver); + + return mapToTeacherInvitationDTO(invitation); +} diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index e6596f9e..982b657b 100644 --- a/backend/src/services/teachers.ts +++ b/backend/src/services/teachers.ts @@ -28,7 +28,7 @@ import { ClassDTO } from '@dwengo-1/common/interfaces/class'; import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getAllTeachers(full: boolean): Promise { @@ -160,10 +160,10 @@ export async function updateClassJoinRequestStatus(studentUsername: string, clas throw new NotFoundException('Join request not found'); } - request.status = ClassJoinRequestStatus.Declined; + request.status = ClassStatus.Declined; if (accepted) { - request.status = ClassJoinRequestStatus.Accepted; + request.status = ClassStatus.Accepted; await addClassStudent(classId, studentUsername); } diff --git a/backend/tests/controllers/teacher-invitations.test.ts b/backend/tests/controllers/teacher-invitations.test.ts new file mode 100644 index 00000000..ed2f5ebf --- /dev/null +++ b/backend/tests/controllers/teacher-invitations.test.ts @@ -0,0 +1,123 @@ +import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { Request, Response } from 'express'; +import { setupTestApp } from '../setup-tests.js'; +import { + createInvitationHandler, + deleteInvitationHandler, + getAllInvitationsHandler, + getInvitationHandler, + updateInvitationHandler, +} from '../../src/controllers/teacher-invitations'; +import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { getClassHandler } from '../../src/controllers/classes'; +import { BadRequestException } from '../../src/exceptions/bad-request-exception'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; + +describe('Teacher controllers', () => { + let req: Partial; + let res: Partial; + + let jsonMock: Mock; + + beforeAll(async () => { + await setupTestApp(); + }); + + beforeEach(() => { + jsonMock = vi.fn(); + res = { + json: jsonMock, + }; + }); + + it('Get teacher invitations by', async () => { + req = { params: { username: 'LimpBizkit' }, query: { sent: 'true' } }; + + await getAllInvitationsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); + + const result = jsonMock.mock.lastCall?.[0]; + // Console.log(result.invitations); + expect(result.invitations).to.have.length.greaterThan(0); + }); + + it('Get teacher invitations for', async () => { + req = { params: { username: 'FooFighters' }, query: { by: 'false' } }; + + await getAllInvitationsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); + + const result = jsonMock.mock.lastCall?.[0]; + expect(result.invitations).to.have.length.greaterThan(0); + }); + + it('Create and delete invitation', async () => { + const body = { + sender: 'LimpBizkit', + receiver: 'testleerkracht1', + class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + } as TeacherInvitationData; + req = { body }; + + await createInvitationHandler(req as Request, res as Response); + + req = { + params: { + sender: 'LimpBizkit', + receiver: 'testleerkracht1', + classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + body: { accepted: 'false' }, + }; + + await deleteInvitationHandler(req as Request, res as Response); + }); + + it('Get invitation', async () => { + req = { + params: { + sender: 'LimpBizkit', + receiver: 'FooFighters', + classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + }; + await getInvitationHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitation: expect.anything() })); + }); + + it('Get invitation error', async () => { + req = { + params: { no: 'no params' }, + }; + + await expect(async () => getInvitationHandler(req as Request, res as Response)).rejects.toThrowError(BadRequestException); + }); + + it('Accept invitation', async () => { + const body = { + sender: 'LimpBizkit', + receiver: 'FooFighters', + class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + } as TeacherInvitationData; + req = { body }; + + await updateInvitationHandler(req as Request, res as Response); + + const result1 = jsonMock.mock.lastCall?.[0]; + expect(result1.invitation.status).toEqual(ClassStatus.Accepted); + + req = { + params: { + id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + }; + + await getClassHandler(req as Request, res as Response); + + const result = jsonMock.mock.lastCall?.[0]; + expect(result.class.teachers).toContain('FooFighters'); + }); +}); diff --git a/backend/tests/setup-tests.ts b/backend/tests/setup-tests.ts index 5bd2fbd6..699e081b 100644 --- a/backend/tests/setup-tests.ts +++ b/backend/tests/setup-tests.ts @@ -13,6 +13,7 @@ import { makeTestAttachments } from './test_assets/content/attachments.testdata. import { makeTestQuestions } from './test_assets/questions/questions.testdata.js'; import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; +import { Collection } from '@mikro-orm/core'; export async function setupTestApp(): Promise { dotenv.config({ path: '.env.test' }); @@ -28,8 +29,8 @@ export async function setupTestApp(): Promise { const assignments = makeTestAssignemnts(em, classes); const groups = makeTestGroups(em, students, assignments); - assignments[0].groups = groups.slice(0, 3); - assignments[1].groups = groups.slice(3, 4); + assignments[0].groups = new Collection(groups.slice(0, 3)); + assignments[1].groups = new Collection(groups.slice(3, 4)); const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); const classJoinRequests = makeTestClassJoinRequests(em, students, classes); diff --git a/backend/tests/test_assets/classes/class-join-requests.testdata.ts b/backend/tests/test_assets/classes/class-join-requests.testdata.ts index 32337b19..63319dc4 100644 --- a/backend/tests/test_assets/classes/class-join-requests.testdata.ts +++ b/backend/tests/test_assets/classes/class-join-requests.testdata.ts @@ -2,31 +2,31 @@ import { EntityManager } from '@mikro-orm/core'; import { ClassJoinRequest } from '../../../src/entities/classes/class-join-request.entity'; import { Student } from '../../../src/entities/users/student.entity'; import { Class } from '../../../src/entities/classes/class.entity'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function makeTestClassJoinRequests(em: EntityManager, students: Student[], classes: Class[]): ClassJoinRequest[] { const classJoinRequest01 = em.create(ClassJoinRequest, { requester: students[4], class: classes[1], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest02 = em.create(ClassJoinRequest, { requester: students[2], class: classes[1], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest03 = em.create(ClassJoinRequest, { requester: students[4], class: classes[2], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest04 = em.create(ClassJoinRequest, { requester: students[3], class: classes[2], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); return [classJoinRequest01, classJoinRequest02, classJoinRequest03, classJoinRequest04]; diff --git a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts index 68204a57..6337a513 100644 --- a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts +++ b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts @@ -2,30 +2,35 @@ import { EntityManager } from '@mikro-orm/core'; import { TeacherInvitation } from '../../../src/entities/classes/teacher-invitation.entity'; import { Teacher } from '../../../src/entities/users/teacher.entity'; import { Class } from '../../../src/entities/classes/class.entity'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function makeTestTeacherInvitations(em: EntityManager, teachers: Teacher[], classes: Class[]): TeacherInvitation[] { const teacherInvitation01 = em.create(TeacherInvitation, { sender: teachers[1], receiver: teachers[0], class: classes[1], + status: ClassStatus.Open, }); const teacherInvitation02 = em.create(TeacherInvitation, { sender: teachers[1], receiver: teachers[2], class: classes[1], + status: ClassStatus.Open, }); const teacherInvitation03 = em.create(TeacherInvitation, { sender: teachers[2], receiver: teachers[0], class: classes[2], + status: ClassStatus.Open, }); const teacherInvitation04 = em.create(TeacherInvitation, { sender: teachers[0], receiver: teachers[1], class: classes[0], + status: ClassStatus.Open, }); return [teacherInvitation01, teacherInvitation02, teacherInvitation03, teacherInvitation04]; diff --git a/common/src/interfaces/class-join-request.ts b/common/src/interfaces/class-join-request.ts index 6787998b..5e8b2683 100644 --- a/common/src/interfaces/class-join-request.ts +++ b/common/src/interfaces/class-join-request.ts @@ -1,8 +1,8 @@ import { StudentDTO } from './student'; -import { ClassJoinRequestStatus } from '../util/class-join-request'; +import { ClassStatus } from '../util/class-join-request'; export interface ClassJoinRequestDTO { requester: StudentDTO; class: string; - status: ClassJoinRequestStatus; + status: ClassStatus; } diff --git a/common/src/interfaces/teacher-invitation.ts b/common/src/interfaces/teacher-invitation.ts index 13709322..f878b741 100644 --- a/common/src/interfaces/teacher-invitation.ts +++ b/common/src/interfaces/teacher-invitation.ts @@ -1,8 +1,16 @@ import { UserDTO } from './user'; -import { ClassDTO } from './class'; +import { ClassStatus } from '../util/class-join-request'; export interface TeacherInvitationDTO { sender: string | UserDTO; receiver: string | UserDTO; - class: string | ClassDTO; + classId: string; + status: ClassStatus; +} + +export interface TeacherInvitationData { + sender: string; + receiver: string; + class: string; + accepted?: boolean; // Use for put requests, else skip } diff --git a/common/src/util/class-join-request.ts b/common/src/util/class-join-request.ts index 5f9410f0..2049a16d 100644 --- a/common/src/util/class-join-request.ts +++ b/common/src/util/class-join-request.ts @@ -1,4 +1,4 @@ -export enum ClassJoinRequestStatus { +export enum ClassStatus { Open = 'open', Accepted = 'accepted', Declined = 'declined', diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..ca73ebc9 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +api/swagger.json diff --git a/docs/api/generate.ts b/docs/api/generate.ts index 24d3a6cb..796369d1 100644 --- a/docs/api/generate.ts +++ b/docs/api/generate.ts @@ -15,6 +15,10 @@ const doc = { url: 'http://localhost:3000/', description: 'Development server', }, + { + url: 'http://localhost/', + description: 'Staging server', + }, { url: 'https://sel2-1.ugent.be/', description: 'Production server', @@ -55,4 +59,4 @@ const doc = { const outputFile = './swagger.json'; const routes = ['../../backend/src/app.ts']; -await swaggerAutogen({ openapi: '3.1.0' })(outputFile, routes, doc); +void swaggerAutogen({ openapi: '3.1.0' })(outputFile, routes, doc); diff --git a/docs/api/swagger.json b/docs/api/swagger.json deleted file mode 100644 index 911839d0..00000000 --- a/docs/api/swagger.json +++ /dev/null @@ -1,1964 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "version": "0.1.0", - "title": "Dwengo-1 Backend API", - "description": "Dwengo-1 Backend API using Express, based on VZW Dwengo", - "license": { - "name": "MIT", - "url": "https://github.com/SELab-2/Dwengo-1/blob/336496ab6352ee3f8bf47490c90b5cf81526cef6/LICENSE" - } - }, - "servers": [ - { - "url": "http://localhost:3000/", - "description": "Development server" - }, - { - "url": "https://sel2-1.ugent.be/", - "description": "Production server" - } - ], - "paths": { - "/api/": { - "get": { - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Student"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "example": "any" - }, - "firstName": { - "example": "any" - }, - "lastName": { - "example": "any" - } - } - } - } - } - } - }, - "delete": { - "tags": ["Student"], - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/student/{username}": { - "delete": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "username", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "username", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/student/{id}/classes": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/student/{id}/submissions": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/assignments": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/groups": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/questions": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/group/": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Group"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/group/{groupid}": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/group/{id}/questions": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/assignment/": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Assignment"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "example": "any" - }, - "language": { - "example": "any" - }, - "learningPath": { - "example": "any" - }, - "title": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/assignment/{id}": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/assignment/{id}/submissions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/assignment/{id}/questions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/{groupid}": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/{id}/questions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/submission/": { - "get": { - "tags": ["Submission"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/submission/{id}": { - "post": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "delete": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/class/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "displayName": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/class/{id}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/class/{id}/teacher-invitations": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{id}/students": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{classid}/assignments/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "example": "any" - }, - "language": { - "example": "any" - }, - "learningPath": { - "example": "any" - }, - "title": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/class/{classid}/assignments/{id}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/class/{classid}/assignments/{id}/submissions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/class/{classid}/assignments/{id}/questions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/{groupid}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/{id}/questions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/question/": { - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Question"], - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "learningObjectIdentifier": { - "example": "any" - }, - "author": { - "example": "any" - }, - "content": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/question/{seq}": { - "delete": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/question/answers/{seq}": { - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/auth/config": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/auth/testAuthenticatedOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "student": [] - }, - { - "teacher": [] - } - ] - } - }, - "/api/auth/testStudentsOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "student": [] - } - ] - } - }, - "/api/auth/testTeachersOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "teacher": [] - } - ] - } - }, - "/api/theme/": { - "get": { - "tags": ["Theme"], - "description": "", - "parameters": [ - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/theme/{theme}": { - "get": { - "tags": ["Theme"], - "description": "", - "parameters": [ - { - "name": "theme", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningPath/": { - "get": { - "tags": ["Learning Path"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "theme", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "search", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "forStudent", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "forGroup", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "assignmentNo", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "classId", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/html": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/html/{attachmentName}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "attachmentName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "default": { - "description": "" - } - } - } - }, - "/api/learningObject/{hruid}/submissions/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/submissions/{id}": { - "post": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "delete": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "learningObjectIdentifier": { - "example": "any" - }, - "author": { - "example": "any" - }, - "content": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/{seq}": { - "delete": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/answers/{seq}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - } - }, - "components": { - "securitySchemes": { - "student": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://sel2-1.ugent.be/idp/realms/student/protocol/openid-connect/auth", - "scopes": { - "openid": "openid", - "profile": "profile", - "email": "email" - } - } - } - }, - "teacher": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://sel2-1.ugent.be/idp/realms/teacher/protocol/openid-connect/auth", - "scopes": { - "openid": "openid", - "profile": "profile", - "email": "email" - } - } - } - } - } - } -} diff --git a/eslint.config.ts b/eslint.config.ts index fb19e5c4..30e8fe2f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -44,16 +44,16 @@ export default [ // All @typescript-eslint configuration options are listed. // If the rules are commented, they are configured by the inherited configurations. - '@typescript-eslint/adjacent-overload-signatures': 'warn', - '@typescript-eslint/array-type': 'warn', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/array-type': 'error', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/ban-ts-comment': ['error', { minimumDescriptionLength: 10 }], '@typescript-eslint/ban-tslint-comment': 'error', camelcase: 'off', - '@typescript-eslint/class-literal-property-style': 'warn', + '@typescript-eslint/class-literal-property-style': 'error', 'class-methods-use-this': 'off', '@typescript-eslint/class-methods-use-this': ['error', { ignoreOverrideMethods: true }], - '@typescript-eslint/consistent-generic-constructors': 'warn', + '@typescript-eslint/consistent-generic-constructors': 'error', '@typescript-eslint/consistent-indexed-object-style': 'error', 'consistent-return': 'off', '@typescript-eslint/consistent-return': 'off', @@ -64,18 +64,18 @@ export default [ 'default-param-last': 'off', '@typescript-eslint/default-param-last': 'error', 'dot-notation': 'off', - '@typescript-eslint/dot-notation': 'warn', - '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/dot-notation': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', '@typescript-eslint/explicit-member-accessibility': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'warn', + '@typescript-eslint/explicit-module-boundary-types': 'error', 'init-declarations': 'off', '@typescript-eslint/init-declarations': 'off', 'max-params': 'off', '@typescript-eslint/max-params': ['error', { max: 6 }], - '@typescript-eslint/member-ordering': 'warn', + '@typescript-eslint/member-ordering': 'error', '@typescript-eslint/method-signature-style': 'off', // Don't care about TypeScript strict mode. '@typescript-eslint/naming-convention': [ - 'warn', + 'error', { // Enforce that all variables, functions and properties are camelCase selector: 'variableLike', @@ -113,7 +113,7 @@ export default [ '@typescript-eslint/no-empty-function': 'error', '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-empty-object-type': 'error', - '@typescript-eslint/no-explicit-any': 'warn', // Once in production, this should be an error. + '@typescript-eslint/no-explicit-any': 'error', // Once in production, this should be an error. '@typescript-eslint/no-extra-non-null-assertion': 'error', '@typescript-eslint/no-extraneous-class': 'error', '@typescript-eslint/no-floating-promises': 'error', @@ -121,7 +121,7 @@ export default [ 'no-implied-eval': 'off', '@typescript-eslint/no-implied-eval': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', - '@typescript-eslint/no-inferrable-types': 'warn', + '@typescript-eslint/no-inferrable-types': 'error', 'no-invalid-this': 'off', '@typescript-eslint/no-invalid-this': 'off', '@typescript-eslint/no-invalid-void-type': 'error', @@ -146,10 +146,10 @@ export default [ '@typescript-eslint/no-unsafe-function-type': 'error', 'no-unused-expressions': 'off', - '@typescript-eslint/no-unused-expressions': 'warn', + '@typescript-eslint/no-unused-expressions': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ - 'warn', + 'error', { args: 'all', argsIgnorePattern: '^_', @@ -164,53 +164,53 @@ export default [ '@typescript-eslint/parameter-properties': 'off', - '@typescript-eslint/prefer-find': 'warn', + '@typescript-eslint/prefer-find': 'error', '@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-readonly-parameter-types': 'off', '@typescript-eslint/prefer-reduce-type-parameter': 'error', - '@typescript-eslint/promise-function-async': 'warn', + '@typescript-eslint/promise-function-async': 'error', - '@typescript-eslint/require-array-sort-compare': 'warn', + '@typescript-eslint/require-array-sort-compare': 'error', - 'no-await-in-loop': 'warn', + 'no-await-in-loop': 'error', 'no-constructor-return': 'error', 'no-inner-declarations': 'error', 'no-self-compare': 'error', 'no-template-curly-in-string': 'error', - 'no-unmodified-loop-condition': 'warn', - 'no-unreachable-loop': 'warn', + 'no-unmodified-loop-condition': 'error', + 'no-unreachable-loop': 'error', 'no-useless-assignment': 'error', - 'arrow-body-style': ['warn', 'as-needed'], - 'block-scoped-var': 'warn', - 'capitalized-comments': 'warn', + 'arrow-body-style': ['error', 'as-needed'], + 'block-scoped-var': 'error', + 'capitalized-comments': 'error', 'consistent-this': 'error', curly: 'error', 'default-case': 'error', 'default-case-last': 'error', eqeqeq: 'error', - 'func-names': 'warn', - 'func-style': ['warn', 'declaration'], - 'grouped-accessor-pairs': ['warn', 'getBeforeSet'], - 'guard-for-in': 'warn', - 'logical-assignment-operators': 'warn', - 'max-classes-per-file': 'warn', + 'func-names': 'error', + 'func-style': ['error', 'declaration'], + 'grouped-accessor-pairs': ['error', 'getBeforeSet'], + 'guard-for-in': 'error', + 'logical-assignment-operators': 'error', + 'max-classes-per-file': 'error', 'no-alert': 'error', - 'no-bitwise': 'warn', - 'no-console': 'warn', - 'no-continue': 'warn', - 'no-else-return': 'warn', + 'no-bitwise': 'error', + 'no-console': 'error', + 'no-continue': 'error', + 'no-else-return': 'error', 'no-eq-null': 'error', 'no-eval': 'error', 'no-extend-native': 'error', 'no-extra-label': 'error', - 'no-implicit-coercion': 'warn', + 'no-implicit-coercion': 'error', 'no-iterator': 'error', - 'no-label-var': 'warn', - 'no-labels': 'warn', + 'no-label-var': 'error', + 'no-labels': 'error', 'no-multi-assign': 'error', 'no-nested-ternary': 'error', 'no-object-constructor': 'error', diff --git a/frontend/package.json b/frontend/package.json index e6ce1426..b6bd5deb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@tanstack/vue-query": "^5.69.0", "axios": "^1.8.2", "oidc-client-ts": "^3.1.0", + "uuid": "^11.1.0", "vue": "^3.5.13", "vue-i18n": "^11.1.2", "vue-router": "^4.5.0", diff --git a/frontend/src/controllers/classes.ts b/frontend/src/controllers/classes.ts index 03e3f560..c9b7f6fa 100644 --- a/frontend/src/controllers/classes.ts +++ b/frontend/src/controllers/classes.ts @@ -2,8 +2,8 @@ import { BaseController } from "./base-controller"; import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; import type { StudentsResponse } from "./students"; import type { AssignmentsResponse } from "./assignments"; -import type { TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation"; import type { TeachersResponse } from "@/controllers/teachers.ts"; +import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations.ts"; export interface ClassesResponse { classes: ClassDTO[] | string[]; @@ -13,14 +13,6 @@ export interface ClassResponse { class: ClassDTO; } -export interface TeacherInvitationsResponse { - invites: TeacherInvitationDTO[]; -} - -export interface TeacherInvitationResponse { - invite: TeacherInvitationDTO; -} - export class ClassController extends BaseController { constructor() { super("class"); diff --git a/frontend/src/controllers/groups.ts b/frontend/src/controllers/groups.ts index de6592b5..4c38290f 100644 --- a/frontend/src/controllers/groups.ts +++ b/frontend/src/controllers/groups.ts @@ -36,11 +36,11 @@ export class GroupController extends BaseController { return this.put(`/${num}`, data); } - async getSubmissions(groupNumber: number, full = true): Promise { - return this.get(`/${groupNumber}/submissions`, { full }); + async getSubmissions(num: number, full = true): Promise { + return this.get(`/${num}/submissions`, { full }); } - async getQuestions(groupNumber: number, full = true): Promise { - return this.get(`/${groupNumber}/questions`, { full }); + async getQuestions(num: number, full = true): Promise { + return this.get(`/${num}/questions`, { full }); } } diff --git a/frontend/src/controllers/submissions.ts b/frontend/src/controllers/submissions.ts index 837d356c..0d9c73f0 100644 --- a/frontend/src/controllers/submissions.ts +++ b/frontend/src/controllers/submissions.ts @@ -11,7 +11,7 @@ export interface SubmissionResponse { export class SubmissionController extends BaseController { constructor(classid: string, assignmentNumber: number, groupNumber: number) { - super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}`); + super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}/submissions`); } async getAll(full = true): Promise { @@ -22,7 +22,7 @@ export class SubmissionController extends BaseController { return this.get(`/${submissionNumber}`); } - async createSubmission(data: unknown): Promise { + async createSubmission(data: SubmissionDTO): Promise { return this.post(`/`, data); } diff --git a/frontend/src/controllers/teacher-invitations.ts b/frontend/src/controllers/teacher-invitations.ts new file mode 100644 index 00000000..7750dea5 --- /dev/null +++ b/frontend/src/controllers/teacher-invitations.ts @@ -0,0 +1,36 @@ +import { BaseController } from "@/controllers/base-controller.ts"; +import type { TeacherInvitationData, TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation"; + +export interface TeacherInvitationsResponse { + invitations: TeacherInvitationDTO[]; +} + +export interface TeacherInvitationResponse { + invitation: TeacherInvitationDTO; +} + +export class TeacherInvitationController extends BaseController { + constructor() { + super("teachers/invitations"); + } + + async getAll(username: string, sent: boolean): Promise { + return this.get(`/${username}`, { sent }); + } + + async getBy(data: TeacherInvitationData): Promise { + return this.get(`/${data.sender}/${data.receiver}/${data.class}`); + } + + async create(data: TeacherInvitationData): Promise { + return this.post("/", data); + } + + async remove(data: TeacherInvitationData): Promise { + return this.delete(`/${data.sender}/${data.receiver}/${data.class}`); + } + + async respond(data: TeacherInvitationData): Promise { + return this.put("/", data); + } +} diff --git a/frontend/src/queries/assignments.ts b/frontend/src/queries/assignments.ts new file mode 100644 index 00000000..3251836a --- /dev/null +++ b/frontend/src/queries/assignments.ts @@ -0,0 +1,188 @@ +import { AssignmentController, type AssignmentResponse, type AssignmentsResponse } from "@/controllers/assignments"; +import type { QuestionsResponse } from "@/controllers/questions"; +import type { SubmissionsResponse } from "@/controllers/submissions"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; +import { computed, toValue, type MaybeRefOrGetter } from "vue"; +import { groupsQueryKey, invalidateAllGroupKeys } from "./groups"; +import type { GroupsResponse } from "@/controllers/groups"; +import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; +import type { QueryClient } from "@tanstack/react-query"; +import { invalidateAllSubmissionKeys } from "./submissions"; + +function assignmentsQueryKey(classid: string, full: boolean) { + return ["assignments", classid, full]; +} +function assignmentQueryKey(classid: string, assignmentNumber: number) { + return ["assignment", classid, assignmentNumber]; +} +function assignmentSubmissionsQueryKey(classid: string, assignmentNumber: number, full: boolean) { + return ["assignment-submissions", classid, assignmentNumber, full]; +} +function assignmentQuestionsQueryKey(classid: string, assignmentNumber: number, full: boolean) { + return ["assignment-questions", classid, assignmentNumber, full]; +} + +export async function invalidateAllAssignmentKeys( + queryClient: QueryClient, + classid?: string, + assignmentNumber?: number, +) { + const keys = ["assignment", "assignment-submissions", "assignment-questions"]; + + for (const key of keys) { + const queryKey = [key, classid, assignmentNumber].filter((arg) => arg !== undefined); + await queryClient.invalidateQueries({ queryKey: queryKey }); + } + + await queryClient.invalidateQueries({ queryKey: ["assignments", classid].filter((arg) => arg !== undefined) }); +} + +function checkEnabled( + classid: string | undefined, + assignmentNumber: number | undefined, + groupNumber: number | undefined, +): boolean { + return Boolean(classid) && !isNaN(Number(groupNumber)) && !isNaN(Number(assignmentNumber)); +} +function toValues( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter, +) { + return { cid: toValue(classid), an: toValue(assignmentNumber), gn: toValue(groupNumber), f: toValue(full) }; +} + +export function useAssignmentsQuery( + classid: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, f } = toValues(classid, 1, 1, full); + + return useQuery({ + queryKey: computed(() => assignmentsQueryKey(cid!, f)), + queryFn: async () => new AssignmentController(cid!).getAll(f), + enabled: () => checkEnabled(cid, 1, 1), + }); +} + +export function useAssignmentQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, +): UseQueryReturnType { + const { cid, an } = toValues(classid, assignmentNumber, 1, true); + + return useQuery({ + queryKey: computed(() => assignmentQueryKey(cid!, an!)), + queryFn: async () => new AssignmentController(cid!).getByNumber(an!), + enabled: () => checkEnabled(cid, an, 1), + }); +} + +export function useCreateAssignmentMutation(): UseMutationReturnType< + AssignmentResponse, + Error, + { cid: string; data: AssignmentDTO }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, data }) => new AssignmentController(cid).createAssignment(data), + onSuccess: async (_) => { + await queryClient.invalidateQueries({ queryKey: ["assignments"] }); + }, + }); +} + +export function useDeleteAssignmentMutation(): UseMutationReturnType< + AssignmentResponse, + Error, + { cid: string; an: number }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, an }) => new AssignmentController(cid).deleteAssignment(an), + onSuccess: async (response) => { + const cid = response.assignment.within; + const an = response.assignment.id; + + await invalidateAllAssignmentKeys(queryClient, cid, an); + await invalidateAllGroupKeys(queryClient, cid, an); + await invalidateAllSubmissionKeys(queryClient, cid, an); + }, + }); +} + +export function useUpdateAssignmentMutation(): UseMutationReturnType< + AssignmentResponse, + Error, + { cid: string; an: number; data: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, an, data }) => new AssignmentController(cid).updateAssignment(an, data), + onSuccess: async (response) => { + const cid = response.assignment.within; + const an = response.assignment.id; + + await invalidateAllGroupKeys(queryClient, cid, an); + await queryClient.invalidateQueries({ queryKey: ["assignments"] }); + }, + }); +} + +export function useAssignmentSubmissionsQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); + + return useQuery({ + queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)), + queryFn: async () => new AssignmentController(cid!).getSubmissions(gn!, f), + enabled: () => checkEnabled(cid, an, gn), + }); +} + +export function useAssignmentQuestionsQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); + + return useQuery({ + queryKey: computed(() => assignmentQuestionsQueryKey(cid!, an!, f)), + queryFn: async () => new AssignmentController(cid!).getQuestions(gn!, f), + enabled: () => checkEnabled(cid, an, gn), + }); +} + +export function useAssignmentGroupsQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); + + return useQuery({ + queryKey: computed(() => groupsQueryKey(cid!, an!, f)), + queryFn: async () => new AssignmentController(cid!).getQuestions(gn!, f), + enabled: () => checkEnabled(cid, an, gn), + }); +} diff --git a/frontend/src/queries/classes.ts b/frontend/src/queries/classes.ts new file mode 100644 index 00000000..68ba0127 --- /dev/null +++ b/frontend/src/queries/classes.ts @@ -0,0 +1,224 @@ +import { ClassController, type ClassesResponse, type ClassResponse } from "@/controllers/classes"; +import type { StudentsResponse } from "@/controllers/students"; +import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; +import { computed, toValue, type MaybeRefOrGetter } from "vue"; +import { invalidateAllAssignmentKeys } from "./assignments"; +import { invalidateAllGroupKeys } from "./groups"; +import { invalidateAllSubmissionKeys } from "./submissions"; + +const classController = new ClassController(); + +/* Query cache keys */ +function classesQueryKey(full: boolean) { + return ["classes", full]; +} +function classQueryKey(classid: string) { + return ["class", classid]; +} +function classStudentsKey(classid: string, full: boolean) { + return ["class-students", classid, full]; +} +function classTeachersKey(classid: string, full: boolean) { + return ["class-teachers", classid, full]; +} +function classTeacherInvitationsKey(classid: string, full: boolean) { + return ["class-teacher-invitations", classid, full]; +} +function classAssignmentsKey(classid: string, full: boolean) { + return ["class-assignments", classid, full]; +} + +export async function invalidateAllClassKeys(queryClient: QueryClient, classid?: string) { + const keys = ["class", "class-students", "class-teachers", "class-teacher-invitations", "class-assignments"]; + + for (const key of keys) { + const queryKey = [key, classid].filter((arg) => arg !== undefined); + await queryClient.invalidateQueries({ queryKey: queryKey }); + } + + await queryClient.invalidateQueries({ queryKey: ["classes"] }); +} + +/* Queries */ +export function useClassesQuery(full: MaybeRefOrGetter = true): UseQueryReturnType { + return useQuery({ + queryKey: computed(() => classesQueryKey(toValue(full))), + queryFn: async () => classController.getAll(toValue(full)), + }); +} + +export function useClassQuery(id: MaybeRefOrGetter): UseQueryReturnType { + return useQuery({ + queryKey: computed(() => classQueryKey(toValue(id)!)), + queryFn: async () => classController.getById(toValue(id)!), + enabled: () => Boolean(toValue(id)), + }); +} + +export function useCreateClassMutation(): UseMutationReturnType { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => classController.createClass(data), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["classes"] }); + }, + }); +} + +export function useDeleteClassMutation(): UseMutationReturnType { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id) => classController.deleteClass(id), + onSuccess: async (data) => { + await invalidateAllClassKeys(queryClient, data.class.id); + await invalidateAllAssignmentKeys(queryClient, data.class.id); + await invalidateAllGroupKeys(queryClient, data.class.id); + await invalidateAllSubmissionKeys(queryClient, data.class.id); + }, + }); +} + +export function useUpdateClassMutation(): UseMutationReturnType< + ClassResponse, + Error, + { cid: string; data: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, data }) => classController.updateClass(cid, data), + onSuccess: async (data) => { + await invalidateAllClassKeys(queryClient, data.class.id); + await invalidateAllAssignmentKeys(queryClient, data.class.id); + await invalidateAllGroupKeys(queryClient, data.class.id); + await invalidateAllSubmissionKeys(queryClient, data.class.id); + }, + }); +} + +export function useClassStudentsQuery( + id: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + return useQuery({ + queryKey: computed(() => classStudentsKey(toValue(id)!, toValue(full))), + queryFn: async () => classController.getStudents(toValue(id)!, toValue(full)), + enabled: () => Boolean(toValue(id)), + }); +} + +export function useClassAddStudentMutation(): UseMutationReturnType< + ClassResponse, + Error, + { id: string; username: string }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, username }) => classController.addStudent(id, username), + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); + await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); + await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); + }, + }); +} + +export function useClassDeleteStudentMutation(): UseMutationReturnType< + ClassResponse, + Error, + { id: string; username: string }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, username }) => classController.deleteStudent(id, username), + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); + await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); + await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); + }, + }); +} + +export function useClassTeachersQuery( + id: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + return useQuery({ + queryKey: computed(() => classTeachersKey(toValue(id)!, toValue(full))), + queryFn: async () => classController.getTeachers(toValue(id)!, toValue(full)), + enabled: () => Boolean(toValue(id)), + }); +} + +export function useClassAddTeacherMutation(): UseMutationReturnType< + ClassResponse, + Error, + { id: string; username: string }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, username }) => classController.addTeacher(id, username), + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); + await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, true) }); + await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, false) }); + }, + }); +} + +export function useClassDeleteTeacherMutation(): UseMutationReturnType< + ClassResponse, + Error, + { id: string; username: string }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, username }) => classController.deleteTeacher(id, username), + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); + await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, true) }); + await queryClient.invalidateQueries({ queryKey: classTeachersKey(data.class.id, false) }); + }, + }); +} + +export function useClassTeacherInvitationsQuery( + id: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + return useQuery({ + queryKey: computed(() => classTeacherInvitationsKey(toValue(id)!, toValue(full))), + queryFn: async () => classController.getTeacherInvitations(toValue(id)!, toValue(full)), + enabled: () => Boolean(toValue(id)), + }); +} + +export function useClassAssignmentsQuery( + id: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + return useQuery({ + queryKey: computed(() => classAssignmentsKey(toValue(id)!, toValue(full))), + queryFn: async () => classController.getAssignments(toValue(id)!, toValue(full)), + enabled: () => Boolean(toValue(id)), + }); +} diff --git a/frontend/src/queries/groups.ts b/frontend/src/queries/groups.ts new file mode 100644 index 00000000..cdef2899 --- /dev/null +++ b/frontend/src/queries/groups.ts @@ -0,0 +1,191 @@ +import type { ClassesResponse } from "@/controllers/classes"; +import { GroupController, type GroupResponse, type GroupsResponse } from "@/controllers/groups"; +import type { QuestionsResponse } from "@/controllers/questions"; +import type { SubmissionsResponse } from "@/controllers/submissions"; +import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; +import { computed, toValue, type MaybeRefOrGetter } from "vue"; +import { invalidateAllAssignmentKeys } from "./assignments"; +import { invalidateAllSubmissionKeys } from "./submissions"; + +export function groupsQueryKey(classid: string, assignmentNumber: number, full: boolean) { + return ["groups", classid, assignmentNumber, full]; +} +function groupQueryKey(classid: string, assignmentNumber: number, groupNumber: number) { + return ["group", classid, assignmentNumber, groupNumber]; +} +function groupSubmissionsQueryKey(classid: string, assignmentNumber: number, groupNumber: number, full: boolean) { + return ["group-submissions", classid, assignmentNumber, groupNumber, full]; +} +function groupQuestionsQueryKey(classid: string, assignmentNumber: number, groupNumber: number, full: boolean) { + return ["group-questions", classid, assignmentNumber, groupNumber, full]; +} + +export async function invalidateAllGroupKeys( + queryClient: QueryClient, + classid?: string, + assignmentNumber?: number, + groupNumber?: number, +) { + const keys = ["group", "group-submissions", "group-questions"]; + + for (const key of keys) { + const queryKey = [key, classid, assignmentNumber, groupNumber].filter((arg) => arg !== undefined); + await queryClient.invalidateQueries({ queryKey: queryKey }); + } + + await queryClient.invalidateQueries({ + queryKey: ["groups", classid, assignmentNumber].filter((arg) => arg !== undefined), + }); +} + +function checkEnabled( + classid: string | undefined, + assignmentNumber: number | undefined, + groupNumber: number | undefined, +): boolean { + return Boolean(classid) && !isNaN(Number(groupNumber)) && !isNaN(Number(assignmentNumber)); +} +function toValues( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter, +) { + return { cid: toValue(classid), an: toValue(assignmentNumber), gn: toValue(groupNumber), f: toValue(full) }; +} + +export function useGroupsQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, an, f } = toValues(classid, assignmentNumber, 1, full); + + return useQuery({ + queryKey: computed(() => groupsQueryKey(cid!, an!, f)), + queryFn: async () => new GroupController(cid!, an!).getAll(f), + enabled: () => checkEnabled(cid, an, 1), + }); +} + +export function useGroupQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, +): UseQueryReturnType { + const { cid, an, gn } = toValues(classid, assignmentNumber, groupNumber, true); + + return useQuery({ + queryKey: computed(() => groupQueryKey(cid!, an!, gn!)), + queryFn: async () => new GroupController(cid!, an!).getByNumber(gn!), + enabled: () => checkEnabled(cid, an, gn), + }); +} + +export function useCreateGroupMutation(): UseMutationReturnType< + GroupResponse, + Error, + { cid: string; an: number; data: GroupDTO }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, an, data }) => new GroupController(cid, an).createGroup(data), + onSuccess: async (response) => { + const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id; + const an = + typeof response.group.assignment === "number" + ? response.group.assignment + : response.group.assignment.id; + + await queryClient.invalidateQueries({ queryKey: groupsQueryKey(cid, an, true) }); + await queryClient.invalidateQueries({ queryKey: groupsQueryKey(cid, an, false) }); + }, + }); +} + +export function useDeleteGroupMutation(): UseMutationReturnType< + GroupResponse, + Error, + { cid: string; an: number; gn: number }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, an, gn }) => new GroupController(cid, an).deleteGroup(gn), + onSuccess: async (response) => { + const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id; + const an = + typeof response.group.assignment === "number" + ? response.group.assignment + : response.group.assignment.id; + const gn = response.group.groupNumber; + + await invalidateAllGroupKeys(queryClient, cid, an, gn); + await invalidateAllSubmissionKeys(queryClient, cid, an, gn); + }, + }); +} + +export function useUpdateGroupMutation(): UseMutationReturnType< + GroupResponse, + Error, + { cid: string; an: number; gn: number; data: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, an, gn, data }) => new GroupController(cid, an).updateGroup(gn, data), + onSuccess: async (response) => { + const cid = typeof response.group.class === "string" ? response.group.class : response.group.class.id; + const an = + typeof response.group.assignment === "number" + ? response.group.assignment + : response.group.assignment.id; + const gn = response.group.groupNumber; + + await invalidateAllGroupKeys(queryClient, cid, an, gn); + }, + }); +} + +export function useGroupSubmissionsQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); + + return useQuery({ + queryKey: computed(() => groupSubmissionsQueryKey(cid!, an!, gn!, f)), + queryFn: async () => new GroupController(cid!, an!).getSubmissions(gn!, f), + enabled: () => checkEnabled(cid, an, gn), + }); +} + +export function useGroupQuestionsQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, an, gn, f } = toValues(classid, assignmentNumber, groupNumber, full); + + return useQuery({ + queryKey: computed(() => groupQuestionsQueryKey(cid!, an!, gn!, f)), + queryFn: async () => new GroupController(cid!, an!).getSubmissions(gn!, f), + enabled: () => checkEnabled(cid, an, gn), + }); +} diff --git a/frontend/src/queries/submissions.ts b/frontend/src/queries/submissions.ts new file mode 100644 index 00000000..97effd15 --- /dev/null +++ b/frontend/src/queries/submissions.ts @@ -0,0 +1,157 @@ +import { SubmissionController, type SubmissionResponse, type SubmissionsResponse } from "@/controllers/submissions"; +import type { SubmissionDTO } from "@dwengo-1/common/interfaces/submission"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; +import { computed, toValue, type MaybeRefOrGetter } from "vue"; + +function submissionsQueryKey(classid: string, assignmentNumber: number, groupNumber: number, full: boolean) { + return ["submissions", classid, assignmentNumber, groupNumber, full]; +} +function submissionQueryKey(classid: string, assignmentNumber: number, groupNumber: number, submissionNumber: number) { + return ["submission", classid, assignmentNumber, groupNumber, submissionNumber]; +} + +export async function invalidateAllSubmissionKeys( + queryClient: QueryClient, + classid?: string, + assignmentNumber?: number, + groupNumber?: number, + submissionNumber?: number, +) { + const keys = ["submission"]; + + for (const key of keys) { + const queryKey = [key, classid, assignmentNumber, groupNumber, submissionNumber].filter( + (arg) => arg !== undefined, + ); + await queryClient.invalidateQueries({ queryKey: queryKey }); + } + + await queryClient.invalidateQueries({ + queryKey: ["submissions", classid, assignmentNumber, groupNumber].filter((arg) => arg !== undefined), + }); + await queryClient.invalidateQueries({ + queryKey: ["group-submissions", classid, assignmentNumber, groupNumber].filter((arg) => arg !== undefined), + }); + await queryClient.invalidateQueries({ + queryKey: ["assignment-submissions", classid, assignmentNumber].filter((arg) => arg !== undefined), + }); +} + +function checkEnabled( + classid: string | undefined, + assignmentNumber: number | undefined, + groupNumber: number | undefined, + submissionNumber: number | undefined, +): boolean { + return ( + Boolean(classid) && + !isNaN(Number(groupNumber)) && + !isNaN(Number(assignmentNumber)) && + !isNaN(Number(submissionNumber)) + ); +} +function toValues( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + submissionNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter, +) { + return { + cid: toValue(classid), + an: toValue(assignmentNumber), + gn: toValue(groupNumber), + sn: toValue(submissionNumber), + f: toValue(full), + }; +} + +export function useSubmissionsQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + const { cid, an, gn, sn, f } = toValues(classid, assignmentNumber, groupNumber, 1, full); + + return useQuery({ + queryKey: computed(() => submissionsQueryKey(cid!, an!, gn!, f)), + queryFn: async () => new SubmissionController(cid!, an!, gn!).getAll(f), + enabled: () => checkEnabled(cid, an, gn, sn), + }); +} + +export function useSubmissionQuery( + classid: MaybeRefOrGetter, + assignmentNumber: MaybeRefOrGetter, + groupNumber: MaybeRefOrGetter, +): UseQueryReturnType { + const { cid, an, gn, sn, f } = toValues(classid, assignmentNumber, groupNumber, 1, true); + + return useQuery({ + queryKey: computed(() => submissionQueryKey(cid!, an!, gn!, sn!)), + queryFn: async () => new SubmissionController(cid!, an!, gn!).getByNumber(sn!), + enabled: () => checkEnabled(cid, an, gn, sn), + }); +} + +export function useCreateSubmissionMutation(): UseMutationReturnType< + SubmissionResponse, + Error, + { cid: string; an: number; gn: number; data: SubmissionDTO }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, an, gn, data }) => new SubmissionController(cid, an, gn).createSubmission(data), + onSuccess: async (response) => { + if (!response.submission.group) { + await invalidateAllSubmissionKeys(queryClient); + } else { + const cls = response.submission.group.class; + const assignment = response.submission.group.assignment; + + const cid = typeof cls === "string" ? cls : cls.id; + const an = typeof assignment === "number" ? assignment : assignment.id; + const gn = response.submission.group.groupNumber; + + await invalidateAllSubmissionKeys(queryClient, cid, an, gn); + } + }, + }); +} + +export function useDeleteSubmissionMutation(): UseMutationReturnType< + SubmissionResponse, + Error, + { cid: string; an: number; gn: number; sn: number }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ cid, an, gn, sn }) => new SubmissionController(cid, an, gn).deleteSubmission(sn), + onSuccess: async (response) => { + if (!response.submission.group) { + await invalidateAllSubmissionKeys(queryClient); + } else { + const cls = response.submission.group.class; + const assignment = response.submission.group.assignment; + + const cid = typeof cls === "string" ? cls : cls.id; + const an = typeof assignment === "number" ? assignment : assignment.id; + const gn = response.submission.group.groupNumber; + + await invalidateAllSubmissionKeys(queryClient, cid, an, gn); + } + }, + }); +} diff --git a/frontend/src/queries/teacher-invitations.ts b/frontend/src/queries/teacher-invitations.ts new file mode 100644 index 00000000..59357c32 --- /dev/null +++ b/frontend/src/queries/teacher-invitations.ts @@ -0,0 +1,78 @@ +import { useMutation, useQuery, type UseMutationReturnType, type UseQueryReturnType } from "@tanstack/vue-query"; +import { computed, toValue } from "vue"; +import type { MaybeRefOrGetter } from "vue"; +import { + TeacherInvitationController, + type TeacherInvitationResponse, + type TeacherInvitationsResponse, +} from "@/controllers/teacher-invitations.ts"; +import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation"; +import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; + +const controller = new TeacherInvitationController(); + +/** + All the invitations the teacher sent +**/ +export function useTeacherInvitationsSentQuery( + username: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryFn: computed(async () => controller.getAll(toValue(username), true)), + enabled: () => Boolean(toValue(username)), + }); +} + +/** + All the pending invitations sent to this teacher + */ +export function useTeacherInvitationsReceivedQuery( + username: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryFn: computed(async () => controller.getAll(toValue(username), false)), + enabled: () => Boolean(toValue(username)), + }); +} + +export function useTeacherInvitationQuery( + data: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryFn: computed(async () => controller.getBy(toValue(data))), + enabled: () => Boolean(toValue(data)), + }); +} + +export function useCreateTeacherInvitationMutation(): UseMutationReturnType< + TeacherInvitationResponse, + Error, + TeacherDTO, + unknown +> { + return useMutation({ + mutationFn: async (data: TeacherInvitationData) => controller.create(data), + }); +} + +export function useRespondTeacherInvitationMutation(): UseMutationReturnType< + TeacherInvitationResponse, + Error, + TeacherDTO, + unknown +> { + return useMutation({ + mutationFn: async (data: TeacherInvitationData) => controller.respond(data), + }); +} + +export function useDeleteTeacherInvitationMutation(): UseMutationReturnType< + TeacherInvitationResponse, + Error, + TeacherDTO, + unknown +> { + return useMutation({ + mutationFn: async (data: TeacherInvitationData) => controller.remove(data), + }); +} diff --git a/frontend/src/views/classes/TeacherClasses.vue b/frontend/src/views/classes/TeacherClasses.vue index 17c4999e..147bb9b0 100644 --- a/frontend/src/views/classes/TeacherClasses.vue +++ b/frontend/src/views/classes/TeacherClasses.vue @@ -250,10 +250,11 @@ - {{ (i.class as ClassDTO).displayName }} + {{ i.classId }} + {{ (i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName }} diff --git a/package-lock.json b/package-lock.json index acf0fca0..58560862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -273,6 +273,7 @@ "@tanstack/vue-query": "^5.69.0", "axios": "^1.8.2", "oidc-client-ts": "^3.1.0", + "uuid": "^11.1.0", "vue": "^3.5.13", "vue-i18n": "^11.1.2", "vue-router": "^4.5.0", diff --git a/package.json b/package.json index 64cfd665..3d9be4d0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "prebuild": "npm run clean", + "prebuild": "npm run clean && npm run swagger --workspace=docs", "build": "tsc --build tsconfig.build.json", "clean": "tsc --build tsconfig.build.json --clean", "watch": "tsc --build tsconfig.build.json --watch",