diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ce061b..698b929 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,12 +3,13 @@ name: CI on: push: branches: - - prod - dev + tags: + - 'v*' pull_request: branches: - - prod - dev + - prod env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -68,7 +69,7 @@ jobs: build-api: runs-on: self-hosted - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.event_name == 'push' needs: - lint-api steps: @@ -97,7 +98,7 @@ jobs: build-front: runs-on: self-hosted - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.event_name == 'push' needs: - lint-front steps: @@ -126,10 +127,11 @@ jobs: deploy-api: runs-on: self-hosted - if : github.event_name == 'push' - environment: ${{ github.ref == 'refs/heads/dev' && 'development' || 'production' }} + if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || startswith(github.ref, 'refs/tags/v')) + environment: ${{ startswith(github.ref, 'refs/tags/') && 'production' || 'development' }} needs: - lint-api + - build-api steps: - name: Checkout repository uses: actions/checkout@v5 @@ -150,14 +152,16 @@ jobs: context: ./backend push: true tags: | - ${{ secrets.REGISTRY_URL }}/integration/api:${{ github.ref == 'refs/heads/prod' && 'prod' || 'dev' }} + ${{ secrets.REGISTRY_URL }}/integration/api:${{ startswith(github.ref, 'refs/tags/') && github.ref_name || 'dev' }} + ${{ startswith(github.ref, 'refs/tags/') && format('{0}/integration/api:prod', secrets.REGISTRY_URL) || '' }} deploy-front: runs-on: self-hosted - if : github.event_name == 'push' - environment: ${{ github.ref == 'refs/heads/dev' && 'development' || 'production' }} + if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || startswith(github.ref, 'refs/tags/v')) + environment: ${{ startswith(github.ref, 'refs/tags/') && 'production' || 'development' }} needs: - lint-front + - build-front steps: - name: Checkout repository uses: actions/checkout@v5 @@ -172,7 +176,7 @@ jobs: - name: Install docker uses: docker/setup-buildx-action@v3 - - name: Front- Build and push + - name: Front - Build and push uses: docker/build-push-action@v6 with: context: ./frontend @@ -199,5 +203,8 @@ jobs: VITE_BDE_SIREN=${{ vars.BDE_SIREN }} VITE_DPO_EMAIL=${{ vars.DPO_EMAIL }} VITE_DPO_NAME=${{ vars.DPO_NAME }} + VITE_APP_VERSION=${{ startswith(github.ref, 'refs/tags/') && github.ref_name || 'dev' }} + VITE_RELEASE_URL=${{ startswith(github.ref, 'refs/tags/') && format('https://github.com/{0}/releases/tag/{1}', github.repository, github.ref_name) || '' }} tags: | - ${{ secrets.REGISTRY_URL }}/integration/front:${{ github.ref == 'refs/heads/prod' && 'prod' || 'dev' }} + ${{ secrets.REGISTRY_URL }}/integration/front:${{ startswith(github.ref, 'refs/tags/') && github.ref_name || 'dev' }} + ${{ startswith(github.ref, 'refs/tags/') && format('{0}/integration/front:prod', secrets.REGISTRY_URL) || '' }} diff --git a/backend/server.ts b/backend/server.ts index 5f5b6dc..65f07e6 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -18,7 +18,7 @@ import challengeRoutes from './src/routes/challenge.routes'; import defaultRoute from './src/routes/default.routes'; import discordRoutes from './src/routes/discord.routes'; import emailRoutes from './src/routes/email.routes'; -import eventRoutes from './src/routes/event.routes'; +import settingsRoutes from './src/routes/settings.routes'; import factionRoutes from './src/routes/faction.routes'; import imexportRouter from './src/routes/im_export.routes'; import newsRoutes from './src/routes/news.routes'; @@ -27,6 +27,7 @@ import roleRoutes from './src/routes/role.routes'; import teamRoutes from './src/routes/team.routes'; import tentRoutes from './src/routes/tent.routes'; import userRoutes from './src/routes/user.routes'; +import makerBattleRoutes from './src/routes/maker_battle.routes'; import bannedRoutes from './src/routes/banned.routes'; import { server_port } from './src/shared/secrets/secrets'; import { initQcmvss } from './src/database/initdb/initQcmvss'; @@ -59,7 +60,7 @@ async function startServer() { app.use('/api/role', authenticateUser, roleRoutes); app.use('/api/user', authenticateUser, userRoutes); app.use('/api/team', authenticateUser, teamRoutes); - app.use('/api/event', authenticateUser, eventRoutes); + app.use('/api/settings', authenticateUser, settingsRoutes); app.use('/api/faction', authenticateUser, factionRoutes); app.use('/api/imexport', authenticateUser, imexportRouter); app.use('/api/permanence', authenticateUser, permanenceRoutes); @@ -69,6 +70,7 @@ async function startServer() { app.use('/api/discord', authenticateUser, discordRoutes); app.use('/api/tent', authenticateUser, tentRoutes); app.use('/api/bus', authenticateUser, busRoutes); + app.use('/api/maker-battle', authenticateUser, makerBattleRoutes); app.use('/api/uploads/news', express.static(path.join(__dirname, '/uploads/news'))); app.use('/api/uploads/notebooks', express.static(path.join(__dirname, '/uploads/notebooks'))); app.use('/api/uploads/foodmenu', express.static(path.join(__dirname, '/uploads/foodmenu'))); diff --git a/backend/src/controllers/event.controller.ts b/backend/src/controllers/event.controller.ts deleted file mode 100644 index b32b11c..0000000 --- a/backend/src/controllers/event.controller.ts +++ /dev/null @@ -1,199 +0,0 @@ -import * as event_service from '../services/event.service'; -import * as team_service from '../services/team.service'; -import { Conflict, Error, Ok, Teapot, Unauthorized } from '../shared/http/responses'; -import { shotgun_password } from '../shared/secrets/secrets'; -import type { AppRequestHandler } from '../types/http'; -import type { ShotgunBody, ToggleStatusBody } from '../dto/event.dto'; - -export const checkShotgunStatus: AppRequestHandler = async (_req, res) => { - try { - const status = await event_service.getEventsStatus(); - Ok(res, { - data: { status: Boolean(status?.shotgun_open), password: status?.shotgun_open ? shotgun_password : '' }, - }); - } catch (error) { - Error(res, { msg: 'Error while catching shotgun status :' + error }); - } -}; - -export const checkPreRegisterStatus: AppRequestHandler = async (_req, res) => { - try { - const status = await event_service.getEventsStatus(); - Ok(res, { data: status?.pre_registration_open }); - } catch (error) { - Error(res, { msg: 'Error while catching pre-registration status :' + error }); - } -}; - -export const checkSDIStatus: AppRequestHandler = async (_req, res) => { - try { - const status = await event_service.getEventsStatus(); - Ok(res, { data: status?.sdi_open }); - } catch (error) { - Error(res, { msg: 'Error while catching SDI status :' + error }); - } -}; - -export const checkWEIStatus: AppRequestHandler = async (_req, res) => { - try { - const status = await event_service.getEventsStatus(); - Ok(res, { data: status?.wei_open }); - } catch (error) { - Error(res, { msg: 'Error while catching WEI status :' + error }); - } -}; - -export const checkFoodStatus: AppRequestHandler = async (_req, res) => { - try { - const status = await event_service.getEventsStatus(); - Ok(res, { data: status?.food_open }); - } catch (error) { - Error(res, { msg: 'Error while catching Food status :' + error }); - } -}; - -export const checkChallStatus: AppRequestHandler = async (_req, res) => { - try { - const status = await event_service.getEventsStatus(); - Ok(res, { data: status?.chall_open }); - } catch (error) { - Error(res, { msg: 'Error while catching Challenge status :' + error }); - } -}; - -export const getShotgunAttempts: AppRequestHandler = async (_req, res) => { - try { - const shotgunAttempts = await event_service.getAllTeamShotguns(); - const shotgunAttemptsWithLeaders = await Promise.all( - shotgunAttempts.map(async (attempt) => { - if (!attempt.teamId) { - return { ...attempt, leaderCount: 0 }; - } - - const teamUsers = await team_service.getTeamUsers(attempt.teamId); - const leaderCount = teamUsers.filter((user) => user.permission !== 'Nouveau').length; - - return { ...attempt, leaderCount }; - }), - ); - - Ok(res, { data: shotgunAttemptsWithLeaders }); - } catch (error) { - Error(res, { msg: 'Erreur lors de la récupération des tentatives shotgun : ' + error }); - } -}; - -export const shotgunAttempt: AppRequestHandler = async (req, res) => { - const { password } = req.body; - - const userId = req.user?.userId; - - if (!userId) { - Unauthorized(res, { msg: 'Utilisateur non authentifié.' }); - return; - } - - if (!shotgun_password) { - Error(res, { msg: 'Mot de passe shotgun non configuré côté serveur.' }); - return; - } - - if (password !== shotgun_password) { - Teapot(res, { msg: 'Le mot de passe shotgun est incorrect.' }); - return; - } - - const status = await event_service.getEventsStatus(); - if (!status?.shotgun_open) { - Unauthorized(res, { msg: 'Le shotgun est fermé.' }); - return; - } - try { - const userTeam = await team_service.getUserTeam(userId); - - if (!userTeam) { - Error(res, { msg: "Erreur : Tu n'as pas d'équipe !" }); - return; - } - - const alreadyShotgun = await event_service.alreadyShotgun(userTeam); - - if (alreadyShotgun) { - Conflict(res, { msg: 'Votre équipe est déjà dans le shotgun.' }); - return; - } - - await event_service.validateShotgun(userTeam); - Ok(res, { msg: 'Shotgun validé !' }); - return; - } catch (error) { - Error(res, { msg: 'Erreur pendant le shotguns : ' + error }); - return; - } -}; - -export const togglePreRegistration: AppRequestHandler = async (req, res) => { - const { preRegistrationOpen } = req.body; - - try { - const result = await event_service.updatepreRegistrationStatus(preRegistrationOpen); - Ok(res, { msg: 'Paramètres mis à jour.', data: result }); - } catch { - Error(res, { msg: 'Erreur lors de la mise à jour.' }); - } -}; - -export const toggleShotgun: AppRequestHandler = async (req, res) => { - const { shotgunOpen } = req.body; - - try { - const result = await event_service.updateShotgunStatus(shotgunOpen); - Ok(res, { msg: 'Paramètres mis à jour.', data: result }); - } catch { - Error(res, { msg: 'Erreur lors de la mise à jour.' }); - } -}; - -export const toggleSDI: AppRequestHandler = async (req, res) => { - const { sdiOpen } = req.body; - - try { - const result = await event_service.updateSDIStatus(sdiOpen); - Ok(res, { msg: 'Paramètres mis à jour.', data: result }); - } catch { - Error(res, { msg: 'Erreur lors de la mise à jour.' }); - } -}; - -export const toggleWEI: AppRequestHandler = async (req, res) => { - const { weiOpen } = req.body; - - try { - const result = await event_service.updateWEIStatus(weiOpen); - Ok(res, { msg: 'Paramètres mis à jour.', data: result }); - } catch { - Error(res, { msg: 'Erreur lors de la mise à jour.' }); - } -}; - -export const toggleFood: AppRequestHandler = async (req, res) => { - const { foodOpen } = req.body; - - try { - const result = await event_service.updateFoodStatus(foodOpen); - Ok(res, { msg: 'Paramètres mis à jour.', data: result }); - } catch { - Error(res, { msg: 'Erreur lors de la mise à jour.' }); - } -}; - -export const toggleChall: AppRequestHandler = async (req, res) => { - const { challOpen } = req.body; - - try { - const result = await event_service.updateChallStatus(challOpen); - Ok(res, { msg: 'Paramètres mis à jour.', data: result }); - } catch { - Error(res, { msg: 'Erreur lors de la mise à jour.' }); - } -}; diff --git a/backend/src/controllers/im_export.controller.ts b/backend/src/controllers/im_export.controller.ts index 668c40f..a7586ff 100644 --- a/backend/src/controllers/im_export.controller.ts +++ b/backend/src/controllers/im_export.controller.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import * as event_service from '../services/event.service'; +import * as settings_service from '../services/settings.service'; import * as export_service from '../services/im_export.service'; import * as permanence_service from '../services/permanence.service'; import * as team_service from '../services/team.service'; @@ -22,7 +22,7 @@ export const exportAllDataToSheets: AppRequestHandler = async (_req, res) => { const userList = await user_service.getUsersAll(); const teamList = await team_service.getTeamsAll(); const permanenceList = await permanence_service.getAllPermanencesWithUsers(); - const shotgunList = await event_service.getAllTeamShotguns(); + const shotgunList = await settings_service.getAllTeamShotguns(); // 2. Mapping -> format pour Google Sheets (array de array) const usersValues = [ @@ -162,23 +162,29 @@ export const updatePlannings: AppRequestHandler = async (_req, res) => { } }; -export const exportUsersCSV: AppRequestHandler = async (_req, res) => { +export const exportBus: AppRequestHandler = async (_req, res) => { try { - await export_service.exportUsersToCSV(); - Ok(res, { msg: 'CSV des bus généré' }); + const exportData = await export_service.exportBus(); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="bus_${Date.now()}.json"`); + + return res.status(200).json(exportData); } catch (error) { console.error(error); - Error(res, { msg: "Erreur lors de l'export CSV" }); + return Error(res, { msg: "Erreur lors de l'exportation des memsbres des équipes : " + error }); } }; -export const exportTeamMembersCSV: AppRequestHandler = async (_req, res) => { +export const exportTeamMembers: AppRequestHandler = async (_req, res) => { try { - await export_service.exportTeamMembersToCSV(); - Ok(res, { msg: "CSV des membres de l'équipe généré" }); + const exportData = await export_service.exportTeamMembers(); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="team_members_${Date.now()}.json"`); + + return res.status(200).json(exportData); } catch (error) { console.error(error); - Error(res, { msg: "Erreur lors de l'export CSV" }); + return Error(res, { msg: "Erreur lors de l'exportation des memsbres des équipes : " + error }); } }; export const getUploadedDocumentStatus: AppRequestHandler = async ( diff --git a/backend/src/controllers/maker_battle.controller.ts b/backend/src/controllers/maker_battle.controller.ts new file mode 100644 index 0000000..cdf6afe --- /dev/null +++ b/backend/src/controllers/maker_battle.controller.ts @@ -0,0 +1,61 @@ +import * as maker_battle_service from '../services/maker_battle.service'; +import type { AppRequestHandler } from '../types/http'; +import { Error, Ok } from '../shared/http/responses'; +import type { Group, GroupsList } from '../dto/maker_battle.dto'; + +export const distributeGroups: AppRequestHandler = async (req, res) => { + const groups = req.body.groups; + if (!groups || !Array.isArray(groups)) { + return Error(res, { msg: 'Invalid request: groups must be an array' }); + } + + try { + for (const group of groups) { + await maker_battle_service.distributeGroups(group); + } + await maker_battle_service.placeTeamsOnTables(groups); + + Ok(res, { data: { success: true, message: 'Groups allocated successfully' } }); + return; + } catch (err) { + return Error(res, { msg: 'Erreur lors de la distribution des groupes : ' + err }); + } +}; + +export const exportGroups: AppRequestHandler = async (req, res) => { + const { group } = req.params; + if (!group || typeof group !== 'string') { + return Error(res, { msg: 'Invalid request: group must be a string' }); + } + + try { + const exportData = await maker_battle_service.exportGroups(group); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="maker_battle_${group}_${Date.now()}.json"`); + + Ok(res, { data: exportData }); + return; + } catch (err) { + return Error(res, { msg: "Erreur lors de l'exportation des groupes : " + err }); + } +}; + +export const getCurrentUser: AppRequestHandler = async (req, res) => { + try { + const userId = req.user.userId; + + if (!userId) { + return Error(res, { msg: 'User not authenticated' }); + } + + const userGroup = await maker_battle_service.getUserTeam(userId); + + Ok(res, { data: { group: userGroup ?? null } }); + return; + } catch (err) { + return Error(res, { + msg: "Erreur lors de la récupération du groupe de l'utilisateur : " + err, + }); + } +}; diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts new file mode 100644 index 0000000..004e768 --- /dev/null +++ b/backend/src/controllers/settings.controller.ts @@ -0,0 +1,139 @@ +import * as settings_service from '../services/settings.service'; +import * as team_service from '../services/team.service'; +import { Conflict, Error, Ok, Teapot, Unauthorized } from '../shared/http/responses'; +import { shotgun_password } from '../shared/secrets/secrets'; +import type { AppRequestHandler } from '../types/http'; +import type { ShotgunBody, ToggleStatusBody } from '../dto/event.dto'; + +export const getSettingStatus: AppRequestHandler = async (req, res) => { + const { setting } = req.params; + + if (!setting || !settings_service.isSetting(setting)) { + Error(res, { msg: 'Setting événement inconnu.' }); + return; + } + + try { + const status = await settings_service.getSettingStatus(setting); + if (setting === 'shotgun') { + Ok(res, { data: { status, password: status ? shotgun_password : '' } }); + } else { + Ok(res, { data: status }); + } + } catch (error) { + Error(res, { msg: 'Erreur lors de la récupération du statut :' + error }); + } +}; + +export const getAvailableSettings: AppRequestHandler = async (req, res) => { + try { + const userPermission = req.user?.userPermission ?? ''; + const userRoles = req.user?.userRoles?.map((role) => role.roleName) ?? []; + const settings = await settings_service.getAvailableSettings(userPermission, userRoles); + Ok(res, { data: settings }); + } catch (error) { + Error(res, { msg: 'Erreur lors de la récupération des settings :' + error }); + } +}; + +export const getAdminSettings: AppRequestHandler = async (_req, res) => { + try { + const settings = await settings_service.getAllSettings(); + Ok(res, { data: settings }); + } catch (error) { + Error(res, { msg: 'Erreur lors de la récupération des settings :' + error }); + } +}; + +export const getShotgunAttempts: AppRequestHandler = async (_req, res) => { + try { + const shotgunAttempts = await settings_service.getAllTeamShotguns(); + const shotgunAttemptsWithLeaders = await Promise.all( + shotgunAttempts.map(async (attempt) => { + if (!attempt.teamId) { + return { ...attempt, leaderCount: 0 }; + } + + const teamUsers = await team_service.getTeamUsers(attempt.teamId); + const leaderCount = teamUsers.filter((user) => user.permission !== 'Nouveau').length; + + return { ...attempt, leaderCount }; + }), + ); + + Ok(res, { data: shotgunAttemptsWithLeaders }); + } catch (error) { + Error(res, { msg: 'Erreur lors de la récupération des tentatives shotgun : ' + error }); + } +}; + +export const shotgunAttempt: AppRequestHandler = async (req, res) => { + const { password } = req.body; + + const userId = req.user?.userId; + + if (!userId) { + Unauthorized(res, { msg: 'Utilisateur non authentifié.' }); + return; + } + + if (!shotgun_password) { + Error(res, { msg: 'Mot de passe shotgun non configuré côté serveur.' }); + return; + } + + if (password !== shotgun_password) { + Teapot(res, { msg: 'Le mot de passe shotgun est incorrect.' }); + return; + } + + const status = await settings_service.getSettingsStatus(); + if (!status?.shotgun_open) { + Unauthorized(res, { msg: 'Le shotgun est fermé.' }); + return; + } + try { + const userTeam = await team_service.getUserTeam(userId); + + if (!userTeam) { + Error(res, { msg: "Erreur : Tu n'as pas d'équipe !" }); + return; + } + + const alreadyShotgun = await settings_service.alreadyShotgun(userTeam); + + if (alreadyShotgun) { + Conflict(res, { msg: 'Votre équipe est déjà dans le shotgun.' }); + return; + } + + await settings_service.validateShotgun(userTeam); + Ok(res, { msg: 'Shotgun validé !' }); + return; + } catch (error) { + Error(res, { msg: 'Erreur pendant le shotguns : ' + error }); + return; + } +}; + +export const updateSettingStatus: AppRequestHandler = async (req, res) => { + const { setting } = req.params; + const { open } = req.body; + + if (!setting || !settings_service.isSetting(setting)) { + Error(res, { msg: 'Setting événement inconnu.' }); + return; + } + + if (typeof open !== 'boolean') { + Error(res, { msg: "Le champ 'open' doit être un booléen." }); + return; + } + + try { + const result = await settings_service.updateSettingStatus(setting, open); + Ok(res, { msg: 'Paramètres mis à jour.', data: result }); + } catch (error) { + Error(res, { msg: 'Erreur lors de la mise à jour : ' + error }); + } +}; diff --git a/backend/src/controllers/team.controller.ts b/backend/src/controllers/team.controller.ts index 5377d9a..78a6461 100644 --- a/backend/src/controllers/team.controller.ts +++ b/backend/src/controllers/team.controller.ts @@ -1,5 +1,5 @@ import { type Event } from '../schemas/Basic/event.schema'; -import * as event_service from '../services/event.service'; +import * as settings_service from '../services/settings.service'; import * as faction_service from '../services/faction.service'; import * as team_service from '../services/team.service'; import { Error, Ok } from '../shared/http/responses'; @@ -14,7 +14,7 @@ export const createNewTeam: AppRequestHandler = async (req, res) Error(res, { msg: "Il n'y a pas de nom d'équipe" }); return; } - const status: Event = await event_service.getEventsStatus(); + const status: Event = await settings_service.getSettingsStatus(); if (!status?.pre_registration_open) { Error(res, { msg: "L'enregistrement d'équipe est fermé." }); return; diff --git a/backend/src/database/migrations/0028_tan_slapstick.sql b/backend/src/database/migrations/0028_tan_slapstick.sql new file mode 100644 index 0000000..e907d3f --- /dev/null +++ b/backend/src/database/migrations/0028_tan_slapstick.sql @@ -0,0 +1,9 @@ +CREATE TABLE "maker_battle_attribution" ( + "user_id" integer PRIMARY KEY NOT NULL, + "maker_team_id" integer NOT NULL, + "faction_id" integer NOT NULL, + "table" integer, + "group" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "maker_battle_attribution" ADD CONSTRAINT "maker_battle_attribution_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/backend/src/database/migrations/meta/0028_snapshot.json b/backend/src/database/migrations/meta/0028_snapshot.json new file mode 100644 index 0000000..8d77e1b --- /dev/null +++ b/backend/src/database/migrations/meta/0028_snapshot.json @@ -0,0 +1,1566 @@ +{ + "id": "affda32f-de8a-46ec-ab02-7425eb922c0b", + "prevId": "6179fb88-23bd-4656-9980-faa52a9d9b24", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.banned_addresses": { + "name": "banned_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "banned_addresses_email_unique": { + "name": "banned_addresses_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenges": { + "name": "challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "challenges_created_by_users_id_fk": { + "name": "challenges_created_by_users_id_fk", + "tableFrom": "challenges", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pre_registration_open": { + "name": "pre_registration_open", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "shotgun_open": { + "name": "shotgun_open", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sdi_open": { + "name": "sdi_open", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "wei_open": { + "name": "wei_open", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "food_open": { + "name": "food_open", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "chall_open": { + "name": "chall_open", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.factions": { + "name": "factions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "factions_name_unique": { + "name": "factions_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news": { + "name": "news", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permanences": { + "name": "permanences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_open": { + "name": "is_open", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socialLink": { + "name": "socialLink", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "riCompatible": { + "name": "riCompatible", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "majeur": { + "name": "majeur", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "male": { + "name": "male", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Nouveau'" + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "vss_form": { + "name": "vss_form", + "type": "vss_form", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vssqcmquestion": { + "name": "vssqcmquestion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "question_en": { + "name": "question_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "question_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bus_attribution": { + "name": "bus_attribution", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "bus": { + "name": "bus", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "departure_time": { + "name": "departure_time", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "bus_attribution_user_id_users_id_fk": { + "name": "bus_attribution_user_id_users_id_fk", + "tableFrom": "bus_attribution", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_validation": { + "name": "challenge_validation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "challenge_id": { + "name": "challenge_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "validated_by_admin_id": { + "name": "validated_by_admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "validated_at": { + "name": "validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "target_user_id": { + "name": "target_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "target_team_id": { + "name": "target_team_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "target_faction_id": { + "name": "target_faction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "added_by_admin_id": { + "name": "added_by_admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "challenge_validation_challenge_id_challenges_id_fk": { + "name": "challenge_validation_challenge_id_challenges_id_fk", + "tableFrom": "challenge_validation", + "tableTo": "challenges", + "columnsFrom": [ + "challenge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_validation_validated_by_admin_id_users_id_fk": { + "name": "challenge_validation_validated_by_admin_id_users_id_fk", + "tableFrom": "challenge_validation", + "tableTo": "users", + "columnsFrom": [ + "validated_by_admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_validation_target_user_id_users_id_fk": { + "name": "challenge_validation_target_user_id_users_id_fk", + "tableFrom": "challenge_validation", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_validation_target_team_id_teams_id_fk": { + "name": "challenge_validation_target_team_id_teams_id_fk", + "tableFrom": "challenge_validation", + "tableTo": "teams", + "columnsFrom": [ + "target_team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_validation_target_faction_id_factions_id_fk": { + "name": "challenge_validation_target_faction_id_factions_id_fk", + "tableFrom": "challenge_validation", + "tableTo": "factions", + "columnsFrom": [ + "target_faction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_validation_added_by_admin_id_users_id_fk": { + "name": "challenge_validation_added_by_admin_id_users_id_fk", + "tableFrom": "challenge_validation", + "tableTo": "users", + "columnsFrom": [ + "added_by_admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.maker_battle_attribution": { + "name": "maker_battle_attribution", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "maker_team_id": { + "name": "maker_team_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "faction_id": { + "name": "faction_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "table": { + "name": "table", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "maker_battle_attribution_user_id_users_id_fk": { + "name": "maker_battle_attribution_user_id_users_id_fk", + "tableFrom": "maker_battle_attribution", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registration_tokens": { + "name": "registration_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "registration_tokens_user_id_users_id_fk": { + "name": "registration_tokens_user_id_users_id_fk", + "tableFrom": "registration_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "registration_tokens_token_unique": { + "name": "registration_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_points": { + "name": "role_points", + "schema": "", + "columns": { + "role_points": { + "name": "role_points", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "role_points_role_points_roles_id_fk": { + "name": "role_points_role_points_roles_id_fk", + "tableFrom": "role_points", + "tableTo": "roles", + "columnsFrom": [ + "role_points" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_points_role_points_pk": { + "name": "role_points_role_points_pk", + "columns": [ + "role_points" + ] + } + }, + "uniqueConstraints": { + "role_points_role_points_unique": { + "name": "role_points_role_points_unique", + "nullsNotDistinct": false, + "columns": [ + "role_points" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_faction": { + "name": "team_faction", + "schema": "", + "columns": { + "faction_id": { + "name": "faction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_faction_faction_id_factions_id_fk": { + "name": "team_faction_faction_id_factions_id_fk", + "tableFrom": "team_faction", + "tableTo": "factions", + "columnsFrom": [ + "faction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_faction_team_id_teams_id_fk": { + "name": "team_faction_team_id_teams_id_fk", + "tableFrom": "team_faction", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_faction_faction_id_team_id_pk": { + "name": "team_faction_faction_id_team_id_pk", + "columns": [ + "faction_id", + "team_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_shotgun": { + "name": "team_shotgun", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_shotgun_team_id_teams_id_fk": { + "name": "team_shotgun_team_id_teams_id_fk", + "tableFrom": "team_shotgun", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_informations": { + "name": "user_informations", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "emergency_contact_name": { + "name": "emergency_contact_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emergency_contact_phone": { + "name": "emergency_contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_informations_user_id_users_id_fk": { + "name": "user_informations_user_id_users_id_fk", + "tableFrom": "user_informations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.respo_permanences": { + "name": "respo_permanences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permanence_id": { + "name": "permanence_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "respo_permanences_user_id_users_id_fk": { + "name": "respo_permanences_user_id_users_id_fk", + "tableFrom": "respo_permanences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "respo_permanences_permanence_id_permanences_id_fk": { + "name": "respo_permanences_permanence_id_permanences_id_fk", + "tableFrom": "respo_permanences", + "tableTo": "permanences", + "columnsFrom": [ + "permanence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "respo_permanences_user_id_permanence_id_pk": { + "name": "respo_permanences_user_id_permanence_id_pk", + "columns": [ + "user_id", + "permanence_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_permanences": { + "name": "user_permanences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permanence_id": { + "name": "permanence_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "claimed": { + "name": "claimed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_permanences_user_id_users_id_fk": { + "name": "user_permanences_user_id_users_id_fk", + "tableFrom": "user_permanences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_permanences_permanence_id_permanences_id_fk": { + "name": "user_permanences_permanence_id_permanences_id_fk", + "tableFrom": "user_permanences", + "tableTo": "permanences", + "columnsFrom": [ + "permanence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_permanences_user_id_permanence_id_pk": { + "name": "user_permanences_user_id_permanence_id_pk", + "columns": [ + "user_id", + "permanence_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preferences_role_id_roles_id_fk": { + "name": "user_preferences_role_id_roles_id_fk", + "tableFrom": "user_preferences", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id_role_id_pk": { + "name": "user_preferences_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_teams": { + "name": "user_teams", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_teams_user_id_users_id_fk": { + "name": "user_teams_user_id_users_id_fk", + "tableFrom": "user_teams", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_teams_team_id_teams_id_fk": { + "name": "user_teams_team_id_teams_id_fk", + "tableFrom": "user_teams", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_teams_user_id_team_id_pk": { + "name": "user_teams_user_id_team_id_pk", + "columns": [ + "user_id", + "team_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tent": { + "name": "user_tent", + "schema": "", + "columns": { + "user_id_1": { + "name": "user_id_1", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id_2": { + "name": "user_id_2", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "confirmed": { + "name": "confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_tent_user_id_1_users_id_fk": { + "name": "user_tent_user_id_1_users_id_fk", + "tableFrom": "user_tent", + "tableTo": "users", + "columnsFrom": [ + "user_id_1" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_tent_user_id_2_users_id_fk": { + "name": "user_tent_user_id_2_users_id_fk", + "tableFrom": "user_tent", + "tableTo": "users", + "columnsFrom": [ + "user_id_2" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_tent_user_id_1_user_id_2_pk": { + "name": "user_tent_user_id_1_user_id_2_pk", + "columns": [ + "user_id_1", + "user_id_2" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vssqcmanswer": { + "name": "vssqcmanswer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "questionid": { + "name": "questionid", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer_en": { + "name": "answer_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "vssqcmanswer_questionid_vssqcmquestion_id_fk": { + "name": "vssqcmanswer_questionid_vssqcmquestion_id_fk", + "tableFrom": "vssqcmanswer", + "tableTo": "vssqcmquestion", + "columnsFrom": [ + "questionid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.vss_form": { + "name": "vss_form", + "schema": "public", + "values": [ + "pending", + "toretry", + "validated", + "rejected" + ] + }, + "public.question_type": { + "name": "question_type", + "schema": "public", + "values": [ + "single_choice", + "multiple_choice" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/src/database/migrations/meta/_journal.json b/backend/src/database/migrations/meta/_journal.json index 3424d95..62fbd6f 100644 --- a/backend/src/database/migrations/meta/_journal.json +++ b/backend/src/database/migrations/meta/_journal.json @@ -1,202 +1,209 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1743857362115, - "tag": "0000_workable_rafael_vega", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1743868246465, - "tag": "0001_stormy_quicksilver", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1743897931065, - "tag": "0002_luxuriant_lockjaw", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1743979532382, - "tag": "0003_material_lorna_dane", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1744064732443, - "tag": "0004_careless_misty_knight", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1744118020611, - "tag": "0005_needy_darwin", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1744126517418, - "tag": "0006_bright_boomerang", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1744212319949, - "tag": "0007_striped_mulholland_black", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1744215076184, - "tag": "0008_striped_bushwacker", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1744241202940, - "tag": "0009_previous_mystique", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1744241242502, - "tag": "0010_fair_mulholland_black", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1744241259194, - "tag": "0011_fearless_nextwave", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1746040673007, - "tag": "0012_productive_giant_man", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1752757642698, - "tag": "0013_curly_angel", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1753638076374, - "tag": "0014_special_reaper", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1753744481956, - "tag": "0015_greedy_genesis", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1754903172897, - "tag": "0016_sudden_ultimatum", - "breakpoints": true - }, - { - "idx": 17, - "version": "7", - "when": 1755637642205, - "tag": "0017_melted_meggan", - "breakpoints": true - }, - { - "idx": 18, - "version": "7", - "when": 1755858317109, - "tag": "0018_puzzling_arachne", - "breakpoints": true - }, - { - "idx": 19, - "version": "7", - "when": 1755906116757, - "tag": "0019_complete_moondragon", - "breakpoints": true - }, - { - "idx": 20, - "version": "7", - "when": 1755907709198, - "tag": "0020_strange_colonel_america", - "breakpoints": true - }, - { - "idx": 21, - "version": "7", - "when": 1756063134903, - "tag": "0021_colossal_madame_web", - "breakpoints": true - }, - { - "idx": 22, - "version": "7", - "when": 1757002717384, - "tag": "0022_light_omega_red", - "breakpoints": true - }, - { - "idx": 23, - "version": "7", - "when": 1782943789540, - "tag": "0023_zippy_colonel_america", - "breakpoints": true - }, - { - "idx": 24, - "version": "7", - "when": 1783776285772, - "tag": "0024_optimal_garia", - "breakpoints": true - }, - { - "idx": 25, - "version": "7", - "when": 1784742510283, - "tag": "0025_perfect_ulik", - "breakpoints": true - }, - { - "idx": 26, - "version": "7", - "when": 1784824629918, - "tag": "0026_swift_excalibur", - "breakpoints": true - }, - { - "idx": 27, - "version": "7", - "when": 1785000000000, - "tag": "0027_faulty_hellion", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1743857362115, + "tag": "0000_workable_rafael_vega", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1743868246465, + "tag": "0001_stormy_quicksilver", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1743897931065, + "tag": "0002_luxuriant_lockjaw", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1743979532382, + "tag": "0003_material_lorna_dane", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1744064732443, + "tag": "0004_careless_misty_knight", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1744118020611, + "tag": "0005_needy_darwin", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1744126517418, + "tag": "0006_bright_boomerang", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1744212319949, + "tag": "0007_striped_mulholland_black", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1744215076184, + "tag": "0008_striped_bushwacker", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1744241202940, + "tag": "0009_previous_mystique", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1744241242502, + "tag": "0010_fair_mulholland_black", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1744241259194, + "tag": "0011_fearless_nextwave", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1746040673007, + "tag": "0012_productive_giant_man", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1752757642698, + "tag": "0013_curly_angel", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1753638076374, + "tag": "0014_special_reaper", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1753744481956, + "tag": "0015_greedy_genesis", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1754903172897, + "tag": "0016_sudden_ultimatum", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1755637642205, + "tag": "0017_melted_meggan", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1755858317109, + "tag": "0018_puzzling_arachne", + "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1755906116757, + "tag": "0019_complete_moondragon", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1755907709198, + "tag": "0020_strange_colonel_america", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1756063134903, + "tag": "0021_colossal_madame_web", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1757002717384, + "tag": "0022_light_omega_red", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1782943789540, + "tag": "0023_zippy_colonel_america", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1783776285772, + "tag": "0024_optimal_garia", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1784742510283, + "tag": "0025_perfect_ulik", + "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1784824629918, + "tag": "0026_swift_excalibur", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1785000000000, + "tag": "0027_faulty_hellion", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1788026112577, + "tag": "0028_tan_slapstick", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/src/dto/battle_maker.dto.ts b/backend/src/dto/battle_maker.dto.ts new file mode 100644 index 0000000..a66483f --- /dev/null +++ b/backend/src/dto/battle_maker.dto.ts @@ -0,0 +1,3 @@ +export interface BattleMakerDTO { + groups: string[]; +} diff --git a/backend/src/dto/event.dto.ts b/backend/src/dto/event.dto.ts index d70cf2f..b126d58 100644 --- a/backend/src/dto/event.dto.ts +++ b/backend/src/dto/event.dto.ts @@ -3,10 +3,5 @@ export type ShotgunBody = { }; export type ToggleStatusBody = { - preRegistrationOpen?: boolean; - shotgunOpen?: boolean; - sdiOpen?: boolean; - weiOpen?: boolean; - foodOpen?: boolean; - challOpen?: boolean; + open?: boolean; }; diff --git a/backend/src/dto/maker_battle.dto.ts b/backend/src/dto/maker_battle.dto.ts new file mode 100644 index 0000000..e8c7b0c --- /dev/null +++ b/backend/src/dto/maker_battle.dto.ts @@ -0,0 +1,6 @@ +export interface GroupsList { + groups: string[]; +} +export interface Group { + group: string; +} diff --git a/backend/src/routes/event.routes.ts b/backend/src/routes/event.routes.ts deleted file mode 100644 index 206174d..0000000 --- a/backend/src/routes/event.routes.ts +++ /dev/null @@ -1,25 +0,0 @@ -import express from 'express'; -import * as eventController from '../controllers/event.controller'; -import { checkRole } from '../middlewares/user.middleware'; - -const eventRouter = express.Router(); - -// User routes -eventRouter.get("/user/shotgunstatus", checkRole("Student", []), eventController.checkShotgunStatus); -eventRouter.get("/user/preregisterstatus", checkRole("Student", []), eventController.checkPreRegisterStatus); -eventRouter.get("/user/sdistatus", eventController.checkSDIStatus); -eventRouter.get("/user/weistatus", eventController.checkWEIStatus); -eventRouter.get("/user/foodstatus", eventController.checkFoodStatus); -eventRouter.get("/user/challstatus", eventController.checkChallStatus); -eventRouter.post("/user/shotgunattempt", checkRole("Student", []), eventController.shotgunAttempt); - -// Admin routes -eventRouter.post("/admin/shotguntoggle", checkRole("Admin", []), eventController.toggleShotgun); -eventRouter.get("/admin/shotgunattempts", checkRole("Admin", ["Respo CE"]), eventController.getShotgunAttempts); -eventRouter.post("/admin/preregistrationtoggle", checkRole("Admin", []), eventController.togglePreRegistration); -eventRouter.post("/admin/sditoggle", checkRole("Admin", []), eventController.toggleSDI); -eventRouter.post("/admin/weitoggle", checkRole("Admin", []), eventController.toggleWEI); -eventRouter.post("/admin/foodtoggle", checkRole("Admin", []), eventController.toggleFood); -eventRouter.post("/admin/challtoggle", checkRole("Admin", []), eventController.toggleChall); - -export default eventRouter; diff --git a/backend/src/routes/im_export.routes.ts b/backend/src/routes/im_export.routes.ts index 7dbb04a..71cfeec 100644 --- a/backend/src/routes/im_export.routes.ts +++ b/backend/src/routes/im_export.routes.ts @@ -15,8 +15,8 @@ imexportRouter.post( ); imexportRouter.post('/admin/exportgsheet', checkRole('Admin', []), imexportController.exportAllDataToSheets); -imexportRouter.get('/admin/exportbus', checkRole('Admin', []), imexportController.exportUsersCSV); -imexportRouter.get('/admin/exportteammembers', checkRole('Admin', []), imexportController.exportTeamMembersCSV); +imexportRouter.get('/admin/exportbus', checkRole('Admin', []), imexportController.exportBus); +imexportRouter.get('/admin/exportteammembers', checkRole('Admin', []), imexportController.exportTeamMembers); imexportRouter.get( '/admin/document/:category/:item', checkRole('Admin', []), diff --git a/backend/src/routes/maker_battle.routes.ts b/backend/src/routes/maker_battle.routes.ts new file mode 100644 index 0000000..d4f3f8f --- /dev/null +++ b/backend/src/routes/maker_battle.routes.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import * as makerBattleController from '../controllers/maker_battle.controller'; +import { checkRole } from '../middlewares/user.middleware'; + +const makerBattleRouter = Router(); +makerBattleRouter.post('/admin/allocate', checkRole('Admin', ['Défis TC']), makerBattleController.distributeGroups); +makerBattleRouter.get('/admin/export/:group', checkRole('Admin', ['Défis TC']), makerBattleController.exportGroups); + +makerBattleRouter.get('/group/me', makerBattleController.getCurrentUser); +export default makerBattleRouter; diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts new file mode 100644 index 0000000..4e8ddc8 --- /dev/null +++ b/backend/src/routes/settings.routes.ts @@ -0,0 +1,17 @@ +import express from 'express'; +import * as settingsController from '../controllers/settings.controller'; +import { checkRole } from '../middlewares/user.middleware'; + +const settingsRouter = express.Router(); + +// User routes +settingsRouter.get('/user/status', settingsController.getAvailableSettings); +settingsRouter.get('/user/status/:setting', settingsController.getSettingStatus); +settingsRouter.post('/user/shotgunattempt', checkRole('Student', []), settingsController.shotgunAttempt); + +// Admin routes +settingsRouter.get('/admin/shotgunattempts', checkRole('Admin', ['Respo CE']), settingsController.getShotgunAttempts); +settingsRouter.get('/admin/settings', checkRole('Admin', []), settingsController.getAdminSettings); +settingsRouter.patch('/admin/status/:setting', checkRole('Admin', []), settingsController.updateSettingStatus); + +export default settingsRouter; diff --git a/backend/src/schemas/Basic/event.schema.ts b/backend/src/schemas/Basic/event.schema.ts index 0ca2969..466fd24 100644 --- a/backend/src/schemas/Basic/event.schema.ts +++ b/backend/src/schemas/Basic/event.schema.ts @@ -1,13 +1,14 @@ -import { boolean, pgTable, serial } from "drizzle-orm/pg-core"; +import { boolean, pgTable, serial } from 'drizzle-orm/pg-core'; -export const eventSchema = pgTable("events", { - id: serial("id").primaryKey(), - pre_registration_open: boolean("pre_registration_open").default(false), - shotgun_open: boolean("shotgun_open").default(false), - sdi_open: boolean("sdi_open").default(false), - wei_open: boolean("wei_open").default(false), - food_open: boolean("food_open").default(false), - chall_open: boolean("chall_open").default(false), +export const eventSchema = pgTable('events', { + id: serial('id').primaryKey(), + pre_registration_open: boolean('pre_registration_open').default(false), + shotgun_open: boolean('shotgun_open').default(false), + sdi_open: boolean('sdi_open').default(false), + wei_open: boolean('wei_open').default(false), + food_open: boolean('food_open').default(false), + chall_open: boolean('chall_open').default(false), + maker_battle_group_open: boolean('chall_open').default(false), }); export type Event = typeof eventSchema.$inferSelect; diff --git a/backend/src/schemas/Relational/makerbattletribution.schema.ts b/backend/src/schemas/Relational/makerbattletribution.schema.ts new file mode 100644 index 0000000..b898937 --- /dev/null +++ b/backend/src/schemas/Relational/makerbattletribution.schema.ts @@ -0,0 +1,15 @@ +import { integer, pgTable, text } from 'drizzle-orm/pg-core'; +import { userSchema } from '../Basic/user.schema'; + +export const MakerBattleAttributionSchema = pgTable('maker_battle_attribution', { + user_id: integer('user_id') + .primaryKey() + .notNull() + .references(() => userSchema.id), + maker_team_id: integer('maker_team_id').notNull(), + faction_id: integer('faction_id').notNull(), + table: integer('table'), + group: text('group').notNull(), +}); + +export type MakerBattleAttributionSchema = typeof MakerBattleAttributionSchema.$inferInsert; diff --git a/backend/src/services/event.service.ts b/backend/src/services/event.service.ts deleted file mode 100644 index 1457dec..0000000 --- a/backend/src/services/event.service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { asc, eq } from "drizzle-orm"; -import { db } from "../database/db"; -import { eventSchema } from "../schemas/Basic/event.schema"; -import { teamSchema } from "../schemas/Basic/team.schema"; -import { teamShotgunSchema } from "../schemas/Relational/teamshotgun.schema"; - -export const getEventsStatus = async () => { - const events = await db.select().from(eventSchema); - if (events.length > 0) { - return events[0]; // Renvoie le premier événement s'il existe - } else { - return null; // ou une valeur par défaut - } -}; - -export const validateShotgun = async (teamId: number) => { - await db.transaction(async (tx) => { - await tx.insert(teamShotgunSchema).values({ team_id: teamId }); - }) -}; - -export const alreadyShotgun = async (teamId: number) => { - const shotgunTeam = await db.select({ shotgunId: teamShotgunSchema.id }) - .from(teamShotgunSchema) - .where(eq(teamShotgunSchema.team_id, teamId)); - - if (shotgunTeam[0]) { - return true - } - else { - return false - } -}; - -export const updatepreRegistrationStatus = async (preRegistrationOpen: boolean) => { - return await db.update(eventSchema) - .set({ pre_registration_open: preRegistrationOpen }) - .returning(); -}; - -export const updateShotgunStatus = async (shotgunOpen: boolean) => { - return await db.update(eventSchema) - .set({ shotgun_open: shotgunOpen }) - .returning(); -}; - -export const getAllTeamShotguns = async () => { - return await db - .select({ - id: teamShotgunSchema.id, - teamId: teamShotgunSchema.team_id, - timestamp: teamShotgunSchema.timestamp, - teamName: teamSchema.name, - teamType: teamSchema.type, - }) - .from(teamShotgunSchema) - .leftJoin(teamSchema, eq(teamShotgunSchema.team_id, teamSchema.id)) - .orderBy(asc(teamShotgunSchema.timestamp), asc(teamShotgunSchema.id)); -}; - -export const updateSDIStatus = async (sdiOpen: boolean) => { - return await db.update(eventSchema) - .set({ sdi_open: sdiOpen }) - .returning(); -}; - -export const updateWEIStatus = async (weiOpen: boolean) => { - return await db.update(eventSchema) - .set({ wei_open: weiOpen }) - .returning(); -}; - -export const updateFoodStatus = async (foodOpen: boolean) => { - return await db.update(eventSchema) - .set({ food_open: foodOpen }) - .returning(); -}; - -export const updateChallStatus = async (challOpen: boolean) => { - return await db.update(eventSchema) - .set({ chall_open: challOpen }) - .returning(); -}; diff --git a/backend/src/services/im_export.service.ts b/backend/src/services/im_export.service.ts index 8f46705..5a99f42 100644 --- a/backend/src/services/im_export.service.ts +++ b/backend/src/services/im_export.service.ts @@ -1,10 +1,29 @@ -import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { google } from 'googleapis'; -import { parse } from 'json2csv'; import * as user_service from './user.service'; import path from 'path'; +export interface teamMemberUser { + prenom: string; + nom: string; + ce: boolean; + equipe: string | null; +} + +export interface busUser { + id: number; + prenom: string; + nom: string; + mail: string; + telephone: string; + nouveau: boolean; + ce: boolean; + num_equipe: number | null; + benevole: boolean; + orga: boolean; + majeur: boolean; +} + const keyFilePath = path.resolve(__dirname, '../utils/google_credentials.json'); // Crée une instance JWT en utilisant la clé du service account @@ -36,7 +55,7 @@ export const writeToGoogleSheet = async (spreadsheetId: string, range: string, v } }; -export const exportUsersToCSV = async (): Promise => { +export const exportBus = async (): Promise => { const users = await user_service.getUsersAll(); const formattedUsers = users.map((u) => { @@ -59,39 +78,13 @@ export const exportUsersToCSV = async (): Promise => { }; }); - const csv = parse(formattedUsers, { - fields: [ - 'id', - 'prenom', - 'nom', - 'mail', - 'telephone', - 'nouveau', - 'ce', - 'num_equipe', - 'benevole', - 'orga', - 'majeur', - 'bus_manual', - ], - }); - - const exportDir = path.join(__dirname, '../../exports/bus'); - const filePath = path.join(exportDir, 'bus.csv'); - - if (!existsSync(exportDir)) { - mkdirSync(exportDir, { recursive: true }); - } - - writeFileSync(filePath, csv); - - return filePath; + return formattedUsers; }; -export const exportTeamMembersToCSV = async (): Promise => { - const users = await user_service.getUsersAll(); +export const exportTeamMembers = async (): Promise => { + const usersInTeams = (await user_service.getUsersAll()).filter((u) => u.teamId !== null); - const formattedUsers = users.map((u) => { + const formattedUsers = usersInTeams.map((u) => { const isCE = u.permission === 'Student' && u.teamId !== null; return { prenom: u.first_name ?? '', @@ -101,19 +94,5 @@ export const exportTeamMembersToCSV = async (): Promise => { }; }); - const csv = parse(formattedUsers, { - fields: ['prenom', 'nom', 'ce', 'equipe'], - }); - - const exportDir = path.join(__dirname, '../../exports/teammembers'); - const filePath = path.join(exportDir, 'teammembers.csv'); - console.log('Exporting team members to:', filePath); - - if (!existsSync(exportDir)) { - mkdirSync(exportDir, { recursive: true }); - } - - writeFileSync(filePath, csv); - - return filePath; + return formattedUsers; }; diff --git a/backend/src/services/maker_battle.service.ts b/backend/src/services/maker_battle.service.ts new file mode 100644 index 0000000..83cd5d9 --- /dev/null +++ b/backend/src/services/maker_battle.service.ts @@ -0,0 +1,365 @@ +import { and, eq, inArray, not, or, sql } from 'drizzle-orm'; +import { db } from '../database/db'; +import { userSchema } from '../schemas/Basic/user.schema'; +import { userTeamsSchema } from '../schemas/Relational/userteams.schema'; +import { teamSchema } from '../schemas/Basic/team.schema'; +import { teamFactionSchema } from '../schemas/Relational/teamfaction.schema'; +import { MakerBattleAttributionSchema } from '../schemas/Relational/makerbattletribution.schema'; + +export interface UserWithTeamFaction { + user_id: number; + group: string; + team_id: number; + faction_id: number; +} + +export interface TeamsWithGroup { + maker_team_id: number; + group: string; +} + +export interface MakerBattleExport { + maker_team_id: number; + table: number; +} + +const TEAM_SIZE = 6; +const MAX_PLACEMENT_ATTEMPTS = 20; + +export const distributeGroups = async (group: string): Promise => { + let usersWithTeamsFactions = []; + + if (group === 'tc') { + usersWithTeamsFactions = await db + .select({ + user_id: userSchema.id, + team_id: userTeamsSchema.team_id, + faction_id: teamFactionSchema.faction_id, + }) + .from(userSchema) + .innerJoin(userTeamsSchema, eq(userSchema.id, userTeamsSchema.user_id)) + .innerJoin(teamSchema, eq(userTeamsSchema.team_id, teamSchema.id)) + .innerJoin(teamFactionSchema, eq(teamSchema.id, teamFactionSchema.team_id)) + .where( + and( + eq(userSchema.permission, 'Nouveau'), + or(eq(userSchema.branch, 'TC'), eq(userSchema.branch, 'IA_BACH')), + ), + ); + usersWithTeamsFactions = usersWithTeamsFactions.map((user) => ({ ...user, group: 'tc' })); + } else if (group === 'ri') { + usersWithTeamsFactions = await db + .select({ + user_id: userSchema.id, + team_id: userTeamsSchema.team_id, + faction_id: teamFactionSchema.faction_id, + }) + .from(userSchema) + .innerJoin(userTeamsSchema, eq(userSchema.id, userTeamsSchema.user_id)) + .innerJoin(teamSchema, eq(userTeamsSchema.team_id, teamSchema.id)) + .innerJoin(teamFactionSchema, eq(teamSchema.id, teamFactionSchema.team_id)) + .where(and(eq(userSchema.permission, 'Nouveau'), eq(userSchema.branch, 'RI'))); + usersWithTeamsFactions = usersWithTeamsFactions.map((user) => ({ ...user, group: 'ri' })); + } else if (group === 'branch') { + usersWithTeamsFactions = await db + .select({ + user_id: userSchema.id, + team_id: userTeamsSchema.team_id, + faction_id: teamFactionSchema.faction_id, + }) + .from(userSchema) + .innerJoin(userTeamsSchema, eq(userSchema.id, userTeamsSchema.user_id)) + .innerJoin(teamSchema, eq(userTeamsSchema.team_id, teamSchema.id)) + .innerJoin(teamFactionSchema, eq(teamSchema.id, teamFactionSchema.team_id)) + .where( + and( + eq(userSchema.permission, 'Nouveau'), + not( + or( + eq(userSchema.branch, 'RI'), + eq(userSchema.branch, 'TC'), + eq(userSchema.branch, 'IA_BACH'), + eq(userSchema.branch, 'MM'), + ), + ), + ), + ); + usersWithTeamsFactions = usersWithTeamsFactions.map((user) => ({ ...user, group: 'branch' })); + } else { + throw new Error(`Unknown group: ${group}`); + } + + if (usersWithTeamsFactions.length === 0) { + return; + } + + const usersByFaction = new Map(); + for (const user of usersWithTeamsFactions) { + if (!usersByFaction.has(user.faction_id)) { + usersByFaction.set(user.faction_id, []); + } + usersByFaction.get(user.faction_id)!.push(user); + } + + let numberOfTeams = 0; + for (const factionUsers of usersByFaction.values()) { + const neededForThisFaction = Math.ceil(factionUsers.length / TEAM_SIZE); + if (neededForThisFaction > numberOfTeams) { + numberOfTeams = neededForThisFaction; + } + } + + if (numberOfTeams === 0) { + return; + } + + const allTeams: UserWithTeamFaction[][] = []; + for (const [, factionUsers] of usersByFaction) { + const teamsForFaction = generateTeamsWithRetry(factionUsers, numberOfTeams, MAX_PLACEMENT_ATTEMPTS); + allTeams.push(...teamsForFaction); + } + + const rows: MakerBattleAttributionSchema[] = []; + let globalTeamId = 1; + + for (const team of allTeams) { + for (const user of team) { + rows.push({ + user_id: user.user_id, + maker_team_id: globalTeamId, + table: null, + faction_id: user.faction_id, + group: user.group, + }); + } + globalTeamId++; + } + + if (rows.length > 0) { + await db + .insert(MakerBattleAttributionSchema) + .values(rows) + .onConflictDoUpdate({ + target: MakerBattleAttributionSchema.user_id, + set: { + maker_team_id: sql`excluded.maker_team_id`, + table: sql`excluded.table`, + faction_id: sql`excluded.faction_id`, + group: sql`excluded.group`, + }, + }); + } + + return; +}; + +const generateTeamsWithRetry = ( + users: UserWithTeamFaction[], + numberOfTeams: number, + maxAttempts = MAX_PLACEMENT_ATTEMPTS, +): UserWithTeamFaction[][] => { + let lastError: unknown; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const result = generateTeams(users, numberOfTeams); + return result; + } catch (err) { + lastError = err; + } + } + + throw new Error( + `Impossible de générer les équipes après ${maxAttempts} tentatives. ` + + `Dernière erreur : ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ); +}; + +export const generateTeams = (users: UserWithTeamFaction[], numberOfTeams: number): UserWithTeamFaction[][] => { + const numberOfSixTeams = users.length - numberOfTeams * 5; + const capacities = [...Array(numberOfSixTeams).fill(6), ...Array(numberOfTeams - numberOfSixTeams).fill(5)]; + + const shuffle = (array: T[]): T[] => { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; + }; + + const usersByTeamId = new Map(); + for (const user of users) { + if (!usersByTeamId.has(user.team_id)) { + usersByTeamId.set(user.team_id, []); + } + usersByTeamId.get(user.team_id)!.push(user); + } + + for (const [teamId, members] of usersByTeamId) { + usersByTeamId.set(teamId, shuffle(members)); + } + + const teams: UserWithTeamFaction[][] = capacities.map(() => []); + + let placedAllUsers = false; + let attemptCount = 0; + const maxAttempts = 100; + + while (!placedAllUsers && attemptCount < maxAttempts) { + attemptCount++; + + for (let i = 0; i < teams.length; i++) { + teams[i] = []; + } + + const allUsers = shuffle([...users]); + + let allPlaced = true; + for (const user of allUsers) { + let placed = false; + + const shuffledTeamIndices = shuffle([...Array(numberOfTeams).keys()]); + + for (const teamIndex of shuffledTeamIndices) { + if (teams[teamIndex].length < capacities[teamIndex]) { + const hasSameTeam = teams[teamIndex].some((u) => u.team_id === user.team_id); + if (!hasSameTeam) { + teams[teamIndex].push(user); + placed = true; + break; + } + } + } + + if (!placed) { + for (const teamIndex of shuffledTeamIndices) { + if (teams[teamIndex].length < capacities[teamIndex]) { + teams[teamIndex].push(user); + placed = true; + break; + } + } + } + + if (!placed) { + allPlaced = false; + break; + } + } + + if (allPlaced) { + placedAllUsers = true; + } + } + + if (!placedAllUsers) { + throw new Error(`Could not place all users after ${maxAttempts} attempts`); + } + + return teams; +}; + +export const placeTeamsOnTables = async (groups: string[]): Promise => { + await db + .update(MakerBattleAttributionSchema) + .set({ table: null }) + .where(inArray(MakerBattleAttributionSchema.group, groups)); + + const teams = await db + .selectDistinct({ + maker_team_id: MakerBattleAttributionSchema.maker_team_id, + group: MakerBattleAttributionSchema.group, + faction_id: MakerBattleAttributionSchema.faction_id, + }) + .from(MakerBattleAttributionSchema) + .where(inArray(MakerBattleAttributionSchema.group, groups)); + if (teams.length === 0) return; + + const groupsMap = new Map(); + for (const t of teams) { + if (!groupsMap.has(t.group)) groupsMap.set(t.group, []); + groupsMap.get(t.group).push(t); + } + + const groupOrder = ['ri', 'branch', 'tc']; + const sortedGroups = groups.sort((a, b) => { + return groupOrder.indexOf(a) - groupOrder.indexOf(b); + }); + + const assignments = []; + let tableNum = 0; + + for (const group of sortedGroups) { + const groupTeams = groupsMap.get(group) || []; + if (groupTeams.length === 0) continue; + + const factions = new Map(); + for (const t of groupTeams) { + if (!factions.has(t.faction_id)) factions.set(t.faction_id, []); + factions.get(t.faction_id).push(t); + } + + const factionIds = Array.from(factions.keys()); + const maxLen = Math.max(...Array.from(factions.values()).map((arr) => arr.length)); + + for (let i = 0; i < maxLen; i++) { + for (const fid of factionIds) { + const fTeams = factions.get(fid); + if (i < fTeams.length) { + tableNum++; + assignments.push({ + maker_team_id: fTeams[i].maker_team_id, + faction_id: fTeams[i].faction_id, + group, + table: tableNum, + }); + } + } + } + } + + for (const assignment of assignments) { + await db + .update(MakerBattleAttributionSchema) + .set({ + table: assignment.table, + }) + .where( + and( + eq(MakerBattleAttributionSchema.maker_team_id, assignment.maker_team_id), + eq(MakerBattleAttributionSchema.group, assignment.group), + ), + ); + } +}; + +export const getUserTeam = async (userId: number): Promise<{ team_id: number; table: number } | undefined> => { + const result = await db + .select({ + team_id: MakerBattleAttributionSchema.maker_team_id, + table: MakerBattleAttributionSchema.table, + }) + .from(MakerBattleAttributionSchema) + .where(eq(MakerBattleAttributionSchema.user_id, userId)); + + return result[0]; +}; + +export const exportGroups = async (group: string): Promise => { + const teams = await db + .selectDistinct({ + first_name: userSchema.first_name, + last_name: userSchema.last_name, + user_id: MakerBattleAttributionSchema.user_id, + group: MakerBattleAttributionSchema.group, + faction_id: MakerBattleAttributionSchema.faction_id, + maker_team_id: MakerBattleAttributionSchema.maker_team_id, + table: MakerBattleAttributionSchema.table, + }) + .from(MakerBattleAttributionSchema) + .innerJoin(userSchema, eq(MakerBattleAttributionSchema.user_id, userSchema.id)) + .where(eq(MakerBattleAttributionSchema.group, group)); + + return teams; +}; diff --git a/backend/src/services/settings.service.ts b/backend/src/services/settings.service.ts new file mode 100644 index 0000000..43055a0 --- /dev/null +++ b/backend/src/services/settings.service.ts @@ -0,0 +1,116 @@ +import { asc, eq } from 'drizzle-orm'; +import { db } from '../database/db'; +import { eventSchema } from '../schemas/Basic/event.schema'; +import { teamSchema } from '../schemas/Basic/team.schema'; +import { teamShotgunSchema } from '../schemas/Relational/teamshotgun.schema'; + +export const settingColumns = { + preRegistration: 'pre_registration_open', + shotgun: 'shotgun_open', + sdi: 'sdi_open', + wei: 'wei_open', + food: 'food_open', + challenge: 'chall_open', + makerBattleGroup: 'maker_battle_group_open', +} as const; + +export type Setting = keyof typeof settingColumns; + +type SettingDefinition = { + key: Setting; + label: string; + column: (typeof settingColumns)[Setting]; + roles: string[]; +}; + +export const settingDefinitions: SettingDefinition[] = [ + { key: 'preRegistration', label: 'Pré-inscription', column: 'pre_registration_open', roles: [] }, + { key: 'shotgun', label: 'Shotgun', column: 'shotgun_open', roles: [] }, + { key: 'sdi', label: 'SDI (Billetterie)', column: 'sdi_open', roles: [] }, + { key: 'wei', label: 'WEI (Billetterie + Tentes)', column: 'wei_open', roles: [] }, + { key: 'food', label: 'Nourriture (Billetterie)', column: 'food_open', roles: [] }, + { key: 'challenge', label: 'Challenges (Affichage des challenges)', column: 'chall_open', roles: [] }, + { + key: 'makerBattleGroup', + label: 'Groupes de défis TC & Branche (Affichage des groupes)', + column: 'maker_battle_group_open', + roles: [], + }, +]; + +export const isSetting = (setting: string): setting is Setting => setting in settingColumns; + +export const getSettingsStatus = async () => { + const events = await db.select().from(eventSchema); + if (events.length > 0) { + return events[0]; // Renvoie le premier événement s'il existe + } else { + return null; // ou une valeur par défaut + } +}; + +export const getSettingStatus = async (setting: Setting) => { + const settings = await getSettingsStatus(); + return settings ? Boolean(settings[settingColumns[setting] as keyof typeof settings]) : false; +}; + +const canAccessSetting = (definition: SettingDefinition, userPermission: string, userRoles: string[]) => + userPermission === 'Admin' || + definition.roles.length === 0 || + definition.roles.includes(userPermission) || + definition.roles.some((role) => userRoles.includes(role)); + +export const getAvailableSettings = async (userPermission: string, userRoles: string[]) => { + const settings = await getSettingsStatus(); + + return settingDefinitions + .filter((definition) => canAccessSetting(definition, userPermission, userRoles)) + .map(({ key, label, roles, column }) => ({ + key, + label, + roles, + open: settings ? Boolean(settings[column as keyof typeof settings]) : false, + })); +}; + +export const getAllSettings = async () => getAvailableSettings('Admin', []); + +export const validateShotgun = async (teamId: number) => { + await db.transaction(async (tx) => { + await tx.insert(teamShotgunSchema).values({ team_id: teamId }); + }); +}; + +export const alreadyShotgun = async (teamId: number) => { + const shotgunTeam = await db + .select({ shotgunId: teamShotgunSchema.id }) + .from(teamShotgunSchema) + .where(eq(teamShotgunSchema.team_id, teamId)); + + if (shotgunTeam[0]) { + return true; + } else { + return false; + } +}; + +export const updateSettingStatus = async (setting: Setting, open: boolean) => { + return await db + .update(eventSchema) + .set({ [settingColumns[setting]]: open }) + .returning(); +}; + +export const getAllTeamShotguns = async () => { + return await db + .select({ + id: teamShotgunSchema.id, + teamId: teamShotgunSchema.team_id, + timestamp: teamShotgunSchema.timestamp, + teamName: teamSchema.name, + teamType: teamSchema.type, + }) + .from(teamShotgunSchema) + .leftJoin(teamSchema, eq(teamShotgunSchema.team_id, teamSchema.id)) + .orderBy(asc(teamShotgunSchema.timestamp), asc(teamShotgunSchema.id)); +}; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 6737f05..d1c7c90 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -18,6 +18,7 @@ import { userInformationSchema } from '../schemas/Relational/userinformation.sch import { addUserToRespondentStudentsList } from '../shared/integrations/billetweb'; import { generateEmailHtml, sendEmail } from './email.service'; import { email_from } from '../shared/secrets/secrets'; +import { MakerBattleAttributionSchema } from '../schemas/Relational/makerbattletribution.schema'; // Fonction pour récupérer un utilisateur par email export const getUserByEmail = async (email: string) => { @@ -206,8 +207,11 @@ export const getUsersAdmin = async () => { contact: userSchema.contact, permission: userSchema.permission, discord_id: userSchema.discord_id, + maker_battle_table: MakerBattleAttributionSchema.table, + maker_battle_team: MakerBattleAttributionSchema.maker_team_id, }) - .from(userSchema); + .from(userSchema) + .leftJoin(MakerBattleAttributionSchema, eq(userSchema.id, MakerBattleAttributionSchema.user_id)); return users; } catch (err) { console.error('Erreur lors de la récupération des utilisateurs ', err); diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f5b8a1e..ab6a1e8 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,6 +22,8 @@ ARG VITE_BDE_SIRET="" ARG VITE_BDE_SIREN="" ARG VITE_DPO_EMAIL="" ARG VITE_DPO_NAME="" +ARG VITE_APP_VERSION="" +ARG VITE_RELEASE_URL="" ENV VITE_CAS_LOGIN_URL=${VITE_CAS_LOGIN_URL} ENV VITE_SERVICE_URL=${VITE_SERVICE_URL} @@ -44,6 +46,8 @@ ENV VITE_BDE_SIRET=${VITE_BDE_SIRET} ENV VITE_BDE_SIREN=${VITE_BDE_SIREN} ENV VITE_DPO_EMAIL=${VITE_DPO_EMAIL} ENV VITE_DPO_NAME=${VITE_DPO_NAME} +ENV VITE_APP_VERSION=${VITE_APP_VERSION} +ENV VITE_RELEASE_URL=${VITE_RELEASE_URL} # COPY package.json package-lock.json ./ # RUN npm install -g npm@latest diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46b0562..8559d9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,12 +7,13 @@ import ProtectedRoute from './components/utils/protectedroute'; import { OnboardingProvider } from './contexts/onboarding'; import { PermanencesProvider } from './contexts/permanences'; import { UserProvider } from './contexts/user'; +import AdminPageMakerBattle from './pages/admin/adminMakerBattle'; const AdminPageBanned = lazy(() => import('./pages/admin/adminBanned')); const AdminPageBus = lazy(() => import('./pages/admin/adminBus')); const AdminPageChallenges = lazy(() => import('./pages/admin/adminChallenges')); const AdminPageEmail = lazy(() => import('./pages/admin/adminEmail')); -const AdminPageEvents = lazy(() => import('./pages/admin/adminEvents')); +const AdminPageSettings = lazy(() => import('./pages/admin/adminSettings')); const AdminPageExport = lazy(() => import('./pages/admin/adminExport')); const AdminPageFaction = lazy(() => import('./pages/admin/adminFaction')); const AdminPageGames = lazy(() => import('./pages/admin/adminGames')); @@ -264,10 +265,10 @@ const App: React.FC = () => { } /> - + } /> @@ -327,6 +328,14 @@ const App: React.FC = () => { } /> + + + + } + /> {/* Fallback */} } /> diff --git a/frontend/src/components/Admin/AdminMakerBattle/adminMakerBattleDownload.tsx b/frontend/src/components/Admin/AdminMakerBattle/adminMakerBattleDownload.tsx new file mode 100644 index 0000000..94204fa --- /dev/null +++ b/frontend/src/components/Admin/AdminMakerBattle/adminMakerBattleDownload.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import Select from 'react-select'; +import Swal from 'sweetalert2'; + +import { type MakerBattleGroupTypeOption } from '../../../interfaces/maker_battle.interface'; +import { fetchExportData } from '../../../services/requests/maker_battle.service'; +import { downloadJsonAsCsv } from '../../../utils/utils'; +import { Button } from '../../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; + +type Props = { + groupTypeOptions: MakerBattleGroupTypeOption[]; +}; + +export const AdminMakerBattleTeamDownload = ({ groupTypeOptions }: Props) => { + const [selectedGroupType, setSelectedGroupType] = useState(null); + + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownloadGroup = async () => { + if (!selectedGroupType) { + Swal.fire({ + icon: 'error', + title: 'Erreur', + text: 'Veuillez sélectionner un groupe.', + }); + return; + } + + setIsDownloading(true); + + try { + const exportData = await fetchExportData(selectedGroupType); + + downloadJsonAsCsv(exportData, `maker_battle_${selectedGroupType.value}_${Date.now()}.csv`); + } catch (error) { + console.error('Erreur lors du téléchargement des groupes:', error); + + Swal.fire({ + icon: 'error', + title: 'Erreur', + text: 'Une erreur est survenue lors du téléchargement des groupes.', + }); + } finally { + setIsDownloading(false); + } + }; + + return ( + + + Téléchargement + + + +
+ + + + inputId="maker-battle-group-types" + options={groupTypeOptions} + value={selectedGroupType} + onChange={setSelectedGroupType} + placeholder="Sélectionner un type" + isClearable + isDisabled={isDownloading} + /> +
+ +
+ +
+
+
+ ); +}; diff --git a/frontend/src/components/Admin/AdminMakerBattle/adminMakerBattleTeamGeneration.tsx b/frontend/src/components/Admin/AdminMakerBattle/adminMakerBattleTeamGeneration.tsx new file mode 100644 index 0000000..f9060fe --- /dev/null +++ b/frontend/src/components/Admin/AdminMakerBattle/adminMakerBattleTeamGeneration.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import Select, { type MultiValue } from 'react-select'; +import Swal from 'sweetalert2'; + +import type { MakerBattleGroupTypeOption } from '../../../interfaces/maker_battle.interface'; +import { allocateGroups } from '../../../services/requests/maker_battle.service'; +import { Button } from '../../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; + +type Props = { + groupTypeOptions: MakerBattleGroupTypeOption[]; +}; + +export const AdminMakerBattleTeamGeneration = ({ groupTypeOptions }: Props) => { + const [selectedGroupTypes, setSelectedGroupTypes] = useState([]); + + const handleGenerateGroups = async () => { + if (selectedGroupTypes.length === 0) { + Swal.fire({ + icon: 'error', + title: 'Erreur', + text: 'Veuillez sélectionner au moins un type de groupe.', + }); + return; + } + + try { + await allocateGroups(selectedGroupTypes); + Swal.fire({ + icon: 'success', + title: 'Répartition réussie', + text: 'Les nouveaux ont été répartis par table avec succès.', + }); + setSelectedGroupTypes([]); // Réinitialiser la sélection après la répartition + } catch (error) { + console.error('Erreur lors de la répartition des groupes:', error); + Swal.fire({ + icon: 'error', + title: 'Erreur', + text: 'Une erreur est survenue lors de la répartition des groupes.', + }); + } + }; + + return ( + + + + Répartition des nouveaux +
+ Batailles de conception +
+
+ +
+ + +
+ {/* Team */} + + + + {/* Team */} diff --git a/frontend/src/components/home/makerBattleSection.tsx b/frontend/src/components/home/makerBattleSection.tsx new file mode 100644 index 0000000..a721f51 --- /dev/null +++ b/frontend/src/components/home/makerBattleSection.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; + +import { useUser } from '../../contexts/user'; +import type { MakerBattleGroupResponseData } from '../../interfaces/maker_battle.interface'; +import { getUserGroup } from '../../services/requests/maker_battle.service'; +import { checkMakerBattleGroupStatus } from '../../services/requests/settings.service'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; + +export const MakerBattle = () => { + const [groupInfos, setGroupInfos] = useState(null); + const [loading, setLoading] = useState(true); + const [makerBattleType, setMakerBattleType] = useState(''); + const { user, loading: userLoading } = useUser(); + + useEffect(() => { + const fetchGroup = async () => { + const isEnabled = await checkMakerBattleGroupStatus(); + + if (!isEnabled) { + setLoading(false); + return; + } + + const { group } = await getUserGroup(); + setGroupInfos(group); + setLoading(false); + }; + + if (user?.permission === 'Nouveau') { + void fetchGroup(); + setMakerBattleType(['TC', 'IA_BACH'].includes(user.branch) ? 'TC' : 'Branche'); + } else { + // if not a new user, we don't need to load team + setLoading(false); + } + }, [user]); + + const loadingDiv = ( +
+ + +

Chargement de ton groupe de défis...

+
+
+
+ ); + + if (userLoading) { + return loadingDiv; + } + + if (user?.permission !== 'Nouveau') { + return; + } + + if (loading) { + return loadingDiv; + } + + if (!groupInfos) { + return; + } + + return ( +
+ + + + Trouve ta table au défi {makerBattleType} ! + + + +

Tu as été placé(e) à la table

+

+ + {groupInfos.table} + +

+

+ Rejoins ta table dès que possible pour rencontrer tes cohéquipiers et cohéquipières pour relever + le défi ! +

+
+
+
+ ); +}; diff --git a/frontend/src/components/home/teamSection.tsx b/frontend/src/components/home/teamSection.tsx index d825d35..7e0a51a 100644 --- a/frontend/src/components/home/teamSection.tsx +++ b/frontend/src/components/home/teamSection.tsx @@ -27,16 +27,18 @@ export const Team = () => { } }, [user]); + const loadingDiv = ( +
+ + +

Chargement de ton équipe...

+
+
+
+ ); + if (userLoading) { - return ( -
- - -

Chargement de ton équipe...

-
-
-
- ); + return loadingDiv; } if (user?.permission !== 'Nouveau') { @@ -44,15 +46,7 @@ export const Team = () => { } if (loading) { - return ( -
- - -

Chargement de ton équipe...

-
-
-
- ); + return loadingDiv; } if (!teamInfos) { diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index 79bd146..f02a0ec 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -91,14 +91,15 @@ export const Navbar = () => { { label: 'Bannis', to: '/admin/banned', rolesAllowed: ['Admin'] }, { label: 'Bus', to: '/admin/bus', rolesAllowed: ['Admin'] }, { label: 'Challenge', to: '/admin/challenge', rolesAllowed: ['Admin', 'Arbitre'] }, + { label: 'Défis TC', to: '/admin/maker-battle', rolesAllowed: ['Admin', 'Défis TC'] }, { label: 'Email', to: '/admin/email', rolesAllowed: ['Admin'] }, - { label: 'Events', to: '/admin/events', rolesAllowed: ['Admin'] }, { label: 'Export / Import', to: '/admin/export-import', rolesAllowed: ['Admin'] }, { label: 'Factions', to: '/admin/factions', rolesAllowed: ['Admin', 'Respo CE'] }, { label: 'Games', to: '/admin/games', rolesAllowed: ['Admin'] }, { label: 'News', to: '/admin/news', rolesAllowed: ['Admin', 'Communication'] }, { label: 'Permanences', to: '/admin/permanences', rolesAllowed: ['Admin', 'Respo CE'] }, { label: 'Roles', to: '/admin/roles', rolesAllowed: ['Admin'] }, + { label: 'Settings', to: '/admin/settings', rolesAllowed: ['Admin'] }, { label: 'Shotgun', to: '/admin/shotgun', rolesAllowed: ['Admin', 'Respo CE'] }, { label: 'Teams', to: '/admin/teams', rolesAllowed: ['Admin', 'Respo CE'] }, { label: 'Tentes', to: '/admin/tent', rolesAllowed: ['Admin'] }, diff --git a/frontend/src/components/shotgun/preregisterCESection.tsx b/frontend/src/components/shotgun/preregisterCESection.tsx index 8a8e907..3c03751 100644 --- a/frontend/src/components/shotgun/preregisterCESection.tsx +++ b/frontend/src/components/shotgun/preregisterCESection.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react'; -import { checkPreRegisterStatus } from "../../services/requests/event.service"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { checkPreRegisterStatus } from '../../services/requests/settings.service'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; export const PreregisterCESection = () => { const [isPreRegistrationOpen, setIsPreRegistrationOpen] = useState(false); @@ -12,7 +12,7 @@ export const PreregisterCESection = () => { const status = await checkPreRegisterStatus(); setIsPreRegistrationOpen(status); } catch { - alert("Erreur lors de la récupération du statut de pré-inscription."); + alert('Erreur lors de la récupération du statut de pré-inscription.'); } }; fetchStatus(); @@ -35,8 +35,7 @@ export const PreregisterCESection = () => { src="https://forms.gle/32yHKGSTzfFvp7NP9" className="absolute inset-0 w-full h-full border-none" title="Formulaire de pré-inscription CE" - loading="lazy" - > + loading="lazy"> Chargement… diff --git a/frontend/src/components/shotgun/preregisterTeamSection.tsx b/frontend/src/components/shotgun/preregisterTeamSection.tsx index 6e3fa46..cd8eae0 100644 --- a/frontend/src/components/shotgun/preregisterTeamSection.tsx +++ b/frontend/src/components/shotgun/preregisterTeamSection.tsx @@ -1,15 +1,15 @@ -import { useEffect, useState } from "react"; -import Select from "react-select"; +import { useEffect, useState } from 'react'; +import Select from 'react-select'; -import { checkPreRegisterStatus } from "../../services/requests/event.service"; -import { createTeam } from "../../services/requests/team.service"; -import { getUsers } from "../../services/requests/user.service"; -import { Button } from "../ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; -import { Input } from "../ui/input"; +import { checkPreRegisterStatus } from '../../services/requests/settings.service'; +import { createTeam } from '../../services/requests/team.service'; +import { getUsers } from '../../services/requests/user.service'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Input } from '../ui/input'; export const PreregisterTeamSection = () => { - const [teamName, setTeamName] = useState(""); + const [teamName, setTeamName] = useState(''); const [members, setMembers] = useState([]); const [isPreRegistrationOpen, setIsPreRegistrationOpen] = useState(false); const [users, setUsers] = useState<{ userId: number; firstName: string; lastName: string }[]>([]); @@ -20,7 +20,7 @@ export const PreregisterTeamSection = () => { const status = await checkPreRegisterStatus(); setIsPreRegistrationOpen(status); } catch { - alert("Erreur lors de la récupération du statut de pré-inscription."); + alert('Erreur lors de la récupération du statut de pré-inscription.'); } }; fetchStatus(); @@ -32,7 +32,7 @@ export const PreregisterTeamSection = () => { const userList = await getUsers(); setUsers(userList); } catch { - alert("Erreur lors de la récupération des utilisateurs."); + alert('Erreur lors de la récupération des utilisateurs.'); } }; fetchUsers(); @@ -72,20 +72,23 @@ export const PreregisterTeamSection = () => { {isPreRegistrationOpen ? ( <> -

Etape 1: le GForm de motivation !

+

+ Etape 1: le GForm de motivation ! +

-

Etape 2: Sélection des membres

+

+ Etape 2: Sélection des membres +

@@ -141,15 +144,15 @@ export const PreregisterTeamSection = () => {

- Si tu ne trouves pas un coéquipier, c'est qu'il ne s'est jamais connecté sur ce site ! + Si tu ne trouves pas un coéquipier, c'est qu'il ne s'est jamais connecté sur ce + site !
Il lui suffit de se connecter une fois pour apparaitre dans cette liste.

@@ -162,6 +165,6 @@ export const PreregisterTeamSection = () => {

)}
-
+
); }; diff --git a/frontend/src/components/shotgun/shotgunSection.tsx b/frontend/src/components/shotgun/shotgunSection.tsx index ba41295..8c62380 100644 --- a/frontend/src/components/shotgun/shotgunSection.tsx +++ b/frontend/src/components/shotgun/shotgunSection.tsx @@ -1,17 +1,17 @@ -import { type AxiosError } from "axios"; -import { useEffect, useState } from "react"; +import { type AxiosError } from 'axios'; +import { useEffect, useState } from 'react'; -import { type ApiErrorResponse } from "../../interfaces/event.interface"; -import { attemptShotgun, checkShotgunStatus } from "../../services/requests/event.service"; -import { Button } from "../ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; -import { Input } from "../ui/input"; +import { type ApiErrorResponse } from '../../interfaces/settings.interface'; +import { attemptShotgun, checkShotgunStatus } from '../../services/requests/settings.service'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Input } from '../ui/input'; export const Shotgun = () => { const [status, setStatus] = useState(false); - const [message, setMessage] = useState(""); - const [inputValue, setInputValue] = useState(""); - const [shotgunPassword, setShotgunPassword] = useState(""); + const [message, setMessage] = useState(''); + const [inputValue, setInputValue] = useState(''); + const [shotgunPassword, setShotgunPassword] = useState(''); useEffect(() => { const fetchStatus = async () => { @@ -26,12 +26,12 @@ export const Shotgun = () => { e.preventDefault(); if (!shotgunPassword) { - setMessage("❌ Erreur : mot de passe shotgun indisponible."); + setMessage('❌ Erreur : mot de passe shotgun indisponible.'); return; } if (inputValue !== shotgunPassword) { - setMessage("❌ Erreur : Mot de passe de Shotgun incorrect."); + setMessage('❌ Erreur : Mot de passe de Shotgun incorrect.'); return; } @@ -40,16 +40,14 @@ export const Shotgun = () => { setMessage(response.message); } catch (error) { const axiosError = error as AxiosError; - setMessage(axiosError.response?.data?.message || "Une erreur est survenue."); + setMessage(axiosError.response?.data?.message || 'Une erreur est survenue.'); } }; return ( - - Shotgun 🎯 - + Shotgun 🎯

Tape exactement la bonne phrase pour valider ton shotgun (majuscules incluses).

@@ -57,9 +55,10 @@ export const Shotgun = () => {

- Mot à entrer :{" "} - - {shotgunPassword || "patience..."} + Mot à entrer :{' '} + + {shotgunPassword || 'patience...'}

{!status && ( @@ -80,17 +79,18 @@ export const Shotgun = () => { /> {message && (

+ className={`text-center text-lg mt-4 ${ + message.includes('Erreur') || + message.toLowerCase().includes('déjà') || + message.toLowerCase().includes('incorrect') + ? 'text-red-500' + : 'text-green-600' + }`}> {message}

)} diff --git a/frontend/src/components/tent/tentSection.tsx b/frontend/src/components/tent/tentSection.tsx index a641ab9..cd6ca7a 100644 --- a/frontend/src/components/tent/tentSection.tsx +++ b/frontend/src/components/tent/tentSection.tsx @@ -6,7 +6,7 @@ import { useOnboarding } from '../../contexts/onboarding'; import { type Tent } from '../../interfaces/tent.interface'; import { type User } from '../../interfaces/user.interface'; import { decodeToken, getToken } from '../../services/requests/auth.service'; -import { checkWEIStatus } from '../../services/requests/event.service'; +import { checkWEIStatus } from '../../services/requests/settings.service'; import { cancelTent, createTent, getUserTent } from '../../services/requests/tent.service'; import { getUsers } from '../../services/requests/user.service'; import { Button } from '../ui/button'; diff --git a/frontend/src/interfaces/maker_battle.interface.ts b/frontend/src/interfaces/maker_battle.interface.ts new file mode 100644 index 0000000..3b4410e --- /dev/null +++ b/frontend/src/interfaces/maker_battle.interface.ts @@ -0,0 +1,13 @@ +export interface MakerBattleGroupTypeOption { + value: string; + label: string; +} + +export interface MakerBattleGroupDownloadResponseData { + filename: string; +} + +export interface MakerBattleGroupResponseData { + table: number; + team_id: number; +} diff --git a/frontend/src/interfaces/event.interface.ts b/frontend/src/interfaces/settings.interface.ts similarity index 84% rename from frontend/src/interfaces/event.interface.ts rename to frontend/src/interfaces/settings.interface.ts index 5954848..983c50f 100644 --- a/frontend/src/interfaces/event.interface.ts +++ b/frontend/src/interfaces/settings.interface.ts @@ -3,6 +3,12 @@ export interface ShotgunStatusData { password: string; } +export interface Setting { + key: string; + label: string; + open: boolean; +} + export interface ShotgunAttemptPayload { password: string; } diff --git a/frontend/src/interfaces/user.interface.ts b/frontend/src/interfaces/user.interface.ts index 5234bc2..b14c0a9 100644 --- a/frontend/src/interfaces/user.interface.ts +++ b/frontend/src/interfaces/user.interface.ts @@ -11,6 +11,11 @@ export interface User { vss_form?: 'pending' | 'toretry' | 'validated' | 'rejected'; } +export interface UserWithMakerBattle extends User { + maker_battle_table: number | null; + maker_battle_team: number | null; +} + export interface UserContactInformation { userId: number; emergency_contact_name: string; diff --git a/frontend/src/pages/admin/adminMakerBattle.tsx b/frontend/src/pages/admin/adminMakerBattle.tsx new file mode 100644 index 0000000..4fd47ca --- /dev/null +++ b/frontend/src/pages/admin/adminMakerBattle.tsx @@ -0,0 +1,26 @@ +import { AdminLayout } from '../../components/Admin/adminLayout'; +import { AdminMakerBattleTeamDownload } from '../../components/Admin/AdminMakerBattle/adminMakerBattleDownload'; +import { AdminMakerBattleTeamGeneration } from '../../components/Admin/AdminMakerBattle/adminMakerBattleTeamGeneration'; +import { RevealSection } from '../../components/ui/revealSection'; +import type { MakerBattleGroupTypeOption } from '../../interfaces/maker_battle.interface'; + +const groupTypeOptions: MakerBattleGroupTypeOption[] = [ + { value: 'tc', label: 'TC' }, + { value: 'ri', label: 'RI' }, + { value: 'branch', label: 'Branche' }, +]; + +const AdminPageMakerBattle: React.FC = () => ( + +
+ + + + + + +
+
+); + +export default AdminPageMakerBattle; diff --git a/frontend/src/pages/admin/adminEvents.tsx b/frontend/src/pages/admin/adminSettings.tsx similarity index 63% rename from frontend/src/pages/admin/adminEvents.tsx rename to frontend/src/pages/admin/adminSettings.tsx index 27b200f..b53e27c 100644 --- a/frontend/src/pages/admin/adminEvents.tsx +++ b/frontend/src/pages/admin/adminSettings.tsx @@ -1,15 +1,15 @@ -import { AdminEvents } from '../../components/Admin/adminEvent'; import { AdminLayout } from '../../components/Admin/adminLayout'; +import { AdminSettings } from '../../components/Admin/adminSettings'; import { RevealSection } from '../../components/ui/revealSection'; -const AdminPageEvents: React.FC = () => ( +const AdminPageSettings: React.FC = () => (
- +
); -export default AdminPageEvents; +export default AdminPageSettings; diff --git a/frontend/src/services/requests/event.service.ts b/frontend/src/services/requests/event.service.ts deleted file mode 100644 index bc46a59..0000000 --- a/frontend/src/services/requests/event.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type ApiMessageResponse, type ShotgunAttemptPayload, type ShotgunAttemptRow, type ShotgunStatusData } from '../../interfaces/event.interface'; -import api from '../api'; - -export const checkShotgunStatus = async (): Promise => { - const response = await api.get<{ data: ShotgunStatusData }>("/event/user/shotgunstatus"); - return response.data.data; -}; - -export const checkPreRegisterStatus = async () => { - const response = await api.get("/event/user/preregisterstatus"); - return response.data.data; -}; - -export const checkSDIStatus = async () => { - const response = await api.get("/event/user/sdistatus"); - return response.data.data; -}; - -export const checkWEIStatus = async () => { - const response = await api.get("/event/user/weistatus"); - return response.data.data; -}; - -export const checkFoodStatus = async () => { - const response = await api.get("/event/user/foodstatus"); - return response.data.data; -}; - -export const checkChallengeStatus = async () => { - const response = await api.get("/event/user/challstatus"); - return response.data.data; -}; - -export const attemptShotgun = async (payload: ShotgunAttemptPayload): Promise => { - const response = await api.post("event/user/shotgunattempt", payload); - return response.data; -}; - -export const getShotgunAttemptsAdmin = async (): Promise => { - const response = await api.get<{ data: ShotgunAttemptRow[] }>("/event/admin/shotgunattempts"); - return response.data.data; -}; - -export const toggleShotgun = async (shotgunOpen: boolean) => { - const response = await api.post(`event/admin/shotguntoggle`, { shotgunOpen }); - return response.data; -}; - -export const togglePreRegistration = async (preRegistrationOpen: boolean) => { - const response = await api.post(`event/admin/preregistrationtoggle`, { preRegistrationOpen }); - return response.data; -}; - -export const toggleSDI = async (sdiOpen: boolean) => { - const response = await api.post(`event/admin/sditoggle`, { sdiOpen }); - return response.data; -}; - -export const toggleWEI = async (weiOpen: boolean) => { - const response = await api.post(`event/admin/weitoggle`, { weiOpen }); - return response.data; -}; - -export const toggleFood = async (foodOpen: boolean) => { - const response = await api.post(`event/admin/foodtoggle`, { foodOpen }); - return response.data; -}; - -export const toggleChallenge = async (challOpen: boolean) => { - const response = await api.post(`event/admin/challtoggle`, { challOpen }); - return response.data; -}; diff --git a/frontend/src/services/requests/maker_battle.service.ts b/frontend/src/services/requests/maker_battle.service.ts new file mode 100644 index 0000000..f58f8b4 --- /dev/null +++ b/frontend/src/services/requests/maker_battle.service.ts @@ -0,0 +1,17 @@ +import type { MakerBattleGroupTypeOption } from '../../interfaces/maker_battle.interface'; +import api from '../api'; + +export const allocateGroups = async (groups: MakerBattleGroupTypeOption[]) => { + const response = await api.post('/maker-battle/admin/allocate', { groups: groups.map((group) => group.value) }); + return response.data.data; +}; + +export const fetchExportData = async (group: MakerBattleGroupTypeOption) => { + const response = await api.get(`/maker-battle/admin/export/${group.value}`); + return response.data.data; +}; + +export const getUserGroup = async () => { + const response = await api.get(`/maker-battle/group/me`); + return response.data.data; +}; diff --git a/frontend/src/services/requests/settings.service.ts b/frontend/src/services/requests/settings.service.ts new file mode 100644 index 0000000..32a8bf9 --- /dev/null +++ b/frontend/src/services/requests/settings.service.ts @@ -0,0 +1,57 @@ +import { + type ApiMessageResponse, + type Setting, + type ShotgunAttemptPayload, + type ShotgunAttemptRow, + type ShotgunStatusData, +} from '../../interfaces/settings.interface'; +import api from '../api'; + +export const checkShotgunStatus = async (): Promise => { + const response = await api.get<{ data: ShotgunStatusData }>('/settings/user/status/shotgun'); + return response.data.data; +}; + +const getSetting = async (key: string): Promise => { + const response = await api.get<{ data: Array }>('/settings/user/status'); + const setting = response.data.data.find((item) => item.key === key); + if (!setting) throw new Error(`Le setting ${key} n'est pas disponible.`); + return setting; +}; + +export const getSettings = async (): Promise => { + const response = await api.get<{ data: Setting[] }>('/settings/user/status'); + return response.data.data; +}; + +export const getAdminSettings = async (): Promise => { + const response = await api.get<{ data: Setting[] }>('/settings/admin/settings'); + return response.data.data; +}; + +export const updateSetting = async (key: string, open: boolean) => { + const response = await api.patch(`/settings/admin/status/${key}`, { open }); + return response.data; +}; + +export const checkPreRegisterStatus = async () => (await getSetting('preRegistration')).open; + +export const checkSDIStatus = async () => (await getSetting('sdi')).open; + +export const checkWEIStatus = async () => (await getSetting('wei')).open; + +export const checkFoodStatus = async () => (await getSetting('food')).open; + +export const checkChallengeStatus = async () => (await getSetting('challenge')).open; + +export const checkMakerBattleGroupStatus = async () => (await getSetting('makerBattleGroup')).open; + +export const attemptShotgun = async (payload: ShotgunAttemptPayload): Promise => { + const response = await api.post('settings/user/shotgunattempt', payload); + return response.data; +}; + +export const getShotgunAttemptsAdmin = async (): Promise => { + const response = await api.get<{ data: ShotgunAttemptRow[] }>('/settings/admin/shotgunattempts'); + return response.data.data; +}; diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 26e8e4d..3a61909 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -38,3 +38,53 @@ export const checkUploadAvailability = async (url: string, callback?: () => void return false; } }; + +type CsvRow = Record; + +const escapeCsvValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + + const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value); + + // CSV : on entoure de guillemets si nécessaire + if (/[",\n\r]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + + return stringValue; +}; + +export const downloadJsonAsCsv = (data: CsvRow[], filename = 'export.csv'): void => { + if (!data.length) { + console.warn('Aucune donnée à exporter.'); + return; + } + + const headers = Array.from(new Set(data.flatMap((row) => Object.keys(row)))); + + const csvRows = [ + headers.map(escapeCsvValue).join(','), + ...data.map((row) => headers.map((header) => escapeCsvValue(row[header])).join(',')), + ]; + + // BOM UTF-8 pour une bonne gestion des accents avec Excel (c'est copilot qui veut) + const csv = `\uFEFF${csvRows.join('\r\n')}`; + + const blob = new Blob([csv], { + type: 'text/csv;charset=utf-8;', + }); + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); +};