Compare commits

...

2 commits

Author SHA1 Message Date
Nathan Lamy
040ae43652 feat: add BJRepas credentials 2025-08-26 00:26:11 +02:00
Nathan Lamy
90722673f8 feat: add meals to database 2025-08-25 19:12:28 +02:00
14 changed files with 400 additions and 26 deletions

4
README.md Normal file
View file

@ -0,0 +1,4 @@
Clean up avant une nouvelle année scolaire :
- Effacer les mails de tous les utilisateurs (users.email) dans la base de données.
- Mettre la dernière activité à 1 mois pour l'autocomplete (auth)
- Supprimer les repas (meals) (et les plats associés avec cascade)

View file

@ -1,5 +1,10 @@
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { registerValidator, requestLoginValidator, verifyCodeValidator } from '#validators/auth' import {
credentialsValidator,
registerValidator,
requestLoginValidator,
verifyCodeValidator,
} from '#validators/auth'
import mail from '@adonisjs/mail/services/main' import mail from '@adonisjs/mail/services/main'
import { AuthService } from '#services/auth_service' import { AuthService } from '#services/auth_service'
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
@ -7,6 +12,7 @@ import app from '@adonisjs/core/services/app'
import env from '#start/env' import env from '#start/env'
import User from '#models/user' import User from '#models/user'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import redis from '@adonisjs/redis/services/main'
@inject() @inject()
export default class AuthController { export default class AuthController {
@ -126,10 +132,7 @@ export default class AuthController {
}) })
} }
const user = await User.query() const user = await User.query().where('id', userId).where('className', className).first()
.where('id', userId)
.where('className', className)
.first()
if (!user) { if (!user) {
return response.badRequest({ return response.badRequest({
success: false, success: false,
@ -163,6 +166,29 @@ export default class AuthController {
} }
} }
// POST /auth/test
public async testCredentials({ request, auth }: HttpContext) {
const { username, password } = await request.validateUsing(credentialsValidator)
await redis.publish(
'jobs_queue',
JSON.stringify({
type: 5, // Test credentials
bj_username: username,
bj_password: password,
user_id: auth.user!.id,
class_name: env.get('DEFAULT_CLASS_NAME'),
})
)
return { success: true, message: 'Testing credentials...' }
}
// GET /auth/status
public async status({ auth }: HttpContext) {
const redisKey = `auth_success_${auth.user!.id}`
return { success: true, data: { authenticated: await redis.get(redisKey) } }
}
// TODO: Magic link login // TODO: Magic link login
// magicLink({ }: HttpContext) { // magicLink({ }: HttpContext) {
// // Validate signed url (adonis) // // Validate signed url (adonis)

View file

@ -1,3 +1,7 @@
import Meal from '#models/meal'
import MealRegistration from '#models/meal_registration'
import env from '#start/env'
import { updateMealsRegistrationsValidator } from '#validators/meal'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import redis from '@adonisjs/redis/services/main' import redis from '@adonisjs/redis/services/main'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
@ -20,6 +24,46 @@ export default class InternalsController {
console.log(`Colles fetching for class ${className} completed.`) console.log(`Colles fetching for class ${className} completed.`)
}) })
} }
// POST /internals/fetch-meals
async fetchMeals({ response }: HttpContext) {
await redis.publish(
'jobs_queue',
JSON.stringify({
type: 3, // Fetch meals
class_name: env.get('DEFAULT_CLASS_NAME'),
})
)
return response.send({ success: true, message: 'Fetching meals...' })
}
// POST /internals/meals-registrations
async updateMealsRegistrations({ response, request }: HttpContext) {
const { userId, meals } = await request.validateUsing(updateMealsRegistrationsValidator)
// Remove all existing registrations for the user
await MealRegistration.query().where('user_id', userId).delete()
for (const mealData of meals) {
const meal = await Meal.query()
.where({
date: mealData.date,
type: mealData.meal_type === 'lunch' ? 0 : 1,
})
.first()
if (meal) {
// Create a new registration
await MealRegistration.create({
mealId: meal.id,
userId: userId,
})
}
}
return response.send({
success: true,
message: 'Meal registrations updated successfully',
})
}
} }
async function queue(className: string) { async function queue(className: string) {

View file

@ -0,0 +1,134 @@
import Meal from '#models/meal'
import MealRegistration from '#models/meal_registration'
import { credentialsValidator } from '#validators/auth'
import { createMealValidator } from '#validators/meal'
import type { HttpContext } from '@adonisjs/core/http'
import redis from '@adonisjs/redis/services/main'
import { DateTime } from 'luxon'
export default class MealsController {
// POST /meals
public async create({ request }: HttpContext) {
const data = await request.validateUsing(createMealValidator)
if ('meals' in data) {
// Remove all already submittable meals
await Meal.query().where('submittable', true).update({ submittable: false })
// Set new meals as submittable
for (const mealData of data.meals) {
const meal = await Meal.query()
.where('date', mealData.date)
.where('type', mealData.meal_type === 'lunch' ? 0 : 1)
.first()
if (meal) {
meal.submittable = true
await meal.save()
} else {
console.warn('Meal to set as submittable not found:', mealData)
}
}
return { message: 'Meals updated successfully' }
}
const { date, type, courses } = data
const mealType = type === 'Déjeuner' ? 0 : 1
// Check if a meal with the same date and type already exists
const existingMeal = await Meal.query()
.where({ date, type: mealType })
.preload('courses')
.first()
if (existingMeal) {
// Check if the existing meal has the same courses
const existingCourseNames = existingMeal.courses.map((course) => course.description)
const newCourseNames = courses.map((course) => course.description)
if (
existingCourseNames.length === newCourseNames.length &&
existingCourseNames.every((name) => newCourseNames.includes(name))
) {
return existingMeal
}
// If not, delete the existing meal (and its courses, thanks to cascade delete)
await existingMeal.delete()
}
// Create the new meal
const meal = await Meal.create({
date,
type: mealType,
})
await meal.related('courses').createMany(courses)
return meal
}
// GET /meals
public async index({ auth }: HttpContext) {
const meals = await Meal.query().orderBy('date', 'asc').preload('courses')
const data = meals.map(async (meal) => {
if (meal.submittable) {
const isRegistered = await MealRegistration.query()
.where('meal_id', meal.id)
.where('user_id', auth.user!.id)
.first()
// Remove temporary registrations that are older than 5 minutes
if (isRegistered?.temporary) {
const oneHourAgo = DateTime.now().minus({ minutes: 5 })
if (isRegistered.createdAt < oneHourAgo) {
await isRegistered.delete()
return meal.serialize()
}
}
return {
...meal.serialize(),
isRegistered: !!isRegistered,
}
} else {
return meal.serialize()
}
})
return Promise.all(data)
}
// POST /meals/:id
// TODO: Unregister from meal
public async registerForMeal({ params, auth, response, request }: HttpContext) {
const { username, password } = await request.validateUsing(credentialsValidator)
const meal = await Meal.find(params.id)
if (!meal) {
return response.notFound({ message: 'Meal not found' })
}
if (!meal.submittable) {
return response.badRequest({ message: 'Meal is not submittable' })
}
// Create a bullshit registration
// (to confirm to the user that the registration is in progress...)
// that will eventually be deleted if the registration fails (worker callback)
await MealRegistration.firstOrCreate({
mealId: meal.id,
userId: auth.user!.id,
temporary: true,
})
// Register the user for the meal
await redis.publish(
'jobs_queue',
JSON.stringify({
type: 4, // Submit meals
meal: {
date: DateTime.fromJSDate(meal.date).toISODate(),
meal_type: meal.type === 0 ? 'lunch' : 'dinner',
},
class_name: auth.user!.className,
user_id: auth.user!.id,
bj_username: username,
bj_password: password,
})
)
return { success: true, message: 'Registration in progress' }
}
}

15
app/models/course.ts Normal file
View file

@ -0,0 +1,15 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
export default class Course extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare mealId: number
@column()
declare name: string
@column()
declare description: string
}

25
app/models/meal.ts Normal file
View file

@ -0,0 +1,25 @@
import { BaseModel, column, computed, hasMany } from '@adonisjs/lucid/orm'
import Course from './course.js'
import type { HasMany } from '@adonisjs/lucid/types/relations'
export default class Meal extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare date: Date
@column()
declare type: 0 | 1 // 0 = lunch, 1 = dinner
@column()
declare submittable: boolean
@computed()
get name() {
return this.type === 0 ? 'Déjeuner' : 'Dîner'
}
@hasMany(() => Course)
declare courses: HasMany<typeof Course>
}

