feat: add colles routes
This commit is contained in:
parent
2f7373f367
commit
c724dac086
19 changed files with 502 additions and 170 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { requestLoginValidator, verifyCodeValidator } from '#validators/auth'
|
||||
import { registerValidator, requestLoginValidator, verifyCodeValidator } from '#validators/auth'
|
||||
import mail from '@adonisjs/mail/services/main'
|
||||
import { AuthService } from '#services/auth_service'
|
||||
import { inject } from '@adonisjs/core'
|
||||
|
|
@ -7,10 +7,9 @@ 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) {}
|
||||
constructor(private authService: AuthService) { }
|
||||
|
||||
// POST /auth/request
|
||||
async requestLogin({ request, response, captcha }: HttpContext) {
|
||||
|
|
@ -19,25 +18,18 @@ export default class AuthController {
|
|||
const validateResult = await (captcha.use('turnstile') as any).validate()
|
||||
if (!validateResult.success) {
|
||||
return response.badRequest({
|
||||
message: 'Captcha validation failed',
|
||||
success: false,
|
||||
message: 'Veuillez valider le captcha',
|
||||
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.generateToken(email, user.id, expiresIn)
|
||||
const payload = await this.authService.generateCode(email, expiresIn)
|
||||
|
||||
// Send email
|
||||
await mail
|
||||
|
|
@ -55,9 +47,6 @@ export default class AuthController {
|
|||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
token: payload.token,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,8 +54,8 @@ export default class AuthController {
|
|||
async verifyCode({ request, response, auth }: HttpContext) {
|
||||
// Validate code
|
||||
const { code } = await request.validateUsing(verifyCodeValidator)
|
||||
const { success, userId, email } = await this.authService.validateCode(code)
|
||||
if (!success || !userId || isNaN(userId)) {
|
||||
const { success, email } = await this.authService.validateCode(code)
|
||||
if (!success) {
|
||||
return response.badRequest({
|
||||
success: false,
|
||||
message: 'Code de vérification invalide',
|
||||
|
|
@ -74,32 +63,78 @@ export default class AuthController {
|
|||
}
|
||||
|
||||
// Find user by id
|
||||
const user = await User.findBy('id', userId)
|
||||
const user = await User.findBy('email', email)
|
||||
if (!user) {
|
||||
return response.notFound({
|
||||
success: false,
|
||||
message: 'Utilisateur non trouvé',
|
||||
})
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// Set user email to the email used to request login (lowercase)
|
||||
user.email = email.toLowerCase()
|
||||
await user.save()
|
||||
|
||||
// Perform login
|
||||
await auth.use('web').login(user, true) // true for remember me
|
||||
return user
|
||||
return {
|
||||
success: true,
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
magicLink({}: HttpContext) {
|
||||
// Validate signed url (adonis)
|
||||
// + login current device
|
||||
// + SSE to notify other devices (and login)
|
||||
// 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({
|
||||
success: false,
|
||||
message: 'Votre lien de connexion est invalide ou a expiré.',
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
listen({}: HttpContext) {
|
||||
// Listen for SSE events
|
||||
// Need an AUTH token to connect
|
||||
// AUTH token sent to client in requestLogin
|
||||
}
|
||||
// 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
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
168
app/controllers/colles_controller.ts
Normal file
168
app/controllers/colles_controller.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
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,7 +21,8 @@ export default class UserController {
|
|||
// const avatar = await drive.use().getSignedUrl(key)
|
||||
return User.create({
|
||||
...payload,
|
||||
avatar,
|
||||
// TODO: No avatar for now!!
|
||||
// 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.message || 'Internal Server Error'
|
||||
const message = error.messages || error.message || 'Internal Server Error'
|
||||
const stack = this.debug ? error.stack : undefined
|
||||
const response = {
|
||||
status: statusCode,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, hasMany, hasOne } from '@adonisjs/lucid/orm'
|
||||
import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm'
|
||||
import User from './user.js'
|
||||
import type { HasMany, HasOne } from '@adonisjs/lucid/types/relations'
|
||||
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'
|
||||
|
|
@ -11,25 +11,42 @@ export default class Colle extends BaseModel {
|
|||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@hasOne(() => User)
|
||||
declare student: HasOne<typeof User>
|
||||
@belongsTo(() => User, { foreignKey: 'studentId' })
|
||||
declare student: BelongsTo<typeof User>
|
||||
|
||||
@hasOne(() => Examiner)
|
||||
declare examiner: HasOne<typeof Examiner>
|
||||
@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()
|
||||
@column({ serializeAs: null })
|
||||
declare bjsecret: string
|
||||
|
||||
@column()
|
||||
@column({ serializeAs: null })
|
||||
declare bjid: string
|
||||
|
||||
// Colle data
|
||||
@hasOne(() => Subject)
|
||||
declare subject: HasOne<typeof Subject>
|
||||
@belongsTo(() => Subject)
|
||||
declare subject: BelongsTo<typeof Subject>
|
||||
|
||||
@hasOne(() => Room)
|
||||
declare room: HasOne<typeof Room>
|
||||
@column({ serializeAs: null })
|
||||
declare subjectId: number
|
||||
|
||||
@belongsTo(() => Room)
|
||||
declare room: BelongsTo<typeof Room>
|
||||
|
||||
@column({ serializeAs: null })
|
||||
declare roomId: number
|
||||
|
||||
@column()
|
||||
declare grade: number
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { BaseModel, column, computed } from '@adonisjs/lucid/orm'
|
||||
import { DbRememberMeTokensProvider } from '@adonisjs/auth/session'
|
||||
|
||||
export default class User extends BaseModel {
|
||||
|
|
@ -17,11 +17,13 @@ export default class User extends BaseModel {
|
|||
@column()
|
||||
declare lastName: string
|
||||
|
||||
@column()
|
||||
declare email: string
|
||||
@computed()
|
||||
get fullName() {
|
||||
return `${this.firstName} ${this.lastName}`
|
||||
}
|
||||
|
||||
@column()
|
||||
declare avatar: string
|
||||
declare email: string
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
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 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
|
||||
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
|
||||
|
||||
// Generate code
|
||||
const formattedOTP = Math.floor(Math.random() * 1000000)
|
||||
|
|
@ -19,7 +15,7 @@ export class AuthService {
|
|||
.padStart(6, '0')
|
||||
await cache.set({
|
||||
key: 'auth:otp:' + formattedOTP,
|
||||
value: identifier,
|
||||
value: email,
|
||||
ttl: expiresIn,
|
||||
})
|
||||
|
||||
|
|
@ -27,81 +23,52 @@ export class AuthService {
|
|||
return {
|
||||
emailTitle,
|
||||
formattedOTP,
|
||||
magicLink,
|
||||
// magicLink,
|
||||
expiresIn,
|
||||
email,
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
parseIdentifier(id: string) {
|
||||
const [email, userId] = id.split(':')
|
||||
return {
|
||||
email: email.toLowerCase(),
|
||||
userId: parseInt(userId, 10),
|
||||
// token,
|
||||
}
|
||||
}
|
||||
|
||||
async validateCode(code: string) {
|
||||
// Validate code
|
||||
const key = 'auth:otp:' + code
|
||||
const id = await cache.get({ key })
|
||||
if (!id) return { success: false, userId: null, email: null }
|
||||
const email = await cache.get({ key })
|
||||
if (!email) return { success: false, email: null }
|
||||
|
||||
// Delete code from cache
|
||||
await cache.delete({ key })
|
||||
|
||||
return {
|
||||
email,
|
||||
success: true,
|
||||
...this.parseIdentifier(id)
|
||||
}
|
||||
}
|
||||
|
||||
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'))
|
||||
}
|
||||
})
|
||||
generateToken(email: string, expiresIn: string) {
|
||||
// Generate token
|
||||
const identifier = email.toLowerCase()
|
||||
const token = encryption.encrypt(identifier, expiresIn)
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresIn,
|
||||
email: identifier,
|
||||
}
|
||||
}
|
||||
|
||||
findUserByEmail(email: string) {
|
||||
return User.query().where('email', email).first()
|
||||
}
|
||||
validateToken(token: string) {
|
||||
// Decrypt token
|
||||
const decrypted = encryption.decrypt(token)
|
||||
if (!decrypted) 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
|
||||
// Validate email format
|
||||
const email = (decrypted as string).toLowerCase()
|
||||
if (!email.includes('@')) return { success: false, email: null }
|
||||
|
||||
// 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
|
||||
return {
|
||||
success: true,
|
||||
email,
|
||||
}
|
||||
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}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
app/services/colle_service.ts
Normal file
95
app/services/colle_service.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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,20 +19,36 @@ export const verifyCodeValidator = vine.compile(
|
|||
})
|
||||
)
|
||||
|
||||
export const magicLinkValidator = 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(
|
||||
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(),
|
||||
})
|
||||
)
|
||||
|
||||
export const listenValidator = vine.compile(
|
||||
vine.object({
|
||||
token: vine.string().uuid(),
|
||||
})
|
||||
)
|
||||
// TODO: Magic link login
|
||||
// export const magicLinkValidator = vine.compile(
|
||||
// vine.object({
|
||||
// token: vine.string(),
|
||||
// })
|
||||
// )
|
||||
|
||||
export const exchangeTokenValidator = 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(),
|
||||
// })
|
||||
// )
|
||||
|
|
|
|||
30
app/validators/colle.ts
Normal file
30
app/validators/colle.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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: true,
|
||||
origin: '*',
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
|
||||
headers: true,
|
||||
exposeHeaders: [],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ export default class extends BaseSchema {
|
|||
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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,3 +18,18 @@ 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,7 +63,6 @@
|
|||
"@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",
|
||||
|
|
|
|||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
|
@ -47,9 +47,6 @@ importers:
|
|||
adonis-captcha-guard:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1(@adonisjs/core@6.19.0(@adonisjs/assembler@7.8.2(typescript@5.8.3))(@vinejs/vine@3.0.1)(edge.js@6.2.1))
|
||||
cmpstr:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
edge.js:
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
|
|
@ -1114,10 +1111,6 @@ packages:
|
|||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
cmpstr@3.0.1:
|
||||
resolution: {integrity: sha512-0gj7U07fS8XaAx4IY854d6xpttqxVKUtaeI/giOguqDrXD9OQaBlW321NwhoE+WCF7rv+Pj55YL5kEW3jVuxEA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
code-block-writer@13.0.3:
|
||||
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||
|
||||
|
|
@ -3858,8 +3851,6 @@ snapshots:
|
|||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
cmpstr@3.0.1: {}
|
||||
|
||||
code-block-writer@13.0.3: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
|
|
|
|||
|
|
@ -2,18 +2,15 @@ Bonjour,
|
|||
|
||||
Nous avons reçu une demande de connexion à votre compte Khollisé.
|
||||
|
||||
Cliquez sur ce lien pour vous connecter :
|
||||
{{ magicLink }}
|
||||
|
||||
Vous pouvez également entrer ce code de vérification à 6 chiffres :
|
||||
Utilisez ce code de vérification à 6 chiffres pour vous connecter :
|
||||
{{ formattedOTP }}
|
||||
|
||||
Ce lien et ce code expireront dans {{ expiresIn }} et ne peuvent être utilisés qu'une seule fois.
|
||||
Ce code expirera dans {{ expiresIn }} et ne peut être utilisé qu'une seule fois.
|
||||
|
||||
Si vous n'avez pas demandé cette connexion, vous pouvez ignorer cet e-mail.
|
||||
Si vous n'avez pas demandé cette connexion, vous pouvez ignorer cet email.
|
||||
|
||||
---
|
||||
|
||||
Cet e-mail a été envoyé à {{ email }}
|
||||
Cet email a été envoyé à {{ email }}
|
||||
|
||||
© {{ new Date().getFullYear() }} Khollisé. Tous droits réservés.
|
||||
|
|
|
|||
|
|
@ -11,17 +11,7 @@
|
|||
<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 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 :
|
||||
Nous avons reçu une demande de connexion à votre compte Khollisé. Utilisez le code de vérification ci-dessous pour vous connecter :
|
||||
</p>
|
||||
|
||||
<div style="margin: 24px 0; text-align: center;">
|
||||
|
|
@ -31,7 +21,7 @@
|
|||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #6b7280; font-style: italic; margin-bottom: 16px;">
|
||||
Le lien et le code ci-dessus expireront dans {{ expiresIn }} et ne peuvent être utilisés qu'une seule fois.
|
||||
Le code ci-dessus expirera dans {{ expiresIn }} et ne peut être utilisé qu'une seule fois.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #6b7280; font-style: italic; margin-bottom: 16px;">
|
||||
|
|
|
|||
|
|
@ -13,4 +13,4 @@ import limiter from '@adonisjs/limiter/services/main'
|
|||
|
||||
export const throttle = limiter.define('global', () => {
|
||||
return limiter.allowRequests(10).every('1 minute')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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()
|
||||
|
|
@ -21,26 +20,37 @@ 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')
|
||||
|
||||
if (app.inDev) {
|
||||
router.post('users', [UserController, 'create'])
|
||||
}
|
||||
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: 2
|
||||
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')
|
||||
}
|
||||
).prefix('/colles')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue