Aller au contenu

Alert-Parent Backend

Description

Alert-Parent est une plateforme interne qui permet aux équipes pédagogiques d'EPITECH Bénin d'envoyer des alertes aux parents et référents financiers des étudiants concernant leurs résultats académiques.

Fonctionnalités principales

  • Gestion des promos : création de promotions par cycle (Bachelor, MSc, Codac) et année de fin
  • Gestion multi-campus : création de campus, promos par campus, étudiants rattachés à un campus
  • Gestion des étudiants : création unitaire, import CSV en masse, changement de campus/classe/promo
  • Alertes pédagogiques : Tepitech, Stumpers, Modules, Présences, Projets, Roadblocks, -42, Évaluation de stage, Absence, Comportement, Information générale
  • Templates d'alertes dynamiques : templates globaux, surcharges par campus, corps HTML WYSIWYG, variables et fichiers attendus
  • Campagnes d'alertes : preview éphémère, brouillon, simulation d'envoi (dry-run), envoi, historique, surcharges par campagne ou par étudiant
  • File d'attente email : les alertes sont mises en queue dans MongoDB et envoyées progressivement via l'API Resend
  • Authentification : JWT classique + Microsoft Azure AD (SSO)

Architecture

Stack technique

Composant Technologie
Framework Express.js (Node.js 20)
Base de données MongoDB Atlas
Email transactionnel Resend API
Authentification JWT + Microsoft Azure AD
Conteneur Docker (node:20-alpine, pnpm)

Routes API

Route Description
/api/users Inscription, login, gestion des comptes admin
/api/students CRUD étudiants, import CSV, bulk update, soft delete
/api/promos CRUD promos, mapping classe/promo
/api/alerts Lecture seule (historique). Les POST d'envoi par type sont désactivés (HTTP 410) → voir /api/alert-campaigns
/api/campuses CRUD campus
/api/alert-templates Templates d'alertes dynamiques (+ field-options par classe)
/api/alert-campaigns Campagnes d'alertes, preview, brouillons, simulation d'envoi, envoi, mode libre, variables
/api/docs Documentation Swagger interactive
/health Endpoint de vérification de santé

Modèle de données

Student
├── firstname, lastname, email, sexe
├── campus (ObjectId → Campus)
├── promo (ObjectId → Promo, populated: {cycle, end_year, campus})
├── classe (TEK1, TEK1 PSO, TEK2, TEK3, PreMSC, MSC1, MSC2, CODAC)
├── active (Boolean, default: true) — soft delete
├── ref_financier_1_* (prénom, nom, email, sexe)
└── ref_financier_2_* (optionnel)

Campus
├── name, city, country, address
├── active (Boolean)
└── createdAt, updatedAt

Promo
├── campus (ObjectId → Campus)
├── cycle (BACHELOR, MSC, CODAC)
└── end_year (ex: 2027)

AlertTemplate
├── key, name, topic, campus (optionnel), baseTemplate (optionnel)
├── ruleKey (règle métier: tepitech, module, project, stumper)
├── subjectTemplate, bodyTemplate (HTML WYSIWYG)
├── variables, variablesSchema, fileSchemas
├── source (system/user), active
└── createdBy, updatedBy

AlertCampaign
├── name, kind (template | free), topic, campus, promo, classe
├── template (null en mode free)
├── freeSubject, freeBody, extraRecipients (mode free)
├── students, globalVariables, files, rows
├── overrideSubjectTemplate, overrideBodyTemplate, overrideVariables
├── studentOverrides, renderedPreviews, fileWarnings, fileErrors
├── mails, status (draft, previewed, queued, sent, partial_failed)
└── createdBy, sentBy, sentAt

MailSended
├── from (user qui a envoyé), to (email destinataire), cc (optionnel)
├── student (ObjectId → Student, null pour un destinataire libre), campus, template, campaign
├── topic (Tepitech, Module, Présence, etc.)
├── obj (sujet), message (HTML)
├── outgoing (adresse d'expédition affichée, ex: "EPITECH Info <no-reply@epitech.bj>")
├── isSent (yes/no)
└── createdAt, updatedAt

Routes étudiants (détail)

Action Méthode Route Body
Lister (actifs par défaut) GET /api/students?active=true —
Lister les inactifs GET /api/students?active=false —
Créer POST /api/students {firstname, lastname, email, ...}
Modifier PUT /api/students/:id {firstname, lastname, ...}
Import CSV POST /api/students/import FormData
Bulk update (classe/promo) PUT /api/students/bulk {students: [ids], classe, promo}
Désactiver PUT /api/students/:id/deactivate —
Réactiver PUT /api/students/:id/reactivate —
Désactiver en masse POST /api/students/bulk/deactivate {students: [ids]}

Soft delete

Les étudiants ne sont jamais supprimés définitivement. La route DELETE /api/students/:id n'existe plus. La désactivation conserve l'historique des alertes associées. Par défaut, GET /api/students ne retourne que les étudiants actifs (active=true).

Promo peuplée

Le champ promo est désormais peuplé automatiquement par l'API. Au lieu d'un simple ObjectId, il retourne {cycle: "BACHELOR", end_year: 2030}.

Campus, templates et campagnes d'alertes

Campus

Les étudiants et promos sont rattachés à un campus. Les anciennes données sans campus sont rattachées automatiquement au campus par défaut Cotonou au démarrage.

Action Méthode Route
Lister GET /api/campuses
Créer POST /api/campuses
Modifier PUT /api/campuses/:id
Supprimer DELETE /api/campuses/:id

Une promo est unique par couple campus + cycle + end_year. Deux campus peuvent donc avoir la même promo en parallèle.

Templates dynamiques

Les templates ne sont plus figés dans le code. Ils sont stockés en base et peuvent être globaux ou spécifiques à un campus.

Action Méthode Route
Lister GET /api/alert-templates
Résoudre le template effectif GET /api/alert-templates/resolve?topic=Module&campus=ID
Options de champ par classe (selects bornés) GET /api/alert-templates/:id/field-options?classe=TEK2
Créer POST /api/alert-templates
Modifier PUT /api/alert-templates/:id
Désactiver DELETE /api/alert-templates/:id

Champs importants :

  • subjectTemplate : sujet texte avec variables {{...}}
  • bodyTemplate : HTML WYSIWYG
  • variablesSchema : champs à afficher côté frontend
  • fileSchemas : fichiers attendus pour les campagnes
  • campus : vide pour template global, renseigné pour surcharge campus

Variables système générées par le backend :

student.* recipient.* promo.* campus.* row.* files.* computed.*

Les variables computed.* sont calculées au rendu, par exemple computed.earnedCredits, computed.moduleResultSummary, computed.projectResultSummary.

Campagnes d'alertes

Le workflow recommandé est :

  1. Preview éphémère sans sauvegarde.
  2. Sauvegarde brouillon si l'utilisateur clique sur Save draft.
  3. Simulation d'envoi (dry-run) optionnelle pour vérifier le nombre de mails générés sans rien envoyer.
  4. Envoi en deux appels create/update -> send.
  5. Historique uniquement pour les campagnes envoyées ou en file d'attente.
Action Méthode Route
Preview sans persistance POST /api/alert-campaigns/preview-draft
Créer un brouillon POST /api/alert-campaigns
Lister les brouillons GET /api/alert-campaigns?scope=drafts
Modifier un brouillon PUT /api/alert-campaigns/:id
Supprimer un brouillon DELETE /api/alert-campaigns/:id
Preview d'un brouillon sauvegardé POST /api/alert-campaigns/:id/preview
Simuler l'envoi (dry-run, aucun mail réel) POST /api/alert-campaigns/:id/simulate-send
Envoyer POST /api/alert-campaigns/:id/send
Historique (paginé, filtres campus/promo/classe/status) GET /api/alert-campaigns?scope=history

DELETE /api/alert-campaigns/:id est autorisé uniquement pour les brouillons draft ou anciennes previews previewed sans mail envoyé/queue.

send crée un MailSended par parent renseigné. Les deux parents reçoivent l'alerte si les deux emails existent.

Simulation d'envoi (simulate-send)

Ajoutée le plus récemment (feat(alert-campaigns): add send simulation). Rejoue exactement la même résolution de destinataires et le même rendu que send (mêmes validations, mêmes fichiers, même ciblage), mais n'insère aucun MailSended et ne touche pas au statut de la campagne. La réponse (controllers/alertCampaignController.js → simulateSendAlertCampaign) renvoie deliveryMode: "dry_run", canSend, recipientsCount, simulatedMailsCount, realEmailsSent: 0, les warnings/errors de validation, et un sample des 5 premiers mails qui seraient générés (destinataire + sujet). Utile pour vérifier le volume et le contenu avant un envoi réel à un grand groupe.

Historique — payload allégé

Depuis fix(alert-campaigns): lighten history list payload, GET /api/alert-campaigns ne renvoie plus les documents complets (fichiers, previews rendues, etc.) mais un résumé par campagne : targetSummary (libellés promo/classe, avec repli sur les promos/classes des étudiants ciblés quand le ciblage s'est fait par sélection directe d'étudiants), queuedMails (total), et le détail queuedMailCount / sentMailsCount / failedMailsCount calculé par agrégation sur MailSended. failedMailsCount reste toujours à 0 en pratique : MailSended.isSent n'a que deux valeurs ("yes"/"no"), il n'existe pas d'état "failed" distinct côté modèle.

Routes legacy /api/alerts/* désactivées (HTTP 410)

Depuis la refonte de juin 2026, les anciennes routes d'envoi par type (POST /api/alerts/tepitech, /stumpers, /modules, /roadblocks, /cheaters, /eval-internship, /presences, /projects, /resend/:id) renvoient 410 Gone. Tout passe désormais par /api/alert-campaigns. Seules les lectures GET /api/alerts et GET /api/alerts/:id restent actives.

Moteur de règles métier et validation

La logique métier propre à chaque type d'alerte est centralisée dans un registre de règles (utils/rules/), keyé par le ruleKey du template (fallback sur le topic) :

ruleKey Règles
tepitech seuils par classe (TEK1/PSO 600, TEK2 700, TEK3 750) ; sessions par classe (TEK1/PSO 6, TEK2/TEK3 4) ; pass/fail
stumper sessions par topic (solo 10, duo 7) ; seuil de validation 15 ; pass/fail
module grade, crédits acquis, résumés de validation
project grade prévisionnel, trajectoire de validation

Un validateur unifié (campaignValidator.js) est appliqué au preview et à l'envoi. Il renvoie { errors[], warnings[] } et couvre 3 couches :

  1. Champs requis déclarés dans variablesSchema (le required est désormais réellement appliqué).
  2. Fichiers : colonnes, login, croisements (campaignFiles.js).
  3. Règle métier : ex. session hors plage pour la classe → erreur invalid_session.

S'il y a des errors, la requête renvoie 400 et l'envoi est bloqué.

Options de champ dynamiques

GET /api/alert-templates/:id/field-options?classe=<classe> renvoie le variablesSchema du template avec les options résolues selon la classe et le topic — pour que le frontend affiche un <select> borné aux valeurs valides (sessions Tepitech 6 en TEK1 / 4 en TEK2, solo 10, duo 7) plutôt qu'une saisie libre rejetée ensuite.

Mode libre (composer sans template)

Une campagne porte un champ kind :

  • template (défaut) : construite depuis un AlertTemplate.
  • free : composer ad hoc — freeSubject + freeBody (WYSIWYG) avec variables {{...}}, destinataires = étudiants ciblés et/ou extraRecipients (emails libres).
Action Méthode Route
Catalogue des variables DB + snippets (salutation/signature) GET /api/alert-campaigns/variables
Enregistrer une campagne libre comme template POST /api/alert-campaigns/:id/save-as-template

En mode libre, template vaut null et les MailSended des destinataires libres ont student: null.

Civilité et rendu

  • Le destinataire est adressé par civilité : recipient.salutation = « Mr <Nom> » / « Mme <Nom> » (repli « Madame, Monsieur »).
  • Les mails sont rendus à la charte EPITECH Africa : carte blanche élargie à max-width:1024px, logo EPITECH dans le header, eyebrow + titre en majuscules (police Anton), accent couleur par topic (utils/mailPresentation.js), footer terminal. Le HTML partagé vit dans mailBody() (utils/mailBodies.js), importé aussi bien par le chemin legacy (mailSend.js) que par le moteur de rendu des campagnes (templateRenderer.js) — un seul header/footer pour tous les mails.
  • Logo en pièce jointe inline (CID), pas en image externe : le header référence <img src="cid:epitechlogo"> (utils/mailBodies.js), et le PNG est attaché à chaque envoi Resend via resendLogoAttachment() (utils/mailLogo.js, logo encodé en base64 dans le code). Avant ce changement (feat(mail): logo EPITECH en inline (CID) au lieu d'une image externe), le logo était chargé depuis une URL externe — bloqué par défaut par la plupart des clients mail ("afficher les images") et cassé si l'URL devenait inaccessible. L'inline CID s'affiche sans action de l'utilisateur.

File d'attente email (Resend)

Un processus asynchrone tourne en boucle dans server.js :

  1. Toutes les 5 secondes, il cherche un MailSended avec isSent: "no"
  2. Il l'envoie via l'API Resend, avec le logo (resendLogoAttachment()) joint en pièce inline CID sur chaque envoi
  3. En cas de succès, il marque isSent: "yes" — et si c'était le dernier mail en attente de sa campagne (campaign renseigné, MailSended.countDocuments({campaign, isSent:"no"}) === 0), la campagne bascule au statut sent
  4. En cas d'erreur, il attend 60 secondes avant de réessayer

Ce mécanisme découple la création d'alertes de leur envoi effectif.

Le statut partial_failed existe mais n'est jamais posé automatiquement

alertCampaignStatus définit bien partial_failed (utilisé côté filtre d'historique et pour verrouiller l'édition d'une campagne déjà envoyée), mais rien dans la boucle mail de server.js ne fait actuellement transitionner une campagne vers cet état — un échec Resend individuel marque juste ce MailSended en retry, sans jamais marquer la campagne "partiellement échouée". À vérifier/adapter si ce statut doit un jour refléter un échec réel.

Collections MongoDB

Collection Rôle
students Étudiants avec leurs référents financiers
promos Promotions par cycle et année
mailsendeds File d'attente des mails à envoyer
users Comptes administrateurs
emailconfirms Tokens de confirmation email
passwordresets Tokens de reset mot de passe

Diagrammes

Architecture globale

graph TB
    User[Utilisateur / Navigateur]
    Frontend["Frontend<br/>alert.epitools.bj"]
    Nginx["Nginx<br/>Reverse proxy + TLS"]
    Backend["Backend API<br/>Express.js :5010"]
    MongoDB["MongoDB Atlas<br/>(Cloud)"]
    Resend["Resend API<br/>(Email)"]
    AzureAD["Azure AD<br/>(SSO)"]

    User --> Nginx
    Nginx --> Frontend
    Nginx --> Backend
    Backend --> MongoDB
    Backend --> Resend
    Backend --> AzureAD

Flux d'envoi d'alerte

sequenceDiagram
    participant Admin as Admin (Frontend)
    participant API as Backend API
    participant DB as MongoDB Atlas
    participant Queue as Mail Queue (loop)
    participant Resend as Resend API
    participant Parent as Parent (Email)

    Admin->>API: POST /api/alert-campaigns (brouillon)
    Admin->>API: POST /api/alert-campaigns/:id/preview
    API->>API: validateCampaign (champs, fichiers, règles)
    API-->>Admin: {previews, warnings, errors}
    Admin->>API: POST /api/alert-campaigns/:id/send
    API->>DB: MailSended.insertMany({isSent: "no"})
    API-->>Admin: {campaign (queued), queuedMails}

    loop Toutes les 5 secondes
        Queue->>DB: MailSended.find({isSent: "no"})
        Queue->>Resend: resend.emails.send(...)
        Resend->>Parent: Email envoyé
        Queue->>DB: MailSended.update({isSent: "yes"})
    end

Docker

Dockerfile

L'image utilise node:20-alpine avec pnpm (installé via npm install -g pnpm@10.18.3) comme gestionnaire de paquets. L'application tourne en tant qu'utilisateur non-root (nodejs, uid 1001) pour la sécurité.

Cache BuildKit pour pnpm install (ea35800e)

RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --frozen-lockfile --prod --store-dir /pnpm/store persiste le store pnpm entre les builds CI (GitHub Actions cache via cache-from/cache-to: type=gha dans deploy.yml) pour accélérer significativement le build de l'image sur le runner self-hosted.

docker-compose.yml

services:
  backend:
    image: ghcr.io/epitechafrik/alert-parent-backend:${IMAGE_TAG:-latest}
    env_file: .env
    ports:
      - "127.0.0.1:5011:5010"
    restart: always
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:5010/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

Points importants :

  • L'image est tirée depuis GHCR (ghcr.io/epitechafrik/alert-parent-backend)
  • Le port est bindé sur 127.0.0.1 uniquement (accessible seulement via Nginx)
  • Les variables d'environnement sont chargées depuis .env via env_file
  • Le healthcheck vérifie /health sur le port interne 5010

CI/CD

Pipeline .github/workflows/deploy.yml, déclenché sur push vers main, 3 jobs :

graph LR
    Build["build<br/>Docker → GHCR"] --> Deploy["deploy<br/>SCP compose + SSH pull/up"] --> Notify["notify<br/>webhook succès/échec"]
  1. build : build l'image (context: ./backend), tag sha-<courtsha> + latest, push sur ghcr.io/epitechafrik/alert-parent-backend.
  2. deploy : SCP du docker-compose.yml vers le VPS, SSH pour reconstruire .env depuis le secret DOTENV_FILE, docker login ghcr.io, IMAGE_TAG=sha-<sha> docker compose pull backend puis up -d --no-deps backend (rolling update, pas d'arrêt du service avant le pull), poll de /health (12 tentatives × 5s), puis docker image prune -f --filter "until=168h".
  3. notify : POST vers secrets.WEBHOOK_URL (succès ou échec) — no-op silencieux si le secret n'est pas configuré.

Jobs sur runner self-hosted, pas ubuntu-latest

Les 3 jobs tournent sur un runner GitHub Actions self-hosted (probablement le VPS lui-même ou une machine dédiée à proximité — le SSH vers le VPS dans le job deploy suggère qu'ils ne sont pas identiques, mais ce n'est pas confirmé). Si ce runner est arrêté ou déconnecté, aucun déploiement ne part, même si le code est correct — GitHub Actions n'affiche alors qu'un job qui reste "queued" indéfiniment. Voir la continuité des accès externes pour qui peut administrer ce runner.

Secrets GitHub Actions utilisés :

Secret Rôle
SERVER_HOST Adresse du VPS (SCP + SSH)
SERVER_USERNAME Utilisateur SSH
SERVER_SSH_KEY Clé privée SSH
DOTENV_FILE Contenu complet du .env de production, réécrit à chaque déploiement
WEBHOOK_URL Webhook de notification succès/échec (optionnel)
GITHUB_TOKEN Fourni automatiquement par GitHub Actions, utilisé pour push/pull GHCR

Health Endpoint

GET /health

Utilisé par Docker Compose (healthcheck), Uptime Kuma (monitoring) et le pipeline CI/CD (vérification post-déploiement).

Réponse (200 OK)

{
  "status": "ok",
  "timestamp": "2026-02-22T10:30:00.000Z",
  "uptime_seconds": 86400,
  "environment": "production",
  "dependencies": {
    "mongodb": {
      "status": "ok",
      "latency_ms": 12
    },
    "resend": {
      "status": "configured"
    },
    "smtp": {
      "status": "configured"
    }
  }
}

Réponse (503 Service Unavailable)

Retourné quand MongoDB est inaccessible :

{
  "status": "degraded",
  "timestamp": "2026-02-22T10:30:00.000Z",
  "uptime_seconds": 120,
  "environment": "production",
  "dependencies": {
    "mongodb": {
      "status": "error",
      "latency_ms": null
    },
    "resend": {
      "status": "configured"
    },
    "smtp": {
      "status": "configured"
    }
  }
}

Utilisation

# Test simple
curl http://127.0.0.1:5011/health

# Test avec formatage
curl -s http://127.0.0.1:5011/health | python3 -m json.tool

# Vérifier uniquement le status code
curl -o /dev/null -s -w "%{http_code}" http://127.0.0.1:5011/health
# 200 = OK, 503 = dégradé

Variables d'environnement

Toutes les variables sont définies dans le fichier .env à la racine du projet (jamais commité dans git). Le template est dans .env.example.

Application

Variable Description Exemple
NODE_ENV Mode d'exécution production
PORT Port interne du serveur 5010

Base de données

Variable Description Exemple
MONGO_URI URI MongoDB Atlas mongodb+srv://user:pass@cluster.mongodb.net/dbname

Authentification

Variable Description Exemple
JWT_SECRET Clé secrète JWT (64 chars min) chaîne aléatoire longue
ADMIN_EMAIL Email du premier admin créé au démarrage admin@epitech.eu
PASSWORD_SIZE Taille minimum des mots de passe 8
PASSWORD_CAPITAL Exiger une majuscule true
PASSWORD_LOWERCASE Exiger une minuscule true
PASSWORD_NUMBER Exiger un chiffre true
PASSWORD_CHAR Exiger un caractère spécial true

Email

Variable Description Exemple
MAIL_NAME Nom d'expéditeur EPITECH Info
MAIL_HOST Serveur SMTP alouette.o2switch.net
MAIL_PORT Port SMTP 465
MAIL_ADDR Adresse d'envoi no-reply@epitech.bj
MAIL_PASS Mot de passe SMTP secret
RESEND_TOKEN Token API Resend (envoi des alertes de campagne + boucle mail) re_xxxxx

Variables MAIL_CC_* (Career Center)

sample.env et utils/mailSend.js définissent aussi un second compte SMTP dédié (MAIL_CC_NAME, MAIL_CC_HOST, MAIL_CC_PORT, MAIL_CC_ADDR, MAIL_CC_PASS) pour les mails envoyés côté Career Center. Il ne fait pas partie du chemin d'envoi des campagnes d'alertes (Resend), c'est un transport SMTP séparé pour les mails legacy (confirmation de compte, reset mot de passe, notation de stage).

URLs

Variable Description Exemple
CORS_ORIGIN Origine autorisée CORS https://alert.epitools.bj
FRONT_URL URL du frontend https://alert.epitools.bj
API_URL URL publique de l'API https://api.epitools.bj

Microsoft Azure AD (optionnel)

Variable Description
MICROSOFT_CLIENT_ID Client ID de l'App Registration Azure
MICROSOFT_TENANT_ID Tenant ID Azure

Docker

Variable Description Exemple
IMAGE_TAG Tag de l'image Docker à utiliser latest ou sha-abc1234

Rollback

Quand faire un rollback

  • Le déploiement a cassé une fonctionnalité critique
  • Le health check échoue après un déploiement
  • Une régression est détectée en production

Procédure

# 1. Trouver la version précédente
docker images ghcr.io/epitechafrik/alert-parent-backend --format "table {{.Tag}}\t{{.CreatedAt}}"

# 2. Exécuter le rollback
cd /root/projects/alert-parent/alert-parent-backend
./scripts/rollback.sh sha-XXXXXXX

# 3. Vérifier
curl -s http://127.0.0.1:5011/health | python3 -m json.tool
docker compose logs backend --tail 20

Après le rollback

  1. Investiguer la cause du problème sur la branche main
  2. Corriger et pousser un fix
  3. Le pipeline redéploiera automatiquement

Backups

MongoDB Atlas

La base de données est hébergée sur MongoDB Atlas, qui gère automatiquement :

  • Snapshots automatiques : toutes les 6 heures
  • Rétention : configurable dans Atlas (7 jours par défaut)
  • Point-in-time recovery : restauration à n'importe quel moment

Backup manuel

mongodump --uri="mongodb+srv://USER:PASSWORD@cluster.mongodb.net/al3rtParent" --out=./dump-$(date +%Y%m%d)

Restauration

# Restaurer un dump dans une base locale (pour test)
mongorestore --db alertParentLocal ./dump-YYYYMMDD/al3rtParent

# Restaurer dans Atlas
mongorestore --uri="mongodb+srv://USER:PASSWORD@cluster.mongodb.net/al3rtParent" ./dump-YYYYMMDD/al3rtParent