View file

@ -0,0 +1,19 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export default class MealRegistration extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare mealId: number
@column()
declare userId: number
@column()
declare temporary: boolean
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
}

View file

@ -27,6 +27,13 @@ export const registerValidator = vine.compile(
}) })
) )
export const credentialsValidator = vine.compile(
vine.object({
username: vine.string().minLength(1).maxLength(255),
password: vine.string().minLength(1).maxLength(255),
})
)
// TODO: Magic link login // TODO: Magic link login
// export const magicLinkValidator = vine.compile( // export const magicLinkValidator = vine.compile(
// vine.object({ // vine.object({

40
app/validators/meal.ts Normal file
View file

@ -0,0 +1,40 @@
import vine from '@vinejs/vine'
const meals = vine.array(
vine.object({
date: vine.string(), // YYYY-MM-DD
meal_type: vine.string().in(['dinner', 'lunch']),
})
)
export const createMealValidator = vine.compile(
vine.union([
vine.union.if(
(data) => vine.helpers.isObject(data) && 'meals' in data,
vine.object({
meals,
})
),
vine.union.else(
vine.object({
date: vine.date(),
type: vine.string().in(['Déjeuner', 'Dîner']),
courses: vine
.array(
vine.object({
name: vine.string().minLength(1).maxLength(255),
description: vine.string().minLength(1),
})
)
.minLength(1),
})
),
])
)
export const updateMealsRegistrationsValidator = vine.compile(
vine.object({
userId: vine.number().min(1),
meals,
})
)

