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"); + } + } +}