diff --git a/package.json b/package.json index 11fab32c..db4c21e1 100644 --- a/package.json +++ b/package.json @@ -63,19 +63,25 @@ "query-string": "^8.1.0", "react": "^18.2.0", "react-charts": "^2.0.0-beta.7", + "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-html5-camera-photo": "^1.5.11", + "react-qr-code": "^2.0.12", + "react-qr-reader": "^3.0.0-beta-1", "react-query": "^3.39.3", "resize-observer-polyfill": "^1.5.1", "safe-buffer": "^5.2.1", "sanitize-filename": "^1.6.3", "sass": "^1.66.1", "sharp": "^0.32.5", + "socket.io": "^4.7.4", + "socket.io-client": "^4.7.4", "sortablejs": "1.15.0", "typeorm": "^0.2.45", "typescript": "5.1.6", "uuid": "^9.0.0", "waveform-data": "^4.3.0", + "webrtc-adapter": "^8.2.3", "winston": "^3.10.0" }, "devDependencies": { diff --git a/server/app.ts b/server/app.ts index ac8f2b22..133e635e 100644 --- a/server/app.ts +++ b/server/app.ts @@ -6,6 +6,7 @@ import express, { Router } from 'express'; import type { Request } from 'express'; import RateLimit from 'express-rate-limit'; import helmet from 'helmet'; +import { Server } from 'http'; import morgan from 'morgan'; import next from 'next'; import path from 'path'; @@ -20,6 +21,7 @@ import { handleErrors } from './middlewares/handle-errors'; import { jsonify } from './middlewares/jsonify'; import { removeTrailingSlash } from './middlewares/remove-trailing-slash'; import { routes } from './routes'; +import { startSocketServer } from './socket'; import { getLocales } from './translations/getLocales'; import { getDefaultDirectives } from './utils/get-default-directives'; @@ -132,7 +134,10 @@ async function startApp() { if (port === false) { logger.error(`Exiting. Invalid port to use: %s`, port); } else { - const server = app.listen(port); + const server = Server(app); + // --- Start socket server --- + startSocketServer(server); + server.listen(port); server.on('error', onError); server.on('listening', () => { logger.info(`App listening on port ${port}!`); diff --git a/server/authentication/index.ts b/server/authentication/index.ts index 6a0ffd7d..87c79f67 100644 --- a/server/authentication/index.ts +++ b/server/authentication/index.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { handleErrors } from '../middlewares/handle-errors'; import { login } from './login'; +import { loginStudent } from './loginStudent'; import { logout } from './logout'; import { resetPassword, updatePassword, verifyEmail } from './password'; import { loginWithPlmSSO } from './plmSSO'; @@ -11,6 +12,7 @@ const authRouter = Router(); authRouter.post('/token', handleErrors(refreshToken)); authRouter.post('/token/reject', handleErrors(rejectAccessToken)); authRouter.post('/login', handleErrors(login)); +authRouter.post('/login/student', handleErrors(loginStudent)); authRouter.post('/login/reset-password', handleErrors(resetPassword)); authRouter.post('/login/update-password', handleErrors(updatePassword)); authRouter.post('/login/verify-email', handleErrors(verifyEmail)); diff --git a/server/authentication/lib/tokens.ts b/server/authentication/lib/tokens.ts index 3e379136..a8d8b884 100644 --- a/server/authentication/lib/tokens.ts +++ b/server/authentication/lib/tokens.ts @@ -62,3 +62,17 @@ export async function revokeRefreshToken(refreshToken: string): Promise { id: parseInt(refreshTokenID, 10) || 0, }); } + +export async function getStudentAccessToken( + projectId: number, + sequencyId: number, + teacherId: number, +): Promise<{ + accessToken: string; +}> { + const accessToken = jwt.sign({ projectId, sequencyId, teacherId, isStudent: true }, APP_SECRET, { expiresIn: '4h' }); + + return { + accessToken, + }; +} diff --git a/server/authentication/login.ts b/server/authentication/login.ts index 4d2162fd..29f53b3b 100644 --- a/server/authentication/login.ts +++ b/server/authentication/login.ts @@ -1,8 +1,9 @@ import type { JSONSchemaType } from 'ajv'; import * as argon2 from 'argon2'; import type { Request, Response } from 'express'; -import { getRepository } from 'typeorm'; +import { getRepository, getConnection } from 'typeorm'; +import { Project } from '../entities/project'; import { User } from '../entities/user'; import { ajv, sendInvalidDataError } from '../lib/json-schema-validator'; import { logger } from '../lib/logger'; @@ -59,10 +60,6 @@ export async function login(req: Request, res: Response): Promise { throw new AppError('forbidden', ['Unauthorized - Account blocked. Please reset password.'], 3); } - if (user.accountRegistration === 10) { - throw new AppError('loginError', ['Unauthorized - Please use SSO.'], 5); - } - if (!isPasswordCorrect) { user.accountRegistration += 1; await getRepository(User).save(user); @@ -72,6 +69,14 @@ export async function login(req: Request, res: Response): Promise { await getRepository(User).save(user); } + // set collaboration mode to false on each user project + await getConnection() + .createQueryBuilder() + .update(Project) + .set({ isCollaborationActive: false, joinCode: false }) + .where({ userId: user.id, isCollaborationActive: true }) + .execute(); + const { accessToken, refreshToken } = await getAccessToken(user.id, !!data.getRefreshToken); res.cookie('access-token', accessToken, { maxAge: 4 * 60 * 60000, diff --git a/server/authentication/loginStudent.ts b/server/authentication/loginStudent.ts new file mode 100644 index 00000000..c19dabd9 --- /dev/null +++ b/server/authentication/loginStudent.ts @@ -0,0 +1,77 @@ +import type { JSONSchemaType } from 'ajv'; +import type { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; + +// import { QuestionStatus } from '../../types/models/question.type'; +import { Project } from '../entities/project'; +import { Question } from '../entities/question'; +import { User } from '../entities/user'; +import { ajv, sendInvalidDataError } from '../lib/json-schema-validator'; +import { AppError } from '../middlewares/handle-errors'; +import { ANONYMOUS_USER } from '../utils/anonymous-user'; +import { getStudentAccessToken } from './lib/tokens'; + +const APP_SECRET: string = process.env.APP_SECRET || ''; + +// --- LOGIN --- +type LoginData = { + projectId: number; + sequencyId: number; + teacherId: number; +}; +const LOGIN_SCHEMA: JSONSchemaType = { + type: 'object', + properties: { + projectId: { type: 'number' }, + sequencyId: { type: 'number' }, + teacherId: { type: 'number' }, + }, + required: ['projectId', 'sequencyId', 'teacherId'], + additionalProperties: false, +}; +const loginValidator = ajv.compile(LOGIN_SCHEMA); +export async function loginStudent(req: Request, res: Response): Promise { + if (APP_SECRET.length === 0 || !req.isCsrfValid) { + throw new AppError('forbidden'); + } + const data = req.body; + if (!loginValidator(data)) { + sendInvalidDataError(loginValidator); + return; + } + + const project = await getRepository(Project).findOne({ where: { id: data.projectId } }); + const teacher = await getRepository(User).findOne({ where: { id: data.teacherId } }); + const sequency = await getRepository(Question).findOne({ + where: { id: data.sequencyId }, + relations: ['project', 'project.user'], + }); + + // Check if data are correct before connect student as anonymous + if ( + project === undefined || + teacher === undefined || + sequency === undefined || + project.id !== sequency.project.id || + teacher.id !== sequency.project.user.id || + project.isCollaborationActive !== true + ) { + throw new AppError('loginError', ['Unauthorized - Invalid QRCode.'], 0); + } + + const { accessToken } = await getStudentAccessToken(project.id, sequency.id, teacher.id); + res.cookie('access-token', accessToken, { + maxAge: 4 * 60 * 60000, + expires: new Date(Date.now() + 4 * 60 * 60000), + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + res.sendJSON({ + accessToken, + projectId: project.id, + sequencyId: sequency.id, + teacherId: teacher.id, + user: ANONYMOUS_USER, + }); +} diff --git a/server/authentication/password.ts b/server/authentication/password.ts index cef44554..521ce2db 100644 --- a/server/authentication/password.ts +++ b/server/authentication/password.ts @@ -94,10 +94,6 @@ export async function updatePassword(req: Request, res: Response): Promise throw new AppError('loginError', ['Unauthorized - Invalid username.'], 0); } - if (user.accountRegistration === 10) { - throw new AppError('loginError', ['Unauthorized - Please use SSO.'], 2); - } - // verify token let isverifyTokenCorrect: boolean = false; try { diff --git a/server/controllers/projects.ts b/server/controllers/projects.ts index 1b75d7f8..05ab5ba4 100644 --- a/server/controllers/projects.ts +++ b/server/controllers/projects.ts @@ -4,7 +4,7 @@ import fs from 'fs-extra'; import http from 'http'; import * as path from 'path'; import sanitize from 'sanitize-filename'; -import { getRepository } from 'typeorm'; +import { getRepository, getConnection } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import type { Title } from '../../types/models/title.type'; @@ -154,15 +154,30 @@ projectController.get({ path: '', userType: UserType.CLASS }, async (req, res) = res.sendJSON(project); }); +projectController.get({ path: '/join/:joinCode(\\d+)' }, async (req, res, next) => { + const joinCode = parseInt(req.params.joinCode, 10) || 0; + const project: Project | undefined = await getRepository(Project).findOne({ + where: { joinCode }, + relations: ['questions'], + }); + if (project === undefined) { + next(); + return; + } + + res.sendJSON(project); +}); + projectController.get({ path: '/:id(\\d+)', userType: UserType.CLASS }, async (req, res, next) => { - if (!req.user) { + const user = req.user; + if (user === undefined) { next(); return; } const id = parseInt(req.params.id, 10) || 0; const includes = new Set((getQueryString(req.query.include) || '').split(',')); const project: Project | undefined = await getRepository(Project).findOne({ - where: { id, userId: req.user.id }, + where: { id, userId: user.teacherId ?? user.id }, relations: getRelations(includes), }); if (project === undefined) { @@ -220,6 +235,10 @@ const POST_PROJECT_SCHEMA: JSONSchemaType = { type: 'number', nullable: true, }, + videoJobId: { + type: 'string', + nullable: true, + }, }, required: ['title', 'themeId', 'scenarioId', 'questions'], additionalProperties: false, @@ -246,6 +265,7 @@ projectController.post({ path: '/', userType: UserType.CLASS }, async (req, res, newProject.soundUrl = data.soundUrl || null; newProject.soundVolume = data.soundVolume || null; newProject.musicBeginTime = data.musicBeginTime || 0; + newProject.videoJobId = data.videoJobId || 0; const languageCode = getQueryString(req.query.languageCode) || req.cookies?.['app-language'] || 'fr'; newProject.languageCode = languageCode; const newQuestions: Question[] = []; @@ -280,6 +300,8 @@ type PutProjectData = { soundUrl?: string | null; soundVolume?: number | null; musicBeginTime?: number; + isCollaborationActive?: boolean; + joinCode?: number; }; const PUT_PROJECT_SCHEMA: JSONSchemaType = { type: 'object', @@ -303,9 +325,20 @@ const PUT_PROJECT_SCHEMA: JSONSchemaType = { type: 'number', nullable: true, }, + isCollaborationActive: { + type: 'boolean', + nullable: true, + }, + joinCode: { + type: 'number', + nullable: true, + }, }, required: [], additionalProperties: false, + dependentSchemas: { + isCollaborationActive: { required: ['joinCode'] }, + }, }; const putProjectValidator = ajv.compile(PUT_PROJECT_SCHEMA); projectController.put({ path: '/:id', userType: UserType.CLASS }, async (req, res, next) => { @@ -332,6 +365,20 @@ projectController.put({ path: '/:id', userType: UserType.CLASS }, async (req, re project.soundUrl = data.soundUrl !== undefined ? data.soundUrl : project.soundUrl; project.soundVolume = data.soundVolume !== undefined ? data.soundVolume : project.soundVolume; project.musicBeginTime = data.musicBeginTime ?? project.musicBeginTime; + if (data.isCollaborationActive === false) { + project.isCollaborationActive = false; + project.joinCode = null; + } else if (data.isCollaborationActive === true) { + project.isCollaborationActive = true; + project.joinCode = data.joinCode; + // set collaboration mode to false on each user project + await getConnection() + .createQueryBuilder() + .update(Project) + .set({ isCollaborationActive: false, joinCode: null }) + .where({ userId: user.id, isCollaborationActive: true }) + .execute(); + } await getRepository(Project).save(project); res.sendJSON(project); }); diff --git a/server/controllers/questions.ts b/server/controllers/questions.ts index 182d70ae..f348f6ce 100644 --- a/server/controllers/questions.ts +++ b/server/controllers/questions.ts @@ -2,6 +2,7 @@ import type { JSONSchemaType } from 'ajv'; import type { FindConditions } from 'typeorm'; import { getManager, getRepository } from 'typeorm'; +import { QuestionStatus } from '../../types/models/question.type'; import type { Title } from '../../types/models/title.type'; import { Question } from '../entities/question'; import { UserType } from '../entities/user'; @@ -173,6 +174,8 @@ type PutQuestionData = { voiceOffBeginTime?: number; soundUrl?: string | null; soundVolume?: number | null; + feedback?: string | null; + status?: QuestionStatus | null; }; const PUT_QUESTION_SCHEMA: JSONSchemaType = { type: 'object', @@ -216,6 +219,14 @@ const PUT_QUESTION_SCHEMA: JSONSchemaType = { minimum: 0, maximum: 200, }, + feedback: { + type: 'string', + nullable: true, + }, + status: { + type: 'number', + nullable: true, + }, }, required: [], additionalProperties: false, @@ -241,6 +252,11 @@ questionController.put({ path: '/:id', userType: UserType.CLASS }, async (req, r question.voiceOffBeginTime = data.voiceOffBeginTime || 0; question.soundUrl = data.soundUrl !== undefined ? data.soundUrl : question.soundUrl; question.soundVolume = data.soundVolume !== undefined ? data.soundVolume : question.soundVolume; + const dataStatus = data.status; + if (dataStatus !== undefined && dataStatus !== null) { + question.status = dataStatus; + question.feedback = [QuestionStatus.ONGOING, QuestionStatus.PREMOUNTING].includes(dataStatus) && data.feedback ? data.feedback : null; + } await getRepository(Question).save(question); res.sendJSON(question); }); diff --git a/server/controllers/scenarios.ts b/server/controllers/scenarios.ts index 8fa17dd8..fcf3499c 100644 --- a/server/controllers/scenarios.ts +++ b/server/controllers/scenarios.ts @@ -93,6 +93,7 @@ const POST_SCENARIO_SCHEMA: JSONSchemaType = { type: 'object', additionalProperties: { type: 'string', + pattern: '([^\\s]*)', }, required: [], }, @@ -155,6 +156,7 @@ const PUT_SCENARIO_SCHEMA: JSONSchemaType = { type: 'object', additionalProperties: { type: 'string', + pattern: '([^\\s]*)', }, required: [], nullable: true, diff --git a/server/controllers/users.ts b/server/controllers/users.ts index 9257593f..9bef3c47 100644 --- a/server/controllers/users.ts +++ b/server/controllers/users.ts @@ -137,7 +137,9 @@ userController.post({ path: '' }, async (req, res) => { if (!isValid) { throw new AppError('forbidden', ['Invite code provided is invalid.']); } else { - await getRepository(Invite).delete({ token: data.inviteCode }); + if (data.expired_at < new Date().toISOString()) { + await getRepository(Invite).delete({ token: data.inviteCode }); + } } } @@ -274,6 +276,10 @@ userController.delete({ path: '/:id' }, async (req, res) => { userController.get({ path: '/invite' }, async (req, res) => { const invite = new Invite(); invite.token = generateToken(20); + invite.created_at = new Date(); + const nextDay = invite.created_at.getDate() + 1; + invite.expired_at = new Date(); + invite.expired_at.setDate(nextDay); await getRepository(Invite).save(invite); res.sendJSON({ inviteCode: invite.token }); }); diff --git a/server/entities/invite.ts b/server/entities/invite.ts index a8bed690..2d138038 100644 --- a/server/entities/invite.ts +++ b/server/entities/invite.ts @@ -1,4 +1,4 @@ -import { Column, Entity, CreateDateColumn, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class Invite { @@ -9,5 +9,8 @@ export class Invite { public token: string; @CreateDateColumn() - public date: Date; + public created_at: Date; + + @UpdateDateColumn() + public expired_at: Date; } diff --git a/server/entities/project.ts b/server/entities/project.ts index b77ff336..0c5ca6e3 100644 --- a/server/entities/project.ts +++ b/server/entities/project.ts @@ -76,4 +76,11 @@ export class Project implements ProjectInterface { // -- video generation job id -- @Column({ type: 'varchar', length: 36, select: false }) public videoJobId: string | null; + + // -- isCollaborationActive -- + @Column({ type: 'boolean', default: false }) + public isCollaborationActive: boolean; + + @Column({ type: 'integer', default: null, nullable: true }) + public joinCode: number | null; } diff --git a/server/entities/question.ts b/server/entities/question.ts index f4c9fbe1..70f717ed 100644 --- a/server/entities/question.ts +++ b/server/entities/question.ts @@ -1,6 +1,7 @@ +import type { Relation } from 'typeorm'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; -import type { Question as QuestionInterface } from '../../types/models/question.type'; +import { QuestionStatus, type Question as QuestionInterface } from '../../types/models/question.type'; import type { Title } from '../../types/models/title.type'; import { Plan } from './plan'; import { Project } from './project'; @@ -18,7 +19,7 @@ export class Question implements QuestionInterface { @ManyToOne(() => Project, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'projectId' }) - public project?: Project; + public project?: Relation; @Column({ nullable: false }) public projectId: number; @@ -40,4 +41,14 @@ export class Question implements QuestionInterface { @Column({ type: 'integer', nullable: true }) public soundVolume: number | null; + + @Column({ + type: 'enum', + enum: QuestionStatus, + default: 0, + }) + status: QuestionStatus; + + @Column({ type: 'varchar', length: 2000, nullable: true }) + public feedback: string | null; } diff --git a/server/entities/user.ts b/server/entities/user.ts index ad7dd03d..2fbf4a55 100644 --- a/server/entities/user.ts +++ b/server/entities/user.ts @@ -37,4 +37,6 @@ export class User implements UserInterface { default: 0, }) type: UserType; + + teacherId?: number | undefined; } diff --git a/server/lib/json-schema-validator.ts b/server/lib/json-schema-validator.ts index ae782f7e..97240044 100644 --- a/server/lib/json-schema-validator.ts +++ b/server/lib/json-schema-validator.ts @@ -1,6 +1,6 @@ import type { DefinedError, ValidateFunction } from 'ajv'; -import Ajv from 'ajv'; import addFormats from 'ajv-formats'; +import Ajv from 'ajv/dist/2019'; import { AppError } from '../middlewares/handle-errors'; diff --git a/server/middlewares/authenticate.ts b/server/middlewares/authenticate.ts index 18bb42a1..ba216aff 100644 --- a/server/middlewares/authenticate.ts +++ b/server/middlewares/authenticate.ts @@ -3,8 +3,11 @@ import jwt from 'jsonwebtoken'; import { getRepository } from 'typeorm'; import { getNewAccessToken } from '../authentication/lib/tokens'; +import { Project } from '../entities/project'; +import { Question } from '../entities/question'; import type { UserType } from '../entities/user'; import { User } from '../entities/user'; +import { ANONYMOUS_USER } from '../utils/anonymous-user'; import { getHeader } from '../utils/get-header'; import { AppError } from './handle-errors'; @@ -53,12 +56,20 @@ export function authenticate(userType: UserType | undefined = undefined): Reques throw new AppError('forbidden'); } + type AccessTokenType = { + userId?: number; + teacherId?: number; + sequencyId?: number; + projectId?: number; + isStudent?: boolean; + iat: number; + exp: number; + }; + // authenticate - let data: { userId: number; iat: number; exp: number }; + let data: AccessTokenType; try { - const decoded: string | { userId: number; iat: number; exp: number } = jwt.verify(token, APP_SECRET) as - | string - | { userId: number; iat: number; exp: number }; + const decoded: string | AccessTokenType = jwt.verify(token, APP_SECRET) as string | AccessTokenType; if (typeof decoded === 'string') { data = JSON.parse(decoded); } else { @@ -67,11 +78,34 @@ export function authenticate(userType: UserType | undefined = undefined): Reques } catch (e) { throw new AppError('forbidden'); } - const user = await getRepository(User).findOne({ where: { id: data.userId } }); - if (userType !== undefined && (user === undefined || user.type < userType)) { - throw new AppError('forbidden'); + + if (data.isStudent) { + const user: User = ANONYMOUS_USER; + const teacher = await getRepository(User).findOne({ where: { id: data.teacherId } }); + user.teacherId = teacher.id; + const project = await getRepository(Project).findOne({ where: { id: data.projectId } }); + const sequency = await getRepository(Question).findOne({ where: { id: data.sequencyId }, relations: ['project', 'project.user'] }); + + // Check if data are valid before continue + if ( + teacher === undefined || + project === undefined || + sequency === undefined || + project.id !== sequency.project?.id || + teacher.id !== sequency.project?.user?.id || + project.isCollaborationActive !== true + ) { + throw new AppError('forbidden'); + } + req.user = user; + next(); + } else { + const user = await getRepository(User).findOne({ where: { id: data.userId } }); + if (userType !== undefined && (user === undefined || user.type < userType)) { + throw new AppError('forbidden'); + } + req.user = user; + next(); } - req.user = user; - next(); }; } diff --git a/server/socket/index.ts b/server/socket/index.ts new file mode 100644 index 00000000..d82d7246 --- /dev/null +++ b/server/socket/index.ts @@ -0,0 +1,49 @@ +import type { Project } from 'server/entities/project'; +import type { Question } from 'server/entities/question'; +import type { Socket } from 'socket.io'; +import { Server } from 'socket.io'; + +import type { AlertStudentData, AlertTeacherData } from '../../types/models/socket.type'; + +export function startSocketServer(server) { + const io = new Server(server, { + cors: { + origin: '*', + methods: ['GET', 'POST', 'INFO'], + }, + }); + + io.on('connection', (socket: Socket) => { + socket.on('startCollaboration', (project: Project) => { + const projectRoom: string = `project-${project.id}`; + socket.join(projectRoom); + project.questions?.map((q: Question) => { + socket.join(`${projectRoom}_question-${q.id}`); + }); + }); + + socket.on('stopCollaboration', (project: Project) => { + socket.broadcast.to(`project-${project.id}`).emit('stopCollaboration'); + }); + + socket.on('joinRoom', (room: string) => { + socket.join(room); + }); + + socket.on('leaveRoom', (room: string) => { + socket.leave(room); + }); + + socket.on('updateProject', (project: Project) => { + socket.broadcast.to(`project-${project.id}`).emit('updateProject', project); + }); + + socket.on('alertTeacher', (data: AlertTeacherData) => { + socket.broadcast.to(`project-${data.projectId}_question-${data.sequencyId}`).emit('alertTeacher', data); + }); + + socket.on('alertStudent', (data: AlertStudentData) => { + socket.broadcast.to(data.room).emit('alertStudent', data); + }); + }); +} diff --git a/server/translations/defaultLocales.ts b/server/translations/defaultLocales.ts index f402e316..805e75ac 100644 --- a/server/translations/defaultLocales.ts +++ b/server/translations/defaultLocales.ts @@ -162,6 +162,8 @@ export const locales = { update: 'Changer', validate: 'Valider', generate: 'Générer', + close: 'Fermer', + see: 'Voir', //--- steps --- all_themes: 'Tout les thèmes', step1: 'Choix du scénario', @@ -185,6 +187,8 @@ export const locales = { login_account_error: 'Compte bloqué, trop de tentatives de connexion. Veuillez réinitialiser votre mot de passe.', login_unknown_error: 'Une erreur inconnue est survenue. Veuillez réessayer plus tard...', login_email_error: 'E-mail invalide.', + login_student: 'Je suis un·e élève', + login_back: 'Retour à la page de connexion', unknown_error: 'Une erreur est survenue...', //--- signup --- signup_title: 'Création du compte classe', @@ -277,4 +281,39 @@ export const locales = { email_reset_password_text: 'Vous pouvez réinitialiser votre mot de passe en cliquant sur le lien ci-dessous :', email_reset_password_button: 'RÉINITIALISER MON MOT DE PASSE', email_reset_password_text_2: "Si vous n'avez pas demandé la réinitialisation de votre mot de passe, vous pouvez ignorer cet email.", + validation_return_back: "Êtes-vous sûr de vouloir retourner à l'étape précédente ? Vous risquez de perdre le travail en cours.", + // -- Collaboration -- + collaboration_alert_modal_student_content_feedback: 'Le professeur vous a fait des retours. Regardez les et modifiez votre travail, puis renvoyer le à validation.', + collaboration_alert_modal_student_content_ok: 'Le professeur à valider votre travail. Si vous étiez à l\'étape n°3 vous pouvez passer à l\'étape n°4. Si vous étiez à l\'étape n°4, alors vous avez terminé, bravo !', + collaboration_alert_modal_student_title_feedback: 'Retour du professeur', + collaboration_alert_modal_student_title_ok: 'Validation du professeur', + collaboration_alert_modal_teacher_content: 'Le groupe d\'élèves affecté à la séquence n° {{sequency}} a terminé sa partie. Vous pouvez vérifier leur travail.', + collaboration_alert_modal_teacher_content_empty: 'Un groupe d\'élèves vient de terminer sa partie. Vous pouvez vérifier leur travail.', + collaboration_alert_modal_teacher_title: 'Travail à vérifier', + collaboration_form_feedback_btn_feedback: 'Envoyer le retour', + collaboration_form_feedback_btn_ok: 'Valider le travail', + collaboration_form_feedback_label: 'Retours', + collaboration_form_feedback_placeholder: 'Vos retours (Raccourcir la durée de la séquence, monter le son, ...)', + collaboration_form_feedback_title: 'Travail à vérifier', + collaboration_join_btn: 'Rejoindre', + collaboration_join_code: 'Code de collaboration', + collaboration_join_sequency_number: 'Séquence n°{{sequency}}', + collaboration_join_sequency_title: 'Sélectionner votre séquence', + collaboration_join_title: 'Rejoindre une session collaborative', + collaboration_modal_feedback_title: 'Retour du professeur', + collaboration_qrcode_scan_hide: 'Fermer le scanner', + collaboration_qrcode_scan_show: 'Scanner un QRCode', + collaboration_send_to_verif_btn: 'Envoyer pour vérification', + collaboration_show_feedback: 'Voir les retours', + collaboration_start: 'Démarrer la collaboration', + collaboration_stop: 'Stopper la collaboration', + + // sequency status + sequency_status_0: 'En cours - Storyboard', + sequency_status_1: 'Soumise - Storyboard', + sequency_status_2: 'En cours - Prémontage', + sequency_status_3: 'Soumise - Prémontage', + sequency_status_4: 'Terminé', + sequency_status_label: 'Statut de la question', + sequency_status_modal_title: 'Modifier le statut de la question', }; diff --git a/server/utils/anonymous-user.ts b/server/utils/anonymous-user.ts new file mode 100644 index 00000000..cbbc075d --- /dev/null +++ b/server/utils/anonymous-user.ts @@ -0,0 +1,12 @@ +import { UserType } from '../entities/user'; +import type { User } from '../entities/user'; + +export const ANONYMOUS_USER: User = { + id: 0, + languageCode: 'fr', + email: '', + pseudo: 'Anonymous', + school: '', + type: UserType.STUDENT, + accountRegistration: 0, +}; diff --git a/src/api/projects/projects.put.ts b/src/api/projects/projects.put.ts index 4583fe8d..44448bae 100644 --- a/src/api/projects/projects.put.ts +++ b/src/api/projects/projects.put.ts @@ -12,6 +12,8 @@ type PUTParams = { soundUrl?: string | null; soundVolume?: number | null; musicBeginTime?: number; + isCollaborationActive?: boolean; + joinCode?: number; }; export const updateProject = async ({ projectId, ...data }: PUTParams): Promise => { diff --git a/src/api/questions/questions.put.ts b/src/api/questions/questions.put.ts index 976c3026..2bd3e072 100644 --- a/src/api/questions/questions.put.ts +++ b/src/api/questions/questions.put.ts @@ -1,9 +1,8 @@ import type { UseMutationOptions } from 'react-query'; import { useMutation, useQueryClient } from 'react-query'; -import type { HttpRequestError } from 'src/utils/http-request'; import { httpRequest } from 'src/utils/http-request'; -import type { Question } from 'types/models/question.type'; +import type { Question, QuestionStatus } from 'types/models/question.type'; import type { Title } from 'types/models/title.type'; type PUTResponse = Question; @@ -15,6 +14,7 @@ type PUTParams = { voiceOffBeginTime?: number; soundUrl?: string | null; soundVolume?: number | null; + status?: QuestionStatus | null; }; export const updateQuestion = async ({ questionId, ...data }: PUTParams): Promise => { diff --git a/src/components/collaboration/AlertModal.tsx b/src/components/collaboration/AlertModal.tsx new file mode 100644 index 00000000..191e72f4 --- /dev/null +++ b/src/components/collaboration/AlertModal.tsx @@ -0,0 +1,87 @@ +import { useRouter } from 'next/router'; +import React from 'react'; + +import { Modal } from '../layout/Modal'; +import useQuery from 'src/hooks/useQuery'; +import { useTranslation } from 'src/i18n/useTranslation'; +import { COLORS } from 'src/utils/colors'; +import { QuestionStatus } from 'types/models/question.type'; + +export const AlertModal: React.FunctionComponent = () => { + const { t } = useTranslation(); + + const [showModal, setShowModal] = React.useState(false); + const [modalTitle, setModalTitle] = React.useState(''); + const [modalContent, setModalContent] = React.useState(''); + const [hasConfirmButton, setHasConfirmButton] = React.useState(false); + const [route, setRoute] = React.useState(null); + const router = useRouter(); + + const query = useQuery(); + + React.useEffect(() => { + if (!query) return; + setModal(query); + }, [query]); + + const setModal = ({ alert, sequency, type, status, projectId }) => { + if (alert && alert === 'teacher') { + setModalTitle(t('collaboration_alert_modal_teacher_title')); + if (sequency) { + setModalContent(t('collaboration_alert_modal_teacher_content', { color: COLORS[sequency], sequency: parseInt(sequency) + 1 })); + } else { + setModalContent(t('collaboration_alert_modal_teacher_content_empty')); + } + + if (sequency && projectId && status && [QuestionStatus.STORYBOARD, QuestionStatus.SUBMITTED].includes(parseInt(status))) { + setHasConfirmButton(true); + setRoute( + parseInt(status) === QuestionStatus.STORYBOARD + ? `/create/3-storyboard?projectId=${projectId}` + : `/create/4-pre-mounting/edit?question=${sequency}&projectId=${projectId}`, + ); + } + setShowModal(true); + } else if (alert && alert === 'student') { + const isFeedback = type === 'feedback'; + setModalTitle(t(`collaboration_alert_modal_student_title_${isFeedback ? 'feedback' : 'ok'}`)); + setModalContent(t(`collaboration_alert_modal_student_content_${isFeedback ? 'feedback' : 'ok'}`)); + setShowModal(true); + } + }; + + const closeModal = (goToPage: boolean = false) => { + if (goToPage) { + router.push(route); + } else { + delete router.query.alert; + delete router.query.sequency; + delete router.query.type; + delete router.query.status; + router.push(router); + } + + setModalContent(''); + setModalTitle(''); + setHasConfirmButton(false); + setRoute(null); + setShowModal(false); + }; + + const modalProps = { + isOpen: showModal, + onClose: () => closeModal(), + onConfirm: hasConfirmButton ? () => closeModal(true) : null, + title: modalTitle, + cancelLabel: t('close'), + confirmLabel: t('see'), + ariaLabelledBy: '', + ariaDescribedBy: '', + }; + + return ( + +
+
+ ); +}; diff --git a/src/components/collaboration/ButtonShowFeedback.tsx b/src/components/collaboration/ButtonShowFeedback.tsx new file mode 100644 index 00000000..d761a111 --- /dev/null +++ b/src/components/collaboration/ButtonShowFeedback.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Button } from '../layout/Button'; +import { useTranslation } from 'src/i18n/useTranslation'; + +interface ButtonShowFeedbackProps { + onClick: () => void; +} + +export const ButtonShowFeedback: React.FunctionComponent = ({ onClick }: ButtonShowFeedbackProps) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/components/collaboration/FeedbackModal.tsx b/src/components/collaboration/FeedbackModal.tsx new file mode 100644 index 00000000..45669693 --- /dev/null +++ b/src/components/collaboration/FeedbackModal.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { Modal } from '../layout/Modal'; +import { useTranslation } from 'src/i18n/useTranslation'; + +interface FeedbackModalProps { + feedback: string; + isOpen: boolean; + onClose: () => void; +} + +export const FeedbackModal: React.FunctionComponent = ({ feedback, isOpen, onClose }: FeedbackModalProps) => { + const { t } = useTranslation(); + + return ( + + {feedback} + + ); +}; diff --git a/src/components/collaboration/FormFeedback.tsx b/src/components/collaboration/FormFeedback.tsx new file mode 100644 index 00000000..6dede0c7 --- /dev/null +++ b/src/components/collaboration/FormFeedback.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { Button } from '../layout/Button'; +import { Field, TextArea } from '../layout/Form'; +import { sendToast } from '../ui/Toasts'; +import { useUpdateQuestionMutation } from 'src/api/questions/questions.put'; +import { useCurrentProject } from 'src/hooks/useCurrentProject'; +import { useSocket } from 'src/hooks/useSocket'; +import { useTranslation } from 'src/i18n/useTranslation'; +import type { Question, QuestionStatus } from 'types/models/question.type'; + +interface FormFeedbackProps { + question: Question; + previousStatus: QuestionStatus; + nextStatus: QuestionStatus; +} + +export const FormFeedback: React.FunctionComponent = ({ question, previousStatus, nextStatus }: FormFeedbackProps) => { + const { t } = useTranslation(); + + const { project, questions, updateProject } = useCurrentProject(); + const { updateProject: updateProjectSocket, alertStudent: alertStudentSocket } = useSocket(); + + const updateSequenceMutation = useUpdateQuestionMutation(); + const [feedback, setFeedback] = React.useState(''); + + const alertStudent = async (question: Question, status: QuestionStatus) => { + try { + const feedbackData = status === previousStatus ? feedback : null; + await updateSequenceMutation.mutateAsync({ + questionId: question.id, + status, + feedback: feedbackData, + }); + // Update projects + const newQuestions = [...questions]; + newQuestions[question.index] = { + ...newQuestions[question.index], + status, + feedback: feedbackData, + }; + const updatedProject = updateProject({ questions: newQuestions }); + if (updatedProject) { + updateProjectSocket(updatedProject); + } + + alertStudentSocket({ room: `project-${project.id}_question-${question.id}`, feedback: feedbackData, projectId: project.id }); + } catch (err) { + console.error(err); + sendToast({ message: t('unknown_error'), type: 'error' }); + } + }; + + return ( +
+

+ {t('collaboration_form_feedback_title')} +

+ + {t('collaboration_form_feedback_label')} :} + input={ +