Compare commits
	
		
			No commits in common. "c724dac0860b7ccccc5082c04ba81bfd0a6bd8a8" and "73db24d3c08e734ba5977a5ba2ef0b6622f47d77" have entirely different histories.
		
	
	
		
			c724dac086
			...
			73db24d3c0
		
	
		
					 28 changed files with 145 additions and 6004 deletions
				
			
		|  | @ -1,5 +1,5 @@ | |||
| import type { HttpContext } from '@adonisjs/core/http' | ||||
| import { registerValidator, requestLoginValidator, verifyCodeValidator } from '#validators/auth' | ||||
| import { requestLoginValidator, verifyCodeValidator } from '#validators/auth' | ||||
| import mail from '@adonisjs/mail/services/main' | ||||
| import { AuthService } from '#services/auth_service' | ||||
| import { inject } from '@adonisjs/core' | ||||
|  | @ -7,6 +7,7 @@ import app from '@adonisjs/core/services/app' | |||
| import env from '#start/env' | ||||
| import User from '#models/user' | ||||
| 
 | ||||
| // TODO: When login, set user.email to the email used to request login (lowercase)
 | ||||
| @inject() | ||||
| export default class AuthController { | ||||
|   constructor(private authService: AuthService) {} | ||||
|  | @ -18,18 +19,25 @@ export default class AuthController { | |||
|       const validateResult = await (captcha.use('turnstile') as any).validate() | ||||
|       if (!validateResult.success) { | ||||
|         return response.badRequest({ | ||||
|           success: false, | ||||
|           message: 'Veuillez valider le captcha', | ||||
|           message: 'Captcha validation failed', | ||||
|           error: validateResult.errorCodes, | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Find user by email
 | ||||
|     const { email } = await request.validateUsing(requestLoginValidator) | ||||
|     const user = await this.authService.findUser(email) | ||||
|     if (!user) { | ||||
|       return response.notFound({ | ||||
|         success: false, | ||||
|         message: 'Utilisateur non trouvé', | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     // Generate token
 | ||||
|     const expiresIn = '15 minutes' | ||||
|     const payload = await this.authService.generateCode(email, expiresIn) | ||||
|     const payload = await this.authService.generateToken(email, user.id, expiresIn) | ||||
| 
 | ||||
|     // Send email
 | ||||
|     await mail | ||||
|  | @ -47,6 +55,9 @@ export default class AuthController { | |||
| 
 | ||||
|     return { | ||||
|       success: true, | ||||
|       data: { | ||||
|         token: payload.token, | ||||
|       }, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -54,8 +65,8 @@ export default class AuthController { | |||
|   async verifyCode({ request, response, auth }: HttpContext) { | ||||
|     // Validate code
 | ||||
|     const { code } = await request.validateUsing(verifyCodeValidator) | ||||
|     const { success, email } = await this.authService.validateCode(code) | ||||
|     if (!success) { | ||||
|     const { success, userId, email } = await this.authService.validateCode(code) | ||||
|     if (!success || !userId || isNaN(userId)) { | ||||
|       return response.badRequest({ | ||||
|         success: false, | ||||
|         message: 'Code de vérification invalide', | ||||
|  | @ -63,78 +74,32 @@ export default class AuthController { | |||
|     } | ||||
| 
 | ||||
|     // Find user by id
 | ||||
|     const user = await User.findBy('email', email) | ||||
|     const user = await User.findBy('id', userId) | ||||
|     if (!user) { | ||||
|       // If the user does not exist, return a token for registration
 | ||||
|       const expiresIn = '1 hour' | ||||
|       const { token, email: userEmail } = this.authService.generateToken(email, expiresIn) | ||||
|       return { | ||||
|         token, | ||||
|         email: userEmail, | ||||
|         success: true, | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Perform login
 | ||||
|     await auth.use('web').login(user, true) // true for remember me
 | ||||
|     return { | ||||
|       success: true, | ||||
|       user | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // POST /auth/register
 | ||||
|   async register({ request, response, auth }: HttpContext) { | ||||
|     const { firstName, lastName, className, token } = await request.validateUsing(registerValidator) | ||||
| 
 | ||||
|     // Validate token
 | ||||
|     const { success, email } = this.authService.validateToken(token) | ||||
|     if (!success || !email) { | ||||
|       return response.badRequest({ | ||||
|       return response.notFound({ | ||||
|         success: false, | ||||
|         message: 'Votre lien de connexion est invalide ou a expiré.', | ||||
|         message: 'Utilisateur non trouvé', | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     // Check if user already exists
 | ||||
|     const existingUser = await User.findBy('email', email) | ||||
|     if (existingUser) { | ||||
|       // If user already exists, perform login
 | ||||
|       await auth.use('web').login(existingUser, true) // true for remember me
 | ||||
|       return { | ||||
|         success: true, | ||||
|         user: existingUser, | ||||
|       } | ||||
|     } | ||||
|     // Set user email to the email used to request login (lowercase)
 | ||||
|     user.email = email.toLowerCase() | ||||
|     await user.save() | ||||
| 
 | ||||
|     // TODO: Check if className is allowed (else redirect for account giving)
 | ||||
| 
 | ||||
|     // TODO: Rewrite user creation (NEVER CREATE USER - use string similarity)
 | ||||
|     // Create new user
 | ||||
|     const user = await User.create({ | ||||
|       firstName, | ||||
|       lastName, | ||||
|       className, | ||||
|       email | ||||
|     }) | ||||
|     // Perform login
 | ||||
|     await auth.use('web').login(user, true) // true for remember me
 | ||||
|     return { | ||||
|       success: true, | ||||
|       user | ||||
|     } | ||||
|     return user | ||||
|   } | ||||
| 
 | ||||
|   // TODO: Magic link login
 | ||||
|   // magicLink({ }: HttpContext) {
 | ||||
|   //   // Validate signed url (adonis)
 | ||||
|   //   // + login current device
 | ||||
|   //   // + SSE to notify other devices (and login)
 | ||||
|   // }
 | ||||
| 
 | ||||
|   // listen({ }: HttpContext) {
 | ||||
|   //   // Listen for SSE events
 | ||||
|   //   // Need an AUTH token to connect
 | ||||
|   //   // AUTH token sent to client in requestLogin
 | ||||
|   // }
 | ||||
|   magicLink({}: HttpContext) { | ||||
|     // Validate signed url (adonis)
 | ||||
|     // + login current device
 | ||||
|     // + SSE to notify other devices (and login)
 | ||||
|   } | ||||
| 
 | ||||
|   listen({}: HttpContext) { | ||||
|     // Listen for SSE events
 | ||||
|     // Need an AUTH token to connect
 | ||||
|     // AUTH token sent to client in requestLogin
 | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,168 +0,0 @@ | |||
| import Colle from '#models/colle' | ||||
| import { ColleService } from '#services/colle_service' | ||||
| import { createColleValidator, createUpcomingCollesValidator } from '#validators/colle' | ||||
| import { inject } from '@adonisjs/core' | ||||
| import type { HttpContext } from '@adonisjs/core/http' | ||||
| import { DateTime } from 'luxon' | ||||
| 
 | ||||
| @inject() | ||||
| export default class CollesController { | ||||
|   constructor(private service: ColleService) {} | ||||
| 
 | ||||
|   async index({ request, response, auth }: HttpContext) { | ||||
|     const { startDate: rawStartDate } = request.qs() | ||||
| 
 | ||||
|     // Validate startDate
 | ||||
|     const startDate = rawStartDate ? DateTime.fromISO(rawStartDate, { zone: 'local' }) : null | ||||
|     if (!rawStartDate || !startDate || !startDate.isValid) { | ||||
|       return response.badRequest({ message: 'Invalid start date format' }) | ||||
|     } | ||||
|     const endDate = startDate.plus({ days: 6 }) // Sunday
 | ||||
| 
 | ||||
|     // Retrieve colles for the authenticated user
 | ||||
|     const data = await this.service.getColles(auth.user!, startDate.toISO(), endDate.toISO()) | ||||
|     return { | ||||
|       success: true, | ||||
|       data, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async show({ params, response, auth }: HttpContext) { | ||||
|     const colleId = parseInt(params.colleId) | ||||
|     if (isNaN(colleId)) { | ||||
|       return response.badRequest({ message: 'Invalid colle ID' }) | ||||
|     } | ||||
|     // Retrieve the colle by ID
 | ||||
|     const colle = await Colle.query() | ||||
|       .where('id', colleId) | ||||
|       .whereHas('student', (query) => { | ||||
|         query.where('className', auth.user!.className) | ||||
|       }) | ||||
|       .preload('student') | ||||
|       .preload('examiner') | ||||
|       .preload('subject') | ||||
|       .preload('room') | ||||
|       .first() | ||||
|     // TODO: Include BJID and BJSecret !
 | ||||
|     if (!colle) { | ||||
|       return response.notFound({ message: 'Colle not found' }) | ||||
|     } | ||||
|     return { | ||||
|       success: true, | ||||
|       data: colle, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async create({ request, response }: HttpContext) { | ||||
|     const { colle: payload, className } = await request.validateUsing(createColleValidator) | ||||
| 
 | ||||
|     // TODO: Use Redis cache to avoid multiple queries
 | ||||
|     // Retrieve or create the necessary entities (relations)
 | ||||
|     const student = await this.service.getStudent(payload.student, className) | ||||
|     const examiner = await this.service.getExaminer(payload.examiner) | ||||
|     const subject = await this.service.getSubject(payload.subject) | ||||
|     const room = await this.service.getRoom(payload.room) | ||||
|     const date = DateTime.fromJSDate(payload.date) | ||||
|     if (!date.isValid) { | ||||
|       return response.badRequest({ message: 'Invalid date format' }) | ||||
|     } | ||||
| 
 | ||||
|     const colleData = { | ||||
|       studentId: student.id, | ||||
|       examinerId: examiner.id, | ||||
|       subjectId: subject.id, | ||||
|       roomId: room.id, | ||||
|       bjsecret: payload.bjsecret, | ||||
|       bjid: payload.bjid, | ||||
|       grade: payload.grade, | ||||
|       content: payload.content, | ||||
|       comment: payload.comment, | ||||
|       date, | ||||
|     } | ||||
| 
 | ||||
|     // Check if the colle already exists
 | ||||
|     const colle = await Colle.query() | ||||
|       .where('studentId', student.id) | ||||
|       .where('subjectId', subject.id) | ||||
|       .where('date', date.toISO()) | ||||
|       .first() | ||||
| 
 | ||||
|     // If it exists, update the existing colle
 | ||||
|     if (colle) { | ||||
|       Object.assign(colle, colleData) | ||||
|       return colle.save() | ||||
|     } | ||||
|     // Create the colle
 | ||||
|     return Colle.create(colleData) | ||||
|   } | ||||
| 
 | ||||
|   async createUpcoming({ request }: HttpContext) { | ||||
|     const payload = await request.validateUsing(createUpcomingCollesValidator) | ||||
|     // ONLY UPCOMING COLLES
 | ||||
|     const now = DateTime.now() | ||||
|     const colles = payload.colles.filter( | ||||
|       (colle) => colle.date && DateTime.fromJSDate(colle.date) > now | ||||
|     ) | ||||
| 
 | ||||
|     // Retrieve all upcoming colles for the class
 | ||||
|     const upcomingColles = await Colle.query() | ||||
|       .whereHas('student', (query) => { | ||||
|         query.where('className', payload.className) | ||||
|       }) | ||||
|       .where('date', '>=', now.toISO()) | ||||
|       .orderBy('date', 'asc') | ||||
|     // Store the upcoming colles ids
 | ||||
|     const upcomingCollesIds = new Set(upcomingColles.map((colle) => colle.id)) | ||||
| 
 | ||||
|     for await (const colle of colles) { | ||||
|       // Find the updated data for the colle
 | ||||
|       const student = await this.service.getStudent(colle.student, payload.className) | ||||
|       const examiner = await this.service.getExaminer(colle.examiner) | ||||
|       const subject = await this.service.getSubject(colle.subject) | ||||
|       const room = await this.service.getRoom(colle.room) | ||||
|       const date = DateTime.fromJSDate(colle.date) | ||||
| 
 | ||||
|       const oldColle = upcomingColles.find( | ||||
|         (c) => | ||||
|           c.studentId === student.id && | ||||
|           c.subjectId === subject.id && | ||||
|           c.date.toISO() === date.toISO() | ||||
|       ) | ||||
| 
 | ||||
|       const updatedColle = { | ||||
|         studentId: student.id, | ||||
|         examinerId: examiner.id, | ||||
|         subjectId: subject.id, | ||||
|         roomId: room.id, | ||||
|         bjsecret: colle.bjsecret, | ||||
|         bjid: colle.bjid, | ||||
|         grade: colle.grade, | ||||
|         content: colle.content, | ||||
|         comment: colle.comment, | ||||
|         date, | ||||
|       } | ||||
| 
 | ||||
|       // Create a new colle if it doesn't exist
 | ||||
|       if (!oldColle) { | ||||
|         await Colle.create(updatedColle) | ||||
|         continue | ||||
|       } | ||||
| 
 | ||||
|       // Update the colle with the new data
 | ||||
|       // and remove it from the list
 | ||||
|       Object.assign(oldColle, updatedColle) | ||||
|       await oldColle.save() | ||||
|       upcomingCollesIds.delete(oldColle.id) | ||||
|     } | ||||
| 
 | ||||
|     // Delete the colles that were not updated
 | ||||
|     const deleted = await Colle.query() | ||||
|       .whereHas('student', (query) => { | ||||
|         query.where('className', payload.className) | ||||
|       }) | ||||
|       .whereIn('id', Array.from(upcomingCollesIds)) | ||||
|       .delete() | ||||
| 
 | ||||
|     console.log(`Deleted ${deleted} upcoming colles that were not updated`) | ||||
|   } | ||||
| } | ||||
|  | @ -21,8 +21,7 @@ export default class UserController { | |||
|     // const avatar = await drive.use().getSignedUrl(key)
 | ||||
|     return User.create({ | ||||
|       ...payload, | ||||
|       // TODO: No avatar for now!!
 | ||||
|       // avatar,
 | ||||
|       avatar, | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -20,7 +20,7 @@ export default class HttpExceptionHandler extends ExceptionHandler { | |||
|    */ | ||||
|   async handle(error: any, ctx: HttpContext) { | ||||
|     const statusCode = error.status || error.statusCode || 500 | ||||
|     const message = error.messages || error.message || 'Internal Server Error' | ||||
|     const message = error.message || 'Internal Server Error' | ||||
|     const stack = this.debug ? error.stack : undefined | ||||
|     const response = { | ||||
|       status: statusCode, | ||||
|  |  | |||
|  | @ -1,72 +0,0 @@ | |||
| import { DateTime } from 'luxon' | ||||
| import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm' | ||||
| import User from './user.js' | ||||
| import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' | ||||
| import Subject from './subject.js' | ||||
| import Room from './room.js' | ||||
| import ColleAttachment from './colle_attachment.js' | ||||
| import Examiner from './examiner.js' | ||||
| 
 | ||||
| export default class Colle extends BaseModel { | ||||
|   @column({ isPrimary: true }) | ||||
|   declare id: number | ||||
| 
 | ||||
|   @belongsTo(() => User, { foreignKey: 'studentId' }) | ||||
|   declare student: BelongsTo<typeof User> | ||||
| 
 | ||||
|   @column({ serializeAs: null }) | ||||
|   declare studentId: number | ||||
| 
 | ||||
|   @belongsTo(() => Examiner) | ||||
|   declare examiner: BelongsTo<typeof Examiner> | ||||
| 
 | ||||
|   @column({ serializeAs: null }) | ||||
|   declare examinerId: number | ||||
| 
 | ||||
|   // @computed()
 | ||||
|   // get examinerName(): string {
 | ||||
|   //   return this.examiner?.name
 | ||||
|   // }
 | ||||
| 
 | ||||
|   // Bjcolle data
 | ||||
|   @column({ serializeAs: null }) | ||||
|   declare bjsecret: string | ||||
| 
 | ||||
|   @column({ serializeAs: null }) | ||||
|   declare bjid: string | ||||
| 
 | ||||
|   // Colle data
 | ||||
|   @belongsTo(() => Subject) | ||||
|   declare subject: BelongsTo<typeof Subject> | ||||
| 
 | ||||
|   @column({ serializeAs: null }) | ||||
|   declare subjectId: number | ||||
| 
 | ||||
|   @belongsTo(() => Room) | ||||
|   declare room: BelongsTo<typeof Room> | ||||
| 
 | ||||
|   @column({ serializeAs: null }) | ||||
|   declare roomId: number | ||||
| 
 | ||||
|   @column() | ||||
|   declare grade: number | ||||
| 
 | ||||
|   @column() | ||||
|   declare content: string | ||||
| 
 | ||||
|   @column() | ||||
|   declare comment: string | ||||
| 
 | ||||
|   @hasMany(() => ColleAttachment) | ||||
|   declare attachments: HasMany<typeof ColleAttachment> | ||||
| 
 | ||||
|   // Time data
 | ||||
|   @column.dateTime() | ||||
|   declare date: DateTime | ||||
| 
 | ||||
|   @column.dateTime({ autoCreate: true }) | ||||
|   declare createdAt: DateTime | ||||
| 
 | ||||
|   @column.dateTime({ autoCreate: true, autoUpdate: true }) | ||||
|   declare updatedAt: DateTime | ||||
| } | ||||
|  | @ -1,12 +0,0 @@ | |||
| import { BaseModel, column } from '@adonisjs/lucid/orm' | ||||
| 
 | ||||
| export default class ColleAttachment extends BaseModel { | ||||
|   @column({ isPrimary: true }) | ||||
|   declare id: number | ||||
| 
 | ||||
|   @column() | ||||
|   declare name: string | ||||
| 
 | ||||
|   @column() | ||||
|   declare path: string | ||||
| } | ||||
|  | @ -1,9 +0,0 @@ | |||
| import { BaseModel, column } from '@adonisjs/lucid/orm' | ||||
| 
 | ||||
| export default class Examiner extends BaseModel { | ||||
|   @column({ isPrimary: true }) | ||||
|   declare id: number | ||||
| 
 | ||||
|   @column() | ||||
|   declare name: string | ||||
| } | ||||
|  | @ -1,9 +0,0 @@ | |||
| import { BaseModel, column } from '@adonisjs/lucid/orm' | ||||
| 
 | ||||
| export default class Room extends BaseModel { | ||||
|   @column({ isPrimary: true }) | ||||
|   declare id: number | ||||
| 
 | ||||
|   @column() | ||||
|   declare name: string | ||||
| } | ||||
|  | @ -1,9 +0,0 @@ | |||
| import { BaseModel, column } from '@adonisjs/lucid/orm' | ||||
| 
 | ||||
| export default class Subject extends BaseModel { | ||||
|   @column({ isPrimary: true }) | ||||
|   declare id: number | ||||
| 
 | ||||
|   @column() | ||||
|   declare name: string | ||||
| } | ||||
|  | @ -1,5 +1,5 @@ | |||
| import { DateTime } from 'luxon' | ||||
| import { BaseModel, column, computed } from '@adonisjs/lucid/orm' | ||||
| import { BaseModel, column } from '@adonisjs/lucid/orm' | ||||
| import { DbRememberMeTokensProvider } from '@adonisjs/auth/session' | ||||
| 
 | ||||
| export default class User extends BaseModel { | ||||
|  | @ -17,14 +17,12 @@ export default class User extends BaseModel { | |||
|   @column() | ||||
|   declare lastName: string | ||||
| 
 | ||||
|   @computed() | ||||
|   get fullName() { | ||||
|     return `${this.firstName} ${this.lastName}` | ||||
|   } | ||||
| 
 | ||||
|   @column() | ||||
|   declare email: string | ||||
| 
 | ||||
|   @column() | ||||
|   declare avatar: string | ||||
| 
 | ||||
|   @column.dateTime({ autoCreate: true }) | ||||
|   declare createdAt: DateTime | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,13 +1,17 @@ | |||
| import encryption from '@adonisjs/core/services/encryption' | ||||
| import cache from '@adonisjs/cache/services/main' | ||||
| import env from '#start/env' | ||||
| import User from '#models/user' | ||||
| import { CmpStrAsync } from 'cmpstr' | ||||
| 
 | ||||
| const cmp = CmpStrAsync.create().setMetric('levenshtein').setFlags('i') | ||||
| 
 | ||||
| export class AuthService { | ||||
|   async generateCode(email: string, expiresIn: string) { | ||||
|     // TODO: Generate magic link token
 | ||||
|     // const identifier = email
 | ||||
|     // const token = encryption.encrypt(identifier, expiresIn)
 | ||||
|     // const magicLink = env.get('PUBLIC_AUTH_URL') + '/success?signature=' + token
 | ||||
|   async generateToken(email: string, userId: number, expiresIn: string) { | ||||
|     // Generate magic link token
 | ||||
|     const identifier = `${email}:${userId}` | ||||
|     const token = encryption.encrypt(identifier, expiresIn) | ||||
|     const magicLink = env.get('PUBLIC_AUTH_URL') + '/success?signature=' + token | ||||
| 
 | ||||
|     // Generate code
 | ||||
|     const formattedOTP = Math.floor(Math.random() * 1000000) | ||||
|  | @ -15,7 +19,7 @@ export class AuthService { | |||
|       .padStart(6, '0') | ||||
|     await cache.set({ | ||||
|       key: 'auth:otp:' + formattedOTP, | ||||
|       value: email, | ||||
|       value: identifier, | ||||
|       ttl: expiresIn, | ||||
|     }) | ||||
| 
 | ||||
|  | @ -23,52 +27,81 @@ export class AuthService { | |||
|     return { | ||||
|       emailTitle, | ||||
|       formattedOTP, | ||||
|       // magicLink,
 | ||||
|       magicLink, | ||||
|       expiresIn, | ||||
|       email, | ||||
|       // token,
 | ||||
|       token, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   parseIdentifier(id: string) { | ||||
|     const [email, userId] = id.split(':') | ||||
|     return { | ||||
|       email: email.toLowerCase(), | ||||
|       userId: parseInt(userId, 10), | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async validateCode(code: string) { | ||||
|     // Validate code
 | ||||
|     const key = 'auth:otp:' + code | ||||
|     const email = await cache.get({ key }) | ||||
|     if (!email) return { success: false, email: null } | ||||
|     const id = await cache.get({ key }) | ||||
|     if (!id) return { success: false, userId: null, email: null } | ||||
| 
 | ||||
|     // Delete code from cache
 | ||||
|     await cache.delete({ key }) | ||||
| 
 | ||||
|     return { | ||||
|       email, | ||||
|       success: true, | ||||
|       ...this.parseIdentifier(id) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   generateToken(email: string, expiresIn: string) { | ||||
|     // Generate token
 | ||||
|     const identifier = email.toLowerCase() | ||||
|     const token = encryption.encrypt(identifier, expiresIn) | ||||
| 
 | ||||
|     return { | ||||
|       token, | ||||
|       expiresIn, | ||||
|       email: identifier, | ||||
|   parseNameFromEmail(email: string): Promise<string> { | ||||
|     // Parse name from email
 | ||||
|     return new Promise((resolve, reject) => { | ||||
|       try { | ||||
|         const [firstName, lastName] = email.split('@')[0].split('.') | ||||
|         resolve(`${firstName} ${lastName}`.toLowerCase()) | ||||
|       } catch (error) { | ||||
|         reject(new Error('Invalid email format')) | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   validateToken(token: string) { | ||||
|     // Decrypt token
 | ||||
|     const decrypted = encryption.decrypt(token) | ||||
|     if (!decrypted) return { success: false, email: null } | ||||
|   findUserByEmail(email: string) { | ||||
|     return User.query().where('email', email).first() | ||||
|   } | ||||
| 
 | ||||
|     // Validate email format
 | ||||
|     const email = (decrypted as string).toLowerCase() | ||||
|     if (!email.includes('@')) return { success: false, email: null } | ||||
|   async findUser(email: string) { | ||||
|     // Try to find user by email
 | ||||
|     let user: User | null | undefined = await this.findUserByEmail(email) | ||||
|     if (user) return user | ||||
| 
 | ||||
|     return { | ||||
|       success: true, | ||||
|       email, | ||||
|     } | ||||
|     // If not found, try to parse name from email and find user by name
 | ||||
|     const name = await this.parseNameFromEmail(email) | ||||
|     const users = await User.query() | ||||
|       // Select all users with no email
 | ||||
|       .whereNull('email') | ||||
|     const names = users.map((user) => `${user.firstName} ${user.lastName}`.toLowerCase()) | ||||
| 
 | ||||
|     // Search for similar names
 | ||||
|     const data = await cmp.searchAsync(name, names) | ||||
|     if (data.length === 0) { | ||||
|       console.warn(`No user found for email "${email}" or name "${name}"`) | ||||
|       return null | ||||
|     } | ||||
|     const source = data[0] | ||||
|     const { match: similarity } = await cmp.testAsync(source, name) | ||||
| 
 | ||||
|     console.log(similarity) | ||||
|     // If similarity is high enough, return the user
 | ||||
|     if (similarity > 0.8) { | ||||
|       user = users.find((u) => `${u.firstName} ${u.lastName}`.toLowerCase() === source) | ||||
|       return user | ||||
|     } | ||||
|     console.warn( | ||||
|       `No user found for email "${email}" or name "${name}". Similarity: ${similarity} with source "${source}"` | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,95 +0,0 @@ | |||
| import Colle from '#models/colle' | ||||
| import Examiner from '#models/examiner' | ||||
| import Room from '#models/room' | ||||
| import Subject from '#models/subject' | ||||
| import User from '#models/user' | ||||
| 
 | ||||
| export class ColleService { | ||||
|   async getStudent(studentName: string, className: string) { | ||||
|     // Find or create a student by name
 | ||||
|     const { firstName, lastName } = this.splitNames(studentName) | ||||
|     const student = await User.query() | ||||
|       .where('first_name', firstName) | ||||
|       .where('last_name', lastName) | ||||
|       .where('class_name', className) | ||||
|       .first() | ||||
|     if (!student) { | ||||
|       return User.create({ firstName, lastName, className }) | ||||
|     } | ||||
|     return student | ||||
|   } | ||||
| 
 | ||||
|   splitNames(fullName: string): { firstName: string; lastName: string } { | ||||
|     const words = fullName.trim().split(/\s+/) | ||||
| 
 | ||||
|     let lastNameParts: string[] = [] | ||||
|     let i = 0 | ||||
| 
 | ||||
|     // Collect all uppercase words at the start
 | ||||
|     while (i < words.length && words[i] === words[i].toUpperCase()) { | ||||
|       lastNameParts.push(words[i]) | ||||
|       i++ | ||||
|     } | ||||
| 
 | ||||
|     const lastName = lastNameParts.join(' ') | ||||
|     const firstName = words.slice(i).join(' ') | ||||
| 
 | ||||
|     return { firstName, lastName } | ||||
|   } | ||||
| 
 | ||||
|   async getExaminer(examinerName: string) { | ||||
|     // Find or create an examiner by name
 | ||||
|     const examiner = await Examiner.query().where('name', examinerName).first() | ||||
|     if (!examiner) return Examiner.create({ name: examinerName }) | ||||
|     return examiner | ||||
|   } | ||||
| 
 | ||||
|   async getSubject(subjectName: string) { | ||||
|     // Find or create a subject by name
 | ||||
|     const subject = await Subject.query().where('name', subjectName).first() | ||||
|     if (!subject) return Subject.create({ name: subjectName }) | ||||
|     return subject | ||||
|   } | ||||
| 
 | ||||
|   async getRoom(roomName: string) { | ||||
|     // Find or create a room by name
 | ||||
|     const room = await Room.query().where('name', roomName).first() | ||||
|     if (!room) return Room.create({ name: roomName }) | ||||
|     return room | ||||
|   } | ||||
| 
 | ||||
|   async getColles(student: User, startDate: string, endDate: string) { | ||||
|     const classColles = await Colle.query() | ||||
|       .preload('student') | ||||
|       .preload('examiner') | ||||
|       .preload('subject') | ||||
|       .preload('room') | ||||
|       .where('date', '>=', startDate) | ||||
|       .where('date', '<=', endDate) | ||||
|       .whereHas('student', (query) => { | ||||
|         query.where('className', student.className) | ||||
|       }) | ||||
|       // Filter only colles that have been graded
 | ||||
|       .whereNotNull('grade') | ||||
|       .orderBy('date', 'asc') | ||||
| 
 | ||||
|     const studentColles = await Colle.query() | ||||
|       .preload('examiner') | ||||
|       .preload('subject') | ||||
|       .preload('room') | ||||
|       .preload('student') | ||||
|       .where('date', '>=', startDate) | ||||
|       .where('date', '<=', endDate) | ||||
|       .where('studentId', student.id) | ||||
|       .orderBy('date', 'asc') | ||||
| 
 | ||||
|     // TODO: Favorite colles
 | ||||
|     const favoriteColles = [] as Colle[] | ||||
| 
 | ||||
|     return { | ||||
|       classColles, | ||||
|       studentColles, | ||||
|       favoriteColles, | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -9,7 +9,7 @@ export const requestLoginValidator = vine.compile( | |||
|       }) | ||||
|       .normalizeEmail({ | ||||
|         all_lowercase: true, | ||||
|       }).trim(), | ||||
|       }), | ||||
|   }) | ||||
| ) | ||||
| 
 | ||||
|  | @ -19,36 +19,20 @@ export const verifyCodeValidator = vine.compile( | |||
|   }) | ||||
| ) | ||||
| 
 | ||||
| function toTitleCase(value: string) { | ||||
|   return value.replace(/\w\S*/g, (txt) => { | ||||
|     return txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase() | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export const registerValidator = vine.compile( | ||||
| export const magicLinkValidator = vine.compile( | ||||
|   vine.object({ | ||||
|     firstName: vine.string().minLength(2).maxLength(50).trim().transform(toTitleCase), | ||||
|     lastName: vine.string().minLength(2).maxLength(50).trim().toUpperCase(), | ||||
|     className: vine.string().minLength(2).maxLength(50), | ||||
|     token: vine.string(), | ||||
|   }) | ||||
| ) | ||||
| 
 | ||||
| // TODO: Magic link login
 | ||||
| // export const magicLinkValidator = vine.compile(
 | ||||
| //   vine.object({
 | ||||
| //     token: vine.string(),
 | ||||
| //   })
 | ||||
| // )
 | ||||
| export const listenValidator = vine.compile( | ||||
|   vine.object({ | ||||
|     token: vine.string().uuid(), | ||||
|   }) | ||||
| ) | ||||
| 
 | ||||
| // export const listenValidator = vine.compile(
 | ||||
| //   vine.object({
 | ||||
| //     token: vine.string().uuid(),
 | ||||
| //   })
 | ||||
| // )
 | ||||
| 
 | ||||
| // export const exchangeTokenValidator = vine.compile(
 | ||||
| //   vine.object({
 | ||||
| //     token: vine.string().uuid(),
 | ||||
| //   })
 | ||||
| // )
 | ||||
| export const exchangeTokenValidator = vine.compile( | ||||
|   vine.object({ | ||||
|     token: vine.string().uuid(), | ||||
|   }) | ||||
| ) | ||||
|  |  | |||
|  | @ -1,30 +0,0 @@ | |||
| import vine from '@vinejs/vine' | ||||
| 
 | ||||
| const colle = vine.object({ | ||||
|   student: vine.string(), | ||||
|   examiner: vine.string(), | ||||
|   subject: vine.string(), | ||||
|   room: vine.string(), | ||||
|   grade: vine.number().min(-1).max(20).optional(), | ||||
|   content: vine.string().optional(), | ||||
|   comment: vine.string().optional(), | ||||
|   date: vine.date({ formats: ['iso8601'] }), | ||||
|   bjsecret: vine.string().optional(), | ||||
|   bjid: vine.string().optional(), | ||||
|   // TODO: Add attachments validation
 | ||||
| }) | ||||
| 
 | ||||
| const className = vine.string() | ||||
| 
 | ||||
| export const createColleValidator = vine.compile(vine.object({ | ||||
|   colle, | ||||
|   className, | ||||
| })) | ||||
| 
 | ||||
| 
 | ||||
| export const createUpcomingCollesValidator = vine.compile( | ||||
|   vine.object({ | ||||
|     colles: vine.array(colle), | ||||
|     className, | ||||
|   }), | ||||
| ) | ||||
|  | @ -9,7 +9,7 @@ import { defineConfig } from '@adonisjs/cors' | |||
| const corsConfig = defineConfig({ | ||||
|   enabled: true, | ||||
|   // TODO: Only same domain
 | ||||
|   origin: '*', | ||||
|   origin: true, | ||||
|   methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'], | ||||
|   headers: true, | ||||
|   exposeHeaders: [], | ||||
|  |  | |||
|  | @ -5,11 +5,12 @@ export default class extends BaseSchema { | |||
| 
 | ||||
|   async up() { | ||||
|     this.schema.createTable(this.tableName, (table) => { | ||||
|       table.increments('id').primary() | ||||
|       table.increments('id').notNullable() | ||||
|       table.string('class_name', 10).notNullable() | ||||
|       table.string('first_name', 50).notNullable() | ||||
|       table.string('last_name', 50).notNullable() | ||||
|       table.string('email', 254).unique().nullable() | ||||
|       table.string('avatar', 254).notNullable() | ||||
|       table.timestamp('created_at').notNullable() | ||||
|       table.timestamp('updated_at').nullable() | ||||
|     }) | ||||
|  |  | |||
|  | @ -1,16 +0,0 @@ | |||
| import { BaseSchema } from '@adonisjs/lucid/schema' | ||||
| 
 | ||||
| export default class extends BaseSchema { | ||||
|   protected tableName = 'rooms' | ||||
| 
 | ||||
|   async up() { | ||||
|     this.schema.createTable(this.tableName, (table) => { | ||||
|       table.increments('id').primary() | ||||
|       table.string('name').notNullable() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async down() { | ||||
|     this.schema.dropTable(this.tableName) | ||||
|   } | ||||
| } | ||||
|  | @ -1,16 +0,0 @@ | |||
| import { BaseSchema } from '@adonisjs/lucid/schema' | ||||
| 
 | ||||
| export default class extends BaseSchema { | ||||
|   protected tableName = 'subjects' | ||||
| 
 | ||||
|   async up() { | ||||
|     this.schema.createTable(this.tableName, (table) => { | ||||
|       table.increments('id').primary() | ||||
|       table.string('name').notNullable() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async down() { | ||||
|     this.schema.dropTable(this.tableName) | ||||
|   } | ||||
| } | ||||
|  | @ -1,16 +0,0 @@ | |||
| import { BaseSchema } from '@adonisjs/lucid/schema' | ||||
| 
 | ||||
| export default class extends BaseSchema { | ||||
|   protected tableName = 'examiners' | ||||
| 
 | ||||
|   async up() { | ||||
|     this.schema.createTable(this.tableName, (table) => { | ||||
|       table.increments('id').primary() | ||||
|       table.string('name').notNullable() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async down() { | ||||
|     this.schema.dropTable(this.tableName) | ||||
|   } | ||||
| } | ||||
|  | @ -1,35 +0,0 @@ | |||
| import { BaseSchema } from '@adonisjs/lucid/schema' | ||||
| 
 | ||||
| export default class extends BaseSchema { | ||||
|   protected tableName = 'colles' | ||||
| 
 | ||||
|   async up() { | ||||
|     this.schema.createTable(this.tableName, (table) => { | ||||
|       table.increments('id').primary(); | ||||
| 
 | ||||
|       // Relations
 | ||||
|       table.integer('student_id').unsigned().references('id').inTable('users').onDelete('CASCADE'); | ||||
|       table.integer('examiner_id').unsigned().references('id').inTable('examiners').onDelete('CASCADE'); | ||||
|       table.integer('subject_id').unsigned().references('id').inTable('subjects').onDelete('SET NULL'); | ||||
|       table.integer('room_id').unsigned().references('id').inTable('rooms').onDelete('SET NULL'); | ||||
| 
 | ||||
|       // Bjcolle data
 | ||||
|       table.string('bjsecret').nullable() | ||||
|       table.string('bjid').nullable() | ||||
| 
 | ||||
|       // Colle data
 | ||||
|       table.decimal('grade', 5, 2).nullable() | ||||
|       table.text('content').nullable() | ||||
|       table.text('comment').nullable() | ||||
| 
 | ||||
|       // Time data
 | ||||
|       table.dateTime('date').notNullable() | ||||
|       table.timestamp('created_at').notNullable() | ||||
|       table.timestamp('updated_at').notNullable() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async down() { | ||||
|     this.schema.dropTable(this.tableName) | ||||
|   } | ||||
| } | ||||
|  | @ -1,22 +0,0 @@ | |||
| import { BaseSchema } from '@adonisjs/lucid/schema' | ||||
| 
 | ||||
| export default class extends BaseSchema { | ||||
|   protected tableName = 'colle_attachments' | ||||
| 
 | ||||
|   async up() { | ||||
|     this.schema.createTable(this.tableName, (table) => { | ||||
|       table.increments('id').primary() | ||||
|       table.string('name').notNullable() | ||||
|       table.string('path').notNullable() | ||||
|       table | ||||
|         .integer('colle_id') | ||||
|         .unsigned() | ||||
|         .references('colles.id') | ||||
|         .onDelete('CASCADE') | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async down() { | ||||
|     this.schema.dropTable(this.tableName) | ||||
|   } | ||||
| } | ||||
|  | @ -18,18 +18,3 @@ services: | |||
|       - ./data:/var/lib/postgresql/data | ||||
|     ports: | ||||
|       - "127.0.0.1:5432:5432" | ||||
| 
 | ||||
|   pgadmin: | ||||
|       image: dpage/pgadmin4 | ||||
|       container_name: pgadmin4_container | ||||
|       restart: unless-stopped | ||||
|       ports: | ||||
|         - "8888:80" | ||||
|       environment: | ||||
|         PGADMIN_DEFAULT_EMAIL: nathan@lamy-charrier.fr | ||||
|         PGADMIN_DEFAULT_PASSWORD: securepass | ||||
|       volumes: | ||||
|         - pgadmin-data:/var/lib/pgadmin | ||||
| 
 | ||||
| volumes: | ||||
|   pgadmin-data: | ||||
|  | @ -63,6 +63,7 @@ | |||
|     "@adonisjs/transmit": "^2.0.2", | ||||
|     "@vinejs/vine": "^3.0.1", | ||||
|     "adonis-captcha-guard": "^1.0.1", | ||||
|     "cmpstr": "^3.0.1", | ||||
|     "edge.js": "^6.2.1", | ||||
|     "luxon": "^3.6.1", | ||||
|     "pg": "^8.16.0", | ||||
|  |  | |||
							
								
								
									
										5306
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										5306
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -2,15 +2,18 @@ Bonjour, | |||
| 
 | ||||
| Nous avons reçu une demande de connexion à votre compte Khollisé. | ||||
| 
 | ||||
| Utilisez ce code de vérification à 6 chiffres pour vous connecter : | ||||
| Cliquez sur ce lien pour vous connecter : | ||||
| {{ magicLink }} | ||||
| 
 | ||||
| Vous pouvez également entrer ce code de vérification à 6 chiffres : | ||||
| {{ formattedOTP }} | ||||
| 
 | ||||
| Ce code expirera dans {{ expiresIn }} et ne peut être utilisé qu'une seule fois. | ||||
| Ce lien et ce code expireront dans {{ expiresIn }} et ne peuvent être utilisés qu'une seule fois. | ||||
| 
 | ||||
| Si vous n'avez pas demandé cette connexion, vous pouvez ignorer cet email. | ||||
| Si vous n'avez pas demandé cette connexion, vous pouvez ignorer cet e-mail. | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| Cet email a été envoyé à {{ email }} | ||||
| Cet e-mail a été envoyé à {{ email }} | ||||
| 
 | ||||
| © {{ new Date().getFullYear() }} Khollisé. Tous droits réservés. | ||||
|  |  | |||
|  | @ -11,7 +11,17 @@ | |||
|         <p style="margin-bottom: 16px;">Bonjour,</p> | ||||
| 
 | ||||
|         <p style="margin-bottom: 16px;"> | ||||
|           Nous avons reçu une demande de connexion à votre compte Khollisé. Utilisez le code de vérification ci-dessous pour vous connecter : | ||||
|           Nous avons reçu une demande de connexion à votre compte Khollisé. Utilisez le bouton ci-dessous pour vous connecter instantanément : | ||||
|         </p> | ||||
| 
 | ||||
|         <div style="text-align: center;"> | ||||
|           <a href="{{ magicLink }}" style="display: inline-block; background-color: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: 600; margin: 16px 0; text-align: center;">Se connecter</a> | ||||
|         </div> | ||||
| 
 | ||||
|         <hr style="margin: 32px 0; border: none; border-top: 1px solid #e5e7eb;" /> | ||||
| 
 | ||||
|         <p style="margin-bottom: 16px;"> | ||||
|           Vous pouvez également utiliser le code de vérification à 6 chiffres ci-dessous : | ||||
|         </p> | ||||
| 
 | ||||
|         <div style="margin: 24px 0; text-align: center;"> | ||||
|  | @ -21,7 +31,7 @@ | |||
|         </div> | ||||
| 
 | ||||
|         <p style="font-size: 14px; color: #6b7280; font-style: italic; margin-bottom: 16px;"> | ||||
|           Le code ci-dessus expirera dans {{ expiresIn }} et ne peut être utilisé qu'une seule fois. | ||||
|           Le lien et le code ci-dessus expireront dans {{ expiresIn }} et ne peuvent être utilisés qu'une seule fois. | ||||
|         </p> | ||||
| 
 | ||||
|         <p style="font-size: 14px; color: #6b7280; font-style: italic; margin-bottom: 16px;"> | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ import router from '@adonisjs/core/services/router' | |||
| import transmit from '@adonisjs/transmit/services/main' | ||||
| import { authThrottle } from './limiters.js' | ||||
| import { throttle } from './limiter.js' | ||||
| import app from '@adonisjs/core/services/app' | ||||
| import { middleware } from './kernel.js' | ||||
| 
 | ||||
| transmit.registerRoutes() | ||||
|  | @ -20,37 +21,13 @@ const AuthController = () => import('#controllers/auth_controller') | |||
| router.group(() => { | ||||
|   router.post('/auth/request', [AuthController, 'requestLogin']).use(authThrottle) | ||||
|   router.post('/auth/verify', [AuthController, 'verifyCode']).use(throttle) | ||||
|   router.post('/auth/register', [AuthController, 'register']).use(throttle) | ||||
|   // TODO: Magic link login
 | ||||
|   // router.get('/auth/magic-link', 'AuthController.magicLink').use(throttle)
 | ||||
|   // router.get('/auth/listen', 'AuthController.listen')
 | ||||
| }) | ||||
| 
 | ||||
| const UserController = () => import('#controllers/user_controller') | ||||
| 
 | ||||
| router.get('/users/@me', [UserController, 'me']).use(middleware.auth()) | ||||
| 
 | ||||
| // TEST ROUTE
 | ||||
| import redis from '@adonisjs/redis/services/main' | ||||
| 
 | ||||
| router.get('/', async () => { | ||||
|   await redis.publish("jobs_queue", JSON.stringify({ | ||||
|     type: 1, | ||||
|     date: "09/12/2024" | ||||
|   })) | ||||
|   return { message: 'Hello, world!' } | ||||
| }) | ||||
| // END TEST ROUTE
 | ||||
| 
 | ||||
| const CollesController = () => import('#controllers/colles_controller') | ||||
| router.group(() => { | ||||
|   // TODO: PRIVATE ROUTES
 | ||||
|   router.post('/', [CollesController, 'create']) | ||||
|   router.post('/upcoming', [CollesController, 'createUpcoming']) | ||||
|   router.get('/', [CollesController, 'index']).use(middleware.auth()) | ||||
|   router.get('/:colleId', [CollesController, 'show']).use(middleware.auth()) | ||||
|   // router.get('/colles/:id', 'CollesController.show')
 | ||||
|   // router.put('/colles/:id', 'CollesController.update')
 | ||||
|   // router.delete('/colles/:id', 'CollesController.delete')
 | ||||
| if (app.inDev) { | ||||
|   router.post('users', [UserController, 'create']) | ||||
| } | ||||
| ).prefix('/colles') | ||||
| router.get('/users/@me', [UserController, 'me']).use(middleware.auth()) | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue