diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 43ee9f0..19acaf9 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -10,7 +10,7 @@ on:
permissions:
contents: read
- packages: write # Required for GHCR
+ packages: write
jobs:
@@ -32,11 +32,9 @@ jobs:
distribution: 'temurin'
cache: maven
- # Build & Test depuis la racine (Reactor gère les dépendances)
- name: Build & Test All Services
run: mvn clean verify -B --no-transfer-progress
- # Upload des rapports JaCoCo (optionnel pour l'artefact de build)
- name: Upload JaCoCo Reports
uses: actions/upload-artifact@v4
if: always()
@@ -45,7 +43,6 @@ jobs:
path: '**/target/site/jacoco/'
retention-days: 7
- # Sauvegarde des JARs pour le job Docker (Vital pour le job suivant)
- name: Upload Service JARs
uses: actions/upload-artifact@v4
with:
@@ -66,7 +63,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
- fetch-depth: 0 # Full history for blame
+ fetch-depth: 0
- name: 'Qodana Scan'
uses: jetbrains/qodana-action@v2025.2
@@ -74,7 +71,6 @@ jobs:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
with:
pr-mode: false
- # Linter is already defined in qodana.yaml
# JOB 3 : Docker Build & Push (GHCR)
docker-build:
@@ -131,4 +127,3 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
-
diff --git a/docker-compose.yml b/docker-compose.yml
index be0bdbc..8b44379 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,23 +3,12 @@ networks:
driver: bridge
volumes:
- mongo-user-data:
- mongo-communication-data:
postgres-keycloak-data:
+ mongo-user-data:
postgres-main-data:
- mysql-financial-data:
- zookeeper-data:
- kafka-data:
- sonarqube_data:
- sonarqube_extensions:
- sonarqube_logs:
- postgresql:
- postgresql_data:
services:
- # ===========================
- # CONFIG SERVICE (Port 8888)
- # ===========================
+
config-service:
build:
context: .
@@ -36,9 +25,6 @@ services:
retries: 5
start_period: 60s
- # =============================
- # EUREKA DISCOVERY (Port 8761)
- # =============================
eureka-service:
build:
context: .
@@ -60,9 +46,6 @@ services:
timeout: 10s
retries: 5
- # ========================
- # API GATEWAY (Port 8080)
- # ========================
api-gateway:
build:
context: .
@@ -87,9 +70,6 @@ services:
networks:
- schoolsphere-network
- # ============================
- # KEYCLOAK DATABASE
- # ============================
postgres-keycloak:
image: postgres:15-alpine
container_name: postgres-keycloak
@@ -109,9 +89,6 @@ services:
timeout: 5s
retries: 5
- # ====================
- # KEYCLOAK SERVICE
- # ====================
keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: keycloak
@@ -138,9 +115,6 @@ services:
retries: 5
start_period: 60s
- # =======================
- # MONGODB - USER SERVICE
- # =======================
mongo-user:
image: mongo:7.0
container_name: mongo-user
@@ -159,9 +133,6 @@ services:
timeout: 5s
retries: 5
- # =========================
- # USER SERVICE (Port 8081)
- # =========================
user-service:
build:
context: .
@@ -173,10 +144,7 @@ services:
- SPRING_PROFILES_ACTIVE=docker
- CONFIG_SERVER_URL=http://config-service:8888
- EUREKA_SERVER_URL=http://eureka-service:8761/eureka
-
- # MongoDB
- MONGODB_URI=mongodb://admin:admin@mongo-user:27017/user_db?authSource=admin
-
# Keycloak
- KEYCLOAK_AUTH_SERVER_URL=http://keycloak:8080
- KEYCLOAK_ISSUER_URI=http://keycloak:8080/realms/schoolsphere
@@ -185,12 +153,17 @@ services:
- KEYCLOAK_ADMIN_PASSWORD=admin
- KEYCLOAK_ADMIN_REALM=master
- KEYCLOAK_ADMIN_CLIENT_ID=admin-cli
+ # Mail (Mailtrap)
+ - MAIL_HOST=sandbox.smtp.mailtrap.io
+ - MAIL_PORT=2525
+ - MAIL_USERNAME=${MAIL_USERNAME}
+ - MAIL_PASSWORD=${MAIL_PASSWORD}
+ # Frontend URL for reset links
+ - FRONTEND_URL=http://localhost:4200
# Cloudinary
- CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME}
- CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY}
- CLOUDINARY_API_SECRET=${CLOUDINARY_API_SECRET}
- # Kafka
- # - KAFKA_BOOTSTRAP_SERVERS=kafka:9092
depends_on:
config-service:
@@ -201,8 +174,6 @@ services:
condition: service_healthy
keycloak:
condition: service_healthy
- # kafka:
- # condition: service_healthy
networks:
- schoolsphere-network
healthcheck:
@@ -212,9 +183,6 @@ services:
retries: 3
start_period: 40s
- # ================================================
- # POSTGRESQL - MAIN DATABASE (Academic & Resource)
- # ================================================
postgres-main:
image: postgres:15-alpine
container_name: postgres-main
@@ -225,7 +193,7 @@ services:
- "5432:5432"
volumes:
- postgres-main-data:/var/lib/postgresql/data
- - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
+ - ./docker/postgresql/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- schoolsphere-network
healthcheck:
@@ -234,9 +202,6 @@ services:
timeout: 5s
retries: 5
- # ============================
- # ACADEMIC SERVICE (Port 8082)
- # ============================
academic-service:
build:
context: .
@@ -255,8 +220,6 @@ services:
- CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME}
- CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY}
- CLOUDINARY_API_SECRET=${CLOUDINARY_API_SECRET}
- # Kafka
- # - KAFKA_BOOTSTRAP_SERVERS=kafka:9092
depends_on:
config-service:
condition: service_healthy
@@ -264,253 +227,6 @@ services:
condition: service_healthy
postgres-main:
condition: service_healthy
- # kafka:
- # condition: service_healthy
+
networks:
- schoolsphere-network
-
- # # PostgreSQL pour SonarQube
- # sonarqube-db:
- # image: postgres:15-alpine
- # container_name: sonarqube-db
- # environment:
- # POSTGRES_USER: sonar
- # POSTGRES_PASSWORD: sonar
- # POSTGRES_DB: sonar
- # volumes:
- # - postgresql:/var/lib/postgresql
- # - postgresql_data:/var/lib/postgresql/data
- # ports:
- # - "5434:5432"
- # networks:
- # - schoolsphere-network
- #
- # # sonarqube server
- # sonarqube:
- # image: sonarqube:community
- # container_name: sonarqube
- # depends_on:
- # - sonarqube-db
- # environment:
- # SONAR_JDBC_URL: jdbc:postgresql://sonarqube-db:5432/sonar
- # SONAR_JDBC_USERNAME: sonar
- # SONAR_JDBC_PASSWORD: sonar
- # volumes:
- # - sonarqube_data:/opt/sonarqube/data
- # - sonarqube_extensions:/opt/sonarqube/extensions
- # - sonarqube_logs:/opt/sonarqube/logs
- # ports:
- # - "9001:9000"
- # networks:
- # - schoolsphere-network
-
- # =====================
- # KAFKA avec KRaft
- # =====================
- # kafka:
- # image: confluentinc/cp-kafka:7.5.0
- # container_name: kafka
- # ports:
- # - "9092:9092"
- # - "9093:9093"
- # environment:
- # # KRaft mode configuration
- # KAFKA_NODE_ID: 1
- # KAFKA_PROCESS_ROLES: broker,controller
- # KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
- # KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
- #
- # # Listener configuration
- # KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
- # KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
- # KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT
- # KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
- #
- # # Cluster ID (généré une fois)
- # CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
- #
- # # Other configurations
- # KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
- # KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
- # KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
- # KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
- # KAFKA_LOG_RETENTION_HOURS: 168
- #
- # # Log directories
- # KAFKA_LOG_DIRS: /var/lib/kafka/data
- # volumes:
- # - kafka-data:/var/lib/kafka/data
- # networks:
- # - schoolsphere-network
- # restart: unless-stopped
- # healthcheck:
- # test: [ "CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092 || exit 1" ]
- # interval: 15s
- # timeout: 10s
- # retries: 10
- # start_period: 60s
-
- # Kafka UI
- # kafka-ui:
- # image: provectuslabs/kafka-ui:latest
- # container_name: kafka-ui
- # depends_on:
- # - kafka
- # ports:
- # - "9090:8080"
- # environment:
- # KAFKA_CLUSTERS_0_NAME: schoolsphere-cluster
- # KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
- # DYNAMIC_CONFIG_ENABLED: 'true'
- # networks:
- # - schoolsphere-network
- # restart: unless-stopped
-
- # =======================================
- # MONGODB - COMMUNICATION SERVICE
- # =======================================
- # mongo-communication:
- # image: mongo:7.0
- # container_name: mongo-communication
- # environment:
- # MONGO_INITDB_ROOT_USERNAME: admin
- # MONGO_INITDB_ROOT_PASSWORD: admin
- # MONGO_INITDB_DATABASE: communication_db
- # ports:
- # - "27019:27017"
- # volumes:
- # - mongo-communication-data:/data/db
- # networks:
- # - schoolsphere-network
- # healthcheck:
- # test: echo 'db.runCommand("ping").ok' | mongosh localhost:27019/test --quiet
- # interval: 10s
- # timeout: 5s
- # retries: 5
-
- # ====================================
- # COMMUNICATION SERVICE (Port 8086)
- # ====================================
- # communication-service:
- # build:
- # context: .
- # dockerfile: services/communication-service/Dockerfile
- # container_name: communication-service
- # ports:
- # - "8086:8086"
- # environment:
- # - SPRING_PROFILES_ACTIVE=docker
- # - CONFIG_SERVER_URL=http://config-service:8888
- # - EUREKA_SERVER_URL=http://eureka-service:8761/eureka
- # - MONGO_URI=mongodb://admin:admin@mongo-communication:27019/communication_db?authSource=admin
- # - KAFKA_BOOTSTRAP_SERVERS=kafka:9092
- #
- # # Configuration Kafka Consumer
- # - KAFKA_GROUP_ID=communication-service-group
- # - KAFKA_AUTO_OFFSET_RESET=earliest
- #
- # # Topics Kafka à écouter
- # - KAFKA_TOPICS_USER_EVENTS=user-events
- # - KAFKA_TOPICS_ACADEMIC_EVENTS=academic-events
- # - KAFKA_TOPICS_FINANCIAL_EVENTS=financial-events
- #
- # # Configuration Email (à adapter selon ton provider)
- # - MAIL_HOST=smtp.gmail.com
- # - MAIL_PORT=587
- # - MAIL_USERNAME=${MAIL_USERNAME}
- # - MAIL_PASSWORD=${MAIL_PASSWORD}
- #
- # # Configuration WebSocket
- # - WEBSOCKET_ENABLED=true
- # depends_on:
- # config-service:
- # condition: service_healthy
- # eureka-service:
- # condition: service_healthy
- # mongo-communication:
- # condition: service_healthy
- # kafka:
- # condition: service_healthy
- # networks:
- # - schoolsphere-network
-
- # ============================
- # RESOURCE SERVICE (Port 8083)
- # ============================
- # resource-service:
- # build:
- # context: .
- # dockerfile: services/resource-service/Dockerfile
- # container_name: resource-service
- # ports:
- # - "8083:8083"
- # environment:
- # - SPRING_PROFILES_ACTIVE=docker
- # - CONFIG_SERVER_URL=http://config-service:8888
- # - EUREKA_SERVER_URL=http://eureka-service:8761/eureka
- # - DB_URL=jdbc:postgresql://postgres-main:5432/resource_db
- # - DB_USERNAME=postgres
- # - DB_PASSWORD=postgres
- # - KAFKA_BOOTSTRAP_SERVERS=kafka:9092
- # depends_on:
- # config-service:
- # condition: service_healthy
- # eureka-service:
- # condition: service_healthy
- # postgres-main:
- # condition: service_healthy
- # kafka:
- # condition: service_healthy
- # networks:
- # - schoolsphere-network
-
- # ==========================
- # MYSQL - FINANCIAL SERVICE
- # ==========================
- # mysql-db:
- # image: mysql:8.0
- # container_name: mysql-db
- # environment:
- # MYSQL_ROOT_PASSWORD: root
- # MYSQL_DATABASE: financial_db
- # ports:
- # - "3307:3306"
- # volumes:
- # - mysql-financial-data:/var/lib/mysql
- # networks:
- # - schoolsphere-network
- # healthcheck:
- # test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot" ]
- # interval: 10s
- # timeout: 5s
- # retries: 5
-
- # ==============================
- # FINANCIAL SERVICE (Port 8084)
- # ==============================
- # financial-service:
- # build:
- # context: .
- # dockerfile: services/financial-service/Dockerfile
- # container_name: financial-service
- # ports:
- # - "8084:8084"
- # environment:
- # - SPRING_PROFILES_ACTIVE=docker
- # - CONFIG_SERVER_URL=http://config-service:8888
- # - EUREKA_SERVER_URL=http://eureka-service:8761/eureka
- # - DB_URL=jdbc:mysql://mysql-db:3306/financial_db
- # - DB_USERNAME=root
- # - DB_PASSWORD=root
- # - KAFKA_BOOTSTRAP_SERVERS=kafka:9092
- # depends_on:
- # config-service:
- # condition: service_healthy
- # eureka-service:
- # condition: service_healthy
- # mysql-db:
- # condition: service_healthy
- # kafka:
- # condition: service_healthy
- # networks:
- # - schoolsphere-network
\ No newline at end of file
diff --git a/docker/postgres/init.sql b/docker/postgresql/init.sql
similarity index 100%
rename from docker/postgres/init.sql
rename to docker/postgresql/init.sql
diff --git a/pom.xml b/pom.xml
index 2c78c59..6231945 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,8 +21,6 @@
SchoolSphere
Parent POM for SchoolSphere microservices
-
-
services/config-service
services/eureka-service
@@ -31,8 +29,6 @@
services/academic-service
-
-
17
@@ -64,7 +60,6 @@
-
@@ -115,7 +110,6 @@
-
@@ -162,7 +156,6 @@
-
org.jacoco
jacoco-maven-plugin
@@ -235,4 +228,4 @@
-
\ No newline at end of file
+
diff --git a/resources/cahier.md b/resources/cahier.md
index 18a0f71..fb2ef7f 100644
--- a/resources/cahier.md
+++ b/resources/cahier.md
@@ -165,11 +165,27 @@ la phase de conception jusqu'à la mise en production.
- Rappels automatiques de paiement
- Génération de reçus
-### 3.10 Certifications et diplômes
+### 3.10 Documents scolaires et demandes administratives
-- Création et attribution de certifications
-- Génération de diplômes et attestations
-- Téléchargement et archivage des documents
+- Demande et suivi de documents officiels (certificat de scolarité, attestation d'inscription, relevé de notes, etc.)
+- Un étudiant ou son parent initie une demande de document
+- Le staff ou le SCHOOL_ADMIN traite la demande (APPROUVE / REJETE)
+- Pièce jointe de la réponse (fichier traité uploadé par le staff)
+- Historique des demandes par étudiant et par année académique
+
+### 3.11 Gestion des présences
+
+- Saisie de feuilles de présence par session (date, groupe, matière, enseignant)
+- Enregistrement individuel du statut de chaque élève (PRESENT, ABSENT, EN_RETARD, EXCUSE)
+- Possibilité d'indiquer un motif d'absence ou de retard
+- Consultation des présences par classe, par matière ou par élève
+
+### 3.12 Réclamations de notes
+
+- Un étudiant peut soumettre une réclamation sur une note d'examen
+- La réclamation contient : score contesté, score max, raison, nom de l'examen et de la matière
+- L'enseignant ou le SCHOOL_ADMIN traite la réclamation (EN_COURS_EXAMEN → RESOLUE / REJETEE)
+- Réponse officielle jointe à la réclamation traitée
---
@@ -184,11 +200,13 @@ la phase de conception jusqu'à la mise en production.
### 4.2 Hiérarchie organisationnelle
-- **École** → contient des **Niveaux** (CP, CM1, etc.)
-- **Niveau** → contient des **Groupes** (CP1, CP2, etc.)
-- **Groupe** → contient des **Étudiants** (avec capacité maximale)
-- Un étudiant appartient à un seul groupe à la fois
-- Un groupe est associé à un seul niveau
+- **École** → contient des **Niveaux scolaires** (CP, CM1, 6ème, etc.)
+- **Niveau scolaire** → contient des **Groupes-Classes** (CP-A, CP-B, etc.) et des **Matières**
+- **Groupe-Classe** → contient des **Élèves** (via `InscriptionClasse`, avec capacité maximale)
+- **Groupe-Classe** → est associé à **un seul Emploi du Temps** (relation 1:1)
+- Un étudiant appartient à un seul groupe-classe actif à la fois
+- Une **Année Académique** est l'entité centrale qui active/désactive les emplois du temps
+- Chaque emploi du temps est obligatoirement lié à une **Année Académique** et à un **Groupe-Classe**
### 4.3 Gestion des utilisateurs
@@ -351,27 +369,35 @@ L'emploi du temps est géré sous forme d'images uploadées par l'administration
### 4.10 Permissions par rôle (RBAC)
-| Fonctionnalité | Admin | Directeur | Staff | Enseignant | Étudiant | Parent |
-|------------------------------|-------|-----------|-------|------------|------------------|-------------|
-| Créer école | O | N | N | N | N | N |
-| Gérer utilisateurs | O | O | O | N | N | N |
-| Créer niveaux/groupes | O | O | O | N | N | N |
-| Créer emploi du temps | O | O | O | N | N | N |
-| Consulter emploi du temps | O | O | O | O | O | O |
-| Créer examens | O | O | O | O | N | N |
-| Saisir notes | O | O | N | O | N | N |
-| Consulter notes | O | O | O | O | O (ses notes) | O (enfants) |
-| Upload ressources | O | O | O | O | N | N |
-| Consulter ressources | O | O | O | O | O | O |
-| Gérer catalogue bibliothèque | O | O | O | N | N | N |
-| Consulter catalogue | O | O | O | O | O | O |
-| Emprunter livres | N | N | N | N | O | N |
-| Voir historique emprunts | O | O | O | O | O (ses emprunts) | O (enfants) |
-| Créer annonces | O | O | O | O | N | N |
-| Gérer paiements | O | O | O | N | N | O (payer) |
-| Planifier réunions | O | O | O | O | N | N |
-
-**0 = Non autorisé, O = Autorisé**
+| Fonctionnalité | ADMIN | SCHOOL_ADMIN | Staff | Enseignant | Étudiant | Parent |
+|------------------------------------|-------|--------------|-------|------------|--------------------|-----------------|
+| Approuver/rejeter écoles | O | N | N | N | N | N |
+| Gérer utilisateurs (CRUD) | N | O | O | N | N | N |
+| Créer Staff | N | O | N | N | N | N |
+| Gérer années académiques | N | O | O | N | N | N |
+| Créer niveaux / groupes-classes | N | O | O | N | N | N |
+| Créer salles physiques | N | O | O | N | N | N |
+| Créer / modifier emploi du temps | N | O | O | N | N | N |
+| Consulter emploi du temps | N | O | O | O | O | O |
+| Attribuer enseignant–matière | N | O | O | N | N | N |
+| Créer examens | N | O | O | O (siens) | N | N |
+| Saisir notes | N | O | N | O (siens) | N | N |
+| Consulter notes | N | O | O | O | O (ses notes) | O (enfants) |
+| Saisir présences | N | O | N | O | N | N |
+| Traiter documents scolaires | N | O | O | N | N | N |
+| Demander document scolaire | N | N | N | N | O | O (enfant) |
+| Soumettre réclamation | N | N | N | N | O | N |
+| Traiter réclamation | N | O | N | O (siens) | N | N |
+| Gérer activités | N | O | O | O | N | N |
+| S'inscrire à une activité | N | N | N | N | O | O (enfant) |
+| Upload ressources pédagogiques | N | O | O | O | N | N |
+| Consulter ressources | N | O | O | O | O | O |
+| Gérer catalogue bibliothèque | N | O | O | N | N | N |
+| Emprunter livres | N | N | N | N | O | N |
+| Gérer paiements | N | O | O | N | N | O (payer) |
+| Planifier réunions | N | O | O | O | N | N |
+
+**N = Non autorisé, O = Autorisé**
### 4.11 Sécurité et conformité
@@ -386,72 +412,96 @@ L'emploi du temps est géré sous forme d'images uploadées par l'administration
### 5.1 Liste des entités principales
+#### Service Utilisateur (MongoDB — implémenté)
1. **School** (École)
-2. **User** (Utilisateur)
-3. **Student** (Étudiant)
-4. **Teacher** (Enseignant)
-5. **Parent** (Parent)
-6. **Staff** (Personnel administratif)
-7. **GradeLevel** (Niveau scolaire)
-8. **Group** (Groupe d'étudiants)
-9. **Classroom** (Salle de classe)
-10. **Subject** (Matière)
-11. **Timetable** (Emploi du temps - image)
-12. **Exam** (Examen)
-13. **Grade** (Note)
-14. **PedagogicalResource** (Ressource pédagogique créée par enseignants)
-15. **LibraryBook** (Livre bibliothèque - auteurs mondiaux)
-16. **BookLoan** (Emprunt de livre)
-17. **Activity** (Activité extrascolaire)
-18. **ActivityEnrollment** (Inscription à une activité)
-19. **Announcement** (Annonce)
-20. **Notification** (Notification)
-21. **Meeting** (Réunion)
-22. **Payment** (Paiement)
-23. **Certification** (Certification/Diplôme!)
+2. **SchoolApprovalRequest** (Demande d'approbation d'école)
+3. **User** (Utilisateur)
+4. **UserProfile** (Profil étendu de l'utilisateur)
+5. **Role** (Rôle : ADMIN, SCHOOL_ADMIN, TEACHER, STUDENT, PARENT, STAFF, GUEST)
+6. **Teacher** (Enseignant)
+7. **Student** (Étudiant)
+8. **Staff** (Personnel administratif)
+9. **Parent** (Parent)
+10. **ParentStudent** (Lien parent–étudiant)
+
+#### Service Académique (PostgreSQL — implémenté)
+11. **AcademicYear** (Année académique)
+12. **GradeLevel** (Niveau scolaire)
+13. **Classroom** (Groupe-classe)
+14. **Subject** (Matière)
+15. **Room** (Salle physique)
+16. **Timetable** (Emploi du temps — image)
+17. **TeacherSubject** (Attribution enseignant–matière)
+18. **StudentClassroom** (Inscription élève dans un groupe-classe)
+19. **Exam** (Examen)
+20. **Grade** (Note)
+21. **Activity** (Activité extrascolaire)
+22. **ResourceLink** (Lien ressource incorporé dans Activity)
+23. **ActivityEnrollment** (Inscription à une activité)
+24. **DocumentScolaire** (Demande de document officiel)
+25. **Attendance** (Feuille de présence)
+26. **AttendanceRecord** (Enregistrement individuel de présence)
+27. **Reclamation** (Réclamation sur une note)
+
+#### Services à venir (non implémentés)
+28. **PedagogicalResource** (Ressource pédagogique — Service Ressource)
+29. **LibraryBook** (Livre — Service Ressource)
+30. **BookLoan** (Emprunt de livre — Service Ressource)
+31. **Announcement** (Annonce — Service Communication)
+32. **Notification** (Notification — Service Communication)
+33. **Meeting** / **MeetingParticipant** (Réunion — Service Communication)
+34. **Payment** / **Invoice** (Paiement — Service Financier)
### 5.2 Relations entre entités
+#### Service Utilisateur
```
-School 1──────n GradeLevel
School 1──────n User
-School 1──────n Classroom
-School 1──────n Subject
-
-GradeLevel 1──────n Group
-
-Group 1──────n Student
-Group n──────n Exam
-Group n──────n Activity
-
-Student n──────1 Parent (via table association)
-Student n──────n Grade
-Student n──────n Payment
-Student n──────n Certification
-
-Teacher n──────n Subject
+School 1──────0..1 SchoolApprovalRequest
+User 1──────0..1 UserProfile
+User 1──────n Role
+User 1──────0..1 Student
+User 1──────0..1 Teacher
+User 1──────0..1 Staff
+User 1──────0..1 Parent
+Student n──────n Parent (via LienParentEleve)
+```
-Classroom 1──────0..1 Timetable
+#### Service Académique
+```
+AcademicYear 1──────n Timetable
-Exam n──────1 Subject
-Exam n──────1 Teacher
-Exam n──────n Group
+GradeLevel 1──────n Classroom (groupe-classe)
+GradeLevel 1──────n Subject
-Grade n──────1 Student
-Grade n──────1 Exam
+Classroom 1──────n StudentClassroom
+Classroom 1──────0..1 Timetable
-PedagogicalResource n──────1 Subject
-PedagogicalResource n──────1 Teacher (créateur)
+Subject 1──────n TeacherSubject
+Subject 1──────n Exam
-LibraryBook 1──────n BookLoan
-BookLoan n──────1 Student
+Exam 1──────n Grade
+Exam 1──────n Reclamation
-Activity n──────n Student (inscription)
+Activity 1──────n ActivityEnrollment
+Activity 1──────n ResourceLink (incorporé)
-Announcement n──────1 User (créateur)
-Notification n──────1 User (destinataire)
+Attendance 1──────n AttendanceRecord
+```
-Meeting n──────n User (participants)
+#### Références cross-service (IDs stockés en String)
+```
+Classroom.studentId → User Service (Student.id)
+TeacherSubject.teacherId → User Service (Teacher.id)
+Exam.teacherId → User Service (Teacher.id)
+Grade.studentId → User Service (Student.id)
+Activity.organizerId → User Service (User.id)
+ActivityEnrollment.studentId → User Service (Student.id)
+DocumentScolaire.studentId → User Service (Student.id)
+Attendance.teacherId → User Service (Teacher.id)
+AttendanceRecord.studentId → User Service (Student.id)
+Reclamation.studentId → User Service (Student.id)
+Timetable.teacherIds → User Service (Teacher.id — liste CSV)
```
### 5.3 Attributs détaillés des entités clés
@@ -546,16 +596,28 @@ Meeting n──────n User (participants)
- uploadedAt
- createdAt
+#### **AcademicYear**
+
+- id (PK, UUID)
+- schoolId (FK)
+- name (ex: "2025-2026") — unique par école
+- startDate, endDate
+- isCurrent (boolean)
+- createdAt / updatedAt
+
#### **Exam**
-- id (PK)
-- subjectId (FK)
-- teacherId (FK)
-- name
-- description
-- examDate
-- duration
+- id (PK, UUID)
+- schoolId (FK)
+- teacherId (ref User Service)
+- subject (FK → Subject)
+- name, description
+- examDate (LocalDateTime)
+- duration (minutes)
- maxScore
+- examType (NATIONAL, INTERNATIONAL, REGIONAL, LOCAL, CLASSROOM, HOMEWORK, QUIZ, PRACTICAL, ORAL, OTHER)
+- roomId (ref Room, optionnel)
+- classroomId (ref Classroom, optionnel)
#### **Grade**
@@ -609,32 +671,31 @@ Meeting n──────n User (participants)
#### **Activity**
-- id (PK)
+- id (PK, UUID)
- schoolId (FK)
-- name
-- description
-- type (SPORT, CLUB, COMPETITION, EVENT, WORKSHOP, FIELD_TRIP, CULTURAL)
-- startDate
-- endDate
-- registrationDeadline
+- organizerId (ref User Service)
+- name, description
+- activityType (SPORT, CLUB, COMPETITION, FIELD_TRIP, WORKSHOP, CULTURAL, EVENT, OTHER)
+- startDate, endDate (LocalDateTime)
+- registrationDeadline (LocalDateTime)
- location
-- maxParticipants
-- currentParticipants
-- fee
-- status (UPCOMING, REGISTRATION_OPEN, REGISTRATION_CLOSED, ONGOING, COMPLETED, CANCELLED)
+- maxParticipants, currentParticipants
+- fee (float)
+- status (DRAFT, UPCOMING, REGISTRATION_OPEN, REGISTRATION_CLOSED, IN_PROGRESS, COMPLETED, CANCELLED, POSTPONED)
- coverImageUrl
-- createdAt
+- resourceLinks : List (label, url, type — incorporé)
+- createdAt / updatedAt
#### **ActivityEnrollment**
-- id (PK)
-- activityId (FK → Activity)
-- studentId (FK → Student)
-- enrolledAt
-- enrolledBy (FK → User - qui a inscrit l'étudiant)
-- status (PENDING, CONFIRMED, CANCELLED, WAITLISTED)
-- paymentStatus
-- createdAt
+- id (PK, UUID)
+- activity (FK → Activity)
+- studentId (ref User Service)
+- enrolledBy (ref User Service — qui a inscrit)
+- status (PENDING, CONFIRMED, WAITLISTED, COMPLETED, CANCELLED)
+- paymentId (optionnel)
+- enrolledAt (LocalDateTime)
+- createdAt / updatedAt
#### **Announcement**
@@ -678,14 +739,56 @@ Meeting n──────n User (participants)
- paidAt
- transactionId
-#### **Certification**
+#### **DocumentScolaire** *(remplace Certification)*
-- id (PK)
-- studentId (FK)
-- title
-- type (DIPLOMA, CERTIFICATE, ATTESTATION)
-- issuedDate
-- fileUrl (S3)
+- id (PK, UUID)
+- schoolId (FK)
+- studentId (ref User Service)
+- requestedById (qui a fait la demande : étudiant ou parent)
+- requestedByRole (STUDENT ou PARENT)
+- documentType (CERTIFICAT_SCOLARITE, ATTESTATION_STAGE, ATTESTATION_INSCRIPTION, RELEVE_NOTES, AUTRE)
+- status (EN_ATTENTE, EN_COURS, APPROUVE, REJETE)
+- reason (motif de la demande)
+- academicYear (ex: "2024-2025")
+- processedById (staff/admin ayant traité)
+- processorNote (note du traitement)
+- responseFileUrl (URL du fichier joint par le staff)
+- createdAt / updatedAt
+
+#### **Attendance** (Feuille de présence)
+
+- id (PK, UUID)
+- schoolId
+- classroomId (ref Classroom)
+- subjectId (ref Subject, optionnel)
+- teacherId (ref User Service)
+- date
+- notes
+- createdAt / updatedAt
+
+#### **AttendanceRecord** (Enregistrement individuel)
+
+- id (PK, UUID)
+- attendanceId (ref Attendance)
+- studentId (ref User Service)
+- status (PRESENT, ABSENT, LATE, EXCUSED)
+- reason (motif d'absence/retard, optionnel)
+- createdAt / updatedAt
+
+#### **Reclamation**
+
+- id (PK, UUID)
+- examId (ref Exam)
+- gradeId (ref Grade)
+- studentId (ref User Service)
+- studentName, examName, subjectName (dénormalisés)
+- score, maxScore (score contesté)
+- reason (motif de la réclamation)
+- status (PENDING, REVIEWING, RESOLVED, REJECTED)
+- response (réponse officielle)
+- teacherId (ref User Service — dénormalisé)
+- schoolId
+- createdAt / updatedAt
---
@@ -693,12 +796,18 @@ Meeting n──────n User (participants)
### 6.1 Vue d'ensemble
-1. **Config Service** - Configuration centralisée (Spring Cloud)
-2. **API Gateway** - Point d'entrée unique (Spring Cloud)
-3. **User Service** - Authentification + Gestion utilisateurs (MongoDB)
-4. **Academic Service** - Organisation académique + Activités extrascolaires (PostgreSQL)
-5. **Resource Service** - Ressources + Communication + Bibliothèque (PostgreSQL)
-6. **Financial Service** - Paiements (MySQL)
+| # | Service | Port | Base de données | Statut |
+|---|---------|------|-----------------|--------|
+| 1 | **Config Service** | 8888 | — | ✅ Implémenté |
+| 2 | **Eureka Service** | 8761 | — | ✅ Implémenté |
+| 3 | **API Gateway** | 8080 | — | ✅ Implémenté |
+| 4 | **User Service** | 8081 | MongoDB | ✅ Implémenté |
+| 5 | **Academic Service** | 8082 | PostgreSQL | ✅ Implémenté |
+| 6 | **Resource Service** | 8083 | MongoDB | 🔜 Prévu |
+| 7 | **Financial Service** | 8084 | MySQL | 🔜 Prévu |
+| 8 | **Communication Service** | 8086 | MongoDB | 🔜 Prévu |
+
+**Note :** L'API Gateway valide les tokens JWT Keycloak et injecte les headers `X-User-Id`, `X-User-Email`, `X-User-Roles` pour tous les microservices en aval.
### 6.2 Découpage en microservices
@@ -714,41 +823,46 @@ Meeting n──────n User (participants)
**Entités gérées :**
-- School
-- User
-- Student
-- Teacher
-- Parent
-- Staff
+- School, SchoolApprovalRequest
+- User, UserProfile
+- Role
+- Teacher, Student, Staff, Parent
+- ParentStudent
#### **Service 2 : Academic Service**
**Responsabilités :**
-- Gestion des niveaux et groupes
-- Gestion des matières
-- Création et gestion des emplois du temps
-- Détection des conflits (enseignant, salle, groupe)
-- Gestion des examens
-- Gestion des notes
-- Calcul des moyennes
-- Génération de bulletins
-- Gestion des salles de classe
-- Gestion des activités extrascolaires
-- Inscription des étudiants aux activités
+- Gestion des années académiques
+- Gestion des niveaux scolaires et groupes-classes
+- Gestion des matières et attribution enseignant–matière
+- Gestion des salles physiques
+- Création et gestion des emplois du temps (images)
+- Inscription des étudiants dans les groupes-classes
+- Gestion des examens et saisie des notes
+- Calcul des moyennes et statistiques
+- Gestion des activités extrascolaires et inscriptions
+- Demandes de documents scolaires
+- Gestion des présences (feuille + enregistrements individuels)
+- Réclamations de notes
**Entités gérées :**
+- AcademicYear
- GradeLevel
-- Group
+- Classroom (groupe-classe)
- Subject
-- Classroom
+- Room (salle physique)
- Timetable
+- TeacherSubject
+- StudentClassroom
- Exam
- Grade
-- Certification
-- Activity
+- Activity, ResourceLink
- ActivityEnrollment
+- DocumentScolaire
+- Attendance, AttendanceRecord
+- Reclamation
#### **Service 3 : Resource Service**
@@ -887,16 +1001,17 @@ schoolsphere/
- Un email de confirmation est envoyé
- Le compte est créé avec le rôle ADMIN
-**US-002 : Création d'une école**
+**US-002 : Inscription d'une école (demande d'approbation)**
-- **En tant que** : Administrateur authentifié
-- **Je veux** : Créer mon établissement scolaire
-- **Afin de** : Commencer à gérer les données de mon école
+- **En tant que** : Directeur d'établissement
+- **Je veux** : Enregistrer mon école sur la plateforme
+- **Afin de** : Obtenir l'approbation de l'ADMIN plateforme et démarrer la gestion
- **Critères d'acceptation** :
- - Le formulaire demande : nom de l'école, adresse (zone/campus), email de contact, téléphone
- - L'école est associée à mon compte admin
- - L'école est isolée des autres écoles (multi-tenant)
- - Je suis redirigé vers le tableau de bord
+ - Le formulaire demande : nom, adresse, ville, email contact, téléphone, type (PUBLIC/PRIVE/EN_LIGNE), année création
+ - La demande est créée avec statut `PENDING` via `SchoolApprovalRequest`
+ - L'ADMIN plateforme consulte les demandes en attente et APPROUVE ou REJETTE
+ - En cas d'approbation : école activée + rôle `SCHOOL_ADMIN` attribué automatiquement au directeur
+ - En cas de rejet : motif de rejet conservé dans `SchoolApprovalRequest.rejectionReason`
**US-003 : Connexion à la plateforme**
@@ -954,35 +1069,45 @@ schoolsphere/
- Le niveau est créé et visible dans la liste des niveaux
- Le niveau est associé à mon école uniquement
-**US-008 : Création d'un groupe d'étudiants**
+**US-007b : Gestion des années académiques**
-- **En tant que :** Admin ou Staff
-- **Je veux :** Créer un groupe pour un niveau donné (ex: CP1)
-- **Afin de :** Organiser les étudiants en groupes gérables
+- **En tant que :** SCHOOL_ADMIN ou Staff
+- **Je veux :** Créer et gérer les années académiques de mon école
+- **Afin de :** Organiser les emplois du temps et données par période scolaire
+- **Critères d'acceptation :**
+ - Le formulaire demande : nom (ex: "2025-2026"), date début, date fin
+ - Une seule année peut être marquée `isCurrent = true` à la fois
+ - L'action "Définir comme courante" bascule le flag automatiquement
+
+**US-008 : Création d'un groupe-classe**
+
+- **En tant que :** SCHOOL_ADMIN ou Staff
+- **Je veux :** Créer un groupe-classe pour un niveau donné (ex: CP-A)
+- **Afin de :** Organiser les étudiants en classes
- **Critères d'acceptation :**
- - Le formulaire demande : nom du groupe, niveau associé, capacité maximale
- - Le groupe est créé avec une capacité actuelle de 0
- - Le groupe est visible dans la liste des groupes du niveau
+ - Le formulaire demande : nom, niveau scolaire, capacité maximale, année académique
+ - Le groupe est créé avec `currentCapacity = 0`
+ - Le groupe est visible dans la liste des classes du niveau
-**US-009 : Attribution d'un étudiant à un groupe**
+**US-009 : Inscription d'un étudiant dans un groupe-classe**
- **En tant que :** Staff
-- **Je veux :** Assigner un étudiant à un groupe
-- **Afin de :** Organiser les classes
+- **Je veux :** Inscrire un étudiant dans un groupe-classe
+- **Afin de :** Affecter les élèves à leurs classes
- **Critères d'acceptation :**
- - Je sélectionne un étudiant et un groupe disponible
- - Le système vérifie que le groupe n'a pas atteint sa capacité maximale
- - L'étudiant est ajouté au groupe et la capacité actuelle est incrémentée
+ - Je sélectionne un étudiant et un groupe-classe disponible
+ - Le système vérifie que `currentCapacity < maxCapacity`
+ - Un `StudentClassroom` est créé avec `isActive = true`
- Si le groupe est plein, un message d'erreur est affiché
-**US-010 : Création d'une salle de classe**
+**US-010 : Création d'une salle physique (Room)**
-- **En tant que :** Admin ou Staff
-- **Je veux :** Créer une salle de classe physique
-- **Afin de :** L'utiliser dans les emplois du temps
+- **En tant que :** SCHOOL_ADMIN ou Staff
+- **Je veux :** Créer une salle physique de l'établissement
+- **Afin de :** La référencer dans les examens et emplois du temps
- **Critères d'acceptation :**
- - Le formulaire demande : nom/numéro de salle (ex: A1), capacité
- - La salle est créée et disponible pour attribution dans les emplois du temps
+ - Le formulaire demande : nom (ex: Salle A1), localisation, capacité, équipement, disponibilité
+ - La salle est créée et disponible pour attribution aux examens
**US-011 : Création d'une matière**
@@ -1129,15 +1254,49 @@ schoolsphere/
- Le bulletin peut être téléchargé en PDF
- Le bulletin est envoyé par email aux parents
-**US-027 : Attribution d'une certification**
+**US-027 : Demande d'un document scolaire**
-- **En tant que :** Admin ou Staff
-- **Je veux :** Attribuer une certification à un étudiant
-- **Afin de :** Reconnaître une réussite ou un diplôme
+- **En tant que :** Étudiant ou Parent
+- **Je veux :** Faire une demande de document officiel (certificat de scolarité, relevé de notes, etc.)
+- **Afin de :** Obtenir un document administratif pour mes démarches
+- **Critères d'acceptation :**
+ - Le formulaire demande : type de document, motif, année académique
+ - La demande est créée avec statut `EN_ATTENTE`
+ - Le staff reçoit la demande et peut l'accepter ou la rejeter
+ - En cas d'acceptation : URL du fichier réponse joint par le staff
+
+**US-027b : Traitement d'une demande de document**
+
+- **En tant que :** Staff ou SCHOOL_ADMIN
+- **Je veux :** Traiter les demandes de documents scolaires en attente
+- **Afin de :** Fournir les documents officiels aux étudiants
+- **Critères d'acceptation :**
+ - Je consulte la liste des demandes (filtrable par statut, type, étudiant)
+ - Je peux passer une demande à `EN_COURS`, puis `APPROUVE` ou `REJETE`
+ - Je peux joindre un fichier réponse et ajouter une note de traitement
+
+**US-027c : Saisie de présences**
+
+- **En tant que :** Enseignant
+- **Je veux :** Saisir la liste de présence d'une session de cours
+- **Afin de :** Suivre l'assiduité des élèves
+- **Critères d'acceptation :**
+ - Je sélectionne : groupe-classe, matière, date
+ - Une liste des étudiants du groupe s'affiche
+ - Je marque chaque étudiant : PRESENT, ABSENT, EN_RETARD, EXCUSE
+ - Je peux ajouter un motif pour les absences/retards
+
+**US-027d : Soumission d'une réclamation de note**
+
+- **En tant que :** Étudiant
+- **Je veux :** Contester une note obtenue à un examen
+- **Afin de :** Obtenir une révision si je pense qu'il y a une erreur
- **Critères d'acceptation :**
- - Le formulaire demande : étudiant, type de certification, titre, date d'émission
- - Un fichier PDF peut être uploadé (ou généré automatiquement)
- - La certification est visible dans le profil de l'étudiant
+ - Je sélectionne un examen noté et je rédige ma réclamation avec un motif
+ - La réclamation est créée avec statut `PENDING`
+ - L'enseignant concerné peut consulter les réclamations qui le concernent
+ - L'enseignant ou le SCHOOL_ADMIN traite la réclamation (`REVIEWING → RESOLVED / REJECTED`)
+ - Une réponse officielle est jointe à la réclamation traitée
#### 7.5 Ressources pédagogiques en ligne
diff --git a/resources/class-en.puml b/resources/class-en.puml
index 6e13800..2f5fb45 100644
--- a/resources/class-en.puml
+++ b/resources/class-en.puml
@@ -1,31 +1,55 @@
-@startuml SchoolSphere - Architecture Microservices Finale (Version English)
+@startuml SchoolSphere - Class Diagram (English)
skinparam packageStyle rectangle
skinparam classAttributeIconSize 0
skinparam linetype ortho
+skinparam shadowing false
+skinparam class {
+ BackgroundColor White
+ BorderColor #333333
+ ArrowColor #333333
+ HeaderBackgroundColor #EEEEEE
+}
+skinparam package {
+ BackgroundColor White
+ BorderColor #333333
+}
'===============================================
-' USER SERVICE (Port 8081 - MongoDB)
+' USER SERVICE (Port 8081 — MongoDB)
'===============================================
-package "USER SERVICE\nPort: 8081\nDB: MongoDB (user_db)" #4ECDC4 {
+package "USER SERVICE | Port 8081 | DB: MongoDB" {
+
+ abstract class BaseDocument {
+ # createdAt: Instant
+ # updatedAt: Instant
+ }
class School {
- id: String
- name: String
- address: String
+ - city: String
- contactEmail: String
- contactPhone: String
- ownerId: String
- establishedYear: int
- - typeOfSchool: SchoolTypeEnum
+ - typeOfSchool: SchoolType
- isActive: Boolean
- - createdAt: Date
- - updatedAt: Date
+ - isDeleted: Boolean
+ - deletedAt: Instant
}
- enum SchoolTypeEnum {
- PUBLIC
- PRIVATE
+ class SchoolApprovalRequest {
+ - id: String
+ - schoolId: String
+ - ownerId: String
+ - status: ApprovalStatus
+ - requestedAt: Instant
+ - reviewedAt: Instant
+ - reviewedBy: String
+ - rejectionReason: String
+ - comments: String
}
class User {
@@ -35,48 +59,42 @@ package "USER SERVICE\nPort: 8081\nDB: MongoDB (user_db)" #4ECDC4 {
- firstName: String
- lastName: String
- email: String
- - phone: String
- - dateOfBirth: Date
+ - phoneNumber: String
+ - birthDate: LocalDate
- isOwner: Boolean
- isActive: Boolean
- emailVerified: Boolean
- - createdAt: Date
- - updatedAt: Date
+ - isDeleted: Boolean
+ - deletedAt: Instant
+ - suspensionReason: String
+ - createdBy: String
}
- class Role {
+ class UserProfile {
- id: String
- userId: String
- - schoolId: String
- - name: RoleEnum
- - permissions: Set
- - createdAt: Date
- }
-
- enum RoleEnum {
- ADMIN
- STAFF
- TEACHER
- STUDENT
- PARENT
+ - profilePictureUrl: String
+ - coverImageUrl: String
+ - bio: String
+ - socialLinks: Map
+ - address: String
+ - city: String
+ - country: String
+ - postalCode: String
+ - customFields: Map
+ - emergencyContacts: Map
}
- class Student {
+ class Role {
- id: String
- userId: String
- schoolId: String
- - enrollmentNumber: String
- - enrollmentDate: Date
- - dateOfBirth: Date
- - status: StudentStatusEnum
- - createdAt: Date
- }
-
- enum StudentStatusEnum {
- ACTIVE
- GRADUATED
- SUSPENDED
- TRANSFERRED
+ - roleType: RoleType
+ - permissions: Set
+ - isActive: Boolean
+ - assignedBy: String
+ - description: String
+ - revokedAt: Instant
}
class Teacher {
@@ -84,20 +102,31 @@ package "USER SERVICE\nPort: 8081\nDB: MongoDB (user_db)" #4ECDC4 {
- userId: String
- schoolId: String
- employeeNumber: String
- - hireDate: Date
+ - hireDate: LocalDate
- specializations: List
- biography: String
- - createdAt: Date
+ }
+
+ class Student {
+ - id: String
+ - userId: String
+ - schoolId: String
+ - registrationNumber: String
+ - registrationDate: LocalDate
+ - birthDate: LocalDate
+ - status: StudentStatus
+ - currentGradeLevel: String
+ - academicYear: String
}
class Staff {
- id: String
- userId: String
- schoolId: String
- - hireDate: Date
+ - employeeNumber: String
+ - hireDate: LocalDate
- position: String
- department: String
- - createdAt: Date
}
class Parent {
@@ -105,272 +134,403 @@ package "USER SERVICE\nPort: 8081\nDB: MongoDB (user_db)" #4ECDC4 {
- userId: String
- schoolId: String
- address: String
- - occupation: String
- - createdAt: Date
+ - profession: String
+ - emergencyContact: String
}
class ParentStudent {
- id: String
- parentId: String
- studentId: String
- - relationship: RelationshipEnum
+ - relation: ParentRelation
- isEmergencyContact: Boolean
- - createdAt: Date
+ - isActive: Boolean
+ }
+
+ enum SchoolType {
+ PUBLIC
+ PRIVATE
+ ONLINE
+ }
+
+ enum ApprovalStatus {
+ PENDING
+ APPROVED
+ REJECTED
+ SUSPENDED
}
- enum RelationshipEnum {
+ enum RoleType {
+ ADMIN
+ SCHOOL_ADMIN
+ TEACHER
+ STUDENT
+ PARENT
+ STAFF
+ GUEST
+ }
+
+ enum StudentStatus {
+ ACTIVE
+ GRADUATED
+ SUSPENDED
+ TRANSFERRED
+ }
+
+ enum ParentRelation {
FATHER
MOTHER
GUARDIAN
OTHER
}
- ' Relations User Service
- School "1" -- "1..*" User
+ ' Inheritance
+ School -up-|> BaseDocument
+ SchoolApprovalRequest -up-|> BaseDocument
+ User -up-|> BaseDocument
+ UserProfile -up-|> BaseDocument
+ Role -up-|> BaseDocument
+ Teacher -up-|> BaseDocument
+ Student -up-|> BaseDocument
+ Staff -up-|> BaseDocument
+ Parent -up-|> BaseDocument
+ ParentStudent -up-|> BaseDocument
+
+ ' Enum associations
+ School -- SchoolType
+ SchoolApprovalRequest -- ApprovalStatus
+ Role -- RoleType
+ Student -- StudentStatus
+ ParentStudent -- ParentRelation
+
+ ' Structural relations
+ School "1" -- "1..*" User : belongs to >
School "1" -- "0..*" Role
- School -- SchoolTypeEnum
+ School "1" -- "0..1" SchoolApprovalRequest
+ User "1" -- "0..1" UserProfile
User "1" -- "1..*" Role
User "1" -- "0..1" Student
User "1" -- "0..1" Teacher
User "1" -- "0..1" Staff
User "1" -- "0..1" Parent
- Role -- RoleEnum
-
- Student -- StudentStatusEnum
Student "1" -- "0..*" ParentStudent
-
- Parent "1" -- "0..*" ParentStudent
- ParentStudent -- RelationshipEnum
+ Parent "1" -- "0..*" ParentStudent
}
'===============================================
-' ACADEMIC SERVICE (Port 8082 - MongoDB)
+' ACADEMIC SERVICE (Port 8082 — PostgreSQL)
'===============================================
-package "ACADEMIC SERVICE\nPort: 8082\nDB: MongoDB (academic_db)" #95E1D3 {
+package "ACADEMIC SERVICE | Port 8082 | DB: PostgreSQL" {
+
+ abstract class BaseEntity {
+ # id: String <>
+ # createdAt: LocalDateTime
+ # updatedAt: LocalDateTime
+ }
+
+ class AcademicYear {
+ - name: String
+ - startDate: LocalDate
+ - endDate: LocalDate
+ - isCurrent: boolean
+ - schoolId: String
+ }
class GradeLevel {
- - id: String
- schoolId: String
- name: String
- description: String
- - order: int
- - createdAt: Date
+ - displayOrder: int
}
- class Group {
- - id: String
+ class Classroom {
- schoolId: String
- - gradeLevelId: String
- name: String
- maxCapacity: int
- currentCapacity: int
- academicYear: String
- - createdAt: Date
+ - gradeLevel: GradeLevel
}
class Subject {
- - id: String
- schoolId: String
- - gradeLevelId: String
- name: String
- code: String
- description: String
- coefficient: float
- - createdAt: Date
+ - hoursPerWeek: int
+ - gradeLevel: GradeLevel
}
- class Classroom {
- - id: String
+ class Room {
- schoolId: String
- name: String
- location: String
- capacity: int
- equipment: String
- - createdAt: Date
+ - isAvailable: boolean
}
class Timetable {
- - id: String
- schoolId: String
- imageUrl: String
- cloudPublicId: String
- - academicYear: String
- - isActive: Boolean
- - uploadedAt: DateTime
- - createdAt: DateTime
- - classroomId: String
+ - isActive: boolean
- teacherIds: String
+ - academicYear: AcademicYear
+ - classroom: Classroom
+ }
+
+ class TeacherSubject {
+ - teacherId: String <[>
+ - schoolId: String
+ - academicYear: String
+ - subject: Subject
+ }
+
+ class StudentClassroom {
+ - studentId: String <][>
+ - enrollmentDate: LocalDate
+ - isActive: boolean
+ - classroom: Classroom
}
class Exam {
- - id: String
- schoolId: String
- - subjectId: String
- - teacherId: String (ref User Service)
+ - teacherId: String <][>
- name: String
- description: String
- - examDate: Date
+ - examDate: LocalDateTime
- duration: int
- maxScore: float
- - examType: ExamTypeEnum
- - createdAt: Date
- }
-
- enum ExamTypeEnum {
- QUIZ
- MIDTERM
- FINAL
- ORAL
- PRACTICAL
- }
-
- class ExamGroup {
- - id: String
- - examId: String
- - groupId: String
- - createdAt: Date
+ - examType: ExamType
+ - roomId: String
+ - classroomId: String
+ - subject: Subject
}
class Grade {
- - id: String
- - studentId: String (ref User Service)
- - examId: String
+ - studentId: String <][>
- score: float
- comment: String
- - gradedAt: Date
- - createdAt: Date
+ - gradedAt: LocalDateTime
+ - gradedBy: String
+ - exam: Exam
}
- class Certification {
- - id: String
+ class Activity {
- schoolId: String
- - studentId: String (ref User Service)
- - title: String
- - type: CertificationTypeEnum
- - issuedDate: Date
- - fileUrl: String
- - createdAt: Date
+ - organizerId: String <][>
+ - name: String
+ - description: String
+ - activityType: ActivityType
+ - startDate: LocalDateTime
+ - endDate: LocalDateTime
+ - registrationDeadline: LocalDateTime
+ - location: String
+ - maxParticipants: int
+ - currentParticipants: int
+ - fee: float
+ - status: ActivityStatus
+ - coverImageUrl: String
+ - resourceLinks: List
}
- enum CertificationTypeEnum {
- DIPLOMA
- CERTIFICATE
- ATTESTATION
- AWARD
+ class ResourceLink <> {
+ - label: String
+ - url: String
+ - type: String
}
- class TeacherSubject {
- - id: String
- - teacherId: String (ref User Service)
- - subjectId: String
+ class ActivityEnrollment {
+ - studentId: String <][>
+ - enrolledBy: String
+ - status: EnrollmentStatus
+ - paymentId: String
+ - enrolledAt: LocalDateTime
+ - activity: Activity
+ }
+
+ class DocumentScolaire {
- schoolId: String
+ - studentId: String <][>
+ - requestedById: String
+ - requestedByRole: String
+ - documentType: DocumentType
+ - status: RequestStatus
+ - reason: String
- academicYear: String
- - createdAt: Date
+ - processedById: String
+ - processorNote: String
+ - responseFileUrl: String
}
- class StudentGroup {
- - id: String
- - studentId: String (ref User Service)
- - groupId: String
- - enrollmentDate: Date
- - isActive: Boolean
- - createdAt: Date
+ class Attendance {
+ - schoolId: String
+ - classroomId: String
+ - subjectId: String
+ - teacherId: String <][>
+ - date: LocalDate
+ - notes: String
}
- class Activity {
- - id: String
+ class AttendanceRecord {
+ - attendanceId: String
+ - studentId: String <][>
+ - status: AttendanceStatus
+ - reason: String
+ }
+
+ class Reclamation {
+ - examId: String
+ - gradeId: String
+ - studentId: String <][>
+ - studentName: String
+ - examName: String
+ - subjectName: String
+ - score: float
+ - maxScore: float
+ - reason: String
+ - status: ReclamationStatus
+ - response: String
+ - teacherId: String <][>
- schoolId: String
- - organizerId: String (ref User Service)
- - name: String
- - description: String
- - type: ActivityTypeEnum
- - startDate: Date
- - endDate: Date
- - registrationDeadline: Date
- - location: String
- - maxParticipants: int
- - currentParticipants: int
- - fee: float
- - status: ActivityStatusEnum
- - coverImageUrl: String
- - createdAt: Date
}
- enum ActivityTypeEnum {
+ enum ExamType {
+ NATIONAL
+ INTERNATIONAL
+ REGIONAL
+ LOCAL
+ CLASSROOM
+ HOMEWORK
+ QUIZ
+ PRACTICAL
+ ORAL
+ OTHER
+ }
+
+ enum ActivityType {
SPORT
CLUB
COMPETITION
- EVENT
- WORKSHOP
FIELD_TRIP
+ WORKSHOP
CULTURAL
+ EVENT
+ OTHER
}
- enum ActivityStatusEnum {
+ enum ActivityStatus {
+ DRAFT
UPCOMING
REGISTRATION_OPEN
REGISTRATION_CLOSED
- ONGOING
+ IN_PROGRESS
COMPLETED
CANCELLED
+ POSTPONED
}
- class ActivityEnrollment {
- - id: String
- - activityId: String
- - studentId: String (ref User Service)
- - enrolledAt: Date
- - enrolledBy: String (ref User Service)
- - status: EnrollmentStatusEnum
- - paymentStatus: String
- - createdAt: Date
- }
-
- enum EnrollmentStatusEnum {
+ enum EnrollmentStatus {
PENDING
CONFIRMED
- CANCELLED
WAITLISTED
+ COMPLETED
+ CANCELLED
}
- ' Relations Academic Service
- GradeLevel "1" -- "0..*" Group
- GradeLevel "1" -- "0..*" Subject
+ enum DocumentType {
+ CERTIFICAT_SCOLARITE
+ ATTESTATION_STAGE
+ ATTESTATION_INSCRIPTION
+ RELEVE_NOTES
+ AUTRE
+ }
- Group "1" -- "0..*" StudentGroup
- Group "1" -- "0..*" ExamGroup
+ enum RequestStatus {
+ EN_ATTENTE
+ EN_COURS
+ APPROUVE
+ REJETE
+ }
- Subject "1" -- "0..*" TeacherSubject
- Subject "1" -- "0..*" Exam
+ enum AttendanceStatus {
+ PRESENT
+ ABSENT
+ LATE
+ EXCUSED
+ }
+ enum ReclamationStatus {
+ PENDING
+ REVIEWING
+ RESOLVED
+ REJECTED
+ }
+
+ ' Inheritance
+ AcademicYear -up-|> BaseEntity
+ GradeLevel -up-|> BaseEntity
+ Classroom -up-|> BaseEntity
+ Subject -up-|> BaseEntity
+ Room -up-|> BaseEntity
+ Timetable -up-|> BaseEntity
+ TeacherSubject -up-|> BaseEntity
+ StudentClassroom -up-|> BaseEntity
+ Exam -up-|> BaseEntity
+ Grade -up-|> BaseEntity
+ Activity -up-|> BaseEntity
+ ActivityEnrollment -up-|> BaseEntity
+ DocumentScolaire -up-|> BaseEntity
+ Attendance -up-|> BaseEntity
+ AttendanceRecord -up-|> BaseEntity
+ Reclamation -up-|> BaseEntity
+
+ ' Enum associations
+ Exam -- ExamType
+ Activity -- ActivityType
+ Activity -- ActivityStatus
+ ActivityEnrollment -- EnrollmentStatus
+ DocumentScolaire -- DocumentType
+ DocumentScolaire -- RequestStatus
+ AttendanceRecord -- AttendanceStatus
+ Reclamation -- ReclamationStatus
+
+ ' Structural relations
+ GradeLevel "1" -- "0..*" Classroom : contains >
+ GradeLevel "1" -- "0..*" Subject : has >
+
+ AcademicYear "1" -- "0..*" Timetable
+
+ Classroom "1" -- "0..*" StudentClassroom
Classroom "1" -- "0..1" Timetable
- Exam "1" -- "0..*" ExamGroup
- Exam "1" -- "0..*" Grade
- Exam -- ExamTypeEnum
+ Subject "1" -- "0..*" TeacherSubject
+ Subject "1" -- "0..*" Exam
- Certification -- CertificationTypeEnum
+ Exam "1" -- "0..*" Grade
+ Exam "1" -- "0..*" Reclamation
- Activity "1" -- "0..*" ActivityEnrollment
- Activity -- ActivityTypeEnum
- Activity -- ActivityStatusEnum
+ Activity "1" -- "0..*" ActivityEnrollment
+ Activity "1" *-- "0..*" ResourceLink : embeds
- ActivityEnrollment -- EnrollmentStatusEnum
+ Attendance "1" -- "0..*" AttendanceRecord
}
'===============================================
-' RESOURCE SERVICE (Port 8083 - MongoDB)
-' SIMPLIFIÉ - Seulement ressources pédagogiques et bibliothèque
+' RESOURCE SERVICE (Port 8083 — MongoDB)
+' Not yet implemented — design phase
'===============================================
-package "RESOURCE SERVICE\nPort: 8083\nDB: MongoDB (resource_db)" #F9E79F {
+package "RESOURCE SERVICE | Port 8083 | DB: MongoDB | [planned]" {
class PedagogicalResource {
- id: String
- schoolId: String
- - teacherId: String (ref User Service)
- - subjectId: String (ref Academic Service)
+ - teacherId: String <][>
+ - subjectId: String <][>
- title: String
- description: String
- type: ResourceTypeEnum
@@ -378,7 +538,6 @@ package "RESOURCE SERVICE\nPort: 8083\nDB: MongoDB (resource_db)" #F9E79F {
- fileSize: long
- isPublic: Boolean
- downloadCount: int
- - uploadedAt: Date
- createdAt: Date
}
@@ -404,14 +563,13 @@ package "RESOURCE SERVICE\nPort: 8083\nDB: MongoDB (resource_db)" #F9E79F {
- coverImageUrl: String
- totalCopies: int
- availableCopies: int
- - addedAt: Date
- createdAt: Date
}
class BookLoan {
- id: String
- bookId: String
- - studentId: String (ref User Service)
+ - studentId: String <][>
- borrowedAt: Date
- dueDate: Date
- returnedAt: Date
@@ -427,21 +585,21 @@ package "RESOURCE SERVICE\nPort: 8083\nDB: MongoDB (resource_db)" #F9E79F {
LOST
}
- ' Relations Resource Service
LibraryBook "1" -- "0..*" BookLoan
PedagogicalResource -- ResourceTypeEnum
BookLoan -- LoanStatusEnum
}
'===============================================
-' FINANCIAL SERVICE (Port 8084 - MySQL)
+' FINANCIAL SERVICE (Port 8084 — MySQL)
+' Not yet implemented — design phase
'===============================================
-package "FINANCIAL SERVICE\nPort: 8084\nDB: MySQL (financial_db)" #F5B7B1 {
+package "FINANCIAL SERVICE | Port 8084 | DB: MySQL | [planned]" {
class Payment {
- id: String
- - schoolId: String (ref User Service)
- - studentId: String (ref User Service)
+ - schoolId: String
+ - studentId: String <][>
- amount: float
- type: PaymentTypeEnum
- status: PaymentStatusEnum
@@ -489,7 +647,6 @@ package "FINANCIAL SERVICE\nPort: 8084\nDB: MySQL (financial_db)" #F5B7B1 {
- createdAt: Date
}
- ' Relations Financial Service
Payment "1" -- "0..1" Invoice
Payment -- PaymentTypeEnum
Payment -- PaymentStatusEnum
@@ -497,14 +654,14 @@ package "FINANCIAL SERVICE\nPort: 8084\nDB: MySQL (financial_db)" #F5B7B1 {
}
'===============================================
-' COMMUNICATION SERVICE (Port 8086 - MongoDB)
-' Communication, Notifications, Annonces, Réunions, Activités
+' COMMUNICATION SERVICE (Port 8086 — MongoDB)
+' Not yet implemented — design phase
'===============================================
-package "COMMUNICATION SERVICE\nPort: 8086\nDB: MongoDB (communication_db)" #FFE5B4 {
+package "COMMUNICATION SERVICE | Port 8086 | DB: MongoDB | [planned]" {
class Notification {
- id: String
- - userId: String (ref User Service)
+ - userId: String <][>
- schoolId: String
- title: String
- message: String
@@ -512,7 +669,7 @@ package "COMMUNICATION SERVICE\nPort: 8086\nDB: MongoDB (communication_db)" #FFE
- priority: PriorityEnum
- isRead: Boolean
- readAt: Date
- - metadata: Map
+ - metadata: Map
- createdAt: Date
}
@@ -539,12 +696,11 @@ package "COMMUNICATION SERVICE\nPort: 8086\nDB: MongoDB (communication_db)" #FFE
class Announcement {
- id: String
- schoolId: String
- - creatorId: String (ref User Service)
+ - creatorId: String <][>
- title: String
- content: String
- targetAudience: AudienceEnum
- targetGroupId: String
- - targetGradeLevelId: String
- isPinned: Boolean
- publishedAt: Date
- expiresAt: Date
@@ -566,17 +722,14 @@ package "COMMUNICATION SERVICE\nPort: 8086\nDB: MongoDB (communication_db)" #FFE
class Meeting {
- id: String
- schoolId: String
- - organizerId: String (ref User Service)
+ - organizerId: String <][>
- title: String
- - description: String
- meetingType: MeetingTypeEnum
- meetingDate: Date
- startTime: Time
- endTime: Time
- location: String
- meetingLink: String
- - agenda: String
- - minutes: String
- status: MeetingStatusEnum
- createdAt: Date
}
@@ -601,11 +754,9 @@ package "COMMUNICATION SERVICE\nPort: 8086\nDB: MongoDB (communication_db)" #FFE
class MeetingParticipant {
- id: String
- meetingId: String
- - userId: String (ref User Service)
- - invitedAt: Date
+ - userId: String <][>
- status: ParticipantStatusEnum
- respondedAt: Date
- - attendedAt: Date
- notes: String
- createdAt: Date
}
@@ -614,26 +765,13 @@ package "COMMUNICATION SERVICE\nPort: 8086\nDB: MongoDB (communication_db)" #FFE
INVITED
CONFIRMED
DECLINED
- TENTATIVE
ATTENDED
ABSENT
}
-
- class EmailTemplate {
- - id: String
- - schoolId: String
- - name: String
- - subject: String
- - body: String
- - variables: List
- - isActive: Boolean
- - createdAt: Date
- }
-
class NotificationPreference {
- id: String
- - userId: String (ref User Service)
+ - userId: String <][>
- emailEnabled: Boolean
- smsEnabled: Boolean
- pushEnabled: Boolean
@@ -643,127 +781,44 @@ package "COMMUNICATION SERVICE\nPort: 8086\nDB: MongoDB (communication_db)" #FFE
- createdAt: Date
}
- ' Relations Notification Service
Notification -- NotificationTypeEnum
Notification -- PriorityEnum
-
Announcement -- AudienceEnum
-
Meeting "1" -- "0..*" MeetingParticipant
Meeting -- MeetingTypeEnum
Meeting -- MeetingStatusEnum
-
MeetingParticipant -- ParticipantStatusEnum
}
'===============================================
-' COMMUNICATIONS CROSS-SERVICE
+' CROSS-SERVICE COMMUNICATION NOTES
'===============================================
-' Academic Service → User Service (REST Synchrone)
note right of Timetable
- REST Calls:
- GET /user-service/api/teachers/{teacherId}
- (for teacher filter validation)
+ REST Calls (sync):
+ Validates teacherIds against User Service
Image Storage:
- Cloudinary or S3 for image upload
+ Cloudinary (imageUrl + cloudPublicId)
end note
note right of Grade
- REST Calls:
- GET /user-service/api/students/{studentId}
-
Kafka Producer:
- Topic: grade.created
- Event: GradeCreatedEvent
+ Topic: grade.created → GradeCreatedEvent
end note
note right of Exam
- REST Calls:
- GET /user-service/api/teachers/{teacherId}
-
- Kafka Producer:
- Topic: exam.scheduled
- Event: ExamScheduledEvent
-end note
-
-' Resource Service → User Service & Academic Service (REST)
-note right of PedagogicalResource
- REST Calls:
- GET /user-service/api/teachers/{teacherId}
- GET /academic-service/api/subjects/{subjectId}
-end note
-
-note right of BookLoan
- REST Calls:
- GET /user-service/api/students/{studentId}
-
- Kafka Producer:
- Topic: book.borrowed
- Topic: book.overdue
-end note
-
-' Financial Service → User Service (REST)
-note right of Payment
- REST Calls:
- GET /user-service/api/students/{studentId}
- GET /user-service/api/schools/{schoolId}
-
- Kafka Producer:
- Topic: payment.created
- Topic: payment.overdue
- Topic: invoice.generated
-end note
-
-' Notification Service → User Service (REST)
-note right of Announcement
- REST Calls:
- GET /user-service/api/users/{creatorId}
- GET /user-service/api/groups/{groupId}/users
- GET /user-service/api/grade-levels/{levelId}/users
-end note
-
-note right of Meeting
- REST Calls:
- GET /user-service/api/users/{organizerId}
-
Kafka Producer:
- Topic: meeting.scheduled
- Topic: meeting.updated
-end note
-
-note right of Activity
- REST Calls:
- GET /user-service/api/users/{organizerId}
- GET /user-service/api/students/{studentId}
+ Topic: exam.scheduled → ExamScheduledEvent
end note
note left of Notification
-
-
- Consumes Topics:
- • user.created
- • student.enrolled
- • grade.created
- • exam.scheduled
- • payment.created
- • payment.overdue
- • book.borrowed
- • book.overdue
- • meeting.scheduled
-
- Actions:
- 1. Create Notification in DB
- 2. Send Email (SMTP)
- 3. Send SMS (Twilio)
- 4. Send Push Notification
-
- Technologies:
- • Spring Kafka Consumer
- • JavaMailSender (Email)
- • Twilio SDK (SMS)
- • Firebase Admin SDK (Push)
+ KAFKA CONSUMER
+ Consumes: grade.created, exam.scheduled,
+ payment.created, payment.overdue,
+ book.overdue, meeting.scheduled
+
+ Actions: Create DB record, Send Email/SMS/Push
end note
-@enduml
\ No newline at end of file
+@enduml
diff --git a/resources/class.puml b/resources/class.puml
index 0950f91..18506af 100644
--- a/resources/class.puml
+++ b/resources/class.puml
@@ -1,30 +1,55 @@
-@startuml SchoolSphere - Architecture Microservices Finale (Version Française)
+@startuml SchoolSphere - Diagramme de Classes (Français)
skinparam packageStyle rectangle
skinparam classAttributeIconSize 0
skinparam linetype ortho
-'=====================
-' SERVICE UTILISATEUR
-'=====================
-package "SERVICE UTILISATEUR" #4ECDC4 {
+skinparam shadowing false
+skinparam class {
+ BackgroundColor White
+ BorderColor #333333
+ ArrowColor #333333
+ HeaderBackgroundColor #EEEEEE
+}
+skinparam package {
+ BackgroundColor White
+ BorderColor #333333
+}
+
+'===============================================
+' SERVICE UTILISATEUR (Port 8081 — MongoDB)
+'===============================================
+package "SERVICE UTILISATEUR | Port 8081 | BD: MongoDB" {
+
+ abstract class DocumentBase {
+ # creeLe: Instant
+ # misAJourLe: Instant
+ }
class Ecole {
- id: String
- nom: String
- adresse: String
+ - ville: String
- emailContact: String
- telephoneContact: String
- proprietaireId: String
- anneeCreation: int
- - typeEcole: TypeEcoleEnum
+ - typeEcole: TypeEcole
- estActif: Boolean
- - creeLe: Date
- - misAJourLe: Date
+ - estSupprime: Boolean
+ - supprimeLe: Instant
}
- enum TypeEcoleEnum {
- PUBLIC
- PRIVEE
+ class DemandeApprobationEcole {
+ - id: String
+ - ecoleId: String
+ - proprietaireId: String
+ - statut: StatutApprobation
+ - demandeLe: Instant
+ - examineLe: Instant
+ - examineePar: String
+ - raisonRejet: String
+ - commentaires: String
}
class Utilisateur {
@@ -35,47 +60,41 @@ package "SERVICE UTILISATEUR" #4ECDC4 {
- nom: String
- email: String
- telephone: String
- - dateNaissance: Date
+ - dateNaissance: LocalDate
- estProprietaire: Boolean
- estActif: Boolean
- emailVerifie: Boolean
- - creeLe: Date
- - misAJourLe: Date
+ - estSupprime: Boolean
+ - supprimeLe: Instant
+ - raisonSuspension: String
+ - creePar: String
}
- class Role {
+ class ProfilUtilisateur {
- id: String
- utilisateurId: String
- - ecoleId: String
- - nom: RoleEnum
- - permissions: Set
- - creeLe: Date
- }
-
- enum RoleEnum {
- ADMIN
- PERSONNEL
- ENSEIGNANT
- ELEVE
- PARENT
+ - urlPhotoProfil: String
+ - urlImageCouverture: String
+ - bio: String
+ - liensReseaux: Map
+ - adresse: String
+ - ville: String
+ - pays: String
+ - codePostal: String
+ - champsPersonnalises: Map
+ - contactsUrgence: Map
}
- class Eleve {
+ class Role {
- id: String
- utilisateurId: String
- ecoleId: String
- - numeroInscription: String
- - dateInscription: Date
- - dateNaissance: Date
- - statut: StatutEleveEnum
- - creeLe: Date
- }
-
- enum StatutEleveEnum {
- ACTIF
- DIPLOME
- SUSPENDU
- TRANSFERE
+ - typeRole: TypeRole
+ - permissions: Set
+ - estActif: Boolean
+ - attribuePar: String
+ - description: String
+ - revoqueLe: Instant
}
class Enseignant {
@@ -83,20 +102,31 @@ package "SERVICE UTILISATEUR" #4ECDC4 {
- utilisateurId: String
- ecoleId: String
- numeroEmploye: String
- - dateEmbauche: Date
+ - dateEmbauche: LocalDate
- specialisations: List
- biographie: String
- - creeLe: Date
+ }
+
+ class Eleve {
+ - id: String
+ - utilisateurId: String
+ - ecoleId: String
+ - numeroInscription: String
+ - dateInscription: LocalDate
+ - dateNaissance: LocalDate
+ - statut: StatutEleve
+ - niveauScolaireActuel: String
+ - anneeAcademique: String
}
class Personnel {
- id: String
- utilisateurId: String
- ecoleId: String
- - dateEmbauche: Date
+ - numeroEmploye: String
+ - dateEmbauche: LocalDate
- poste: String
- departement: String
- - creeLe: Date
}
class Parent {
@@ -105,282 +135,413 @@ package "SERVICE UTILISATEUR" #4ECDC4 {
- ecoleId: String
- adresse: String
- profession: String
- - creeLe: Date
+ - contactUrgence: String
}
- class ParentEleve {
+ class LienParentEleve {
- id: String
- parentId: String
- eleveId: String
- - lien: LienEnum
+ - relation: RelationParentale
- estContactUrgence: Boolean
- - creeLe: Date
+ - estActif: Boolean
+ }
+
+ enum TypeEcole {
+ PUBLIQUE
+ PRIVEE
+ EN_LIGNE
}
- enum LienEnum {
+ enum StatutApprobation {
+ EN_ATTENTE
+ APPROUVE
+ REJETE
+ SUSPENDU
+ }
+
+ enum TypeRole {
+ ADMIN
+ SCHOOL_ADMIN
+ ENSEIGNANT
+ ELEVE
+ PARENT
+ PERSONNEL
+ INVITE
+ }
+
+ enum StatutEleve {
+ ACTIF
+ DIPLOME
+ SUSPENDU
+ TRANSFERE
+ }
+
+ enum RelationParentale {
PERE
MERE
TUTEUR
AUTRE
}
- ' Relations Service Utilisateur
- Ecole "1" -- "1..*" Utilisateur
+ ' Héritage
+ Ecole -up-|> DocumentBase
+ DemandeApprobationEcole -up-|> DocumentBase
+ Utilisateur -up-|> DocumentBase
+ ProfilUtilisateur -up-|> DocumentBase
+ Role -up-|> DocumentBase
+ Enseignant -up-|> DocumentBase
+ Eleve -up-|> DocumentBase
+ Personnel -up-|> DocumentBase
+ Parent -up-|> DocumentBase
+ LienParentEleve -up-|> DocumentBase
+
+ ' Associations aux énumérations
+ Ecole -- TypeEcole
+ DemandeApprobationEcole -- StatutApprobation
+ Role -- TypeRole
+ Eleve -- StatutEleve
+ LienParentEleve -- RelationParentale
+
+ ' Relations structurelles
+ Ecole "1" -- "1..*" Utilisateur : appartient à >
Ecole "1" -- "0..*" Role
- Ecole -- TypeEcoleEnum
+ Ecole "1" -- "0..1" DemandeApprobationEcole
+ Utilisateur "1" -- "0..1" ProfilUtilisateur
Utilisateur "1" -- "1..*" Role
Utilisateur "1" -- "0..1" Eleve
Utilisateur "1" -- "0..1" Enseignant
Utilisateur "1" -- "0..1" Personnel
Utilisateur "1" -- "0..1" Parent
- Role -- RoleEnum
+ Eleve "1" -- "0..*" LienParentEleve
+ Parent "1" -- "0..*" LienParentEleve
+}
- Eleve -- StatutEleveEnum
- Eleve "1" -- "0..*" ParentEleve
+'===============================================
+' SERVICE ACADEMIQUE (Port 8082 — PostgreSQL)
+'===============================================
+package "SERVICE ACADEMIQUE | Port 8082 | BD: PostgreSQL" {
- Parent "1" -- "0..*" ParentEleve
- ParentEleve -- LienEnum
-}
+ abstract class EntiteBase {
+ # id: String <>
+ # creeLe: LocalDateTime
+ # misAJourLe: LocalDateTime
+ }
-'====================
-' SERVICE ACADEMIQUE
-'====================
-package "SERVICE ACADEMIQUE" #95E1D3 {
+ class AnneeAcademique {
+ - nom: String
+ - dateDebut: LocalDate
+ - dateFin: LocalDate
+ - estCourante: boolean
+ - ecoleId: String
+ }
class NiveauScolaire {
- - id: String
- ecoleId: String
- nom: String
- description: String
- - ordre: int
- - creeLe: Date
+ - ordreAffichage: int
}
- class Groupe {
- - id: String
+ class GroupeClasse {
- ecoleId: String
- - niveauScolaireId: String
- nom: String
- capaciteMax: int
- capaciteActuelle: int
- anneeAcademique: String
- - creeLe: Date
+ - niveauScolaire: NiveauScolaire
}
class Matiere {
- - id: String
- ecoleId: String
- - niveauScolaireId: String
- nom: String
- code: String
- description: String
- coefficient: float
- - creeLe: Date
+ - heuresParSemaine: int
+ - niveauScolaire: NiveauScolaire
}
- class SalleDeClasse {
- - id: String
+ class Salle {
- ecoleId: String
- nom: String
- localisation: String
- capacite: int
- equipement: String
- - creeLe: Date
+ - estDisponible: boolean
}
class EmploiDuTemps {
- - id: String
- ecoleId: String
- urlImage: String
- cloudPublicId: String
- - anneeAcademique: String
- - estActif: Boolean
- - televerseLe: DateTime
- - creeLe: DateTime
- - salleDeClasseId: String
+ - estActif: boolean
- enseignantIds: String
+ - anneeAcademique: AnneeAcademique
+ - groupeClasse: GroupeClasse
+ }
+
+ class EnseignantMatiere {
+ - enseignantId: String <][>
+ - ecoleId: String
+ - anneeAcademique: String
+ - matiere: Matiere
+ }
+
+ class InscriptionClasse {
+ - eleveId: String <][>
+ - dateInscription: LocalDate
+ - estActif: boolean
+ - groupeClasse: GroupeClasse
}
class Examen {
- - id: String
- ecoleId: String
- - matiereId: String
- - enseignantId: String (ref Service Utilisateur)
+ - enseignantId: String <][>
- nom: String
- description: String
- - dateExamen: Date
+ - dateExamen: LocalDateTime
- duree: int
- noteMaximale: float
- - typeExamen: TypeExamenEnum
- - creeLe: Date
- }
-
- enum TypeExamenEnum {
- QUIZ
- PARTIEL
- FINAL
- ORAL
- PRATIQUE
- }
-
- class ExamenGroupe {
- - id: String
- - examenId: String
- - groupeId: String
- - creeLe: Date
+ - typeExamen: TypeExamen
+ - salleId: String
+ - groupeClasseId: String
+ - matiere: Matiere
}
class Note {
- - id: String
- - eleveId: String (ref Service Utilisateur)
- - examenId: String
+ - eleveId: String <][>
- score: float
- commentaire: String
- - noteLe: Date
- - creeLe: Date
+ - noteLe: LocalDateTime
+ - notePar: String
+ - examen: Examen
}
- class Certification {
- - id: String
+ class Activite {
- ecoleId: String
- - eleveId: String (ref Service Utilisateur)
- - titre: String
- - type: TypeCertificationEnum
- - dateDelivrance: Date
- - urlFichier: String
- - creeLe: Date
+ - organisateurId: String <][>
+ - nom: String
+ - description: String
+ - typeActivite: TypeActivite
+ - dateDebut: LocalDateTime
+ - dateFin: LocalDateTime
+ - dateLimiteInscription: LocalDateTime
+ - lieu: String
+ - participantsMax: int
+ - participantsActuels: int
+ - frais: float
+ - statut: StatutActivite
+ - urlImageCouverture: String
+ - liensRessources: List
}
- enum TypeCertificationEnum {
- DIPLOME
- CERTIFICAT
- ATTESTATION
- RECOMPENSE
+ class LienRessource <> {
+ - libelle: String
+ - url: String
+ - type: String
}
- class EnseignantMatiere {
- - id: String
- - enseignantId: String (ref Service Utilisateur)
- - matiereId: String
+ class InscriptionActivite {
+ - eleveId: String <][>
+ - inscritPar: String
+ - statut: StatutInscription
+ - paiementId: String
+ - inscritLe: LocalDateTime
+ - activite: Activite
+ }
+
+ class DocumentScolaire {
- ecoleId: String
+ - eleveId: String <][>
+ - demandeParId: String
+ - roleDemandeur: String
+ - typeDocument: TypeDocument
+ - statut: StatutDemande
+ - raison: String
- anneeAcademique: String
- - creeLe: Date
+ - traitePar: String
+ - noteTraitement: String
+ - urlFichierReponse: String
}
- class EleveGroupe {
- - id: String
- - eleveId: String (ref Service Utilisateur)
- - groupeId: String
- - dateInscription: Date
- - estActif: Boolean
- - creeLe: Date
+ class Presence {
+ - ecoleId: String
+ - groupeClasseId: String
+ - matiereId: String
+ - enseignantId: String <][>
+ - date: LocalDate
+ - notes: String
}
- class Activite {
- - id: String
+ class EnregistrementPresence {
+ - presenceId: String
+ - eleveId: String <][>
+ - statut: StatutPresence
+ - raison: String
+ }
+
+ class Reclamation {
+ - examenId: String
+ - noteId: String
+ - eleveId: String <][>
+ - nomEleve: String
+ - nomExamen: String
+ - nomMatiere: String
+ - score: float
+ - scoreMax: float
+ - raison: String
+ - statut: StatutReclamation
+ - reponse: String
+ - enseignantId: String <][>
- ecoleId: String
- - organisateurId: String (ref Service Utilisateur)
- - nom: String
- - description: String
- - type: TypeActiviteEnum
- - dateDebut: Date
- - dateFin: Date
- - dateLimiteInscription: Date
- - lieu: String
- - participantsMax: int
- - participantsActuels: int
- - frais: float
- - statut: StatutActiviteEnum
- - urlImageCouverture: String
- - creeLe: Date
}
- enum TypeActiviteEnum {
+ enum TypeExamen {
+ NATIONAL
+ INTERNATIONAL
+ REGIONAL
+ LOCAL
+ EN_CLASSE
+ DEVOIR
+ QUIZ
+ PRATIQUE
+ ORAL
+ AUTRE
+ }
+
+ enum TypeActivite {
SPORT
CLUB
COMPETITION
- EVENEMENT
- ATELIER
SORTIE_SCOLAIRE
+ ATELIER
CULTUREL
+ EVENEMENT
+ AUTRE
}
- enum StatutActiviteEnum {
+ enum StatutActivite {
+ BROUILLON
A_VENIR
INSCRIPTIONS_OUVERTES
INSCRIPTIONS_FERMEES
EN_COURS
TERMINEE
ANNULEE
+ REPORTEE
}
- class InscriptionActivite {
- - id: String
- - activiteId: String
- - eleveId: String (ref Service Utilisateur)
- - inscritLe: Date
- - inscritPar: String (ref Service Utilisateur)
- - statut: StatutInscriptionEnum
- - statutPaiement: String
- - creeLe: Date
- }
-
- enum StatutInscriptionEnum {
+ enum StatutInscription {
EN_ATTENTE
CONFIRMEE
- ANNULEE
LISTE_ATTENTE
+ COMPLETEE
+ ANNULEE
}
- ' Relations Service Académique
- NiveauScolaire "1" -- "0..*" Groupe
- NiveauScolaire "1" -- "0..*" Matiere
+ enum TypeDocument {
+ CERTIFICAT_SCOLARITE
+ ATTESTATION_STAGE
+ ATTESTATION_INSCRIPTION
+ RELEVE_NOTES
+ AUTRE
+ }
- Groupe "1" -- "0..*" EleveGroupe
- Groupe "1" -- "0..*" ExamenGroupe
+ enum StatutDemande {
+ EN_ATTENTE
+ EN_COURS
+ APPROUVE
+ REJETE
+ }
+
+ enum StatutPresence {
+ PRESENT
+ ABSENT
+ EN_RETARD
+ EXCUSE
+ }
+
+ enum StatutReclamation {
+ EN_ATTENTE
+ EN_COURS_EXAMEN
+ RESOLUE
+ REJETEE
+ }
+
+ ' Héritage
+ AnneeAcademique -up-|> EntiteBase
+ NiveauScolaire -up-|> EntiteBase
+ GroupeClasse -up-|> EntiteBase
+ Matiere -up-|> EntiteBase
+ Salle -up-|> EntiteBase
+ EmploiDuTemps -up-|> EntiteBase
+ EnseignantMatiere -up-|> EntiteBase
+ InscriptionClasse -up-|> EntiteBase
+ Examen -up-|> EntiteBase
+ Note -up-|> EntiteBase
+ Activite -up-|> EntiteBase
+ InscriptionActivite -up-|> EntiteBase
+ DocumentScolaire -up-|> EntiteBase
+ Presence -up-|> EntiteBase
+ EnregistrementPresence -up-|> EntiteBase
+ Reclamation -up-|> EntiteBase
+
+ ' Associations aux énumérations
+ Examen -- TypeExamen
+ Activite -- TypeActivite
+ Activite -- StatutActivite
+ InscriptionActivite -- StatutInscription
+ DocumentScolaire -- TypeDocument
+ DocumentScolaire -- StatutDemande
+ EnregistrementPresence -- StatutPresence
+ Reclamation -- StatutReclamation
+
+ ' Relations structurelles
+ NiveauScolaire "1" -- "0..*" GroupeClasse : contient >
+ NiveauScolaire "1" -- "0..*" Matiere : a >
+
+ AnneeAcademique "1" -- "0..*" EmploiDuTemps
+
+ GroupeClasse "1" -- "0..*" InscriptionClasse
+ GroupeClasse "1" -- "0..1" EmploiDuTemps
Matiere "1" -- "0..*" EnseignantMatiere
Matiere "1" -- "0..*" Examen
- SalleDeClasse "1" -- "0..1" EmploiDuTemps
-
- Examen "1" -- "0..*" ExamenGroupe
Examen "1" -- "0..*" Note
- Examen -- TypeExamenEnum
-
- Certification -- TypeCertificationEnum
+ Examen "1" -- "0..*" Reclamation
Activite "1" -- "0..*" InscriptionActivite
- Activite -- TypeActiviteEnum
- Activite -- StatutActiviteEnum
+ Activite "1" *-- "0..*" LienRessource : incorpore
- InscriptionActivite -- StatutInscriptionEnum
+ Presence "1" -- "0..*" EnregistrementPresence
}
-'===================
-' SERVICE RESSOURCE
-'===================
-package "SERVICE RESSOURCE" #F9E79F {
+'===============================================
+' SERVICE RESSOURCE (Port 8083 — MongoDB)
+' Non implémenté — phase conception
+'===============================================
+package "SERVICE RESSOURCE | Port 8083 | BD: MongoDB | [prévu]" {
class RessourcePedagogique {
- id: String
- ecoleId: String
- - enseignantId: String (ref Service Utilisateur)
- - matiereId: String (ref Service Académique)
+ - enseignantId: String <][>
+ - matiereId: String <][>
- titre: String
- description: String
- - type: TypeRessourceEnum
+ - type: TypeRessource
- urlFichier: String
- tailleFichier: long
- estPublic: Boolean
- nombreTelechargements: int
- - televerseLe: Date
- creeLe: Date
}
- enum TypeRessourceEnum {
+ enum TypeRessource {
VIDEO
PDF
DOCUMENT
@@ -402,48 +563,47 @@ package "SERVICE RESSOURCE" #F9E79F {
- urlCouverture: String
- exemplairesTotaux: int
- exemplairesDisponibles: int
- - ajouteLe: Date
- creeLe: Date
}
class EmpruntLivre {
- id: String
- livreId: String
- - eleveId: String (ref Service Utilisateur)
+ - eleveId: String <][>
- emprunteLe: Date
- dateRetourPrevue: Date
- retourneLe: Date
- - statut: StatutEmpruntEnum
+ - statut: StatutEmprunt
- montantAmende: float
- creeLe: Date
}
- enum StatutEmpruntEnum {
+ enum StatutEmprunt {
ACTIF
RETOURNE
RETARD
PERDU
}
- ' Relations Service Ressource
LivreBibliotheque "1" -- "0..*" EmpruntLivre
- RessourcePedagogique -- TypeRessourceEnum
- EmpruntLivre -- StatutEmpruntEnum
+ RessourcePedagogique -- TypeRessource
+ EmpruntLivre -- StatutEmprunt
}
-'===================
-' SERVICE FINANCIER
-'===================
-package "SERVICE FINANCIER" #F5B7B1 {
+'===============================================
+' SERVICE FINANCIER (Port 8084 — MySQL)
+' Non implémenté — phase conception
+'===============================================
+package "SERVICE FINANCIER | Port 8084 | BD: MySQL | [prévu]" {
class Paiement {
- id: String
- - ecoleId: String (ref Service Utilisateur)
- - eleveId: String (ref Service Utilisateur)
+ - ecoleId: String
+ - eleveId: String <][>
- montant: float
- - type: TypePaiementEnum
- - statut: StatutPaiementEnum
- - methode: MethodePaiementEnum
+ - type: TypePaiement
+ - statut: StatutPaiement
+ - methode: MethodePaiement
- dateEcheance: Date
- payeLe: Date
- transactionId: String
@@ -451,7 +611,7 @@ package "SERVICE FINANCIER" #F5B7B1 {
- creeLe: Date
}
- enum TypePaiementEnum {
+ enum TypePaiement {
SCOLARITE
ACTIVITE
AMENDE_BIBLIOTHEQUE
@@ -460,7 +620,7 @@ package "SERVICE FINANCIER" #F5B7B1 {
AUTRE
}
- enum StatutPaiementEnum {
+ enum StatutPaiement {
EN_ATTENTE
PAYE
RETARD
@@ -468,7 +628,7 @@ package "SERVICE FINANCIER" #F5B7B1 {
REMBOURSE
}
- enum MethodePaiementEnum {
+ enum MethodePaiement {
LIQUIDE
CARTE_CREDIT
VIREMENT_BANCAIRE
@@ -487,33 +647,33 @@ package "SERVICE FINANCIER" #F5B7B1 {
- creeLe: Date
}
- ' Relations Service Financier
Paiement "1" -- "0..1" Facture
- Paiement -- TypePaiementEnum
- Paiement -- StatutPaiementEnum
- Paiement -- MethodePaiementEnum
+ Paiement -- TypePaiement
+ Paiement -- StatutPaiement
+ Paiement -- MethodePaiement
}
-'======================
-' SERVICE COMMUNICATION
-'======================
-package "SERVICE COMMUNICATION" #FFE5B4 {
+'===============================================
+' SERVICE COMMUNICATION (Port 8086 — MongoDB)
+' Non implémenté — phase conception
+'===============================================
+package "SERVICE COMMUNICATION | Port 8086 | BD: MongoDB | [prévu]" {
class Notification {
- id: String
- - utilisateurId: String (ref Service Utilisateur)
+ - utilisateurId: String <][>
- ecoleId: String
- titre: String
- message: String
- - type: TypeNotificationEnum
- - priorite: PrioriteEnum
+ - type: TypeNotification
+ - priorite: Priorite
- estLu: Boolean
- luLe: Date
- - metadata: Map
+ - metadata: Map
- creeLe: Date
}
- enum TypeNotificationEnum {
+ enum TypeNotification {
INFO
AVERTISSEMENT
RAPPEL
@@ -526,7 +686,7 @@ package "SERVICE COMMUNICATION" #FFE5B4 {
ANNONCE
}
- enum PrioriteEnum {
+ enum Priorite {
BASSE
MOYENNE
HAUTE
@@ -536,12 +696,11 @@ package "SERVICE COMMUNICATION" #FFE5B4 {
class Annonce {
- id: String
- ecoleId: String
- - createurId: String (ref Service Utilisateur)
+ - createurId: String <][>
- titre: String
- contenu: String
- - publicCible: PublicCibleEnum
+ - publicCible: PublicCible
- groupeIdCible: String
- - niveauScolaireIdCible: String
- estEpingle: Boolean
- publieLe: Date
- expireLe: Date
@@ -550,7 +709,7 @@ package "SERVICE COMMUNICATION" #FFE5B4 {
- creeLe: Date
}
- enum PublicCibleEnum {
+ enum PublicCible {
TOUS
ELEVES_SEULEMENT
ENSEIGNANTS_SEULEMENT
@@ -563,22 +722,19 @@ package "SERVICE COMMUNICATION" #FFE5B4 {
class Reunion {
- id: String
- ecoleId: String
- - organisateurId: String (ref Service Utilisateur)
+ - organisateurId: String <][>
- titre: String
- - description: String
- - typeReunion: TypeReunionEnum
+ - typeReunion: TypeReunion
- dateReunion: Date
- heureDebut: Time
- heureFin: Time
- lieu: String
- lienReunion: String
- - ordreDuJour: String
- - compteRendu: String
- - statut: StatutReunionEnum
+ - statut: StatutReunion
- creeLe: Date
}
- enum TypeReunionEnum {
+ enum TypeReunion {
PARENT_ENSEIGNANT
REUNION_PERSONNEL
CONSEIL_ADMINISTRATION
@@ -587,7 +743,7 @@ package "SERVICE COMMUNICATION" #FFE5B4 {
AUTRE
}
- enum StatutReunionEnum {
+ enum StatutReunion {
PLANIFIEE
EN_COURS
TERMINEE
@@ -598,164 +754,71 @@ package "SERVICE COMMUNICATION" #FFE5B4 {
class ParticipantReunion {
- id: String
- reunionId: String
- - utilisateurId: String (ref Service Utilisateur)
- - inviteLe: Date
- - statut: StatutParticipantEnum
+ - utilisateurId: String <][>
+ - statut: StatutParticipant
- reponduLe: Date
- - participeLe: Date
- notes: String
- creeLe: Date
}
- enum StatutParticipantEnum {
+ enum StatutParticipant {
INVITE
CONFIRME
DECLINE
- TENTATIF
PARTICIPE
ABSENT
}
-
- class ModeleEmail {
- - id: String
- - ecoleId: String
- - nom: String
- - sujet: String
- - corps: String
- - variables: List
- - estActif: Boolean
- - creeLe: Date
- }
-
class PreferenceNotification {
- id: String
- - utilisateurId: String (ref Service Utilisateur)
+ - utilisateurId: String <][>
- emailActive: Boolean
- smsActive: Boolean
- notificationPushActive: Boolean
- - typesNotification: List
+ - typesNotification: List
- heuresCalmesDebut: Time
- heuresCalmesFin: Time
- creeLe: Date
}
- ' Relations Service Notification
- Notification -- TypeNotificationEnum
- Notification -- PrioriteEnum
-
- Annonce -- PublicCibleEnum
-
+ Notification -- TypeNotification
+ Notification -- Priorite
+ Annonce -- PublicCible
Reunion "1" -- "0..*" ParticipantReunion
- Reunion -- TypeReunionEnum
- Reunion -- StatutReunionEnum
-
- ParticipantReunion -- StatutParticipantEnum
+ Reunion -- TypeReunion
+ Reunion -- StatutReunion
+ ParticipantReunion -- StatutParticipant
}
-'===============================
-' COMMUNICATIONS CROSS-SERVICE
-'===============================
+'===============================================
+' COMMUNICATIONS INTER-SERVICES
+'===============================================
-' Service Académique → Service Utilisateur (REST Synchrone)
note right of EmploiDuTemps
- Appels REST:
- GET /user-service/api/enseignants/{enseignantId}
- (pour validation du filtre enseignant)
+ Appels REST (sync) :
+ Validation des enseignantIds → Service Utilisateur
- Stockage Image:
- Cloudinary ou S3 pour upload d'image
+ Stockage Image :
+ Cloudinary (urlImage + cloudPublicId)
end note
note right of Note
- Appels REST:
- GET /user-service/api/eleves/{eleveId}
-
- Producteur Kafka:
- Topic: note.creee
- Event: NoteCreeeEvent
+ Producteur Kafka :
+ Topic: note.creee → NoteCreeeEvent
end note
note right of Examen
- Appels REST:
- GET /user-service/api/enseignants/{enseignantId}
-
- Producteur Kafka:
- Topic: examen.planifie
- Event: ExamenPlanifieEvent
-end note
-
-' Service Ressource → Service Utilisateur & Service Académique (REST)
-note right of RessourcePedagogique
- Appels REST:
- GET /user-service/api/enseignants/{enseignantId}
- GET /academic-service/api/matieres/{matiereId}
-end note
-
-note right of EmpruntLivre
- Appels REST:
- GET /user-service/api/eleves/{eleveId}
-
- Producteur Kafka:
- Topic: livre.emprunte
- Topic: livre.retard
-end note
-
-' Service Financier → Service Utilisateur (REST)
-note right of Paiement
- Appels REST:
- GET /user-service/api/eleves/{eleveId}
- GET /user-service/api/ecoles/{ecoleId}
-
- Producteur Kafka:
- Topic: paiement.cree
- Topic: paiement.retard
- Topic: facture.generee
-end note
-
-' Service Notification → Service Utilisateur (REST)
-note right of Annonce
- Appels REST:
- GET /user-service/api/utilisateurs/{createurId}
- GET /user-service/api/groupes/{groupeId}/utilisateurs
- GET /user-service/api/niveaux-scolaires/{niveauId}/utilisateurs
-end note
-
-note right of Reunion
- Appels REST:
- GET /user-service/api/utilisateurs/{organisateurId}
-
- Producteur Kafka:
- Topic: reunion.planifiee
- Topic: reunion.mise-a-jour
-end note
-
-note right of Activite
- Appels REST:
- GET /user-service/api/utilisateurs/{organisateurId}
- GET /user-service/api/eleves/{eleveId}
+ Producteur Kafka :
+ Topic: examen.planifie → ExamenPlanifieEvent
end note
note left of Notification
-
-
- Consomme les Topics:
- • utilisateur.cree
- • eleve.inscrit
- • note.creee
- • examen.planifie
- • paiement.cree
- • paiement.retard
- • livre.emprunte
- • livre.retard
- • reunion.planifiee
-
- Actions:
- 1. Créer Notification en base
- 2. Envoyer Email
- 3. Envoyer SMS
- 4. Envoyer Notification Push
+ CONSOMMATEUR KAFKA
+ Consomme : note.creee, examen.planifie,
+ paiement.cree, paiement.retard,
+ livre.retard, reunion.planifiee
+ Actions : Créer en BD, Envoyer Email/SMS/Push
end note
-@enduml
\ No newline at end of file
+@enduml
diff --git a/services/academic-service/pom.xml b/services/academic-service/pom.xml
index 296fca5..1a9bd36 100644
--- a/services/academic-service/pom.xml
+++ b/services/academic-service/pom.xml
@@ -23,7 +23,7 @@
spring-boot-starter-web
-
+
org.springframework.boot
spring-boot-starter-data-jpa
@@ -75,7 +75,7 @@
springdoc-openapi-starter-webmvc-ui
-
+
org.springframework.boot
spring-boot-starter-oauth2-resource-server
diff --git a/services/academic-service/src/main/java/com/academicsService/AcademicServiceApplication.java b/services/academic-service/src/main/java/com/academicsService/AcademicServiceApplication.java
index d3ba75a..d06901a 100644
--- a/services/academic-service/src/main/java/com/academicsService/AcademicServiceApplication.java
+++ b/services/academic-service/src/main/java/com/academicsService/AcademicServiceApplication.java
@@ -4,10 +4,12 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
+@EnableScheduling
public class AcademicServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AcademicServiceApplication.class, args);
diff --git a/services/academic-service/src/main/java/com/academicsService/client/UserServiceClient.java b/services/academic-service/src/main/java/com/academicsService/client/UserServiceClient.java
index e8111c6..6dd8b6b 100644
--- a/services/academic-service/src/main/java/com/academicsService/client/UserServiceClient.java
+++ b/services/academic-service/src/main/java/com/academicsService/client/UserServiceClient.java
@@ -4,6 +4,7 @@
import com.academicsService.config.FeignConfig;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(
name = "user-service",
@@ -13,4 +14,7 @@ public interface UserServiceClient {
@GetMapping("/api/v1/users/me")
UserResponse getMyProfile();
+
+ @GetMapping("/api/v1/users/{userId}")
+ UserResponse getUserById(@PathVariable("userId") String userId);
}
\ No newline at end of file
diff --git a/services/academic-service/src/main/java/com/academicsService/controller/ActivityEnrollmentController.java b/services/academic-service/src/main/java/com/academicsService/controller/ActivityEnrollmentController.java
index e9a8eb7..92706d0 100644
--- a/services/academic-service/src/main/java/com/academicsService/controller/ActivityEnrollmentController.java
+++ b/services/academic-service/src/main/java/com/academicsService/controller/ActivityEnrollmentController.java
@@ -24,8 +24,19 @@ public class ActivityEnrollmentController {
private final ActivityEnrollmentService enrollmentService;
+ @GetMapping
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER')")
+ public ResponseEntity> findBySchool(
+ @RequestParam("schoolId") String schoolId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size) {
+ log.info("GET /api/v1/activity-enrollments?schoolId={}", schoolId);
+ PageRequest pageable = PageRequest.of(page, size, Sort.by("enrolledAt").descending());
+ return ResponseEntity.ok(enrollmentService.findBySchool(schoolId, pageable));
+ }
+
@PostMapping
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'STUDENT', 'TEACHER')")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'STUDENT', 'TEACHER', 'PARENT')")
public ResponseEntity enroll(
@Valid @RequestBody ActivityEnrollmentCreateDto dto,
@RequestHeader("X-User-Id") String currentUserId) {
@@ -80,7 +91,7 @@ public ResponseEntity> findByStudent(
}
@PatchMapping("/{id}/cancel")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER', 'STUDENT')")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER', 'STUDENT', 'PARENT')")
public ResponseEntity cancel(
@PathVariable("id") String id,
@RequestHeader("X-User-Id") String currentUserId) {
diff --git a/services/academic-service/src/main/java/com/academicsService/controller/CertificateController.java b/services/academic-service/src/main/java/com/academicsService/controller/CertificateController.java
deleted file mode 100644
index 3435f29..0000000
--- a/services/academic-service/src/main/java/com/academicsService/controller/CertificateController.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package com.academicsService.controller;
-
-import com.academicsService.dto.create.CertificateCreateDto;
-import com.academicsService.dto.response.CertificateResponseDto;
-import com.academicsService.dto.update.CertificateUpdateDto;
-import com.academicsService.model.enums.CertificateType;
-import com.academicsService.service.CertificateService;
-import jakarta.validation.Valid;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Sort;
-import org.springframework.format.annotation.DateTimeFormat;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-import java.time.LocalDate;
-
-@RestController
-@RequestMapping("/api/v1/certificates")
-@RequiredArgsConstructor
-@Slf4j
-public class CertificateController {
-
- private final CertificateService certificateService;
-
- @PostMapping
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
- public ResponseEntity create(
- @Valid @RequestBody CertificateCreateDto dto,
- @RequestHeader("X-User-Id") String currentUserId) {
- log.info("POST /api/v1/certificates by user: {}", currentUserId);
- return ResponseEntity.status(HttpStatus.CREATED)
- .body(certificateService.create(dto, currentUserId));
- }
-
- @PutMapping("/{id}")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
- public ResponseEntity update(
- @PathVariable("id") String id,
- @Valid @RequestBody CertificateUpdateDto dto,
- @RequestHeader("X-User-Id") String currentUserId) {
- log.info("PUT /api/v1/certificates/{} by user: {}", id, currentUserId);
- return ResponseEntity.ok(certificateService.update(id, dto, currentUserId));
- }
-
- @GetMapping("/{id}")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'TEACHER', 'STAFF', 'STUDENT', 'PARENT')")
- public ResponseEntity findById(
- @PathVariable("id") String id) {
- log.info("GET /api/v1/certificates/{}", id);
- return ResponseEntity.ok(certificateService.findById(id));
- }
-
- @GetMapping("/verify/{certificateNumber}")
- public ResponseEntity verify(
- @PathVariable("certificateNumber") String certificateNumber) {
- log.info("GET /api/v1/certificates/verify/{}", certificateNumber);
- return ResponseEntity.ok(
- certificateService.findByCertificateNumber(certificateNumber)
- );
- }
-
- @GetMapping
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
- public ResponseEntity> findBySchool(
- @RequestParam("schoolId") String schoolId,
- @RequestParam(value = "type", required = false) CertificateType type,
- @RequestParam(value = "academicYear", required = false) String academicYear,
- @RequestParam(defaultValue = "0") int page,
- @RequestParam(defaultValue = "10") int size) {
- log.info("GET /api/v1/certificates?schoolId={}&type={}&academicYear={}",
- schoolId, type, academicYear);
- PageRequest pageable = PageRequest.of(page, size,
- Sort.by("issueDate").descending());
-
- if (type != null) {
- return ResponseEntity.ok(
- certificateService.findBySchoolAndType(schoolId, type, pageable)
- );
- }
- if (academicYear != null) {
- return ResponseEntity.ok(
- certificateService.findBySchoolAndYear(schoolId, academicYear, pageable)
- );
- }
- return ResponseEntity.ok(certificateService.findBySchool(schoolId, pageable));
- }
-
- @GetMapping("/range")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
- public ResponseEntity> findByDateRange(
- @RequestParam("schoolId") String schoolId,
- @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
- @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
- @RequestParam(defaultValue = "0") int page,
- @RequestParam(defaultValue = "10") int size) {
- log.info("GET /api/v1/certificates/range?schoolId={}", schoolId);
- return ResponseEntity.ok(certificateService.findBySchoolAndDateRange(
- schoolId, startDate, endDate,
- PageRequest.of(page, size, Sort.by("issueDate").descending())
- ));
- }
-
- @GetMapping("/student/{studentId}")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'TEACHER', 'STAFF', 'STUDENT', 'PARENT')")
- public ResponseEntity> findByStudent(
- @PathVariable("studentId") String studentId,
- @RequestParam("schoolId") String schoolId,
- @RequestParam(defaultValue = "0") int page,
- @RequestParam(defaultValue = "10") int size) {
- log.info("GET /api/v1/certificates/student/{}", studentId);
- return ResponseEntity.ok(certificateService.findByStudent(
- studentId, schoolId,
- PageRequest.of(page, size, Sort.by("issueDate").descending())
- ));
- }
-
- @DeleteMapping("/{id}")
- @PreAuthorize("hasRole('SCHOOL_ADMIN')")
- public ResponseEntity delete(
- @PathVariable("id") String id,
- @RequestHeader("X-User-Id") String currentUserId) {
- log.info("DELETE /api/v1/certificates/{} by user: {}", id, currentUserId);
- certificateService.delete(id, currentUserId);
- return ResponseEntity.noContent().build();
- }
-}
-
diff --git a/services/academic-service/src/main/java/com/academicsService/controller/DocumentScolaireController.java b/services/academic-service/src/main/java/com/academicsService/controller/DocumentScolaireController.java
new file mode 100644
index 0000000..0922ab6
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/controller/DocumentScolaireController.java
@@ -0,0 +1,134 @@
+package com.academicsService.controller;
+
+import com.academicsService.dto.create.DocumentScolaireCreateDto;
+import com.academicsService.dto.response.DocumentScolaireResponseDto;
+import com.academicsService.dto.update.DocumentScolaireProcessDto;
+import com.academicsService.model.enums.DocumentType;
+import com.academicsService.model.enums.RequestStatus;
+import com.academicsService.service.DocumentScolaireService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/document-scolaires")
+@RequiredArgsConstructor
+@Slf4j
+public class DocumentScolaireController {
+
+ private final DocumentScolaireService documentScolaireService;
+
+ // ────────────────────────────────────────────────────────────────
+ // POST — Soumettre une demande (STUDENT / PARENT)
+ // ────────────────────────────────────────────────────────────────
+
+ @PostMapping
+ @PreAuthorize("hasAnyRole('STUDENT', 'PARENT')")
+ public ResponseEntity create(
+ @Valid @RequestBody DocumentScolaireCreateDto dto,
+ @RequestHeader("X-User-Id") String currentUserId,
+ @RequestHeader("X-User-Roles") String currentUserRoles) {
+ log.info("POST /api/v1/document-scolaires par l'utilisateur '{}'", currentUserId);
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(documentScolaireService.create(dto, currentUserId, currentUserRoles));
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // PATCH — Traiter une demande (SCHOOL_ADMIN / STAFF)
+ // ────────────────────────────────────────────────────────────────
+
+ @PatchMapping("/{id}/process")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity process(
+ @PathVariable("id") String id,
+ @Valid @RequestBody DocumentScolaireProcessDto dto,
+ @RequestHeader("X-User-Id") String currentUserId) {
+ log.info("PATCH /api/v1/document-scolaires/{}/process par '{}'", id, currentUserId);
+ return ResponseEntity.ok(documentScolaireService.process(id, dto, currentUserId));
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // GET — Consulter une demande par ID
+ // ────────────────────────────────────────────────────────────────
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'STUDENT', 'PARENT')")
+ public ResponseEntity findById(@PathVariable("id") String id) {
+ log.info("GET /api/v1/document-scolaires/{}", id);
+ return ResponseEntity.ok(documentScolaireService.findById(id));
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // GET — Toutes les demandes d'une école (SCHOOL_ADMIN / STAFF)
+ // ────────────────────────────────────────────────────────────────
+
+ @GetMapping
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity> findBySchool(
+ @RequestParam("schoolId") String schoolId,
+ @RequestParam(value = "status", required = false) RequestStatus status,
+ @RequestParam(value = "documentType", required = false) DocumentType documentType,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size) {
+ log.info("GET /api/v1/document-scolaires?schoolId={}&status={}&documentType={}",
+ schoolId, status, documentType);
+ PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
+ return ResponseEntity.ok(
+ documentScolaireService.findBySchool(schoolId, status, documentType, pageable));
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // GET — Mes propres demandes (STUDENT / PARENT)
+ // ────────────────────────────────────────────────────────────────
+
+ @GetMapping("/my-requests")
+ @PreAuthorize("hasAnyRole('STUDENT', 'PARENT')")
+ public ResponseEntity> findMyRequests(
+ @RequestParam("schoolId") String schoolId,
+ @RequestHeader("X-User-Id") String currentUserId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size) {
+ log.info("GET /api/v1/document-scolaires/my-requests pour '{}'", currentUserId);
+ PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
+ return ResponseEntity.ok(
+ documentScolaireService.findMyRequests(currentUserId, schoolId, pageable));
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // GET — Demandes d'un étudiant donné
+ // ────────────────────────────────────────────────────────────────
+
+ @GetMapping("/student/{studentId}")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'STUDENT', 'PARENT')")
+ public ResponseEntity> findByStudent(
+ @PathVariable("studentId") String studentId,
+ @RequestParam("schoolId") String schoolId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size) {
+ log.info("GET /api/v1/document-scolaires/student/{}", studentId);
+ PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
+ return ResponseEntity.ok(
+ documentScolaireService.findByStudent(studentId, schoolId, pageable));
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // DELETE (SCHOOL_ADMIN uniquement)
+ // ────────────────────────────────────────────────────────────────
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasRole('SCHOOL_ADMIN')")
+ public ResponseEntity delete(
+ @PathVariable("id") String id,
+ @RequestHeader("X-User-Id") String currentUserId) {
+ log.info("DELETE /api/v1/document-scolaires/{} par '{}'", id, currentUserId);
+ documentScolaireService.delete(id, currentUserId);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/controller/ReclamationController.java b/services/academic-service/src/main/java/com/academicsService/controller/ReclamationController.java
new file mode 100644
index 0000000..193e4b7
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/controller/ReclamationController.java
@@ -0,0 +1,113 @@
+package com.academicsService.controller;
+
+import com.academicsService.dto.create.ReclamationCreateDto;
+import com.academicsService.dto.response.ReclamationResponseDto;
+import com.academicsService.dto.update.ReclamationUpdateDto;
+import com.academicsService.service.ReclamationService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/reclamations")
+@RequiredArgsConstructor
+@Slf4j
+public class ReclamationController {
+
+ private final ReclamationService reclamationService;
+
+ @PostMapping
+ @PreAuthorize("hasRole('STUDENT')")
+ public ResponseEntity create(
+ @Valid @RequestBody ReclamationCreateDto dto) {
+ log.info("POST /api/v1/reclamations — student: {}", dto.getStudentId());
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(reclamationService.create(dto));
+ }
+
+ @PutMapping("/{id}/respond")
+ @PreAuthorize("hasAnyRole('TEACHER', 'SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity respond(
+ @PathVariable String id,
+ @Valid @RequestBody ReclamationUpdateDto dto,
+ @RequestHeader("X-User-Id") String currentUserId) {
+ log.info("PUT /api/v1/reclamations/{}/respond by user: {}", id, currentUserId);
+ return ResponseEntity.ok(reclamationService.respond(id, dto, currentUserId));
+ }
+
+ @GetMapping("/student/{studentId}")
+ @PreAuthorize("hasAnyRole('STUDENT', 'PARENT', 'SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity> getByStudent(
+ @PathVariable String studentId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "50") int size) {
+ log.info("GET /api/v1/reclamations/student/{}", studentId);
+ return ResponseEntity.ok(reclamationService.getByStudent(
+ studentId,
+ PageRequest.of(page, size, Sort.by("createdAt").descending())
+ ));
+ }
+
+ @GetMapping("/teacher/{teacherId}")
+ @PreAuthorize("hasAnyRole('TEACHER', 'SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity> getByTeacher(
+ @PathVariable String teacherId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "50") int size) {
+ log.info("GET /api/v1/reclamations/teacher/{}", teacherId);
+ return ResponseEntity.ok(reclamationService.getByTeacher(
+ teacherId,
+ PageRequest.of(page, size, Sort.by("createdAt").descending())
+ ));
+ }
+
+ @GetMapping("/exam/{examId}")
+ @PreAuthorize("hasAnyRole('TEACHER', 'SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity> getByExam(
+ @PathVariable String examId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "50") int size) {
+ log.info("GET /api/v1/reclamations/exam/{}", examId);
+ return ResponseEntity.ok(reclamationService.getByExam(
+ examId,
+ PageRequest.of(page, size, Sort.by("createdAt").descending())
+ ));
+ }
+
+ @GetMapping
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity> getBySchool(
+ @RequestParam String schoolId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "50") int size) {
+ log.info("GET /api/v1/reclamations?schoolId={}", schoolId);
+ return ResponseEntity.ok(reclamationService.getBySchool(
+ schoolId,
+ PageRequest.of(page, size, Sort.by("createdAt").descending())
+ ));
+ }
+
+ @GetMapping("/grade/{gradeId}/has-pending")
+ @PreAuthorize("hasAnyRole('STUDENT', 'TEACHER', 'SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity hasPendingForGrade(@PathVariable String gradeId) {
+ log.info("GET /api/v1/reclamations/grade/{}/has-pending", gradeId);
+ return ResponseEntity.ok(reclamationService.hasPendingForGrade(gradeId));
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasAnyRole('STUDENT', 'SCHOOL_ADMIN', 'STAFF')")
+ public ResponseEntity delete(
+ @PathVariable String id,
+ @RequestHeader("X-User-Id") String currentUserId) {
+ log.info("DELETE /api/v1/reclamations/{} by user: {}", id, currentUserId);
+ reclamationService.delete(id, currentUserId);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java b/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java
index 1615201..df30347 100644
--- a/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java
+++ b/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java
@@ -54,7 +54,7 @@ public ResponseEntity findById(
}
@GetMapping("/teacher/{teacherId}")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'TEACHER', 'STAFF')")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'TEACHER', 'STAFF', 'STUDENT', 'PARENT')")
public ResponseEntity> findByTeacher(
@PathVariable("teacherId") String teacherId,
@RequestParam(value = "academicYear", required = false) String academicYear,
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/create/ActivityCreateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/create/ActivityCreateDto.java
index ccbaeae..c07ec4f 100644
--- a/services/academic-service/src/main/java/com/academicsService/dto/create/ActivityCreateDto.java
+++ b/services/academic-service/src/main/java/com/academicsService/dto/create/ActivityCreateDto.java
@@ -1,10 +1,13 @@
package com.academicsService.dto.create;
+import com.academicsService.model.ResourceLink;
import com.academicsService.model.enums.ActivityType;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
@Getter
@Setter
@@ -43,5 +46,8 @@ public class ActivityCreateDto {
private float fee;
private String coverImageUrl;
+
+ @Builder.Default
+ private List resourceLinks = new ArrayList<>();
}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/create/CertificateCreateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/create/CertificateCreateDto.java
deleted file mode 100644
index 5396ea3..0000000
--- a/services/academic-service/src/main/java/com/academicsService/dto/create/CertificateCreateDto.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.academicsService.dto.create;
-
-import com.academicsService.model.enums.CertificateType;
-import jakarta.validation.constraints.*;
-import lombok.*;
-
-import java.time.LocalDate;
-
-@Getter
-@Setter
-@AllArgsConstructor
-@NoArgsConstructor
-@Builder
-public class CertificateCreateDto {
-
- @NotBlank(message = "Student ID is required")
- private String studentId;
-
- @NotBlank(message = "Title is required")
- @Size(min = 2, max = 200, message = "Title must be between 2 and 200 characters")
- private String title;
-
- @Size(max = 1000, message = "Description cannot exceed 1000 characters")
- private String description;
-
- @NotNull(message = "Certificate type is required")
- private CertificateType certificateType;
-
- @NotNull(message = "Issue date is required")
- private LocalDate issueDate;
-
- private LocalDate expiryDate;
-
- @NotBlank(message = "Academic year is required")
- private String academicYear;
-
- private String fileUrl;
-}
-
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/create/DocumentScolaireCreateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/create/DocumentScolaireCreateDto.java
new file mode 100644
index 0000000..3abb6de
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/dto/create/DocumentScolaireCreateDto.java
@@ -0,0 +1,26 @@
+package com.academicsService.dto.create;
+
+import com.academicsService.model.enums.DocumentType;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class DocumentScolaireCreateDto {
+
+ /** Identifiant Keycloak de l'étudiant concerné par la demande.
+ * Pour un parent, c'est l'ID de son enfant. */
+ @NotBlank(message = "L'identifiant de l'étudiant est obligatoire")
+ private String studentId;
+
+ @NotNull(message = "Le type de document est obligatoire")
+ private DocumentType documentType;
+
+ @NotBlank(message = "Le motif de la demande est obligatoire")
+ @Size(max = 500, message = "Le motif ne peut pas dépasser 500 caractères")
+ private String reason;
+
+ @NotBlank(message = "L'année scolaire est obligatoire")
+ private String academicYear;
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/create/ReclamationCreateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/create/ReclamationCreateDto.java
new file mode 100644
index 0000000..1220ddf
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/dto/create/ReclamationCreateDto.java
@@ -0,0 +1,43 @@
+package com.academicsService.dto.create;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class ReclamationCreateDto {
+
+ @NotBlank(message = "Exam ID is required")
+ private String examId;
+
+ @NotBlank(message = "Grade ID is required")
+ private String gradeId;
+
+ @NotBlank(message = "Student ID is required")
+ private String studentId;
+
+ @NotBlank(message = "Student name is required")
+ private String studentName;
+
+ @NotBlank(message = "Exam name is required")
+ private String examName;
+
+ @NotBlank(message = "Subject name is required")
+ private String subjectName;
+
+ @NotNull(message = "Score is required")
+ @Positive(message = "Score must be positive")
+ private Float score;
+
+ @NotNull(message = "Max score is required")
+ @Positive(message = "Max score must be positive")
+ private Float maxScore;
+
+ @NotBlank(message = "Reason is required")
+ private String reason;
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/response/ActivityResponseDto.java b/services/academic-service/src/main/java/com/academicsService/dto/response/ActivityResponseDto.java
index 0db6bfe..7b86ebb 100644
--- a/services/academic-service/src/main/java/com/academicsService/dto/response/ActivityResponseDto.java
+++ b/services/academic-service/src/main/java/com/academicsService/dto/response/ActivityResponseDto.java
@@ -1,9 +1,11 @@
package com.academicsService.dto.response;
+import com.academicsService.model.ResourceLink;
import com.academicsService.model.enums.ActivityStatus;
import com.academicsService.model.enums.ActivityType;
import java.time.LocalDateTime;
+import java.util.List;
public record ActivityResponseDto(
String id,
@@ -25,6 +27,7 @@ public record ActivityResponseDto(
boolean isRegistrationOpen,
boolean isFull,
String coverImageUrl,
+ List resourceLinks,
LocalDateTime createdAt
) {}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/response/CertificateResponseDto.java b/services/academic-service/src/main/java/com/academicsService/dto/response/CertificateResponseDto.java
deleted file mode 100644
index cc4713e..0000000
--- a/services/academic-service/src/main/java/com/academicsService/dto/response/CertificateResponseDto.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.academicsService.dto.response;
-
-import com.academicsService.model.enums.CertificateType;
-
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-
-public record CertificateResponseDto(
- String id,
- String schoolId,
- String studentId,
- String title,
- String description,
- CertificateType certificateType,
- String certificateNumber,
- LocalDate issueDate,
- LocalDate expiryDate,
- String academicYear,
- String fileUrl,
- String issuedBy,
- boolean isExpired,
- LocalDateTime createdAt
-) {}
-
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/response/DocumentScolaireResponseDto.java b/services/academic-service/src/main/java/com/academicsService/dto/response/DocumentScolaireResponseDto.java
new file mode 100644
index 0000000..858c661
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/dto/response/DocumentScolaireResponseDto.java
@@ -0,0 +1,31 @@
+package com.academicsService.dto.response;
+
+import com.academicsService.model.enums.DocumentType;
+import com.academicsService.model.enums.RequestStatus;
+
+import java.time.LocalDateTime;
+
+public record DocumentScolaireResponseDto(
+ String id,
+ String schoolId,
+ // ── Étudiant concerné ──
+ String studentId,
+ String studentFirstName,
+ String studentLastName,
+ // ── Demandeur (étudiant ou parent) ──
+ String requestedById,
+ String requestedByRole,
+ String requestedByFirstName,
+ String requestedByLastName,
+ // ── Données de la demande ──
+ DocumentType documentType,
+ RequestStatus status,
+ String reason,
+ String academicYear,
+ // ── Traitement ──
+ String processedById,
+ String processorNote,
+ String responseFileUrl,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/response/ReclamationResponseDto.java b/services/academic-service/src/main/java/com/academicsService/dto/response/ReclamationResponseDto.java
new file mode 100644
index 0000000..6534b4b
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/dto/response/ReclamationResponseDto.java
@@ -0,0 +1,24 @@
+package com.academicsService.dto.response;
+
+import com.academicsService.model.enums.ReclamationStatus;
+
+import java.time.LocalDateTime;
+
+public record ReclamationResponseDto(
+ String id,
+ String examId,
+ String gradeId,
+ String studentId,
+ String studentName,
+ String examName,
+ String subjectName,
+ float score,
+ float maxScore,
+ String reason,
+ ReclamationStatus status,
+ String response,
+ String teacherId,
+ String schoolId,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/update/ActivityUpdateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/update/ActivityUpdateDto.java
index 4ffa7a0..6ffe432 100644
--- a/services/academic-service/src/main/java/com/academicsService/dto/update/ActivityUpdateDto.java
+++ b/services/academic-service/src/main/java/com/academicsService/dto/update/ActivityUpdateDto.java
@@ -1,10 +1,13 @@
package com.academicsService.dto.update;
+import com.academicsService.model.ResourceLink;
import com.academicsService.model.enums.ActivityStatus;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
@Getter
@Setter
@@ -41,5 +44,8 @@ public class ActivityUpdateDto {
@NotNull(message = "Status is required")
private ActivityStatus status;
+
+ @Builder.Default
+ private List resourceLinks = new ArrayList<>();
}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/update/CertificateUpdateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/update/CertificateUpdateDto.java
deleted file mode 100644
index 1a1618c..0000000
--- a/services/academic-service/src/main/java/com/academicsService/dto/update/CertificateUpdateDto.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.academicsService.dto.update;
-
-import jakarta.validation.constraints.*;
-import lombok.*;
-
-import java.time.LocalDate;
-
-@Getter
-@Setter
-@AllArgsConstructor
-@NoArgsConstructor
-@Builder
-public class CertificateUpdateDto {
-
- @NotBlank(message = "Title is required")
- @Size(min = 2, max = 200, message = "Title must be between 2 and 200 characters")
- private String title;
-
- @Size(max = 1000, message = "Description cannot exceed 1000 characters")
- private String description;
-
- @NotNull(message = "Issue date is required")
- private LocalDate issueDate;
-
- private LocalDate expiryDate;
-
- private String fileUrl;
-}
-
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/update/DocumentScolaireProcessDto.java b/services/academic-service/src/main/java/com/academicsService/dto/update/DocumentScolaireProcessDto.java
new file mode 100644
index 0000000..ee20415
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/dto/update/DocumentScolaireProcessDto.java
@@ -0,0 +1,22 @@
+package com.academicsService.dto.update;
+
+import com.academicsService.model.enums.RequestStatus;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * DTO utilisé par le staff / school_admin pour traiter une demande de document.
+ */
+@Data
+public class DocumentScolaireProcessDto {
+
+ @NotNull(message = "Le nouveau statut est obligatoire")
+ private RequestStatus status;
+
+ @Size(max = 1000, message = "La note ne peut pas dépasser 1000 caractères")
+ private String processorNote;
+
+ /** URL vers le document traité uploadé sur Cloudinary ou équivalent. */
+ private String responseFileUrl;
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/dto/update/ReclamationUpdateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/update/ReclamationUpdateDto.java
new file mode 100644
index 0000000..f2c5018
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/dto/update/ReclamationUpdateDto.java
@@ -0,0 +1,18 @@
+package com.academicsService.dto.update;
+
+import com.academicsService.model.enums.ReclamationStatus;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class ReclamationUpdateDto {
+
+ @NotNull
+ private ReclamationStatus status;
+
+ private String response;
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/mapper/ActivityMapper.java b/services/academic-service/src/main/java/com/academicsService/mapper/ActivityMapper.java
index d4d92ae..eead0bf 100644
--- a/services/academic-service/src/main/java/com/academicsService/mapper/ActivityMapper.java
+++ b/services/academic-service/src/main/java/com/academicsService/mapper/ActivityMapper.java
@@ -6,6 +6,8 @@
import com.academicsService.model.enums.ActivityStatus;
import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
public class ActivityMapper {
@@ -26,6 +28,7 @@ public static Activity toEntity(ActivityCreateDto dto,
.fee(dto.getFee())
.status(ActivityStatus.DRAFT)
.coverImageUrl(dto.getCoverImageUrl())
+ .resourceLinks(dto.getResourceLinks() != null ? new ArrayList<>(dto.getResourceLinks()) : new ArrayList<>())
.build();
}
@@ -63,6 +66,7 @@ public static ActivityResponseDto toResponseDto(Activity entity) {
isRegistrationOpen,
isFull,
entity.getCoverImageUrl(),
+ entity.getResourceLinks() != null ? Collections.unmodifiableList(entity.getResourceLinks()) : Collections.emptyList(),
entity.getCreatedAt()
);
}
diff --git a/services/academic-service/src/main/java/com/academicsService/mapper/CertificateMapper.java b/services/academic-service/src/main/java/com/academicsService/mapper/CertificateMapper.java
deleted file mode 100644
index e0bbd0e..0000000
--- a/services/academic-service/src/main/java/com/academicsService/mapper/CertificateMapper.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.academicsService.mapper;
-
-import com.academicsService.dto.create.CertificateCreateDto;
-import com.academicsService.dto.response.CertificateResponseDto;
-import com.academicsService.model.Certificate;
-import com.academicsService.util.CertificateNumberGenerator;
-
-import java.time.LocalDate;
-
-public class CertificateMapper {
-
- public static Certificate toEntity(CertificateCreateDto dto,
- String schoolId, String issuedBy) {
- return Certificate.builder()
- .schoolId(schoolId)
- .studentId(dto.getStudentId())
- .title(dto.getTitle().trim())
- .description(dto.getDescription())
- .certificateType(dto.getCertificateType())
- .issueDate(dto.getIssueDate())
- .expiryDate(dto.getExpiryDate())
- .academicYear(dto.getAcademicYear())
- .fileUrl(dto.getFileUrl())
- .issuedBy(issuedBy)
- .certificateNumber(CertificateNumberGenerator.generate(dto.getCertificateType()))
- .build();
- }
-
- public static CertificateResponseDto toResponseDto(Certificate entity) {
- boolean isExpired = entity.getExpiryDate() != null &&
- entity.getExpiryDate().isBefore(LocalDate.now());
- return new CertificateResponseDto(
- entity.getId(),
- entity.getSchoolId(),
- entity.getStudentId(),
- entity.getTitle(),
- entity.getDescription(),
- entity.getCertificateType(),
- entity.getCertificateNumber(),
- entity.getIssueDate(),
- entity.getExpiryDate(),
- entity.getAcademicYear(),
- entity.getFileUrl(),
- entity.getIssuedBy(),
- isExpired,
- entity.getCreatedAt()
- );
- }
-
- private CertificateMapper() {}
-}
-
diff --git a/services/academic-service/src/main/java/com/academicsService/mapper/DocumentScolaireMapper.java b/services/academic-service/src/main/java/com/academicsService/mapper/DocumentScolaireMapper.java
new file mode 100644
index 0000000..49085bb
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/mapper/DocumentScolaireMapper.java
@@ -0,0 +1,64 @@
+package com.academicsService.mapper;
+
+import com.academicsService.client.dto.UserResponse;
+import com.academicsService.dto.create.DocumentScolaireCreateDto;
+import com.academicsService.dto.response.DocumentScolaireResponseDto;
+import com.academicsService.model.DocumentScolaire;
+import com.academicsService.model.enums.RequestStatus;
+
+public class DocumentScolaireMapper {
+
+ public static DocumentScolaire toEntity(DocumentScolaireCreateDto dto,
+ String schoolId,
+ String requestedById,
+ String requestedByRole) {
+ return DocumentScolaire.builder()
+ .schoolId(schoolId)
+ .studentId(dto.getStudentId())
+ .requestedById(requestedById)
+ .requestedByRole(requestedByRole)
+ .documentType(dto.getDocumentType())
+ .status(RequestStatus.EN_ATTENTE)
+ .reason(dto.getReason().trim())
+ .academicYear(dto.getAcademicYear())
+ .build();
+ }
+
+ /**
+ * Convertit une entité en DTO de réponse enrichi avec les noms
+ * de l'étudiant et du demandeur.
+ */
+ public static DocumentScolaireResponseDto toResponseDto(DocumentScolaire entity,
+ UserResponse student,
+ UserResponse requester) {
+ return new DocumentScolaireResponseDto(
+ entity.getId(),
+ entity.getSchoolId(),
+ entity.getStudentId(),
+ student != null ? student.getFirstName() : null,
+ student != null ? student.getLastName() : null,
+ entity.getRequestedById(),
+ entity.getRequestedByRole(),
+ requester != null ? requester.getFirstName() : null,
+ requester != null ? requester.getLastName() : null,
+ entity.getDocumentType(),
+ entity.getStatus(),
+ entity.getReason(),
+ entity.getAcademicYear(),
+ entity.getProcessedById(),
+ entity.getProcessorNote(),
+ entity.getResponseFileUrl(),
+ entity.getCreatedAt(),
+ entity.getUpdatedAt()
+ );
+ }
+
+ /**
+ * Surcharge sans enrichissement (fallback si l'appel user-service échoue).
+ */
+ public static DocumentScolaireResponseDto toResponseDto(DocumentScolaire entity) {
+ return toResponseDto(entity, null, null);
+ }
+
+ private DocumentScolaireMapper() {}
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/mapper/ReclamationMapper.java b/services/academic-service/src/main/java/com/academicsService/mapper/ReclamationMapper.java
new file mode 100644
index 0000000..f0bdf79
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/mapper/ReclamationMapper.java
@@ -0,0 +1,49 @@
+package com.academicsService.mapper;
+
+import com.academicsService.dto.create.ReclamationCreateDto;
+import com.academicsService.dto.response.ReclamationResponseDto;
+import com.academicsService.model.Reclamation;
+import com.academicsService.model.enums.ReclamationStatus;
+
+public class ReclamationMapper {
+
+ public static Reclamation toEntity(ReclamationCreateDto dto, String teacherId, String schoolId) {
+ return Reclamation.builder()
+ .examId(dto.getExamId())
+ .gradeId(dto.getGradeId())
+ .studentId(dto.getStudentId())
+ .studentName(dto.getStudentName())
+ .examName(dto.getExamName())
+ .subjectName(dto.getSubjectName())
+ .score(dto.getScore())
+ .maxScore(dto.getMaxScore())
+ .reason(dto.getReason())
+ .status(ReclamationStatus.PENDING)
+ .teacherId(teacherId)
+ .schoolId(schoolId)
+ .build();
+ }
+
+ public static ReclamationResponseDto toResponseDto(Reclamation entity) {
+ return new ReclamationResponseDto(
+ entity.getId(),
+ entity.getExamId(),
+ entity.getGradeId(),
+ entity.getStudentId(),
+ entity.getStudentName(),
+ entity.getExamName(),
+ entity.getSubjectName(),
+ entity.getScore(),
+ entity.getMaxScore(),
+ entity.getReason(),
+ entity.getStatus(),
+ entity.getResponse(),
+ entity.getTeacherId(),
+ entity.getSchoolId(),
+ entity.getCreatedAt(),
+ entity.getUpdatedAt()
+ );
+ }
+
+ private ReclamationMapper() {}
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/model/Activity.java b/services/academic-service/src/main/java/com/academicsService/model/Activity.java
index 47bcb93..36ef7c5 100644
--- a/services/academic-service/src/main/java/com/academicsService/model/Activity.java
+++ b/services/academic-service/src/main/java/com/academicsService/model/Activity.java
@@ -4,10 +4,14 @@
import com.academicsService.model.enums.ActivityType;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.CascadeType;
+import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
@@ -74,6 +78,11 @@ public class Activity extends BaseEntity {
@Column(name = "cover_image_url")
private String coverImageUrl;
+ @ElementCollection(fetch = FetchType.EAGER)
+ @CollectionTable(name = "activity_resource_links", joinColumns = @JoinColumn(name = "activity_id"))
+ @Builder.Default
+ private List resourceLinks = new ArrayList<>();
+
@OneToMany(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@Builder.Default
diff --git a/services/academic-service/src/main/java/com/academicsService/model/Certificate.java b/services/academic-service/src/main/java/com/academicsService/model/Certificate.java
deleted file mode 100644
index 891d80d..0000000
--- a/services/academic-service/src/main/java/com/academicsService/model/Certificate.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.academicsService.model;
-
-import com.academicsService.model.enums.CertificateType;
-import jakarta.persistence.*;
-import lombok.*;
-
-import java.time.LocalDate;
-
-@Entity
-@Table(name = "certificates")
-@Getter
-@Setter
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class Certificate extends BaseEntity {
-
- @Column(name = "school_id", nullable = false)
- private String schoolId;
-
- @Column(name = "student_id", nullable = false)
- private String studentId;
-
- @Column(nullable = false)
- private String title;
-
- @Column(columnDefinition = "TEXT")
- private String description;
-
- @Enumerated(EnumType.STRING)
- @Column(name = "certificate_type", nullable = false)
- private CertificateType certificateType;
-
- @Column(name = "issue_date", nullable = false)
- private LocalDate issueDate;
-
- @Column(name = "expiry_date")
- private LocalDate expiryDate;
-
- @Column(name = "certificate_number", unique = true, nullable = false)
- private String certificateNumber;
-
- @Column(name = "file_url")
- private String fileUrl;
-
- @Column(name = "issued_by", nullable = false)
- private String issuedBy;
-
- @Column(name = "academic_year", nullable = false)
- private String academicYear;
-}
\ No newline at end of file
diff --git a/services/academic-service/src/main/java/com/academicsService/model/DocumentScolaire.java b/services/academic-service/src/main/java/com/academicsService/model/DocumentScolaire.java
new file mode 100644
index 0000000..a29d418
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/model/DocumentScolaire.java
@@ -0,0 +1,55 @@
+package com.academicsService.model;
+
+import com.academicsService.model.enums.DocumentType;
+import com.academicsService.model.enums.RequestStatus;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "document_scolaires")
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DocumentScolaire extends BaseEntity {
+
+ @Column(nullable = false)
+ private String schoolId;
+
+ /** Étudiant pour lequel le document est demandé */
+ @Column(nullable = false)
+ private String studentId;
+
+ /** Identifiant de la personne qui a fait la demande (étudiant ou parent) */
+ @Column(nullable = false)
+ private String requestedById;
+
+ /** Rôle du demandeur : STUDENT ou PARENT */
+ @Column(nullable = false)
+ private String requestedByRole;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private DocumentType documentType;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ @Builder.Default
+ private RequestStatus status = RequestStatus.EN_ATTENTE;
+
+ @Column(length = 500)
+ private String reason;
+
+ @Column(nullable = false)
+ private String academicYear;
+
+ // Champs de traitement (remplis par le staff / school_admin)
+ private String processedById;
+
+ @Column(length = 1000)
+ private String processorNote;
+
+ /** URL du document traité joint par le staff */
+ private String responseFileUrl;
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/model/Reclamation.java b/services/academic-service/src/main/java/com/academicsService/model/Reclamation.java
new file mode 100644
index 0000000..cad2cd6
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/model/Reclamation.java
@@ -0,0 +1,57 @@
+package com.academicsService.model;
+
+import com.academicsService.model.enums.ReclamationStatus;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "reclamations")
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class Reclamation extends BaseEntity {
+
+ @Column(name = "exam_id", nullable = false)
+ private String examId;
+
+ @Column(name = "grade_id", nullable = false)
+ private String gradeId;
+
+ @Column(name = "student_id", nullable = false)
+ private String studentId;
+
+ @Column(name = "student_name", nullable = false)
+ private String studentName;
+
+ @Column(name = "exam_name", nullable = false)
+ private String examName;
+
+ @Column(name = "subject_name", nullable = false)
+ private String subjectName;
+
+ @Column(nullable = false)
+ private float score;
+
+ @Column(name = "max_score", nullable = false)
+ private float maxScore;
+
+ @Column(columnDefinition = "TEXT", nullable = false)
+ private String reason;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ @Builder.Default
+ private ReclamationStatus status = ReclamationStatus.PENDING;
+
+ @Column(columnDefinition = "TEXT")
+ private String response;
+
+ /** Denormalized from Exam for efficient queries */
+ @Column(name = "teacher_id", nullable = false)
+ private String teacherId;
+
+ @Column(name = "school_id", nullable = false)
+ private String schoolId;
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/model/ResourceLink.java b/services/academic-service/src/main/java/com/academicsService/model/ResourceLink.java
new file mode 100644
index 0000000..3d31ad3
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/model/ResourceLink.java
@@ -0,0 +1,27 @@
+package com.academicsService.model;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Embeddable
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ResourceLink {
+
+ @Column(name = "link_label", nullable = false)
+ private String label;
+
+ @Column(name = "link_url", nullable = false)
+ private String url;
+
+ @Column(name = "link_type")
+ private String type; // e.g. "form", "document", "video", "website"
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/model/Timetable.java b/services/academic-service/src/main/java/com/academicsService/model/Timetable.java
index 9c5d386..94a0243 100644
--- a/services/academic-service/src/main/java/com/academicsService/model/Timetable.java
+++ b/services/academic-service/src/main/java/com/academicsService/model/Timetable.java
@@ -36,7 +36,6 @@ public class Timetable extends BaseEntity {
@Column(name = "is_active", nullable = false)
private boolean isActive;
-
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "classroom_id", nullable = false)
private Classroom classroom;
diff --git a/services/academic-service/src/main/java/com/academicsService/model/enums/CertificateType.java b/services/academic-service/src/main/java/com/academicsService/model/enums/CertificateType.java
deleted file mode 100644
index 8646c34..0000000
--- a/services/academic-service/src/main/java/com/academicsService/model/enums/CertificateType.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.academicsService.model.enums;
-
-public enum CertificateType {
- DIPLOMA,
- CERTIFICATE_OF_COMPLETION,
- CERTIFICATE_OF_ACHIEVEMENT,
- HONOR_ROLL,
- AWARD,
- OTHER
-}
diff --git a/services/academic-service/src/main/java/com/academicsService/model/enums/DocumentType.java b/services/academic-service/src/main/java/com/academicsService/model/enums/DocumentType.java
new file mode 100644
index 0000000..05a2f37
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/model/enums/DocumentType.java
@@ -0,0 +1,9 @@
+package com.academicsService.model.enums;
+
+public enum DocumentType {
+ CERTIFICAT_SCOLARITE,
+ ATTESTATION_STAGE,
+ ATTESTATION_INSCRIPTION,
+ RELEVE_NOTES,
+ AUTRE
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/model/enums/ReclamationStatus.java b/services/academic-service/src/main/java/com/academicsService/model/enums/ReclamationStatus.java
new file mode 100644
index 0000000..7fc4b28
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/model/enums/ReclamationStatus.java
@@ -0,0 +1,8 @@
+package com.academicsService.model.enums;
+
+public enum ReclamationStatus {
+ PENDING,
+ REVIEWING,
+ RESOLVED,
+ REJECTED
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/model/enums/RequestStatus.java b/services/academic-service/src/main/java/com/academicsService/model/enums/RequestStatus.java
new file mode 100644
index 0000000..b4c01d4
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/model/enums/RequestStatus.java
@@ -0,0 +1,8 @@
+package com.academicsService.model.enums;
+
+public enum RequestStatus {
+ EN_ATTENTE,
+ EN_COURS,
+ APPROUVE,
+ REJETE
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/repository/ActivityEnrollmentRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/ActivityEnrollmentRepository.java
index ae5b85c..1d9a027 100644
--- a/services/academic-service/src/main/java/com/academicsService/repository/ActivityEnrollmentRepository.java
+++ b/services/academic-service/src/main/java/com/academicsService/repository/ActivityEnrollmentRepository.java
@@ -29,4 +29,6 @@ Optional findByStudentIdAndActivityId(
List findByActivityIdAndStatusOrderByEnrolledAtAsc(
String activityId, EnrollmentStatus status);
+
+ Page findByActivity_SchoolId(String schoolId, Pageable pageable);
}
diff --git a/services/academic-service/src/main/java/com/academicsService/repository/ActivityRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/ActivityRepository.java
index 7b1430e..fff50f4 100644
--- a/services/academic-service/src/main/java/com/academicsService/repository/ActivityRepository.java
+++ b/services/academic-service/src/main/java/com/academicsService/repository/ActivityRepository.java
@@ -11,6 +11,7 @@
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
+import java.util.List;
@Repository
public interface ActivityRepository extends JpaRepository {
@@ -51,4 +52,18 @@ Page findActivitiesWithAvailableSpots(
boolean existsBySchoolIdAndNameAndStartDate(
String schoolId, String name, LocalDateTime startDate);
+
+ /** Activities whose registration should be auto-closed:
+ * - status is REGISTRATION_OPEN
+ * - AND (deadline passed OR capacity is full)
+ */
+ @Query("""
+ SELECT a FROM Activity a
+ WHERE a.status = 'REGISTRATION_OPEN'
+ AND (
+ (a.registrationDeadline IS NOT NULL AND a.registrationDeadline < :now)
+ OR (a.maxParticipants > 0 AND a.currentParticipants >= a.maxParticipants)
+ )
+ """)
+ List findActivitiesToAutoClose(@Param("now") LocalDateTime now);
}
diff --git a/services/academic-service/src/main/java/com/academicsService/repository/CertificateRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/CertificateRepository.java
deleted file mode 100644
index 58d2aab..0000000
--- a/services/academic-service/src/main/java/com/academicsService/repository/CertificateRepository.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.academicsService.repository;
-
-import com.academicsService.model.Certificate;
-import com.academicsService.model.enums.CertificateType;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
-
-import java.time.LocalDate;
-import java.util.Optional;
-
-@Repository
-public interface CertificateRepository extends JpaRepository {
-
- Page findBySchoolId(String schoolId, Pageable pageable);
-
- Page findBySchoolIdAndCertificateType(
- String schoolId, CertificateType type, Pageable pageable);
-
- Page findBySchoolIdAndIssueDateBetween(
- String schoolId, LocalDate startDate, LocalDate endDate, Pageable pageable);
-
- Page findBySchoolIdAndAcademicYear(
- String schoolId, String academicYear, Pageable pageable);
-
- Page findByStudentIdAndSchoolId(
- String studentId, String schoolId, Pageable pageable);
-
- Optional findByCertificateNumber(String certificateNumber);
-
- boolean existsByStudentIdAndCertificateTypeAndAcademicYear(
- String studentId, CertificateType type, String academicYear);
-}
diff --git a/services/academic-service/src/main/java/com/academicsService/repository/DocumentScolaireRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/DocumentScolaireRepository.java
new file mode 100644
index 0000000..ad8735c
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/repository/DocumentScolaireRepository.java
@@ -0,0 +1,30 @@
+package com.academicsService.repository;
+
+import com.academicsService.model.DocumentScolaire;
+import com.academicsService.model.enums.DocumentType;
+import com.academicsService.model.enums.RequestStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface DocumentScolaireRepository extends JpaRepository {
+
+ // ── Requêtes pour le staff / school_admin ────────────────────────────────
+
+ Page findBySchoolId(String schoolId, Pageable pageable);
+
+ Page findBySchoolIdAndStatus(String schoolId, RequestStatus status, Pageable pageable);
+
+ Page findBySchoolIdAndDocumentType(String schoolId, DocumentType documentType, Pageable pageable);
+
+ Page findBySchoolIdAndStatusAndDocumentType(
+ String schoolId, RequestStatus status, DocumentType documentType, Pageable pageable);
+
+ Page findByStudentIdAndSchoolId(String studentId, String schoolId, Pageable pageable);
+
+ // ── Requêtes pour les étudiants / parents ────────────────────────────────
+
+ Page findByRequestedByIdAndSchoolId(String requestedById, String schoolId, Pageable pageable);
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/repository/ReclamationRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/ReclamationRepository.java
new file mode 100644
index 0000000..f2036e6
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/repository/ReclamationRepository.java
@@ -0,0 +1,22 @@
+package com.academicsService.repository;
+
+import com.academicsService.model.Reclamation;
+import com.academicsService.model.enums.ReclamationStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface ReclamationRepository extends JpaRepository {
+
+ Page findByStudentId(String studentId, Pageable pageable);
+
+ Page findByTeacherId(String teacherId, Pageable pageable);
+
+ Page findByExamId(String examId, Pageable pageable);
+
+ Page findBySchoolId(String schoolId, Pageable pageable);
+
+ boolean existsByGradeIdAndStatus(String gradeId, ReclamationStatus status);
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/ActivityEnrollmentService.java b/services/academic-service/src/main/java/com/academicsService/service/ActivityEnrollmentService.java
index 30514d0..862ed64 100644
--- a/services/academic-service/src/main/java/com/academicsService/service/ActivityEnrollmentService.java
+++ b/services/academic-service/src/main/java/com/academicsService/service/ActivityEnrollmentService.java
@@ -26,6 +26,8 @@ Page findByActivityAndStatus(
Page findByStudent(
String studentId, Pageable pageable);
+ Page findBySchool(String schoolId, Pageable pageable);
+
void cancel(String id, String currentUserId);
}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/CertificateService.java b/services/academic-service/src/main/java/com/academicsService/service/CertificateService.java
deleted file mode 100644
index c0a7f81..0000000
--- a/services/academic-service/src/main/java/com/academicsService/service/CertificateService.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.academicsService.service;
-
-import com.academicsService.dto.create.CertificateCreateDto;
-import com.academicsService.dto.response.CertificateResponseDto;
-import com.academicsService.dto.update.CertificateUpdateDto;
-import com.academicsService.model.enums.CertificateType;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.Pageable;
-
-import java.time.LocalDate;
-
-public interface CertificateService {
-
- CertificateResponseDto create(CertificateCreateDto dto, String currentUserId);
-
- CertificateResponseDto update(String id, CertificateUpdateDto dto, String currentUserId);
-
- CertificateResponseDto findById(String id);
-
- CertificateResponseDto findByCertificateNumber(String certificateNumber);
-
- Page findBySchool(String schoolId, Pageable pageable);
-
- Page findBySchoolAndType(
- String schoolId, CertificateType type, Pageable pageable);
-
- Page findBySchoolAndYear(
- String schoolId, String academicYear, Pageable pageable);
-
- Page findBySchoolAndDateRange(
- String schoolId, LocalDate startDate, LocalDate endDate, Pageable pageable);
-
- Page findByStudent(
- String studentId, String schoolId, Pageable pageable);
-
- void delete(String id, String currentUserId);
-}
-
diff --git a/services/academic-service/src/main/java/com/academicsService/service/DocumentScolaireService.java b/services/academic-service/src/main/java/com/academicsService/service/DocumentScolaireService.java
new file mode 100644
index 0000000..b051063
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/service/DocumentScolaireService.java
@@ -0,0 +1,36 @@
+package com.academicsService.service;
+
+import com.academicsService.dto.create.DocumentScolaireCreateDto;
+import com.academicsService.dto.response.DocumentScolaireResponseDto;
+import com.academicsService.dto.update.DocumentScolaireProcessDto;
+import com.academicsService.model.enums.DocumentType;
+import com.academicsService.model.enums.RequestStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+public interface DocumentScolaireService {
+
+ /** Soumettre une nouvelle demande de document (STUDENT / PARENT). */
+ DocumentScolaireResponseDto create(DocumentScolaireCreateDto dto,
+ String currentUserId,
+ String currentUserRole);
+
+ /** Traiter une demande : changer le statut, ajouter une note et/ou le document (SCHOOL_ADMIN / STAFF). */
+ DocumentScolaireResponseDto process(String id, DocumentScolaireProcessDto dto, String currentUserId);
+
+ DocumentScolaireResponseDto findById(String id);
+
+ /** Liste toutes les demandes d'une école avec filtres optionnels. */
+ Page findBySchool(String schoolId,
+ RequestStatus status,
+ DocumentType documentType,
+ Pageable pageable);
+
+ /** Demandes d'un étudiant spécifique. */
+ Page findByStudent(String studentId, String schoolId, Pageable pageable);
+
+ /** Mes propres demandes (vue étudiant / parent). */
+ Page findMyRequests(String currentUserId, String schoolId, Pageable pageable);
+
+ void delete(String id, String currentUserId);
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/ReclamationService.java b/services/academic-service/src/main/java/com/academicsService/service/ReclamationService.java
new file mode 100644
index 0000000..82ea399
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/service/ReclamationService.java
@@ -0,0 +1,26 @@
+package com.academicsService.service;
+
+import com.academicsService.dto.create.ReclamationCreateDto;
+import com.academicsService.dto.response.ReclamationResponseDto;
+import com.academicsService.dto.update.ReclamationUpdateDto;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+public interface ReclamationService {
+
+ ReclamationResponseDto create(ReclamationCreateDto dto);
+
+ ReclamationResponseDto respond(String id, ReclamationUpdateDto dto, String currentUserId);
+
+ Page getByStudent(String studentId, Pageable pageable);
+
+ Page getByTeacher(String teacherId, Pageable pageable);
+
+ Page getByExam(String examId, Pageable pageable);
+
+ Page getBySchool(String schoolId, Pageable pageable);
+
+ boolean hasPendingForGrade(String gradeId);
+
+ void delete(String id, String currentUserId);
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityEnrollmentServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityEnrollmentServiceImpl.java
index e768c4d..c5dc318 100644
--- a/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityEnrollmentServiceImpl.java
+++ b/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityEnrollmentServiceImpl.java
@@ -41,21 +41,21 @@ public ActivityEnrollmentResponseDto enroll(ActivityEnrollmentCreateDto dto,
log.info("Enrolling student '{}' in activity '{}' by user '{}'",
dto.getStudentId(), dto.getActivityId(), currentUserId);
- // 1. Valider école
+ // Valider école
schoolValidationService.getSchoolIdByUserId(currentUserId);
- // 2. Valider que l'activité existe
+ // Valider que l'activité existe
Activity activity = findActivityById(dto.getActivityId());
- // 3. Vérifier que l'activité accepte des inscriptions
+ // Vérifier que l'activité accepte des inscriptions
if (activity.getStatus() != ActivityStatus.REGISTRATION_OPEN) {
throw new BusinessRuleException(
"Activity '" + activity.getName() +
- "' is not open for registration. Status: " + activity.getStatus()
+ "' is not open for registratioStatus: " + activity.getStatus()
);
}
- // 4. Vérifier la deadline d'inscription
+ // Vérifier la deadline d'inscription
if (activity.getRegistrationDeadline() != null &&
activity.getRegistrationDeadline().isBefore(LocalDateTime.now())) {
throw new BusinessRuleException(
@@ -64,7 +64,7 @@ public ActivityEnrollmentResponseDto enroll(ActivityEnrollmentCreateDto dto,
);
}
- // 5. Vérifier que l'étudiant n'est pas déjà inscrit
+ // Vérifier que l'étudiant n'est pas déjà inscrit
if (enrollmentRepository.existsByStudentIdAndActivityId(
dto.getStudentId(), dto.getActivityId())) {
throw new DuplicateResourceException(
@@ -72,7 +72,7 @@ public ActivityEnrollmentResponseDto enroll(ActivityEnrollmentCreateDto dto,
);
}
- // 6. Déterminer le statut : CONFIRMED si place dispo, WAITLISTED sinon
+ // Déterminer le statut : CONFIRMED si place dispo, WAITLISTED sinon
long confirmed = enrollmentRepository.countByActivityIdAndStatus(
dto.getActivityId(), EnrollmentStatus.CONFIRMED
);
@@ -80,7 +80,17 @@ public ActivityEnrollmentResponseDto enroll(ActivityEnrollmentCreateDto dto,
EnrollmentStatus enrollmentStatus;
if (confirmed < activity.getMaxParticipants()) {
enrollmentStatus = EnrollmentStatus.CONFIRMED;
- activity.setCurrentParticipants(activity.getCurrentParticipants() + 1);
+ int newCount = activity.getCurrentParticipants() + 1;
+ activity.setCurrentParticipants(newCount);
+
+ // Auto-close registration when capacity is now reached
+ if (newCount >= activity.getMaxParticipants() &&
+ activity.getStatus() == ActivityStatus.REGISTRATION_OPEN) {
+ activity.setStatus(ActivityStatus.REGISTRATION_CLOSED);
+ log.info("Activity '{}' auto-closed: capacity reached ({}/{})",
+ activity.getName(), newCount, activity.getMaxParticipants());
+ }
+
activityRepository.save(activity);
} else {
enrollmentStatus = EnrollmentStatus.WAITLISTED;
@@ -181,6 +191,13 @@ public Page findByStudent(
.map(ActivityEnrollmentMapper::toResponseDto);
}
+ @Override
+ @Transactional(readOnly = true)
+ public Page findBySchool(String schoolId, Pageable pageable) {
+ return enrollmentRepository.findByActivity_SchoolId(schoolId, pageable)
+ .map(ActivityEnrollmentMapper::toResponseDto);
+ }
+
@Override
@Transactional
public void cancel(String id, String currentUserId) {
@@ -213,7 +230,6 @@ public void cancel(String id, String currentUserId) {
// ================================================================
// PRIVATE HELPERS
// ================================================================
-
/** Promouvoir automatiquement le premier étudiant en liste d'attente (FIFO) */
private void promoteFromWaitlist(Activity activity) {
List waitlist = enrollmentRepository
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityServiceImpl.java
index d398f0e..32a337f 100644
--- a/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityServiceImpl.java
+++ b/services/academic-service/src/main/java/com/academicsService/service/impl/ActivityServiceImpl.java
@@ -21,7 +21,10 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.scheduling.annotation.Scheduled;
+
import java.time.LocalDateTime;
+import java.util.List;
@Service
@RequiredArgsConstructor
@@ -38,10 +41,10 @@ public ActivityResponseDto create(ActivityCreateDto dto, String currentUserId) {
String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
- // 1. Valider les dates
+ // Valider les dates
validateDates(dto.getStartDate(), dto.getEndDate(), dto.getRegistrationDeadline());
- // 2. Vérifier doublon — même nom, même date de début, même école
+ // Vérifier doublon — même nom, même date de début, même école
if (activityRepository.existsBySchoolIdAndNameAndStartDate(
schoolId, dto.getName().trim(), dto.getStartDate())) {
throw new DuplicateResourceException(
@@ -66,14 +69,14 @@ public ActivityResponseDto update(String id, ActivityUpdateDto dto, String curre
Activity activity = findEntityById(id);
String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
- // 1. Vérifier appartenance école
+ // Vérifier appartenance école
if (!activity.getSchoolId().equals(schoolId)) {
throw new BusinessRuleException(
"You don't have permission to update this activity"
);
}
- // 2. Ne pas modifier une activité terminée ou annulée
+ // Ne pas modifier une activité terminée ou annulée
if (activity.getStatus() == ActivityStatus.COMPLETED ||
activity.getStatus() == ActivityStatus.CANCELLED) {
throw new BusinessRuleException(
@@ -81,10 +84,10 @@ public ActivityResponseDto update(String id, ActivityUpdateDto dto, String curre
);
}
- // 3. Valider les dates
+ // Valider les dates
validateDates(dto.getStartDate(), dto.getEndDate(), dto.getRegistrationDeadline());
- // 4. Ne pas réduire maxParticipants en dessous de currentParticipants
+ // Ne pas réduire maxParticipants en dessous de currentParticipants
if (dto.getMaxParticipants() < activity.getCurrentParticipants()) {
throw new BusinessRuleException(
"Max participants (" + dto.getMaxParticipants() +
@@ -103,6 +106,10 @@ public ActivityResponseDto update(String id, ActivityUpdateDto dto, String curre
activity.setFee(dto.getFee());
activity.setCoverImageUrl(dto.getCoverImageUrl());
activity.setStatus(dto.getStatus());
+ if (dto.getResourceLinks() != null) {
+ activity.getResourceLinks().clear();
+ activity.getResourceLinks().addAll(dto.getResourceLinks());
+ }
Activity updated = activityRepository.save(activity);
log.info("Activity '{}' updated", id);
@@ -110,9 +117,11 @@ public ActivityResponseDto update(String id, ActivityUpdateDto dto, String curre
}
@Override
- @Transactional(readOnly = true)
+ @Transactional
public ActivityResponseDto findById(String id) {
- return ActivityMapper.toResponseDto(findEntityById(id));
+ Activity activity = findEntityById(id);
+ syncStatusIfNeeded(activity);
+ return ActivityMapper.toResponseDto(activity);
}
@Override
@@ -171,7 +180,7 @@ public ActivityResponseDto updateStatus(String id, ActivityStatus newStatus,
validateStatusTransition(activity.getStatus(), newStatus);
- // Si on ouvre les inscriptions → vérifier registrationDeadline
+ // on ouvre les inscriptions → vérifier registrationDeadline
if (newStatus == ActivityStatus.REGISTRATION_OPEN) {
if (activity.getRegistrationDeadline() == null) {
throw new BusinessRuleException(
@@ -205,7 +214,7 @@ public void delete(String id, String currentUserId) {
);
}
- // Ne pas supprimer si des étudiants sont inscrits et confirmés
+ // pas supprimer si des étudiants sont inscrits et confirmés
if (activity.getCurrentParticipants() > 0 &&
activity.getStatus() != ActivityStatus.CANCELLED) {
throw new BusinessRuleException(
@@ -218,10 +227,60 @@ public void delete(String id, String currentUserId) {
log.info("Activity '{}' deleted", id);
}
+ //==============================================================
+ //HEDULED — auto-close expired /ll activities
+ //==============================================================
+
+ /**
+ * Runs every 5 minutes.
+ * Closes any activity whose registration deadline has passed
+ * or whose capacity is fully reached, without requiring manual admin action.
+ */
+ @Scheduled(fixedRate = 300_000)
+ @Transactional
+ public void autoCloseExpiredActivities() {
+ List toClose = activityRepository.findActivitiesToAutoClose(LocalDateTime.now());
+ if (toClose.isEmpty()) return;
+
+ toClose.forEach(a -> {
+ boolean deadlinePassed = a.getRegistrationDeadline() != null &&
+ a.getRegistrationDeadline().isBefore(LocalDateTime.now());
+ boolean isFull = a.getMaxParticipants() > 0 &&
+ a.getCurrentParticipants() >= a.getMaxParticipants();
+
+ a.setStatus(ActivityStatus.REGISTRATION_CLOSED);
+ log.info("Activity '{}' auto-closed [scheduled]: deadlinePassed={}, isFull={}",
+ a.getName(), deadlinePassed, isFull);
+ });
+
+ activityRepository.saveAll(toClose);
+ log.info("Auto-closed {} activit(ies) via scheduled job.", toClose.size());
+ }
+
// ================================================================
// PRIVATE HELPERS
// ================================================================
+ /**
+ * Lazily synchronises a single activity's status on-read.
+ * Called in findById so the detail view always reflects the real state.
+ */
+ private void syncStatusIfNeeded(Activity activity) {
+ if (activity.getStatus() != ActivityStatus.REGISTRATION_OPEN) return;
+
+ boolean deadlinePassed = activity.getRegistrationDeadline() != null &&
+ activity.getRegistrationDeadline().isBefore(LocalDateTime.now());
+ boolean isFull = activity.getMaxParticipants() > 0 &&
+ activity.getCurrentParticipants() >= activity.getMaxParticipants();
+
+ if (deadlinePassed || isFull) {
+ activity.setStatus(ActivityStatus.REGISTRATION_CLOSED);
+ activityRepository.save(activity);
+ log.info("Activity '{}' lazily auto-closed: deadlinePassed={}, isFull={}",
+ activity.getName(), deadlinePassed, isFull);
+ }
+ }
+
private void validateDates(LocalDateTime startDate, LocalDateTime endDate,
LocalDateTime registrationDeadline) {
if (!endDate.isAfter(startDate)) {
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/CertificateServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/CertificateServiceImpl.java
deleted file mode 100644
index 7eeea1a..0000000
--- a/services/academic-service/src/main/java/com/academicsService/service/impl/CertificateServiceImpl.java
+++ /dev/null
@@ -1,211 +0,0 @@
-package com.academicsService.service.impl;
-
-import com.academicsService.client.SchoolValidationService;
-import com.academicsService.dto.create.CertificateCreateDto;
-import com.academicsService.dto.response.CertificateResponseDto;
-import com.academicsService.dto.update.CertificateUpdateDto;
-import com.academicsService.exception.BusinessRuleException;
-import com.academicsService.exception.DuplicateResourceException;
-import com.academicsService.exception.InvalidDataException;
-import com.academicsService.exception.ResourceNotFoundException;
-import com.academicsService.mapper.CertificateMapper;
-import com.academicsService.model.Certificate;
-import com.academicsService.model.enums.CertificateType;
-import com.academicsService.repository.CertificateRepository;
-import com.academicsService.service.CertificateService;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.Pageable;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDate;
-
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class CertificateServiceImpl implements CertificateService {
-
- private final CertificateRepository certificateRepository;
- private final SchoolValidationService schoolValidationService;
-
- @Override
- @Transactional
- public CertificateResponseDto create(CertificateCreateDto dto, String currentUserId) {
- log.info("Creating certificate for student '{}' by user '{}'",
- dto.getStudentId(), currentUserId);
-
- // 1. Récupérer schoolId depuis user-service via X-User-Id header
- String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
-
- // 2. Valider que la date d'émission n'est pas dans le futur
- if (dto.getIssueDate().isAfter(LocalDate.now())) {
- throw new InvalidDataException("Issue date cannot be in the future");
- }
-
- // 3. Valider que la date d'expiration est après la date d'émission
- if (dto.getExpiryDate() != null &&
- !dto.getExpiryDate().isAfter(dto.getIssueDate())) {
- throw new InvalidDataException("Expiry date must be after issue date");
- }
-
- // 4. Vérifier qu'un étudiant n'a pas déjà ce type de certificat
- // pour la même année académique
- if (certificateRepository.existsByStudentIdAndCertificateTypeAndAcademicYear(
- dto.getStudentId(), dto.getCertificateType(), dto.getAcademicYear())) {
- throw new DuplicateResourceException(
- "Student already has a '" + dto.getCertificateType() +
- "' certificate for academic year '" + dto.getAcademicYear() + "'"
- );
- }
-
- Certificate saved = certificateRepository.save(
- CertificateMapper.toEntity(dto, schoolId, currentUserId)
- );
-
- log.info("Certificate '{}' created with number '{}'",
- saved.getId(), saved.getCertificateNumber());
-
- return CertificateMapper.toResponseDto(saved);
- }
-
- @Override
- @Transactional
- public CertificateResponseDto update(String id, CertificateUpdateDto dto,
- String currentUserId) {
- log.info("Updating certificate '{}' by user '{}'", id, currentUserId);
-
- Certificate certificate = findEntityById(id);
- String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
-
- // 1. Vérifier que le certificat appartient à la même école
- if (!certificate.getSchoolId().equals(schoolId)) {
- throw new BusinessRuleException(
- "You don't have permission to update this certificate"
- );
- }
-
- // 2. Valider les dates
- if (dto.getIssueDate().isAfter(LocalDate.now())) {
- throw new InvalidDataException("Issue date cannot be in the future");
- }
-
- if (dto.getExpiryDate() != null &&
- !dto.getExpiryDate().isAfter(dto.getIssueDate())) {
- throw new InvalidDataException("Expiry date must be after issue date");
- }
-
- certificate.setTitle(dto.getTitle().trim());
- certificate.setDescription(dto.getDescription());
- certificate.setIssueDate(dto.getIssueDate());
- certificate.setExpiryDate(dto.getExpiryDate());
- certificate.setFileUrl(dto.getFileUrl());
-
- Certificate updated = certificateRepository.save(certificate);
- log.info("Certificate '{}' updated", id);
-
- return CertificateMapper.toResponseDto(updated);
- }
-
- @Override
- @Transactional(readOnly = true)
- public CertificateResponseDto findById(String id) {
- return CertificateMapper.toResponseDto(findEntityById(id));
- }
-
- @Override
- @Transactional(readOnly = true)
- public CertificateResponseDto findByCertificateNumber(String certificateNumber) {
- log.debug("Fetching certificate by number '{}'", certificateNumber);
- return certificateRepository.findByCertificateNumber(certificateNumber)
- .map(CertificateMapper::toResponseDto)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Certificate not found with number: " + certificateNumber
- ));
- }
-
- @Override
- @Transactional(readOnly = true)
- public Page findBySchool(String schoolId, Pageable pageable) {
- log.debug("Fetching certificates for school '{}'", schoolId);
- return certificateRepository.findBySchoolId(schoolId, pageable)
- .map(CertificateMapper::toResponseDto);
- }
-
- @Override
- @Transactional(readOnly = true)
- public Page findBySchoolAndType(
- String schoolId, CertificateType type, Pageable pageable) {
- log.debug("Fetching certificates for school '{}' type '{}'", schoolId, type);
- return certificateRepository
- .findBySchoolIdAndCertificateType(schoolId, type, pageable)
- .map(CertificateMapper::toResponseDto);
- }
-
- @Override
- @Transactional(readOnly = true)
- public Page findBySchoolAndYear(
- String schoolId, String academicYear, Pageable pageable) {
- log.debug("Fetching certificates for school '{}' year '{}'", schoolId, academicYear);
- return certificateRepository
- .findBySchoolIdAndAcademicYear(schoolId, academicYear, pageable)
- .map(CertificateMapper::toResponseDto);
- }
-
- @Override
- @Transactional(readOnly = true)
- public Page findBySchoolAndDateRange(
- String schoolId, LocalDate startDate, LocalDate endDate, Pageable pageable) {
- log.debug("Fetching certificates for school '{}' between '{}' and '{}'",
- schoolId, startDate, endDate);
-
- if (startDate.isAfter(endDate)) {
- throw new InvalidDataException("Start date must be before end date");
- }
-
- return certificateRepository
- .findBySchoolIdAndIssueDateBetween(schoolId, startDate, endDate, pageable)
- .map(CertificateMapper::toResponseDto);
- }
-
- @Override
- @Transactional(readOnly = true)
- public Page findByStudent(
- String studentId, String schoolId, Pageable pageable) {
- log.debug("Fetching certificates for student '{}' school '{}'", studentId, schoolId);
- return certificateRepository
- .findByStudentIdAndSchoolId(studentId, schoolId, pageable)
- .map(CertificateMapper::toResponseDto);
- }
-
- @Override
- @Transactional
- public void delete(String id, String currentUserId) {
- log.info("Deleting certificate '{}' by user '{}'", id, currentUserId);
-
- Certificate certificate = findEntityById(id);
- String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
-
- if (!certificate.getSchoolId().equals(schoolId)) {
- throw new BusinessRuleException(
- "You don't have permission to delete this certificate"
- );
- }
-
- certificateRepository.delete(certificate);
- log.info("Certificate '{}' deleted", id);
- }
-
- // ================================================================
- // PRIVATE HELPERS
- // ================================================================
-
- private Certificate findEntityById(String id) {
- return certificateRepository.findById(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Certificate not found with id: " + id
- ));
- }
-}
-
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/DocumentScolaireServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/DocumentScolaireServiceImpl.java
new file mode 100644
index 0000000..d96876e
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/service/impl/DocumentScolaireServiceImpl.java
@@ -0,0 +1,209 @@
+package com.academicsService.service.impl;
+
+import com.academicsService.client.SchoolValidationService;
+import com.academicsService.client.UserServiceClient;
+import com.academicsService.client.dto.UserResponse;
+import com.academicsService.dto.create.DocumentScolaireCreateDto;
+import com.academicsService.dto.response.DocumentScolaireResponseDto;
+import com.academicsService.dto.update.DocumentScolaireProcessDto;
+import com.academicsService.exception.BusinessRuleException;
+import com.academicsService.exception.ResourceNotFoundException;
+import com.academicsService.mapper.DocumentScolaireMapper;
+import com.academicsService.model.DocumentScolaire;
+import com.academicsService.model.enums.DocumentType;
+import com.academicsService.model.enums.RequestStatus;
+import com.academicsService.repository.DocumentScolaireRepository;
+import com.academicsService.service.DocumentScolaireService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class DocumentScolaireServiceImpl implements DocumentScolaireService {
+
+ private final DocumentScolaireRepository documentScolaireRepository;
+ private final SchoolValidationService schoolValidationService;
+ private final UserServiceClient userServiceClient;
+
+ @Override
+ @Transactional
+ public DocumentScolaireResponseDto create(DocumentScolaireCreateDto dto,
+ String currentUserId,
+ String currentUserRole) {
+ log.info("Nouvelle demande de document '{}' par l'utilisateur '{}'",
+ dto.getDocumentType(), currentUserId);
+
+ String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
+ String primaryRole = extractPrimaryRole(currentUserRole);
+
+ // Règle métier : un ÉTUDIANT ne peut soumettre que pour lui-même
+ if ("STUDENT".equals(primaryRole) && !currentUserId.equals(dto.getStudentId())) {
+ throw new BusinessRuleException(
+ "Un étudiant ne peut soumettre une demande que pour lui-même.");
+ }
+
+ DocumentScolaire saved = documentScolaireRepository.save(
+ DocumentScolaireMapper.toEntity(dto, schoolId, currentUserId, primaryRole)
+ );
+
+ log.info("Demande '{}' créée avec statut EN_ATTENTE", saved.getId());
+ return toEnrichedDto(saved);
+ }
+
+ @Override
+ @Transactional
+ public DocumentScolaireResponseDto process(String id, DocumentScolaireProcessDto dto,
+ String currentUserId) {
+ log.info("Traitement de la demande '{}' par l'utilisateur '{}'", id, currentUserId);
+
+ DocumentScolaire request = findEntityById(id);
+ String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
+
+ if (!request.getSchoolId().equals(schoolId)) {
+ throw new BusinessRuleException(
+ "Vous n'avez pas la permission de traiter cette demande");
+ }
+
+ request.setStatus(dto.getStatus());
+ request.setProcessedById(currentUserId);
+
+ if (dto.getProcessorNote() != null) {
+ request.setProcessorNote(dto.getProcessorNote().trim());
+ }
+ if (dto.getResponseFileUrl() != null) {
+ request.setResponseFileUrl(dto.getResponseFileUrl().trim());
+ }
+
+ DocumentScolaire updated = documentScolaireRepository.save(request);
+ log.info("Demande '{}' mise à jour → statut '{}'", id, dto.getStatus());
+
+ return toEnrichedDto(updated);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public DocumentScolaireResponseDto findById(String id) {
+ return toEnrichedDto(findEntityById(id));
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Page findBySchool(String schoolId,
+ RequestStatus status,
+ DocumentType documentType,
+ Pageable pageable) {
+ log.debug("Listing demandes école '{}' status='{}' type='{}'",
+ schoolId, status, documentType);
+
+ if (status != null && documentType != null) {
+ return documentScolaireRepository
+ .findBySchoolIdAndStatusAndDocumentType(schoolId, status, documentType, pageable)
+ .map(this::toEnrichedDto);
+ }
+ if (status != null) {
+ return documentScolaireRepository
+ .findBySchoolIdAndStatus(schoolId, status, pageable)
+ .map(this::toEnrichedDto);
+ }
+ if (documentType != null) {
+ return documentScolaireRepository
+ .findBySchoolIdAndDocumentType(schoolId, documentType, pageable)
+ .map(this::toEnrichedDto);
+ }
+ return documentScolaireRepository
+ .findBySchoolId(schoolId, pageable)
+ .map(this::toEnrichedDto);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Page findByStudent(String studentId,
+ String schoolId,
+ Pageable pageable) {
+ log.debug("Listing demandes étudiant '{}' école '{}'", studentId, schoolId);
+ return documentScolaireRepository
+ .findByStudentIdAndSchoolId(studentId, schoolId, pageable)
+ .map(this::toEnrichedDto);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Page findMyRequests(String currentUserId,
+ String schoolId,
+ Pageable pageable) {
+ log.debug("Listing mes demandes pour l'utilisateur '{}'", currentUserId);
+ return documentScolaireRepository
+ .findByRequestedByIdAndSchoolId(currentUserId, schoolId, pageable)
+ .map(this::toEnrichedDto);
+ }
+
+ @Override
+ @Transactional
+ public void delete(String id, String currentUserId) {
+ log.info("Suppression de la demande '{}' par l'utilisateur '{}'", id, currentUserId);
+
+ DocumentScolaire request = findEntityById(id);
+ String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
+
+ if (!request.getSchoolId().equals(schoolId)) {
+ throw new BusinessRuleException(
+ "Vous n'avez pas la permission de supprimer cette demande");
+ }
+
+ documentScolaireRepository.delete(request);
+ log.info("Demande '{}' supprimée", id);
+ }
+
+ // ════════════════════════════════════════════════════════════════
+ // PRIVATE HELPERS
+ // ════════════════════════════════════════════════════════════════
+
+ private DocumentScolaire findEntityById(String id) {
+ return documentScolaireRepository.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Demande introuvable avec l'id : " + id));
+ }
+
+ /**
+ * Enrichit le DTO avec les noms de l'étudiant et du demandeur.
+ * En cas d'échec du user-service, retourne le DTO sans noms (graceful degradation).
+ */
+ private DocumentScolaireResponseDto toEnrichedDto(DocumentScolaire entity) {
+ UserResponse student = fetchUserSafe(entity.getStudentId());
+ UserResponse requester = entity.getStudentId().equals(entity.getRequestedById())
+ ? student
+ : fetchUserSafe(entity.getRequestedById());
+
+ return DocumentScolaireMapper.toResponseDto(entity, student, requester);
+ }
+
+ /**
+ * Appelle le user-service pour récupérer un utilisateur.
+ * Retourne null en cas d'erreur (ne doit pas bloquer la réponse principale).
+ */
+ private UserResponse fetchUserSafe(String userId) {
+ if (userId == null || userId.isBlank()) return null;
+ try {
+ return userServiceClient.getUserById(userId);
+ } catch (Exception e) {
+ log.warn("Impossible de récupérer les infos de l'utilisateur '{}': {}", userId, e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Extrait le rôle principal du demandeur depuis le header X-User-Roles
+ * (valeurs séparées par virgule, ex: "STUDENT,USER").
+ */
+ private String extractPrimaryRole(String rolesHeader) {
+ if (rolesHeader == null) return "UNKNOWN";
+ if (rolesHeader.contains("PARENT")) return "PARENT";
+ if (rolesHeader.contains("STUDENT")) return "STUDENT";
+ return "UNKNOWN";
+ }
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/ExamServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/ExamServiceImpl.java
index 8d3b13a..008e2f3 100644
--- a/services/academic-service/src/main/java/com/academicsService/service/impl/ExamServiceImpl.java
+++ b/services/academic-service/src/main/java/com/academicsService/service/impl/ExamServiceImpl.java
@@ -31,9 +31,9 @@
@Slf4j
public class ExamServiceImpl implements ExamService {
- private final ExamRepository examRepository;
- private final SubjectRepository subjectRepository;
- private final ClassroomRepository classroomRepository;
+ private final ExamRepository examRepository;
+ private final SubjectRepository subjectRepository;
+ private final ClassroomRepository classroomRepository;
private final SchoolValidationService schoolValidationService;
@Override
@@ -41,47 +41,47 @@ public class ExamServiceImpl implements ExamService {
public ExamResponseDto create(ExamCreateDto dto, String currentUserId) {
log.info("Creating exam '{}' by user '{}'", dto.getName(), currentUserId);
- // 1. Resolve schoolId from authenticated user
+ // Resolve schoolId from authenticated user
String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
- // 2. Validate subject exists and belongs to the same school
+ // Validate subject exists and belongs to the same school
Subject subject = findSubjectById(dto.getSubjectId());
if (!subject.getSchoolId().equals(schoolId)) {
throw new InvalidDataException("Subject does not belong to your school");
}
- // 3. Validate classroom exists and belongs to the same school
+ // Validate classroom exists and belongs to the same school
Classroom classroom = findClassroomById(dto.getClassroomId());
if (!classroom.getSchoolId().equals(schoolId)) {
throw new InvalidDataException("Classroom does not belong to your school");
}
- // 4. Validate exam date is in the future
+ // Validate exam date is in the future
if (!dto.getExamDate().isAfter(LocalDateTime.now())) {
throw new InvalidDataException("Exam date must be in the future");
}
- // 5. Check teacher conflict: same teacher, same room, same time
+ // Check teacher conflict: same teacher, same room, same time
if (dto.getRoomId() != null &&
- examRepository.existsByTeacherIdAndExamDateAndRoomId(
- dto.getTeacherId(), dto.getExamDate(), dto.getRoomId())) {
+ examRepository.existsByTeacherIdAndExamDateAndRoomId(
+ dto.getTeacherId(), dto.getExamDate(), dto.getRoomId())) {
throw new DuplicateResourceException(
- "Teacher already has an exam scheduled in this room at this time"
+ "Teacher already has an exam scheduled in this room at this time"
);
}
- // 6. Check classroom conflict: same classroom, same subject, same time
+ // Check classroom conflict: same classroom, same subject, same time
if (examRepository.existsByClassroomIdAndSubjectIdAndExamDate(
dto.getClassroomId(), dto.getSubjectId(), dto.getExamDate())) {
throw new DuplicateResourceException(
- "An exam for this subject is already scheduled for this classroom at this time"
+ "An exam for this subject is already scheduled for this classroom at this time"
);
}
- // 7. Validate subject grade level matches classroom grade level
+ // Validate subject grade level matches classroom grade level
if (!subject.getGradeLevel().getId().equals(classroom.getGradeLevel().getId())) {
throw new BusinessRuleException(
- "Subject grade level does not match classroom grade level"
+ "Subject grade level does not match classroom grade level"
);
}
@@ -99,28 +99,28 @@ public ExamResponseDto update(String id, ExamUpdateDto dto, String currentUserId
Exam exam = findEntityById(id);
String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
- // Verify exam belongs to the same school
+ //verify exam belongs to the same school
if (!exam.getSchoolId().equals(schoolId)) {
throw new BusinessRuleException("You don't have permission to update this exam");
}
- // Verify exam has not already taken place
+ //verify exam has not already taken place
if (exam.getExamDate().isBefore(LocalDateTime.now())) {
throw new BusinessRuleException(
- "Cannot update an exam that has already taken place"
+ "Cannot update an exam that has already taken place"
);
}
- // Check teacher conflict if date or room changed
+ //heck teacher conflict if date or room changed
boolean dateChanged = !exam.getExamDate().equals(dto.getExamDate());
boolean roomChanged = dto.getRoomId() != null &&
- !dto.getRoomId().equals(exam.getRoomId());
+ !dto.getRoomId().equals(exam.getRoomId());
if ((dateChanged || roomChanged) && dto.getRoomId() != null &&
- examRepository.existsByTeacherIdAndExamDateAndRoomId(
- exam.getTeacherId(), dto.getExamDate(), dto.getRoomId())) {
+ examRepository.existsByTeacherIdAndExamDateAndRoomId(
+ exam.getTeacherId(), dto.getExamDate(), dto.getRoomId())) {
throw new DuplicateResourceException(
- "Teacher already has an exam scheduled in this room at this time"
+ "Teacher already has an exam scheduled in this room at this time"
);
}
@@ -152,8 +152,8 @@ public ExamResponseDto findById(String id) {
public Page findBySchool(String schoolId, Pageable pageable) {
log.debug("Fetching exams for school '{}'", schoolId);
return examRepository
- .findBySchoolIdOrderByExamDateAsc(schoolId, pageable)
- .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
+ .findBySchoolIdOrderByExamDateAsc(schoolId, pageable)
+ .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
}
@Override
@@ -162,8 +162,8 @@ public Page findBySchoolAndType(
String schoolId, ExamType type, Pageable pageable) {
log.debug("Fetching exams for school '{}' type '{}'", schoolId, type);
return examRepository
- .findBySchoolIdAndExamType(schoolId, type, pageable)
- .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
+ .findBySchoolIdAndExamType(schoolId, type, pageable)
+ .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
}
@Override
@@ -172,8 +172,8 @@ public Page findByClassroom(String classroomId, Pageable pageab
log.debug("Fetching exams for classroom '{}'", classroomId);
Classroom classroom = findClassroomById(classroomId);
return examRepository
- .findByClassroomId(classroomId, pageable)
- .map(exam -> ExamMapper.toResponseDto(exam, classroom));
+ .findByClassroomId(classroomId, pageable)
+ .map(exam -> ExamMapper.toResponseDto(exam, classroom));
}
@Override
@@ -181,8 +181,8 @@ public Page findByClassroom(String classroomId, Pageable pageab
public Page findByTeacher(String teacherId, Pageable pageable) {
log.debug("Fetching exams for teacher '{}'", teacherId);
return examRepository
- .findByTeacherId(teacherId, pageable)
- .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
+ .findByTeacherId(teacherId, pageable)
+ .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
}
@Override
@@ -191,8 +191,8 @@ public Page findBySubject(String subjectId, Pageable pageable)
log.debug("Fetching exams for subject '{}'", subjectId);
findSubjectById(subjectId);
return examRepository
- .findBySubjectId(subjectId, pageable)
- .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
+ .findBySubjectId(subjectId, pageable)
+ .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
}
@Override
@@ -200,8 +200,8 @@ public Page findBySubject(String subjectId, Pageable pageable)
public Page findUpcoming(String schoolId, Pageable pageable) {
log.debug("Fetching upcoming exams for school '{}'", schoolId);
return examRepository
- .findUpcomingExams(schoolId, LocalDateTime.now(), pageable)
- .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
+ .findUpcomingExams(schoolId, LocalDateTime.now(), pageable)
+ .map(exam -> ExamMapper.toResponseDto(exam, findClassroomById(exam.getClassroomId())));
}
@Override
@@ -210,7 +210,7 @@ public Page findByClassroomAndDateRange(
String classroomId, LocalDateTime startDate,
LocalDateTime endDate, Pageable pageable) {
log.debug("Fetching exams for classroom '{}' between '{}' and '{}'",
- classroomId, startDate, endDate);
+ classroomId, startDate, endDate);
if (startDate.isAfter(endDate)) {
throw new InvalidDataException("Start date must be before end date");
@@ -218,8 +218,8 @@ public Page findByClassroomAndDateRange(
Classroom classroom = findClassroomById(classroomId);
return examRepository
- .findByClassroomIdAndDateRange(classroomId, startDate, endDate, pageable)
- .map(exam -> ExamMapper.toResponseDto(exam, classroom));
+ .findByClassroomIdAndDateRange(classroomId, startDate, endDate, pageable)
+ .map(exam -> ExamMapper.toResponseDto(exam, classroom));
}
@Override
@@ -236,13 +236,13 @@ public void delete(String id, String currentUserId) {
if (exam.getExamDate().isBefore(LocalDateTime.now())) {
throw new BusinessRuleException(
- "Cannot delete an exam that has already taken place"
+ "Cannot delete an exam that has already taken place"
);
}
if (!exam.getGrades().isEmpty()) {
throw new BusinessRuleException(
- "Cannot delete an exam that has grades recorded. Delete all grades first."
+ "Cannot delete an exam that has grades recorded. Delete all grades first."
);
}
@@ -253,26 +253,25 @@ public void delete(String id, String currentUserId) {
// ================================================================
// PRIVATE HELPERS
// ================================================================
-
private Exam findEntityById(String id) {
return examRepository.findById(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Exam not found with id: " + id
- ));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Exam not found with id: " + id
+ ));
}
private Subject findSubjectById(String id) {
return subjectRepository.findById(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Subject not found with id: " + id
- ));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Subject not found with id: " + id
+ ));
}
private Classroom findClassroomById(String id) {
return classroomRepository.findById(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Classroom not found with id: " + id
- ));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Classroom not found with id: " + id
+ ));
}
}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/GradeServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/GradeServiceImpl.java
index 6e42b8d..e06403c 100644
--- a/services/academic-service/src/main/java/com/academicsService/service/impl/GradeServiceImpl.java
+++ b/services/academic-service/src/main/java/com/academicsService/service/impl/GradeServiceImpl.java
@@ -31,75 +31,75 @@
@Slf4j
public class GradeServiceImpl implements GradeService {
- private final GradeRepository gradeRepository;
- private final ExamRepository examRepository;
+ private final GradeRepository gradeRepository;
+ private final ExamRepository examRepository;
private final StudentClassroomRepository studentClassroomRepository;
- private final SchoolValidationService schoolValidationService;
+ private final SchoolValidationService schoolValidationService;
@Override
@Transactional
public GradeResponseDto create(GradeCreateDto dto, String currentUserId) {
log.info("Creating grade for student '{}' exam '{}' by user '{}'",
- dto.getStudentId(), dto.getExamId(), currentUserId);
+ dto.getStudentId(), dto.getExamId(), currentUserId);
- // 1. Récupérer schoolId — valide que le user est bien dans une école
+ // Récupérer schoolId — valide que le user est bien dans une école
String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
- // 2. Valider que l'exam existe et appartient à la même école
+ // Valider que l'exam existe et appartient à la même école
Exam exam = findExamById(dto.getExamId());
if (!exam.getSchoolId().equals(schoolId)) {
throw new BusinessRuleException(
- "You don't have permission to grade this exam"
+ "You don't have permission to grade this exam"
);
}
- // 3. Valider que l'exam a bien eu lieu avant de noter
+ // Valider que l'exam a bien eu lieu avant de noter
if (exam.getExamDate().isAfter(LocalDateTime.now())) {
throw new BusinessRuleException(
- "Cannot grade an exam that has not taken place yet. " +
- "Exam date: " + exam.getExamDate()
+ "Cannot grade an exam that has not taken place yet. " +
+ "Exam date: " + exam.getExamDate()
);
}
- // 4. Vérifier que l'étudiant n'a pas déjà une note pour cet exam
+ // Vérifier que l'étudiant n'a pas déjà une note pour cet exam
if (gradeRepository.existsByStudentIdAndExamId(
dto.getStudentId(), dto.getExamId())) {
throw new DuplicateResourceException(
- "Student already has a grade for this exam. Use update instead."
+ "Student already has a grade for this exam. Use update instead."
);
}
- // 5. Valider que le score ne dépasse pas le max score de l'exam
+ // Valider que le score ne dépasse pas le max score de l'exam
if (dto.getScore() > exam.getMaxScore()) {
throw new InvalidDataException(
- "Score " + dto.getScore() +
- " exceeds maximum score of " + exam.getMaxScore()
+ "Score " + dto.getScore() +
+ " exceeds maximum score of " + exam.getMaxScore()
);
}
- // 6. Valider que l'étudiant est bien inscrit dans la classroom de l'exam
+ // Valider que l'étudiant est bien inscrit dans la classroom de l'exam
boolean isEnrolled = studentClassroomRepository
- .existsByStudentIdAndClassroomIdAndIsActiveTrue(
- dto.getStudentId(), exam.getClassroomId()
- );
+ .existsByStudentIdAndClassroomIdAndIsActiveTrue(
+ dto.getStudentId(), exam.getClassroomId()
+ );
if (!isEnrolled) {
throw new BusinessRuleException(
- "Student is not enrolled in the classroom assigned to this exam"
+ "Student is not enrolled in the classroom assigned to this exam"
);
}
Grade saved = gradeRepository.save(
- GradeMapper.toEntity(
- dto.getStudentId(),
- dto.getScore(),
- dto.getComment(),
- currentUserId,
- exam
- )
+ GradeMapper.toEntity(
+ dto.getStudentId(),
+ dto.getScore(),
+ dto.getComment(),
+ currentUserId,
+ exam
+ )
);
log.info("Grade created with id '{}' for student '{}'",
- saved.getId(), dto.getStudentId());
+ saved.getId(), dto.getStudentId());
return GradeMapper.toResponseDto(saved);
}
@@ -112,18 +112,18 @@ public GradeResponseDto update(String id, GradeUpdateDto dto, String currentUser
Grade grade = findEntityById(id);
String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
- // 1. Vérifier que la note appartient à la même école
+ // Vérifier que la note appartient à la même école
if (!grade.getExam().getSchoolId().equals(schoolId)) {
throw new BusinessRuleException(
- "You don't have permission to update this grade"
+ "You don't have permission to update this grade"
);
}
- // 2. Valider que le nouveau score ne dépasse pas le max score
+ // Valider que le nouveau score ne dépasse pas le max score
if (dto.getScore() > grade.getExam().getMaxScore()) {
throw new InvalidDataException(
- "Score " + dto.getScore() +
- " exceeds maximum score of " + grade.getExam().getMaxScore()
+ "Score " + dto.getScore() +
+ " exceeds maximum score of " + grade.getExam().getMaxScore()
);
}
@@ -188,31 +188,31 @@ public ExamStatsResponseDto getExamStats(String examId) {
Exam exam = findExamById(examId);
List grades = gradeRepository.findByExamId(
- examId, Pageable.unpaged()
+ examId, Pageable.unpaged()
).getContent();
if (grades.isEmpty()) {
return new ExamStatsResponseDto(
- examId, exam.getName(), 0, 0.0,
- exam.getMaxScore(), 0, 0
+ examId, exam.getName(), 0, 0.0,
+ exam.getMaxScore(), 0, 0
);
}
double average = grades.stream()
- .mapToDouble(Grade::getScore).average().orElse(0.0);
- float highest = (float) grades.stream()
- .mapToDouble(Grade::getScore).max().orElse(0.0);
- float lowest = (float) grades.stream()
- .mapToDouble(Grade::getScore).min().orElse(0.0);
+ .mapToDouble(Grade::getScore).average().orElse(0.0);
+ float highest = (float) grades.stream()
+ .mapToDouble(Grade::getScore).max().orElse(0.0);
+ float lowest = (float) grades.stream()
+ .mapToDouble(Grade::getScore).min().orElse(0.0);
return new ExamStatsResponseDto(
- examId,
- exam.getName(),
- grades.size(),
- Math.round(average * 100.0) / 100.0,
- exam.getMaxScore(),
- highest,
- lowest
+ examId,
+ exam.getName(),
+ grades.size(),
+ Math.round(average * 100.0) / 100.0,
+ exam.getMaxScore(),
+ highest,
+ lowest
);
}
@@ -227,7 +227,7 @@ public void delete(String id, String currentUserId) {
// Vérifier que la note appartient à la même école
if (!grade.getExam().getSchoolId().equals(schoolId)) {
throw new BusinessRuleException(
- "You don't have permission to delete this grade"
+ "You don't have permission to delete this grade"
);
}
@@ -238,19 +238,18 @@ public void delete(String id, String currentUserId) {
// ================================================================
// PRIVATE HELPERS
// ================================================================
-
private Grade findEntityById(String id) {
return gradeRepository.findById(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Grade not found with id: " + id
- ));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Grade not found with id: " + id
+ ));
}
private Exam findExamById(String id) {
return examRepository.findById(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Exam not found with id: " + id
- ));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Exam not found with id: " + id
+ ));
}
}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/ReclamationServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/ReclamationServiceImpl.java
new file mode 100644
index 0000000..1dffd13
--- /dev/null
+++ b/services/academic-service/src/main/java/com/academicsService/service/impl/ReclamationServiceImpl.java
@@ -0,0 +1,133 @@
+package com.academicsService.service.impl;
+
+import com.academicsService.dto.create.ReclamationCreateDto;
+import com.academicsService.dto.response.ReclamationResponseDto;
+import com.academicsService.dto.update.ReclamationUpdateDto;
+import com.academicsService.exception.BusinessRuleException;
+import com.academicsService.exception.ResourceNotFoundException;
+import com.academicsService.mapper.ReclamationMapper;
+import com.academicsService.model.Exam;
+import com.academicsService.model.Reclamation;
+import com.academicsService.model.enums.ReclamationStatus;
+import com.academicsService.repository.ExamRepository;
+import com.academicsService.repository.ReclamationRepository;
+import com.academicsService.service.ReclamationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ReclamationServiceImpl implements ReclamationService {
+
+ private final ReclamationRepository reclamationRepository;
+ private final ExamRepository examRepository;
+
+ @Override
+ @Transactional
+ public ReclamationResponseDto create(ReclamationCreateDto dto) {
+ log.info("Creating reclamation for grade '{}' by student '{}'", dto.getGradeId(), dto.getStudentId());
+
+ // Prevent duplicate pending reclamation for the same grade
+ if (reclamationRepository.existsByGradeIdAndStatus(dto.getGradeId(), ReclamationStatus.PENDING)) {
+ throw new BusinessRuleException("A pending reclamation already exists for this grade.");
+ }
+
+ // Fetch exam to get teacherId and schoolId
+ Exam exam = examRepository.findById(dto.getExamId())
+ .orElseThrow(() -> new ResourceNotFoundException("Exam not found with id: " + dto.getExamId()));
+
+ Reclamation saved = reclamationRepository.save(
+ ReclamationMapper.toEntity(dto, exam.getTeacherId(), exam.getSchoolId())
+ );
+
+ log.info("Reclamation '{}' created", saved.getId());
+ return ReclamationMapper.toResponseDto(saved);
+ }
+
+ @Override
+ @Transactional
+ public ReclamationResponseDto respond(String id, ReclamationUpdateDto dto, String currentUserId) {
+ log.info("Teacher '{}' responding to reclamation '{}'", currentUserId, id);
+
+ Reclamation reclamation = findEntityById(id);
+
+ if (reclamation.getStatus() == ReclamationStatus.RESOLVED
+ || reclamation.getStatus() == ReclamationStatus.REJECTED) {
+ throw new BusinessRuleException("Cannot respond to an already closed reclamation.");
+ }
+
+ reclamation.setStatus(dto.getStatus());
+ if (dto.getResponse() != null && !dto.getResponse().isBlank()) {
+ reclamation.setResponse(dto.getResponse());
+ }
+
+ Reclamation updated = reclamationRepository.save(reclamation);
+ log.info("Reclamation '{}' updated to status '{}'", id, dto.getStatus());
+ return ReclamationMapper.toResponseDto(updated);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Page getByStudent(String studentId, Pageable pageable) {
+ log.debug("Fetching reclamations for student '{}'", studentId);
+ return reclamationRepository.findByStudentId(studentId, pageable)
+ .map(ReclamationMapper::toResponseDto);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Page getByTeacher(String teacherId, Pageable pageable) {
+ log.debug("Fetching reclamations for teacher '{}'", teacherId);
+ return reclamationRepository.findByTeacherId(teacherId, pageable)
+ .map(ReclamationMapper::toResponseDto);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Page getByExam(String examId, Pageable pageable) {
+ log.debug("Fetching reclamations for exam '{}'", examId);
+ return reclamationRepository.findByExamId(examId, pageable)
+ .map(ReclamationMapper::toResponseDto);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Page getBySchool(String schoolId, Pageable pageable) {
+ log.debug("Fetching reclamations for school '{}'", schoolId);
+ return reclamationRepository.findBySchoolId(schoolId, pageable)
+ .map(ReclamationMapper::toResponseDto);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public boolean hasPendingForGrade(String gradeId) {
+ return reclamationRepository.existsByGradeIdAndStatus(gradeId, ReclamationStatus.PENDING);
+ }
+
+ @Override
+ @Transactional
+ public void delete(String id, String currentUserId) {
+ log.info("Deleting reclamation '{}' by user '{}'", id, currentUserId);
+
+ Reclamation reclamation = findEntityById(id);
+
+ // Only PENDING reclamations can be cancelled by the student
+ if (reclamation.getStatus() != ReclamationStatus.PENDING) {
+ throw new BusinessRuleException("Only PENDING reclamations can be cancelled.");
+ }
+
+ reclamationRepository.delete(reclamation);
+ log.info("Reclamation '{}' deleted", id);
+ }
+
+ // PRIVATE HELPERS
+ private Reclamation findEntityById(String id) {
+ return reclamationRepository.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("Reclamation not found with id: " + id));
+ }
+}
diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java
index 6cbffa4..dbc10af 100644
--- a/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java
+++ b/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java
@@ -29,43 +29,43 @@
public class TeacherSubjectServiceImpl implements TeacherSubjectService {
private final TeacherSubjectRepository teacherSubjectRepository;
- private final SubjectRepository subjectRepository;
- private final SchoolValidationService schoolValidationService;
+ private final SubjectRepository subjectRepository;
+ private final SchoolValidationService schoolValidationService;
@Override
@Transactional
public TeacherSubjectResponseDto assign(TeacherSubjectCreateDto dto, String currentUserId) {
log.info("Assigning teacher '{}' to subject '{}'", dto.getTeacherId(), dto.getSubjectId());
- // 1. Resolve schoolId from authenticated user
+ // Resolve schoolId from authenticated user
String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId);
- // 2. Validate subject exists and belongs to same school
+ // Validate subject exists and belongs to same school
Subject subject = findSubjectById(dto.getSubjectId());
if (!subject.getSchoolId().equals(schoolId)) {
throw new InvalidDataException("Subject does not belong to your school");
}
- // 3. Check assignment does not already exist
+ // Check assignment does not already exist
if (teacherSubjectRepository.existsByTeacherIdAndSubjectIdAndAcademicYear(
dto.getTeacherId(), dto.getSubjectId(), dto.getAcademicYear())) {
throw new DuplicateResourceException(
- "Teacher is already assigned to this subject for academic year '"
- + dto.getAcademicYear() + "'"
+ "Teacher is already assigned to this subject for academic year '"
+ + dto.getAcademicYear() + "'"
);
}
TeacherSubject saved = teacherSubjectRepository.save(
- TeacherSubjectMapper.toEntity(
- dto.getTeacherId(),
- dto.getAcademicYear(),
- schoolId,
- subject
- )
+ TeacherSubjectMapper.toEntity(
+ dto.getTeacherId(),
+ dto.getAcademicYear(),
+ schoolId,
+ subject
+ )
);
log.info("Teacher '{}' assigned to subject '{}' with id '{}'",
- dto.getTeacherId(), subject.getName(), saved.getId());
+ dto.getTeacherId(), subject.getName(), saved.getId());
return TeacherSubjectMapper.toResponseDto(saved);
}
@@ -85,14 +85,14 @@ public TeacherSubjectResponseDto update(String id, TeacherSubjectUpdateDto dto)
// Check duplicate only if subject or year changed
boolean subjectChanged = !teacherSubject.getSubject().getId().equals(dto.getSubjectId());
- boolean yearChanged = !teacherSubject.getAcademicYear().equals(dto.getAcademicYear());
+ boolean yearChanged = !teacherSubject.getAcademicYear().equals(dto.getAcademicYear());
if ((subjectChanged || yearChanged) &&
- teacherSubjectRepository.existsByTeacherIdAndSubjectIdAndAcademicYear(
- teacherSubject.getTeacherId(), dto.getSubjectId(), dto.getAcademicYear())) {
+ teacherSubjectRepository.existsByTeacherIdAndSubjectIdAndAcademicYear(
+ teacherSubject.getTeacherId(), dto.getSubjectId(), dto.getAcademicYear())) {
throw new DuplicateResourceException(
- "Teacher is already assigned to this subject for academic year '"
- + dto.getAcademicYear() + "'"
+ "Teacher is already assigned to this subject for academic year '"
+ + dto.getAcademicYear() + "'"
);
}
@@ -116,10 +116,10 @@ public TeacherSubjectResponseDto findById(String id) {
public Page findByTeacher(String teacherId, Pageable pageable) {
log.debug("Fetching all subjects for teacher '{}'", teacherId);
List content = teacherSubjectRepository
- .findByTeacherId(teacherId)
- .stream()
- .map(TeacherSubjectMapper::toResponseDto)
- .toList();
+ .findByTeacherId(teacherId)
+ .stream()
+ .map(TeacherSubjectMapper::toResponseDto)
+ .toList();
return new PageImpl<>(content, pageable, content.size());
}
@@ -129,10 +129,10 @@ public Page findByTeacherAndYear(
String teacherId, String academicYear, Pageable pageable) {
log.debug("Fetching subjects for teacher '{}' year '{}'", teacherId, academicYear);
List content = teacherSubjectRepository
- .findByTeacherIdAndAcademicYear(teacherId, academicYear)
- .stream()
- .map(TeacherSubjectMapper::toResponseDto)
- .toList();
+ .findByTeacherIdAndAcademicYear(teacherId, academicYear)
+ .stream()
+ .map(TeacherSubjectMapper::toResponseDto)
+ .toList();
return new PageImpl<>(content, pageable, content.size());
}
@@ -143,11 +143,11 @@ public Page findBySubjectAndYear(
log.debug("Fetching teachers for subject '{}' year '{}'", subjectId, academicYear);
findSubjectById(subjectId);
List raw = (academicYear != null && !academicYear.isBlank())
- ? teacherSubjectRepository.findBySubjectIdAndAcademicYear(subjectId, academicYear)
- : teacherSubjectRepository.findBySubjectId(subjectId);
+ ? teacherSubjectRepository.findBySubjectIdAndAcademicYear(subjectId, academicYear)
+ : teacherSubjectRepository.findBySubjectId(subjectId);
List content = raw.stream()
- .map(TeacherSubjectMapper::toResponseDto)
- .toList();
+ .map(TeacherSubjectMapper::toResponseDto)
+ .toList();
return new PageImpl<>(content, pageable, content.size());
}
@@ -157,10 +157,10 @@ public Page findBySchoolAndYear(
String schoolId, String academicYear, Pageable pageable) {
log.debug("Fetching teacher subjects for school '{}' year '{}'", schoolId, academicYear);
List content = teacherSubjectRepository
- .findBySchoolIdAndAcademicYear(schoolId, academicYear)
- .stream()
- .map(TeacherSubjectMapper::toResponseDto)
- .toList();
+ .findBySchoolIdAndAcademicYear(schoolId, academicYear)
+ .stream()
+ .map(TeacherSubjectMapper::toResponseDto)
+ .toList();
return new PageImpl<>(content, pageable, content.size());
}
@@ -169,10 +169,10 @@ public Page findBySchoolAndYear(
public Page findBySchool(String schoolId, Pageable pageable) {
log.debug("Fetching all teacher subjects for school '{}'", schoolId);
List content = teacherSubjectRepository
- .findBySchoolId(schoolId)
- .stream()
- .map(TeacherSubjectMapper::toResponseDto)
- .toList();
+ .findBySchoolId(schoolId)
+ .stream()
+ .map(TeacherSubjectMapper::toResponseDto)
+ .toList();
return new PageImpl<>(content, pageable, content.size());
}
@@ -190,16 +190,16 @@ public void delete(String id) {
private TeacherSubject findEntityById(String id) {
return teacherSubjectRepository.findByIdWithGraph(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Teacher subject assignment not found with id: " + id
- ));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Teacher subject assignment not found with id: " + id
+ ));
}
private Subject findSubjectById(String id) {
return subjectRepository.findById(id)
- .orElseThrow(() -> new ResourceNotFoundException(
- "Subject not found with id: " + id
- ));
+ .orElseThrow(() -> new ResourceNotFoundException(
+ "Subject not found with id: " + id
+ ));
}
}
diff --git a/services/academic-service/src/main/java/com/academicsService/util/CertificateNumberGenerator.java b/services/academic-service/src/main/java/com/academicsService/util/CertificateNumberGenerator.java
deleted file mode 100644
index 55e9556..0000000
--- a/services/academic-service/src/main/java/com/academicsService/util/CertificateNumberGenerator.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.academicsService.util;
-
-import com.academicsService.model.enums.CertificateType;
-
-import java.time.LocalDate;
-import java.util.UUID;
-
-/**
- * Generates unique certificate numbers.
- * Format : CERT-{TYPE}-{YEAR}-{RANDOM8}
- * Examples:
- * CERT-DIPL-2026-A3F9B2C1
- * CERT-HONO-2026-X7K2M9P4
- * CERT-OTHE-2026-Q1W2E3R4
- */
-public class CertificateNumberGenerator {
-
- public static String generate(CertificateType type) {
- String year = String.valueOf(LocalDate.now().getYear());
- String typeCode = type.name().substring(0, Math.min(4, type.name().length()));
- String random = UUID.randomUUID()
- .toString()
- .replace("-", "")
- .substring(0, 8)
- .toUpperCase();
- return String.format("CERT-%s-%s-%s", typeCode, year, random);
- }
-
- private CertificateNumberGenerator() {}
-}
-
diff --git a/services/academic-service/src/main/resources/db/migration/V10__add_activity_link.sql b/services/academic-service/src/main/resources/db/migration/V10__add_activity_link.sql
new file mode 100644
index 0000000..1f47acd
--- /dev/null
+++ b/services/academic-service/src/main/resources/db/migration/V10__add_activity_link.sql
@@ -0,0 +1,11 @@
+CREATE TABLE activity_resource_links
+(
+ activity_id VARCHAR(255) NOT NULL,
+ link_label VARCHAR(255) NOT NULL,
+ link_url VARCHAR(255) NOT NULL,
+ link_type VARCHAR(255)
+);
+
+ALTER TABLE activity_resource_links
+ ADD CONSTRAINT fk_activity_resource_links_on_activity FOREIGN KEY (activity_id) REFERENCES activities (id);
+
diff --git a/services/academic-service/src/main/resources/db/migration/V11__change_certifications_par_documents.sql b/services/academic-service/src/main/resources/db/migration/V11__change_certifications_par_documents.sql
new file mode 100644
index 0000000..defe283
--- /dev/null
+++ b/services/academic-service/src/main/resources/db/migration/V11__change_certifications_par_documents.sql
@@ -0,0 +1,18 @@
+CREATE TABLE document_requests
+(
+ id VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP WITHOUT TIME ZONE,
+ school_id VARCHAR(255) NOT NULL,
+ student_id VARCHAR(255) NOT NULL,
+ requested_by_id VARCHAR(255) NOT NULL,
+ requested_by_role VARCHAR(255) NOT NULL,
+ document_type VARCHAR(255) NOT NULL,
+ status VARCHAR(255) NOT NULL,
+ reason VARCHAR(500),
+ academic_year VARCHAR(255) NOT NULL,
+ processed_by_id VARCHAR(255),
+ processor_note VARCHAR(1000),
+ response_file_url VARCHAR(255),
+ CONSTRAINT pk_document_requests PRIMARY KEY (id)
+);
diff --git a/services/academic-service/src/main/resources/db/migration/V12__remove_certification_entity.sql b/services/academic-service/src/main/resources/db/migration/V12__remove_certification_entity.sql
new file mode 100644
index 0000000..1d13283
--- /dev/null
+++ b/services/academic-service/src/main/resources/db/migration/V12__remove_certification_entity.sql
@@ -0,0 +1 @@
+DROP TABLE certificates CASCADE;
diff --git a/services/academic-service/src/main/resources/db/migration/V13__reneme_documents_scolaire.sql b/services/academic-service/src/main/resources/db/migration/V13__reneme_documents_scolaire.sql
new file mode 100644
index 0000000..8e95cc1
--- /dev/null
+++ b/services/academic-service/src/main/resources/db/migration/V13__reneme_documents_scolaire.sql
@@ -0,0 +1,20 @@
+CREATE TABLE document_scolaires
+(
+ id VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP WITHOUT TIME ZONE,
+ school_id VARCHAR(255) NOT NULL,
+ student_id VARCHAR(255) NOT NULL,
+ requested_by_id VARCHAR(255) NOT NULL,
+ requested_by_role VARCHAR(255) NOT NULL,
+ document_type VARCHAR(255) NOT NULL,
+ status VARCHAR(255) NOT NULL,
+ reason VARCHAR(500),
+ academic_year VARCHAR(255) NOT NULL,
+ processed_by_id VARCHAR(255),
+ processor_note VARCHAR(1000),
+ response_file_url VARCHAR(255),
+ CONSTRAINT pk_document_scolaires PRIMARY KEY (id)
+);
+
+DROP TABLE document_requests CASCADE;
diff --git a/services/academic-service/src/main/resources/db/migration/V9__reclation_entity.sql b/services/academic-service/src/main/resources/db/migration/V9__reclation_entity.sql
new file mode 100644
index 0000000..fa796d9
--- /dev/null
+++ b/services/academic-service/src/main/resources/db/migration/V9__reclation_entity.sql
@@ -0,0 +1,21 @@
+CREATE TABLE reclamations
+(
+ id VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ updated_at TIMESTAMP WITHOUT TIME ZONE,
+ exam_id VARCHAR(255) NOT NULL,
+ grade_id VARCHAR(255) NOT NULL,
+ student_id VARCHAR(255) NOT NULL,
+ student_name VARCHAR(255) NOT NULL,
+ exam_name VARCHAR(255) NOT NULL,
+ subject_name VARCHAR(255) NOT NULL,
+ score FLOAT NOT NULL,
+ max_score FLOAT NOT NULL,
+ reason TEXT NOT NULL,
+ status VARCHAR(255) NOT NULL,
+ response TEXT,
+ teacher_id VARCHAR(255) NOT NULL,
+ school_id VARCHAR(255) NOT NULL,
+ CONSTRAINT pk_reclamations PRIMARY KEY (id)
+);
+
diff --git a/services/config-service/src/main/resources/config-repo/api-gateway.yml b/services/config-service/src/main/resources/config-repo/api-gateway.yml
index cc34221..6dacf45 100644
--- a/services/config-service/src/main/resources/config-repo/api-gateway.yml
+++ b/services/config-service/src/main/resources/config-repo/api-gateway.yml
@@ -90,27 +90,6 @@ spring:
filters:
- RewritePath=/api/academic/(?.*), /api/${segment}
- - id: resource-service
- uri: lb://resource-service
- predicates:
- - Path=/api/resources/**
- filters:
- - RewritePath=/api/resources/(?.*), /api/${segment}
-
- - id: financial-service
- uri: lb://financial-service
- predicates:
- - Path=/api/financial/**
- filters:
- - RewritePath=/api/financial/(?.*), /api/${segment}
-
- - id: notification-service
- uri: lb://notification-service
- predicates:
- - Path=/api/notifications/**
- filters:
- - RewritePath=/api/notifications/(?.*), /api/${segment}
-
logging:
level:
org.springframework.cloud.gateway: DEBUG
diff --git a/services/config-service/src/main/resources/config-repo/application.yml b/services/config-service/src/main/resources/config-repo/application.yml
index b86908b..54be93f 100644
--- a/services/config-service/src/main/resources/config-repo/application.yml
+++ b/services/config-service/src/main/resources/config-repo/application.yml
@@ -9,7 +9,7 @@ eureka:
prefer-ip-address: true
lease-renewal-interval-in-seconds: 30
-# Keycloak configuration commune
+# Keycloak configuration for authentication and authorization
keycloak:
auth-server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://keycloak:8080}
realm: schoolsphere
diff --git a/services/user-service/pom.xml b/services/user-service/pom.xml
index 7423554..71e0a71 100644
--- a/services/user-service/pom.xml
+++ b/services/user-service/pom.xml
@@ -107,6 +107,12 @@
1.36.0
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
me.paulschwarz
@@ -150,7 +156,7 @@
-
+
org.apache.maven.plugins
maven-compiler-plugin
@@ -171,7 +177,7 @@
mapstruct-processor
${mapstruct.version}
-
+
org.projectlombok
lombok-mapstruct-binding
diff --git a/services/user-service/src/main/java/com/userservice/config/RolePermissionsConfig.java b/services/user-service/src/main/java/com/userservice/config/RolePermissionsConfig.java
index 8b04958..4f0bfea 100644
--- a/services/user-service/src/main/java/com/userservice/config/RolePermissionsConfig.java
+++ b/services/user-service/src/main/java/com/userservice/config/RolePermissionsConfig.java
@@ -31,11 +31,9 @@ public Set getDefaultPermissions(RoleType roleType) {
*/
private Set getSuperAdminPermissions() {
return EnumSet.of(
- // Accès super admin
Permission.SUPER_ADMIN_ACCESS,
Permission.APPROVE_SCHOOLS,
- // Toutes les permissions
Permission.USER_CREATE,
Permission.USER_READ,
Permission.USER_UPDATE,
@@ -84,53 +82,44 @@ private Set getSuperAdminPermissions() {
*/
private Set getSchoolAdminPermissions() {
return EnumSet.of(
- // Gestion de son école
Permission.SCHOOL_MANAGE,
Permission.SCHOOL_SETTINGS,
- // Gestion complète des utilisateurs de son école
Permission.USER_CREATE,
Permission.USER_READ,
Permission.USER_UPDATE,
Permission.USER_DELETE,
Permission.USER_MANAGE_ROLES,
- // Gestion des élèves
Permission.STUDENT_CREATE,
Permission.STUDENT_READ,
Permission.STUDENT_UPDATE,
Permission.STUDENT_DELETE,
Permission.STUDENT_ENROLL,
- // Gestion des enseignants
Permission.TEACHER_CREATE,
Permission.TEACHER_READ,
Permission.TEACHER_UPDATE,
Permission.TEACHER_DELETE,
Permission.TEACHER_ASSIGN,
- // Gestion du personnel
Permission.STAFF_CREATE,
Permission.STAFF_READ,
Permission.STAFF_UPDATE,
Permission.STAFF_DELETE,
- // Gestion des parents
Permission.PARENT_CREATE,
Permission.PARENT_READ,
Permission.PARENT_UPDATE,
Permission.PARENT_DELETE,
- // Gestion académique
Permission.ACADEMIC_MANAGE,
Permission.GRADE_MANAGE,
Permission.SCHEDULE_MANAGE,
- // Gestion financière
Permission.PAYMENT_MANAGE,
Permission.PAYMENT_VIEW,
- // Rapports
Permission.REPORT_VIEW,
Permission.REPORT_GENERATE
).stream().map(Permission::getPermissionString).collect(Collectors.toSet());
@@ -189,7 +178,7 @@ private Set getStaffPermissions() {
}
/**
- * GUEST - Invité (accès minimal)
+ * GUEST
*/
private Set getGuestPermissions() {
return EnumSet.of(
diff --git a/services/user-service/src/main/java/com/userservice/controller/AuthController.java b/services/user-service/src/main/java/com/userservice/controller/AuthController.java
index 20ce636..22bb8e5 100644
--- a/services/user-service/src/main/java/com/userservice/controller/AuthController.java
+++ b/services/user-service/src/main/java/com/userservice/controller/AuthController.java
@@ -48,6 +48,14 @@ public ResponseEntity forgotPassword(
return ResponseEntity.ok().build();
}
+ @PostMapping("/reset-password")
+ public ResponseEntity resetPassword(
+ @Valid @RequestBody ResetPasswordDto dto) {
+ log.info("Reset password request with token");
+ authService.resetPassword(dto);
+ return ResponseEntity.noContent().build();
+ }
+
@PostMapping("/change-password")
public ResponseEntity changePassword(
@RequestHeader("X-User-Id") String currentUserId,
diff --git a/services/user-service/src/main/java/com/userservice/controller/ParentController.java b/services/user-service/src/main/java/com/userservice/controller/ParentController.java
index 97d63be..ea82cb9 100644
--- a/services/user-service/src/main/java/com/userservice/controller/ParentController.java
+++ b/services/user-service/src/main/java/com/userservice/controller/ParentController.java
@@ -36,7 +36,7 @@ public ResponseEntity> getParentsBySchool(
}
@GetMapping("/parents/{parentId}")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'STUDENT')")
public ResponseEntity getParentById(@PathVariable("parentId") String parentId) {
log.debug("Fetching parent by ID: {}", parentId);
ParentResponseDto parent = parentService.getParentById(parentId);
diff --git a/services/user-service/src/main/java/com/userservice/controller/StudentController.java b/services/user-service/src/main/java/com/userservice/controller/StudentController.java
index e6962c9..9c4c348 100644
--- a/services/user-service/src/main/java/com/userservice/controller/StudentController.java
+++ b/services/user-service/src/main/java/com/userservice/controller/StudentController.java
@@ -87,7 +87,7 @@ public ResponseEntity enrollStudent(
}
@GetMapping("/students/{studentId}/parents")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER')")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER', 'STUDENT')")
public ResponseEntity]> getStudentParents(@PathVariable("studentId") String studentId) {
log.debug("Fetching parents for student: {}", studentId);
List parents = studentService.getStudentParents(studentId);
diff --git a/services/user-service/src/main/java/com/userservice/controller/TeacherController.java b/services/user-service/src/main/java/com/userservice/controller/TeacherController.java
index 97e5c31..5ccdc47 100644
--- a/services/user-service/src/main/java/com/userservice/controller/TeacherController.java
+++ b/services/user-service/src/main/java/com/userservice/controller/TeacherController.java
@@ -22,7 +22,7 @@ public class TeacherController {
private final TeacherService teacherService;
@GetMapping("/schools/{schoolId}/teachers")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'STUDENT', 'PARENT')")
public ResponseEntity> getTeachersBySchool(
@PathVariable("schoolId") String schoolId,
@RequestParam(defaultValue = "0") int page,
@@ -33,7 +33,7 @@ public ResponseEntity> getTeachersBySchool(
}
@GetMapping("/teachers/{teacherId}")
- @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER')")
+ @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER', 'STUDENT', 'PARENT')")
public ResponseEntity getTeacherById(@PathVariable("teacherId") String teacherId) {
log.debug("Fetching teacher by ID: {}", teacherId);
TeacherResponseDto teacher = teacherService.getTeacherById(teacherId);
diff --git a/services/user-service/src/main/java/com/userservice/controller/UserController.java b/services/user-service/src/main/java/com/userservice/controller/UserController.java
index 66af34d..5a02c10 100644
--- a/services/user-service/src/main/java/com/userservice/controller/UserController.java
+++ b/services/user-service/src/main/java/com/userservice/controller/UserController.java
@@ -106,7 +106,7 @@ public ResponseEntity activateUser(@PathVariable("userId") String userId)
@PreAuthorize("hasAnyRole('ADMIN', 'SCHOOL_ADMIN', 'STAFF')")
public ResponseEntity deactivateUser(@PathVariable("userId") String userId) {
log.info("Deactivating user: {}", userId);
- userService.deleteUser(userId, "deactivated");
+ userService.deactivateUser(userId);
return ResponseEntity.noContent().build();
}
@@ -139,4 +139,4 @@ public ResponseEntity> getUsersBySchool(
Page users = userService.getUsersBySchool(schoolId, page, size);
return ResponseEntity.ok(users);
}
-}
\ No newline at end of file
+}
diff --git a/services/user-service/src/main/java/com/userservice/dto/auth/ResetPasswordDto.java b/services/user-service/src/main/java/com/userservice/dto/auth/ResetPasswordDto.java
new file mode 100644
index 0000000..63792f5
--- /dev/null
+++ b/services/user-service/src/main/java/com/userservice/dto/auth/ResetPasswordDto.java
@@ -0,0 +1,14 @@
+package com.userservice.dto.auth;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record ResetPasswordDto(
+
+ @NotBlank(message = "Token is required")
+ String token,
+
+ @NotBlank(message = "New password is required")
+ @Size(min = 8, message = "Password must be at least 8 characters")
+ String newPassword
+) {}
diff --git a/services/user-service/src/main/java/com/userservice/model/PasswordResetToken.java b/services/user-service/src/main/java/com/userservice/model/PasswordResetToken.java
new file mode 100644
index 0000000..6ee70f5
--- /dev/null
+++ b/services/user-service/src/main/java/com/userservice/model/PasswordResetToken.java
@@ -0,0 +1,36 @@
+package com.userservice.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.data.mongodb.core.mapping.Field;
+
+import java.time.Instant;
+
+@Document(collection = "password_reset_tokens")
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+public class PasswordResetToken {
+
+ @Id
+ private String id;
+
+ @Indexed(unique = true)
+ private String token;
+
+ @Field("keycloak_id")
+ private String keycloakId;
+
+ private String email;
+
+ @Field("expires_at")
+ private Instant expiresAt;
+
+ private boolean used = false;
+}
diff --git a/services/user-service/src/main/java/com/userservice/repository/PasswordResetTokenRepository.java b/services/user-service/src/main/java/com/userservice/repository/PasswordResetTokenRepository.java
new file mode 100644
index 0000000..9d2eae8
--- /dev/null
+++ b/services/user-service/src/main/java/com/userservice/repository/PasswordResetTokenRepository.java
@@ -0,0 +1,13 @@
+package com.userservice.repository;
+
+import com.userservice.model.PasswordResetToken;
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+import java.util.Optional;
+
+public interface PasswordResetTokenRepository extends MongoRepository {
+
+ Optional findByToken(String token);
+
+ void deleteByKeycloakId(String keycloakId);
+}
diff --git a/services/user-service/src/main/java/com/userservice/service/AuthService.java b/services/user-service/src/main/java/com/userservice/service/AuthService.java
index da9a48c..95c77ba 100644
--- a/services/user-service/src/main/java/com/userservice/service/AuthService.java
+++ b/services/user-service/src/main/java/com/userservice/service/AuthService.java
@@ -8,6 +8,8 @@ public interface AuthService {
void forgotPassword(ForgotPasswordDto dto);
+ void resetPassword(ResetPasswordDto dto);
+
void changePassword(String keycloakId, ChangePasswordDto dto);
TokenResponseDto refreshToken(RefreshTokenDto dto);
diff --git a/services/user-service/src/main/java/com/userservice/service/UserService.java b/services/user-service/src/main/java/com/userservice/service/UserService.java
index 22f9a0f..ef04246 100644
--- a/services/user-service/src/main/java/com/userservice/service/UserService.java
+++ b/services/user-service/src/main/java/com/userservice/service/UserService.java
@@ -23,6 +23,8 @@ public interface UserService {
void deleteUser(String userId, String deletedBy);
+ void deactivateUser(String userId);
+
void activateUser(String userId);
void suspendUser(String userId, String reason, Instant suspendUntil);
diff --git a/services/user-service/src/main/java/com/userservice/service/impl/AuthServiceImpl.java b/services/user-service/src/main/java/com/userservice/service/impl/AuthServiceImpl.java
index 41f9bd0..a63ef93 100644
--- a/services/user-service/src/main/java/com/userservice/service/impl/AuthServiceImpl.java
+++ b/services/user-service/src/main/java/com/userservice/service/impl/AuthServiceImpl.java
@@ -5,7 +5,9 @@
import com.userservice.dto.response.UserProfileResponseDto;
import com.userservice.exception.UnauthorizedException;
import com.userservice.exception.UserNotFoundException;
+import com.userservice.model.PasswordResetToken;
import com.userservice.model.User;
+import com.userservice.repository.PasswordResetTokenRepository;
import com.userservice.repository.UserRepository;
import com.userservice.service.AuthService;
import com.userservice.service.KeycloakService;
@@ -14,15 +16,19 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
+import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.UUID;
@Service
@RequiredArgsConstructor
@@ -33,7 +39,8 @@ public class AuthServiceImpl implements AuthService {
private final UserService userService;
private final RestTemplate restTemplate;
private final KeycloakService keycloakService;
- // private final EmailService emailService; notification service
+ private final PasswordResetTokenRepository passwordResetTokenRepository;
+ private final JavaMailSender mailSender;
@Value("${keycloak.auth-server-url}")
private String keycloakUrl;
@@ -47,6 +54,12 @@ public class AuthServiceImpl implements AuthService {
@Value("${keycloak.credentials.secret}")
private String clientSecret;
+ @Value("${app.frontend-url:http://localhost:4200}")
+ private String frontendUrl;
+
+ @Value("${spring.mail.username}")
+ private String mailFrom;
+
@Override
public LoginResponseDto login(LoginRequestDto loginRequestDto) {
log.info("Login attempt for email: {}", loginRequestDto.getEmail());
@@ -117,19 +130,65 @@ public void forgotPassword(ForgotPasswordDto dto) {
User user = userRepository.findByEmail(dto.getEmail())
.orElseThrow(() -> new UserNotFoundException("User not found with email: " + dto.getEmail()));
- if (user.getIsDeleted()) {
+ if (user.getIsDeleted() || !user.getIsActive()) {
throw new UnauthorizedException("Account not found");
}
+ // Delete any existing token for this user
+ passwordResetTokenRepository.deleteByKeycloakId(user.getKeycloakId());
+
+ // Generate a secure token valid for 1 hour
+ String token = UUID.randomUUID().toString();
+ PasswordResetToken resetToken = new PasswordResetToken();
+ resetToken.setToken(token);
+ resetToken.setKeycloakId(user.getKeycloakId());
+ resetToken.setEmail(user.getEmail());
+ resetToken.setExpiresAt(Instant.now().plusSeconds(3600));
+ passwordResetTokenRepository.save(resetToken);
+
+ // Send custom email with Angular app link
+ String resetLink = frontendUrl + "/auth/change-password?token=" + token;
try {
- keycloakService.sendResetPasswordEmail(user.getKeycloakId());
- log.info("Password reset email sent for user: {}", user.getId());
+ SimpleMailMessage message = new SimpleMailMessage();
+ message.setFrom(mailFrom);
+ message.setTo(user.getEmail());
+ message.setSubject("SchoolSphere — Password Reset Request");
+ message.setText(
+ "Hello " + user.getFirstName() + ",\n\n" +
+ "You requested a password reset for your SchoolSphere account.\n\n" +
+ "Click the link below to set a new password:\n" +
+ resetLink + "\n\n" +
+ "This link will expire in 1 hour.\n\n" +
+ "If you did not request this, please ignore this email.\n\n" +
+ "— The SchoolSphere Team"
+ );
+ mailSender.send(message);
+ log.info("Password reset email sent to: {}", user.getEmail());
} catch (Exception e) {
log.error("Failed to send password reset email", e);
throw new RuntimeException("Failed to send password reset email");
}
}
+ @Override
+ public void resetPassword(ResetPasswordDto dto) {
+ log.info("Reset password attempt with token");
+
+ PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(dto.token())
+ .orElseThrow(() -> new UnauthorizedException("Invalid or expired reset token"));
+
+ if (resetToken.isUsed() || Instant.now().isAfter(resetToken.getExpiresAt())) {
+ passwordResetTokenRepository.delete(resetToken);
+ throw new UnauthorizedException("Reset token has expired. Please request a new one.");
+ }
+
+ keycloakService.resetPassword(resetToken.getKeycloakId(), dto.newPassword());
+
+ resetToken.setUsed(true);
+ passwordResetTokenRepository.delete(resetToken);
+ log.info("Password reset successfully for keycloakId: {}", resetToken.getKeycloakId());
+ }
+
@Override
public void changePassword(String keycloakId, ChangePasswordDto dto) {
log.info("Change password attempt for user: {}", keycloakId);
diff --git a/services/user-service/src/main/java/com/userservice/service/impl/UserServiceImpl.java b/services/user-service/src/main/java/com/userservice/service/impl/UserServiceImpl.java
index f324fff..7cddeeb 100644
--- a/services/user-service/src/main/java/com/userservice/service/impl/UserServiceImpl.java
+++ b/services/user-service/src/main/java/com/userservice/service/impl/UserServiceImpl.java
@@ -160,6 +160,14 @@ public void deleteUser(String userId, String deletedBy) {
userRepository.save(user);
}
+ @Override
+ public void deactivateUser(String userId) {
+ User user = findUserById(userId);
+ log.debug("deactivateUser userId={}", userId);
+ user.setIsActive(false);
+ userRepository.save(user);
+ }
+
@Override
public void activateUser(String userId) {
User user = findUserById(userId);
diff --git a/services/user-service/src/main/resources/application.yml b/services/user-service/src/main/resources/application.yml
index 962f1f6..9343a4b 100644
--- a/services/user-service/src/main/resources/application.yml
+++ b/services/user-service/src/main/resources/application.yml
@@ -3,6 +3,17 @@ spring:
name: user-service
config:
import: optional:configserver:${CONFIG_SERVER_URL:http://localhost:8888}
+ mail:
+ host: ${MAIL_HOST:sandbox.smtp.mailtrap.io}
+ port: ${MAIL_PORT:2525}
+ username: ${MAIL_USERNAME:}
+ password: ${MAIL_PASSWORD:}
+ properties:
+ mail:
+ smtp:
+ auth: true
+ starttls:
+ enable: true
server:
port: 8081
@@ -12,3 +23,7 @@ cloudinary:
cloud-name: ${CLOUDINARY_CLOUD_NAME:}
api-key: ${CLOUDINARY_API_KEY:}
api-secret: ${CLOUDINARY_API_SECRET:}
+
+# App config
+app:
+ frontend-url: ${FRONTEND_URL:http://localhost:4200}
diff --git a/services/user-service/src/test/java/com/userservice/unit/AuthServiceImplTest.java b/services/user-service/src/test/java/com/userservice/unit/AuthServiceImplTest.java
index d85ecdd..ea8e451 100644
--- a/services/user-service/src/test/java/com/userservice/unit/AuthServiceImplTest.java
+++ b/services/user-service/src/test/java/com/userservice/unit/AuthServiceImplTest.java
@@ -7,6 +7,7 @@
import com.userservice.exception.UserNotFoundException;
import com.userservice.model.User;
import com.userservice.model.enums.RoleType;
+import com.userservice.repository.PasswordResetTokenRepository;
import com.userservice.repository.UserRepository;
import com.userservice.service.KeycloakService;
import com.userservice.service.UserService;
@@ -22,6 +23,8 @@
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
@@ -37,328 +40,339 @@
@DisplayName("AuthService Unit Tests")
class AuthServiceImplTest {
- @Mock
- private UserRepository userRepository;
-
- @Mock
- private UserService userService;
-
- @Mock
- private RestTemplate restTemplate;
-
- @Mock
- private KeycloakService keycloakService;
-
- @InjectMocks
- private AuthServiceImpl authService;
-
- private User testUser;
- private LoginRequestDto loginRequest;
- private UserProfileResponseDto userProfile;
-
- @BeforeEach
- void setUp() {
- // Set up Keycloak properties
- ReflectionTestUtils.setField(authService, "keycloakUrl", "http://localhost:8080");
- ReflectionTestUtils.setField(authService, "realm", "schoolsphere");
- ReflectionTestUtils.setField(authService, "clientId", "user-service");
- ReflectionTestUtils.setField(authService, "clientSecret", "secret");
-
- // Create test user
- testUser = new User();
- testUser.setId("user-123");
- testUser.setKeycloakId("keycloak-123");
- testUser.setEmail("test@example.com");
- testUser.setFirstName("John");
- testUser.setLastName("Doe");
- testUser.setSchoolId("school-123");
- testUser.setIsActive(true);
- testUser.setIsDeleted(false);
-
- // Create login request
- loginRequest = new LoginRequestDto();
- loginRequest.setEmail("test@example.com");
- loginRequest.setPassword("password123");
-
- // Create user profile response
- List roles = List.of(
- new RoleResponseDto("role-1", "user-123", "school-123", RoleType.TEACHER,
- Set.of("READ", "WRITE"), true, "admin", "Teacher role", Instant.now(), Instant.now())
- );
-
- userProfile = new UserProfileResponseDto(
- "user-123", "keycloak-123", "test@example.com", "John", "Doe",
- "1234567890", true, "school-123", "Test School", "test", "http://example.com/profile.jpg", "http://example.com/cover.jpg",
- "123 Main St", "City", "Country", "12345", Map.of("linkedin", "http://linkedin.com/in/test"), Map.of(), Map.of("customField1", "value1"), Instant.now(), Instant.now(), roles
- );
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private UserService userService;
+
+ @Mock
+ private RestTemplate restTemplate;
+
+ @Mock
+ private KeycloakService keycloakService;
+
+ @Mock
+ private PasswordResetTokenRepository passwordResetTokenRepository;
+
+ @Mock
+ private JavaMailSender mailSender;
+
+ @InjectMocks
+ private AuthServiceImpl authService;
+
+ private User testUser;
+ private LoginRequestDto loginRequest;
+ private UserProfileResponseDto userProfile;
+
+ @BeforeEach
+ void setUp() {
+ // Set up Keycloak properties
+ ReflectionTestUtils.setField(authService, "keycloakUrl", "http://localhost:8080");
+ ReflectionTestUtils.setField(authService, "realm", "schoolsphere");
+ ReflectionTestUtils.setField(authService, "clientId", "user-service");
+ ReflectionTestUtils.setField(authService, "clientSecret", "secret");
+ ReflectionTestUtils.setField(authService, "frontendUrl", "http://localhost:4200");
+ ReflectionTestUtils.setField(authService, "mailFrom", "noreply@schoolsphere.com");
+
+ // Create test user
+ testUser = new User();
+ testUser.setId("user-123");
+ testUser.setKeycloakId("keycloak-123");
+ testUser.setEmail("test@example.com");
+ testUser.setFirstName("John");
+ testUser.setLastName("Doe");
+ testUser.setSchoolId("school-123");
+ testUser.setIsActive(true);
+ testUser.setIsDeleted(false);
+
+ // Create login request
+ loginRequest = new LoginRequestDto();
+ loginRequest.setEmail("test@example.com");
+ loginRequest.setPassword("password123");
+
+ // Create user profile response
+ List roles = List.of(
+ new RoleResponseDto("role-1", "user-123", "school-123", RoleType.TEACHER,
+ Set.of("READ", "WRITE"), true, "admin", "Teacher role", Instant.now(), Instant.now())
+ );
+
+ userProfile = new UserProfileResponseDto(
+ "user-123", "keycloak-123", "test@example.com", "John", "Doe",
+ "1234567890", true, "school-123", "Test School", "test", "http://example.com/profile.jpg", "http://example.com/cover.jpg",
+ "123 Main St", "City", "Country", "12345", Map.of("linkedin", "http://linkedin.com/in/test"), Map.of(), Map.of("customField1", "value1"), Instant.now(), Instant.now(), roles
+ );
+ }
+
+ @Nested
+ @DisplayName("Login Tests")
+ class LoginTests {
+
+ @Test
+ @DisplayName("Should login successfully with valid credentials")
+ void login_WithValidCredentials_ShouldReturnLoginResponse() {
+ // Arrange
+ Map tokenResponse = new HashMap<>();
+ tokenResponse.put("access_token", "access-token-123");
+ tokenResponse.put("refresh_token", "refresh-token-123");
+ tokenResponse.put("expires_in", 300);
+
+ when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
+ .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK));
+ when(userService.getMyProfile(testUser.getKeycloakId())).thenReturn(userProfile);
+
+ // Act
+ LoginResponseDto result = authService.login(loginRequest);
+
+ // Assert
+ assertThat(result).isNotNull();
+ assertThat(result.accessToken()).isEqualTo("access-token-123");
+ assertThat(result.refreshToken()).isEqualTo("refresh-token-123");
+ assertThat(result.email()).isEqualTo("test@example.com");
+ assertThat(result.firstName()).isEqualTo("John");
+ assertThat(result.lastName()).isEqualTo("Doe");
+
+ verify(userRepository).findByEmail(loginRequest.getEmail());
+ verify(userService).getMyProfile(testUser.getKeycloakId());
}
- @Nested
- @DisplayName("Login Tests")
- class LoginTests {
-
- @Test
- @DisplayName("Should login successfully with valid credentials")
- void login_WithValidCredentials_ShouldReturnLoginResponse() {
- // Arrange
- Map tokenResponse = new HashMap<>();
- tokenResponse.put("access_token", "access-token-123");
- tokenResponse.put("refresh_token", "refresh-token-123");
- tokenResponse.put("expires_in", 300);
-
- when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
- .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK));
- when(userService.getMyProfile(testUser.getKeycloakId())).thenReturn(userProfile);
-
- // Act
- LoginResponseDto result = authService.login(loginRequest);
-
- // Assert
- assertThat(result).isNotNull();
- assertThat(result.accessToken()).isEqualTo("access-token-123");
- assertThat(result.refreshToken()).isEqualTo("refresh-token-123");
- assertThat(result.email()).isEqualTo("test@example.com");
- assertThat(result.firstName()).isEqualTo("John");
- assertThat(result.lastName()).isEqualTo("Doe");
-
- verify(userRepository).findByEmail(loginRequest.getEmail());
- verify(userService).getMyProfile(testUser.getKeycloakId());
- }
-
- @Test
- @DisplayName("Should throw UserNotFoundException when user not found")
- void login_WithNonExistentUser_ShouldThrowUserNotFoundException() {
- // Arrange
- when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.empty());
-
- // Act & Assert
- assertThatThrownBy(() -> authService.login(loginRequest))
- .isInstanceOf(UserNotFoundException.class)
- .hasMessage("Invalid credentials");
-
- verify(userRepository).findByEmail(loginRequest.getEmail());
- verifyNoInteractions(restTemplate);
- }
-
- @Test
- @DisplayName("Should throw UnauthorizedException when user is inactive")
- void login_WithInactiveUser_ShouldThrowUnauthorizedException() {
- // Arrange
- testUser.setIsActive(false);
- when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
-
- // Act & Assert
- assertThatThrownBy(() -> authService.login(loginRequest))
- .isInstanceOf(UnauthorizedException.class)
- .hasMessage("Account is inactive. Please contact support.");
-
- verify(userRepository).findByEmail(loginRequest.getEmail());
- verifyNoInteractions(restTemplate);
- }
-
- @Test
- @DisplayName("Should throw UnauthorizedException when user is deleted")
- void login_WithDeletedUser_ShouldThrowUnauthorizedException() {
- // Arrange
- testUser.setIsDeleted(true);
- when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
-
- // Act & Assert
- assertThatThrownBy(() -> authService.login(loginRequest))
- .isInstanceOf(UnauthorizedException.class)
- .hasMessage("Account is inactive. Please contact support.");
- }
-
- @Test
- @DisplayName("Should throw UnauthorizedException when password is invalid")
- void login_WithInvalidPassword_ShouldThrowUnauthorizedException() {
- // Arrange
- when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
- .thenThrow(HttpClientErrorException.Unauthorized.create(HttpStatus.UNAUTHORIZED, "Unauthorized", null, null, null));
-
- // Act & Assert
- assertThatThrownBy(() -> authService.login(loginRequest))
- .isInstanceOf(UnauthorizedException.class)
- .hasMessage("Invalid email or password");
- }
+ @Test
+ @DisplayName("Should throw UserNotFoundException when user not found")
+ void login_WithNonExistentUser_ShouldThrowUserNotFoundException() {
+ // Arrange
+ when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.empty());
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.login(loginRequest))
+ .isInstanceOf(UserNotFoundException.class)
+ .hasMessage("Invalid credentials");
+
+ verify(userRepository).findByEmail(loginRequest.getEmail());
+ verifyNoInteractions(restTemplate);
}
- @Nested
- @DisplayName("Forgot Password Tests")
- class ForgotPasswordTests {
-
- @Test
- @DisplayName("Should send reset password email successfully")
- void forgotPassword_WithValidEmail_ShouldSendResetEmail() {
- // Arrange
- ForgotPasswordDto dto = new ForgotPasswordDto("test@example.com");
- when(userRepository.findByEmail(dto.getEmail())).thenReturn(Optional.of(testUser));
- doNothing().when(keycloakService).sendResetPasswordEmail(testUser.getKeycloakId());
-
- // Act
- authService.forgotPassword(dto);
-
- // Assert
- verify(userRepository).findByEmail(dto.getEmail());
- verify(keycloakService).sendResetPasswordEmail(testUser.getKeycloakId());
- }
-
- @Test
- @DisplayName("Should throw UserNotFoundException when email not found")
- void forgotPassword_WithNonExistentEmail_ShouldThrowUserNotFoundException() {
- // Arrange
- ForgotPasswordDto dto = new ForgotPasswordDto("unknown@example.com");
- when(userRepository.findByEmail(dto.getEmail())).thenReturn(Optional.empty());
-
- // Act & Assert
- assertThatThrownBy(() -> authService.forgotPassword(dto))
- .isInstanceOf(UserNotFoundException.class)
- .hasMessageContaining("User not found with email");
-
- verifyNoInteractions(keycloakService);
- }
-
- @Test
- @DisplayName("Should throw UnauthorizedException when user is deleted")
- void forgotPassword_WithDeletedUser_ShouldThrowUnauthorizedException() {
- // Arrange
- testUser.setIsDeleted(true);
- ForgotPasswordDto dto = new ForgotPasswordDto("test@example.com");
- when(userRepository.findByEmail(dto.getEmail())).thenReturn(Optional.of(testUser));
-
- // Act & Assert
- assertThatThrownBy(() -> authService.forgotPassword(dto))
- .isInstanceOf(UnauthorizedException.class)
- .hasMessage("Account not found");
- }
+ @Test
+ @DisplayName("Should throw UnauthorizedException when user is inactive")
+ void login_WithInactiveUser_ShouldThrowUnauthorizedException() {
+ // Arrange
+ testUser.setIsActive(false);
+ when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.login(loginRequest))
+ .isInstanceOf(UnauthorizedException.class)
+ .hasMessage("Account is inactive. Please contact support.");
+
+ verify(userRepository).findByEmail(loginRequest.getEmail());
+ verifyNoInteractions(restTemplate);
}
- @Nested
- @DisplayName("Change Password Tests")
- class ChangePasswordTests {
-
- @Test
- @DisplayName("Should change password successfully")
- void changePassword_WithValidCurrentPassword_ShouldChangePassword() {
- // Arrange
- ChangePasswordDto dto = new ChangePasswordDto("oldPassword", "newPassword");
- when(userRepository.findByKeycloakId("keycloak-123")).thenReturn(Optional.of(testUser));
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
- .thenReturn(new ResponseEntity<>(new HashMap<>(), HttpStatus.OK));
- doNothing().when(keycloakService).resetPassword("keycloak-123", "newPassword");
-
- // Act
- authService.changePassword("keycloak-123", dto);
-
- // Assert
- verify(userRepository).findByKeycloakId("keycloak-123");
- verify(keycloakService).resetPassword("keycloak-123", "newPassword");
- }
-
- @Test
- @DisplayName("Should throw UnauthorizedException when current password is incorrect")
- void changePassword_WithIncorrectCurrentPassword_ShouldThrowUnauthorizedException() {
- // Arrange
- ChangePasswordDto dto = new ChangePasswordDto("wrongPassword", "newPassword");
- when(userRepository.findByKeycloakId("keycloak-123")).thenReturn(Optional.of(testUser));
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
- .thenThrow(HttpClientErrorException.Unauthorized.create(HttpStatus.UNAUTHORIZED, "Unauthorized", null, null, null));
-
- // Act & Assert
- assertThatThrownBy(() -> authService.changePassword("keycloak-123", dto))
- .isInstanceOf(UnauthorizedException.class)
- .hasMessage("Current password is incorrect");
-
- verify(keycloakService, never()).resetPassword(anyString(), anyString());
- }
-
- @Test
- @DisplayName("Should throw UserNotFoundException when user not found")
- void changePassword_WithNonExistentUser_ShouldThrowUserNotFoundException() {
- // Arrange
- ChangePasswordDto dto = new ChangePasswordDto("oldPassword", "newPassword");
- when(userRepository.findByKeycloakId("unknown-id")).thenReturn(Optional.empty());
-
- // Act & Assert
- assertThatThrownBy(() -> authService.changePassword("unknown-id", dto))
- .isInstanceOf(UserNotFoundException.class);
- }
+ @Test
+ @DisplayName("Should throw UnauthorizedException when user is deleted")
+ void login_WithDeletedUser_ShouldThrowUnauthorizedException() {
+ // Arrange
+ testUser.setIsDeleted(true);
+ when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.login(loginRequest))
+ .isInstanceOf(UnauthorizedException.class)
+ .hasMessage("Account is inactive. Please contact support.");
}
- @Nested
- @DisplayName("Refresh Token Tests")
- class RefreshTokenTests {
-
- @Test
- @DisplayName("Should refresh token successfully")
- void refreshToken_WithValidToken_ShouldReturnNewTokens() {
- // Arrange
- RefreshTokenDto dto = new RefreshTokenDto("valid-refresh-token");
- Map tokenResponse = new HashMap<>();
- tokenResponse.put("access_token", "new-access-token");
- tokenResponse.put("refresh_token", "new-refresh-token");
- tokenResponse.put("expires_in", 300);
-
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
- .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK));
-
- // Act
- TokenResponseDto result = authService.refreshToken(dto);
-
- // Assert
- assertThat(result).isNotNull();
- assertThat(result.accessToken()).isEqualTo("new-access-token");
- assertThat(result.refreshToken()).isEqualTo("new-refresh-token");
- assertThat(result.expiresIn()).isEqualTo(300L);
- }
-
- @Test
- @DisplayName("Should throw UnauthorizedException when refresh token is invalid")
- void refreshToken_WithInvalidToken_ShouldThrowUnauthorizedException() {
- // Arrange
- RefreshTokenDto dto = new RefreshTokenDto("invalid-refresh-token");
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
- .thenThrow(new HttpClientErrorException(HttpStatus.BAD_REQUEST));
-
- // Act & Assert
- assertThatThrownBy(() -> authService.refreshToken(dto))
- .isInstanceOf(UnauthorizedException.class)
- .hasMessage("Invalid or expired refresh token");
- }
+ @Test
+ @DisplayName("Should throw UnauthorizedException when password is invalid")
+ void login_WithInvalidPassword_ShouldThrowUnauthorizedException() {
+ // Arrange
+ when(userRepository.findByEmail(loginRequest.getEmail())).thenReturn(Optional.of(testUser));
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
+ .thenThrow(HttpClientErrorException.Unauthorized.create(HttpStatus.UNAUTHORIZED, "Unauthorized", null, null, null));
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.login(loginRequest))
+ .isInstanceOf(UnauthorizedException.class)
+ .hasMessage("Invalid email or password");
}
+ }
+
+ @Nested
+ @DisplayName("Forgot Password Tests")
+ class ForgotPasswordTests {
+
+ @Test
+ @DisplayName("Should send reset password email successfully")
+ void forgotPassword_WithValidEmail_ShouldSendResetEmail() {
+ // Arrange
+ ForgotPasswordDto dto = new ForgotPasswordDto("test@example.com");
+ when(userRepository.findByEmail(dto.getEmail())).thenReturn(Optional.of(testUser));
+ doNothing().when(passwordResetTokenRepository).deleteByKeycloakId(testUser.getKeycloakId());
+ doNothing().when(mailSender).send(any(SimpleMailMessage.class));
+
+ // Act
+ authService.forgotPassword(dto);
+
+ // Assert
+ verify(userRepository).findByEmail(dto.getEmail());
+ verify(passwordResetTokenRepository).deleteByKeycloakId(testUser.getKeycloakId());
+ verify(passwordResetTokenRepository).save(any());
+ verify(mailSender).send(any(SimpleMailMessage.class));
+ verifyNoInteractions(keycloakService);
+ }
+
+ @Test
+ @DisplayName("Should throw UserNotFoundException when email not found")
+ void forgotPassword_WithNonExistentEmail_ShouldThrowUserNotFoundException() {
+ // Arrange
+ ForgotPasswordDto dto = new ForgotPasswordDto("unknown@example.com");
+ when(userRepository.findByEmail(dto.getEmail())).thenReturn(Optional.empty());
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.forgotPassword(dto))
+ .isInstanceOf(UserNotFoundException.class)
+ .hasMessageContaining("User not found with email");
- @Nested
- @DisplayName("Logout Tests")
- class LogoutTests {
-
- @Test
- @DisplayName("Should logout successfully")
- void logout_WithValidToken_ShouldLogoutSuccessfully() {
- // Arrange
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Void.class)))
- .thenReturn(new ResponseEntity<>(HttpStatus.OK));
-
- // Act
- authService.logout("keycloak-123", "refresh-token");
-
- // Assert
- verify(restTemplate).postForEntity(anyString(), any(HttpEntity.class), eq(Void.class));
- }
-
- @Test
- @DisplayName("Should throw RuntimeException when logout fails")
- void logout_WhenKeycloakFails_ShouldThrowRuntimeException() {
- // Arrange
- when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Void.class)))
- .thenThrow(new RuntimeException("Keycloak error"));
-
- // Act & Assert
- assertThatThrownBy(() -> authService.logout("keycloak-123", "refresh-token"))
- .isInstanceOf(RuntimeException.class)
- .hasMessage("Failed to logout");
- }
+ verifyNoInteractions(keycloakService);
+ }
+
+ @Test
+ @DisplayName("Should throw UnauthorizedException when user is deleted")
+ void forgotPassword_WithDeletedUser_ShouldThrowUnauthorizedException() {
+ // Arrange
+ testUser.setIsDeleted(true);
+ ForgotPasswordDto dto = new ForgotPasswordDto("test@example.com");
+ when(userRepository.findByEmail(dto.getEmail())).thenReturn(Optional.of(testUser));
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.forgotPassword(dto))
+ .isInstanceOf(UnauthorizedException.class)
+ .hasMessage("Account not found");
+ }
+ }
+
+ @Nested
+ @DisplayName("Change Password Tests")
+ class ChangePasswordTests {
+
+ @Test
+ @DisplayName("Should change password successfully")
+ void changePassword_WithValidCurrentPassword_ShouldChangePassword() {
+ // Arrange
+ ChangePasswordDto dto = new ChangePasswordDto("oldPassword", "newPassword");
+ when(userRepository.findByKeycloakId("keycloak-123")).thenReturn(Optional.of(testUser));
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
+ .thenReturn(new ResponseEntity<>(new HashMap<>(), HttpStatus.OK));
+ doNothing().when(keycloakService).resetPassword("keycloak-123", "newPassword");
+
+ // Act
+ authService.changePassword("keycloak-123", dto);
+
+ // Assert
+ verify(userRepository).findByKeycloakId("keycloak-123");
+ verify(keycloakService).resetPassword("keycloak-123", "newPassword");
+ }
+
+ @Test
+ @DisplayName("Should throw UnauthorizedException when current password is incorrect")
+ void changePassword_WithIncorrectCurrentPassword_ShouldThrowUnauthorizedException() {
+ // Arrange
+ ChangePasswordDto dto = new ChangePasswordDto("wrongPassword", "newPassword");
+ when(userRepository.findByKeycloakId("keycloak-123")).thenReturn(Optional.of(testUser));
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
+ .thenThrow(HttpClientErrorException.Unauthorized.create(HttpStatus.UNAUTHORIZED, "Unauthorized", null, null, null));
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.changePassword("keycloak-123", dto))
+ .isInstanceOf(UnauthorizedException.class)
+ .hasMessage("Current password is incorrect");
+
+ verify(keycloakService, never()).resetPassword(anyString(), anyString());
}
-}
+ @Test
+ @DisplayName("Should throw UserNotFoundException when user not found")
+ void changePassword_WithNonExistentUser_ShouldThrowUserNotFoundException() {
+ // Arrange
+ ChangePasswordDto dto = new ChangePasswordDto("oldPassword", "newPassword");
+ when(userRepository.findByKeycloakId("unknown-id")).thenReturn(Optional.empty());
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.changePassword("unknown-id", dto))
+ .isInstanceOf(UserNotFoundException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("Refresh Token Tests")
+ class RefreshTokenTests {
+
+ @Test
+ @DisplayName("Should refresh token successfully")
+ void refreshToken_WithValidToken_ShouldReturnNewTokens() {
+ // Arrange
+ RefreshTokenDto dto = new RefreshTokenDto("valid-refresh-token");
+ Map tokenResponse = new HashMap<>();
+ tokenResponse.put("access_token", "new-access-token");
+ tokenResponse.put("refresh_token", "new-refresh-token");
+ tokenResponse.put("expires_in", 300);
+
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
+ .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK));
+
+ // Act
+ TokenResponseDto result = authService.refreshToken(dto);
+
+ // Assert
+ assertThat(result).isNotNull();
+ assertThat(result.accessToken()).isEqualTo("new-access-token");
+ assertThat(result.refreshToken()).isEqualTo("new-refresh-token");
+ assertThat(result.expiresIn()).isEqualTo(300L);
+ }
+
+ @Test
+ @DisplayName("Should throw UnauthorizedException when refresh token is invalid")
+ void refreshToken_WithInvalidToken_ShouldThrowUnauthorizedException() {
+ // Arrange
+ RefreshTokenDto dto = new RefreshTokenDto("invalid-refresh-token");
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class)))
+ .thenThrow(new HttpClientErrorException(HttpStatus.BAD_REQUEST));
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.refreshToken(dto))
+ .isInstanceOf(UnauthorizedException.class)
+ .hasMessage("Invalid or expired refresh token");
+ }
+ }
+
+ @Nested
+ @DisplayName("Logout Tests")
+ class LogoutTests {
+
+ @Test
+ @DisplayName("Should logout successfully")
+ void logout_WithValidToken_ShouldLogoutSuccessfully() {
+ // Arrange
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Void.class)))
+ .thenReturn(new ResponseEntity<>(HttpStatus.OK));
+
+ // Act
+ authService.logout("keycloak-123", "refresh-token");
+
+ // Assert
+ verify(restTemplate).postForEntity(anyString(), any(HttpEntity.class), eq(Void.class));
+ }
+
+ @Test
+ @DisplayName("Should throw RuntimeException when logout fails")
+ void logout_WhenKeycloakFails_ShouldThrowRuntimeException() {
+ // Arrange
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Void.class)))
+ .thenThrow(new RuntimeException("Keycloak error"));
+
+ // Act & Assert
+ assertThatThrownBy(() -> authService.logout("keycloak-123", "refresh-token"))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("Failed to logout");
+ }
+ }
+}