View file

@ -0,0 +1,18 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'meals'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.date('date').notNullable()
table.integer('type').notNullable() // 0 = lunch, 1 = dinner
table.boolean('submittable').defaultTo(false)
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View file

@ -0,0 +1,18 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'courses'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('meal_id').unsigned().references('id').inTable('meals').onDelete('CASCADE')
table.string('name').notNullable()
table.text('description').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View file

@ -0,0 +1,19 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'meal_registrations'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('meal_id').unsigned().references('id').inTable('meals').onDelete('CASCADE')
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE')
table.boolean('temporary').defaultTo(false)
table.timestamp('created_at').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View file

@ -74,5 +74,5 @@ export default await Env.create(new URL('../', import.meta.url), {
API_BEARER_TOKEN: Env.schema.string(), API_BEARER_TOKEN: Env.schema.string(),
MENUS_PATH: Env.schema.string(), DEFAULT_CLASS_NAME: Env.schema.string(),
}) })

View file

@ -18,16 +18,20 @@ import { middleware } from './kernel.js'
const AuthController = () => import('#controllers/auth_controller') const AuthController = () => import('#controllers/auth_controller')
router.group(() => { router
router.post('/auth/request', [AuthController, 'requestLogin']).use(authThrottle) .group(() => {
router.post('/auth/verify', [AuthController, 'verifyCode']).use(throttle) router.post('/request', [AuthController, 'requestLogin']).use(authThrottle)
router.post('/auth/register', [AuthController, 'register']).use(throttle) router.post('/verify', [AuthController, 'verifyCode']).use(throttle)
router.post('/auth/logout', [AuthController, 'logout']) router.post('/register', [AuthController, 'register']).use(throttle)
router.get('/auth/autocomplete', [AuthController, 'listNames']).use(throttle) router.post('/logout', [AuthController, 'logout'])
// TODO: Magic link login router.get('/autocomplete', [AuthController, 'listNames']).use(throttle)
// router.get('/auth/magic-link', 'AuthController.magicLink').use(throttle) router.post('/test', [AuthController, 'testCredentials']).use(throttle).use(middleware.auth())
// router.get('/auth/listen', 'AuthController.listen') router.get('/status', [AuthController, 'status']).use(middleware.auth())
}) // TODO: Magic link login
// router.get('/auth/magic-link', 'AuthController.magicLink').use(throttle)
// router.get('/auth/listen', 'AuthController.listen')
})
.prefix('/auth')
const UserController = () => import('#controllers/user_controller') const UserController = () => import('#controllers/user_controller')
router.get('/users/@me', [UserController, 'me']).use(middleware.auth()) router.get('/users/@me', [UserController, 'me']).use(middleware.auth())
@ -67,20 +71,21 @@ const InternalsController = () => import('#controllers/internals_controller')
router router
.group(() => { .group(() => {
router.post('/back-fetch', [InternalsController, 'backFetch']) router.post('/back-fetch', [InternalsController, 'backFetch'])
router.post('/fetch-meals', [InternalsController, 'fetchMeals'])
router.post('/meals-registrations', [InternalsController, 'updateMealsRegistrations'])
}) })
.prefix('/internals') .prefix('/internals')
.use(middleware.internal()) .use(middleware.internal())
const MealsController = () => import('#controllers/meals_controller')
router
.group(() => {
router.post('/', [MealsController, 'create']).use(middleware.internal())
router.get('/', [MealsController, 'index']).use(middleware.auth())
router.post('/:id', [MealsController, 'registerForMeal']).use(middleware.auth())
})
.prefix('/meals')
router.get('/health', async () => { router.get('/health', async () => {
return { status: 'ok' } return { status: 'ok' }
}) })
// BETA: Serve menus.json file
import fs from 'node:fs/promises'
import env from './env.js'
router.get('/menus', async () => {
// Return menus.json file
const data = await fs.readFile(env.get('MENUS_PATH'), 'utf-8')
return JSON.parse(data)
})