Compare commits

...

35 Commits

Author SHA1 Message Date
c934466e47 Merge pull request 'feat(#37): Inscription Parent - Étape 2 (Parent 2)' (#74) from feature/37-frontend-inscription-parent-step2 into master
feat(#37): Inscription Parent - Étape 2 (Parent 2)
2025-12-01 22:36:31 +00:00
90d8fa8669 feat(#37): Inscription Parent - Étape 2 (Parent 2)
Frontend Step2:
- Suppression des champs mot de passe et confirmation
- Correction de l'indicateur d'étape: 2/5 → 2/6
- Améliorations visuelles (mêmes que Step1):
  * Taille des labels: 18 → 22px
  * Taille de police des champs: 18 → 20px
  * Espacement entre champs: 20 → 32px
  * Meilleure répartition verticale avec spaceEvenly

Note: Le champ password est conservé dans le modèle ParentData pour compatibilité
2025-12-01 23:36:02 +01:00
90cdf16709 docs: Correction numérotation tickets et ajout statuts terminés
- Correction numérotation pour correspondre à Gitea (#36-#63)
- Ajout tickets #34 et #35 (réservés)
- Marquage tickets terminés avec :
  * #3, #4, #7 (BDD)
  * #18, #19, #20, #21 (Backend API Parent)
  * #36 (Frontend Step1)
- Correction doublons (#38, #39, #41, #42, #47, #48)
- Renumération tickets Frontend et Tests
2025-12-01 23:28:08 +01:00
bde97c24db Merge pull request 'feat(#36): Inscription Parent - Étape 1 (Parent 1)' (#73) from feature/36-frontend-inscription-parent-step1 into master
feat(#36): Inscription Parent - Étape 1 (Parent 1)
2025-12-01 22:21:02 +00:00
9ae6533b4d feat(#36): Inscription Parent - Étape 1 (Parent 1)
Backend:
- Retrait des champs non-CDC: profession, situation_familiale, date_naissance
- Nettoyage des DTOs RegisterParentCompletDto et RegisterParentDto
- Mise à jour de la logique dans auth.service.ts (inscrireParentComplet et legacy)

Frontend Step1:
- Suppression des champs mot de passe et confirmation
- Correction de l'indicateur d'étape: 1/5 → 1/6
- Améliorations visuelles:
  * Taille des labels: 18 → 22px
  * Taille de police des champs: 18 → 20px
  * Espacement entre champs: 20 → 32px
  * Meilleure répartition verticale avec spaceEvenly

Note: Le champ password est conservé dans le modèle ParentData pour compatibilité avec Step2
2025-12-01 23:19:58 +01:00
579b6cae90 [Backend] API Inscription Parent - REFONTE Workflow 6 etapes (#72)
Co-authored-by: Julien Martin <julien.martin@ptits-pas.fr>
Co-committed-by: Julien Martin <julien.martin@ptits-pas.fr>
2025-12-01 21:43:36 +00:00
9aea26805d Merge pull request '[Backend] Endpoint inscription parent + Nettoyage code #9' (#71) from feature/9-endpoints-inscription-parent into master 2025-11-30 15:10:21 +00:00
40c7f40d12 feat(backend): endpoint inscription parent + nettoyage code #9
🧹 NETTOYAGE CODE ÉTUDIANT:
- Suppression console.log/error (4 occurrences)
- Suppression code commenté inutile
- Suppression champs obsolètes (mobile, telephone_fixe)
- Correction password nullable dans Users entity

 NOUVELLES FONCTIONNALITÉS:
- Ajout champs token_creation_mdp dans Users entity
- Création RegisterParentDto (validation complète)
- Endpoint POST /auth/register/parent
- Méthode registerParent() avec transaction
- Gestion Parent 1 + Parent 2 (co-parent optionnel)
- Génération tokens UUID pour création MDP
- Lecture durée token depuis AppConfigService
- Création automatique entités Parents
- Statut EN_ATTENTE par défaut
- Intégration AppConfigModule dans AuthModule
- Amélioration méthode login() (vérif statut + password null)

📋 WORKFLOW CDC CONFORME:
- Inscription SANS mot de passe
- Token envoyé par email (TODO)
- Validation gestionnaire requise
- Support co-parent avec même adresse

Réf: docs/20_WORKFLOW-CREATION-COMPTE.md
Ticket: #9 (ou #16)
2025-11-30 16:09:54 +01:00
61b45cd830 Merge pull request '[Backend] API Documents Légaux #31' (#70) from feature/31-api-documents-legaux into master 2025-11-30 15:00:52 +00:00
f53fd903e5 feat(backend): API documents légaux #31
- Création DTOs (UploadDocumentDto, DocumentsActifsResponseDto, DocumentVersionDto)
- Création DocumentsLegauxController avec 6 endpoints:
  * GET /documents-legaux/actifs (public)
  * GET /documents-legaux/:type/versions (admin)
  * POST /documents-legaux (upload, admin)
  * PATCH /documents-legaux/:id/activer (admin)
  * GET /documents-legaux/:id/download (public)
  * GET /documents-legaux/:id/verifier-integrite (admin)
- Support upload multipart/form-data avec FileInterceptor
- Validation des types (cgu/privacy)
- Stream PDF pour téléchargement
- Intégration dans DocumentsLegauxModule
- Compilation OK

TODO: Ajouter guards auth (JwtAuthGuard, RolesGuard)

Réf: docs/22_DOCUMENTS-LEGAUX.md
2025-11-30 16:00:12 +01:00
98082187b5 Merge pull request '[Backend] Service gestion documents légaux #8' (#69) from feature/8-service-documents-legaux into master
Merge pull request #69: Service gestion documents légaux

Ticket #8 complété
2025-11-30 14:51:36 +00:00
1fb8c33cbf feat(backend): service gestion documents légaux #8
- Création entité DocumentLegal
- Création entité AcceptationDocument
- Création DocumentsLegauxService avec méthodes:
  * getDocumentsActifs()
  * uploadNouvelleVersion() (avec hash SHA-256)
  * activerVersion() (transaction)
  * listerVersions()
  * telechargerDocument()
  * verifierIntegrite()
  * enregistrerAcceptation()
  * getAcceptationsUtilisateur()
- Création DocumentsLegauxModule
- Intégration dans AppModule
- Ajout dépendances multer + @types/multer

Réf: docs/22_DOCUMENTS-LEGAUX.md
2025-11-30 15:50:38 +01:00
6ceb0f0ea9 Merge feature/7-tables-documents-legaux into master
Ticket #7: Ajout tables documents légaux versionnés
- Table documents_legaux
- Table acceptations_documents
- Colonnes CGU dans utilisateurs
- Seed documents v1
2025-11-30 15:36:31 +01:00
bebd3c74da feat(bdd): ajout tables documents_legaux et acceptations_documents #7
- Création table documents_legaux (versioning + hash SHA-256)
- Création table acceptations_documents (traçabilité RGPD)
- Ajout colonnes dans utilisateurs (cgu_version_acceptee, etc.)
- Seed documents génériques v1 (CGU + Privacy)
- Index pour performance

Réf: docs/22_DOCUMENTS-LEGAUX.md
2025-11-30 15:34:28 +01:00
e1628da9cb Merge pull request '[Backend] API admin configuration avec test SMTP' (#67) from feature/6-admin-configuration into master
Merge pull request #67: [Backend] API admin configuration avec test SMTP

Implémentation de l'API REST pour la gestion de la configuration système avec test SMTP intégré.

Closes #6
2025-11-28 16:24:57 +00:00
eb1583b35b feat(backend): API admin configuration avec test SMTP (#6)
Implémentation de l'API REST pour la gestion de la configuration
système par les administrateurs.

Nouveaux fichiers :
- modules/config/config.controller.ts : Controller REST
- modules/config/dto/update-config.dto.ts : DTO mise à jour
- modules/config/dto/test-smtp.dto.ts : DTO test SMTP

Endpoints créés :
 GET /api/v1/configuration/setup/status
   → Vérifier si la configuration initiale est terminée

 POST /api/v1/configuration/setup/complete
   → Marquer la configuration comme terminée

 POST /api/v1/configuration/test-smtp
   → Tester la connexion SMTP + envoi email de test

 PATCH /api/v1/configuration/bulk
   → Mise à jour multiple des configurations

 GET /api/v1/configuration
   → Récupérer toutes les configurations (admin)

 GET /api/v1/configuration/:category
   → Récupérer par catégorie (email/app/security)

Fonctionnalités :
- Validation des données avec class-validator
- Test SMTP avec Nodemailer
- Envoi d'email de test HTML
- Gestion d'erreurs complète
- Rechargement automatique du cache
- Traçabilité des modifications

Sécurité :
- Guards commentés (à activer avec JWT)
- Validation des catégories
- Mots de passe masqués dans les réponses

Dépendances ajoutées :
- nodemailer ^6.9.16
- @types/nodemailer ^6.4.16

Tests effectués :
 GET /setup/status → {setupCompleted: false}
 GET /email → 8 configurations email
 Build Docker réussi
 Toutes les routes mappées correctement

Ref: #6
2025-11-28 17:00:55 +01:00
ec485b5a3e Merge pull request '[Backend] Service de configuration avec cache et encryption' (#66) from feature/5-service-configuration into master
Merge pull request #66: [Backend] Service de configuration avec cache et encryption

Implémentation du service de configuration dynamique avec cache en mémoire et chiffrement AES-256-CBC.

Closes #5
2025-11-28 15:33:20 +00:00
80afe2fa2f feat(backend): service de configuration avec cache et encryption (#5)
Implémentation du service de configuration dynamique pour
le déploiement on-premise de l'application.

Nouveaux fichiers :
- entities/configuration.entity.ts : Entité TypeORM
- modules/config/config.service.ts : Service avec cache et encryption
- modules/config/config.module.ts : Module NestJS
- modules/config/index.ts : Export centralisé

Fonctionnalités :
 Cache en mémoire au démarrage (16 configurations)
 Chiffrement AES-256-CBC pour valeurs sensibles
 Conversion automatique de types (string/number/boolean/json)
 Méthodes get/set avec traçabilité
 Récupération par catégorie (email/app/security)
 Masquage automatique des mots de passe
 Support setup wizard (isSetupCompleted)

Sécurité :
- Clé de chiffrement depuis CONFIG_ENCRYPTION_KEY
- Format iv:encrypted pour AES-256-CBC
- Mots de passe masqués dans les API

Intégration :
- AppConfigModule ajouté à app.module.ts
- Service global exporté pour utilisation dans toute l'app
- Chargement automatique au démarrage (OnModuleInit)

Tests :
 Build Docker réussi
 16 configurations chargées en cache
 Service démarré sans erreur

Ref: #5
2025-11-28 16:29:33 +01:00
4149d0147f Merge pull request '[BDD] Ajout table configuration système' (#65) from feature/4-table-configuration into master
Merge pull request #65: [BDD] Ajout table configuration système

Ajout de la table configuration pour la gestion dynamique de la configuration on-premise.

Closes #4
2025-11-28 15:23:31 +00:00
47dbe94b02 feat(bdd): ajout table configuration système (#4)
Ajout de la table configuration pour la gestion dynamique
de la configuration on-premise de l'application.

Structure :
- Table configuration (clé/valeur avec types)
- Index sur cle et categorie pour performance
- Contrainte UNIQUE sur cle
- Référence vers utilisateurs pour traçabilité

Données initiales (seed) :
- Configuration Email (SMTP) : 8 paramètres
- Configuration Application : 4 paramètres
- Configuration Sécurité : 4 paramètres

Types supportés :
- string : chaînes de caractères
- number : nombres entiers/décimaux
- boolean : true/false
- json : objets JSON
- encrypted : valeurs chiffrées AES-256

Catégories :
- email : Configuration SMTP
- app : Paramètres application
- security : Paramètres de sécurité

Base de données recréée et testée 
16 configurations insérées par défaut 

Ref: #4
2025-11-28 16:19:46 +01:00
fd4f5e6b12 Merge pull request '[BDD] Conformité CDC v1.3 - Schéma unifié' (#64) from feature/3-ajout-champs-bdd into master
Merge pull request #64: [BDD] Conformité CDC v1.3 - Schéma unifié

Modifications du schéma BDD.sql pour conformité CDC v1.3.

Closes #3
2025-11-28 15:03:43 +00:00
40b1eb2192 feat(bdd): conformité CDC v1.3 - schéma unifié (#3)
Modifications du schéma BDD.sql :

Table utilisateurs :
- password devient NULLABLE (créé après validation via token)
- Ajout token_creation_mdp + token_creation_mdp_expire_le
- telephone unifié (suppression mobile/telephone_fixe)
- Ajout index sur token_creation_mdp

Table assistantes_maternelles :
- date_agrement devient NOT NULL (obligatoire)
- Suppression annee_experience
- Suppression specialite

Table enfants :
- genre devient NOT NULL (obligatoire H/F)

Autres modifications :
- docker-compose.yml : pointage vers BDD.sql unifié
- Suppression des anciens fichiers de migration (01-07)
- Base de données recréée et testée 

Ref: #3
2025-11-28 16:00:17 +01:00
933793aad8 docs: ajout roadmap générale (Phases 1-5) et mise à jour index 2025-11-28 14:54:29 +01:00
2285069a52 docs: ajout ligne vide finale + ignore token Gitea 2025-11-28 10:31:11 +01:00
2e3139b5fc docs: mise à jour contrats API (backend-database et frontend-backend) 2025-11-26 14:33:46 +01:00
93306d287b docs: mise à jour workflow création compte (corrections cohérence CDC v1.3) 2025-11-26 14:33:20 +01:00
d0827a119e docs: ajout documentation technique complète (configuration, documents légaux, tickets, décisions, backlog Phase 2) 2025-11-26 14:33:04 +01:00
a5dae7a017 docs: Workflow création de compte + refonte documentation
- Ajout Cahier des Charges v1.3
- Ajout Workflow technique création de compte (v1.0)
- Réorganisation docs avec préfixes numériques (00_, 01_, etc.)
- Ajout données de test CSV
- Modifications principales :
  * Champ téléphone unique (suppression mobile/fixe)
  * Inscription sans mot de passe (Parents + AM)
  * Création MDP par email après validation (7j)
  * Genre enfant obligatoire (H/F)
  * Date agrément obligatoire pour AM
2025-11-25 00:28:35 +01:00
48b01ed3fe docs: Ajout de la documentation complète (API, Database, Audit)
- Création du dossier docs/ pour centraliser la documentation
- Ajout de API.md : documentation complète de tous les endpoints
- Ajout de DATABASE.md : schéma complet de la base de données
- Ajout de AUDIT.md : audit du projet YNOV
- Déplacement des README-ARCHITECTURE.md et README-DEPLOYMENT.md vers docs/
- Ajout d'un README.md index dans docs/
2025-11-24 21:39:01 +01:00
aa61831878 feat: Création de la structure api-contracts
- Contrats d'API Frontend ↔ Backend (OpenAPI 3.0)
- Contrats Backend ↔ Database (Prisma/SQL)
- Documentation complète pour génération de code
- Permet l'interchangeabilité des composants
2025-11-24 15:45:07 +01:00
ad81a2f4f4 feat: Configuration Docker Compose à 3 services
- Frontend: Flutter web (app.ptits-pas.fr)
- Backend: NestJS API (app.ptits-pas.fr/api)
- Database: PostgreSQL 17 + PgAdmin (app.ptits-pas.fr/pgadmin)
- Réseau: ptitspas_network + proxy_network (Traefik)
- Documentation architecture et déploiement
2025-11-24 15:44:58 +01:00
bbf73458cb feat: Intégration de la base de données PostgreSQL depuis YNOV
- Structure complète: utilisateurs, parents, assmat, enfants, contrats
- Migrations SQL avec enums et contraintes
- Seed: 1 super_admin (admin@ptits-pas.fr)
- Mot de passe: 4dm1n1strateur (hash bcrypt)
2025-11-24 15:44:39 +01:00
9cb4162165 feat: Intégration du frontend Flutter depuis YNOV
- Framework: Flutter web
- Pages: Login, inscription, dashboards
- Services: API client, authentification, gestion d'état
- Intégration avec backend NestJS
- Dockerfile pour déploiement web
2025-11-24 15:44:15 +01:00
33d6e7b0c3 feat: Intégration du backend NestJS depuis YNOV
- Framework: NestJS avec TypeORM
- Authentification: JWT (access + refresh tokens)
- Gestion utilisateurs: CRUD complet avec validation
- Routes: auth, users, parents, assistantes maternelles
- Dockerfile pour conteneurisation
2025-11-24 15:44:07 +01:00
49f0684ad3 nettoyage: Suppression des dossiers de la maquette initiale 2025-11-24 15:43:53 +01:00
277 changed files with 31411 additions and 2851 deletions

1
.gitignore vendored
View File

@ -52,3 +52,4 @@ Xcf/**
# Release notes # Release notes
CHANGELOG.md CHANGELOG.md
Ressources/ Ressources/
.gitea-token

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

87
api-contracts/README.md Normal file
View File

@ -0,0 +1,87 @@
# 📜 API Contracts - PtitsPas
Ce dossier contient les **contrats d'API** qui définissent les interfaces entre les différentes couches de l'application.
## 🎯 Objectif
Garantir que **Frontend**, **Backend** et **Database** respectent des contrats stricts, permettant de les rendre **interchangeables** sans casser l'application.
---
## 📁 Structure
```
api-contracts/
├── frontend-backend/ # Contrat Frontend ↔ Backend (HTTP REST)
│ ├── openapi.yaml # Spécification OpenAPI 3.0 (source de vérité)
│ └── generated/ # Code généré automatiquement
│ ├── dart/ # Client API pour Flutter
│ └── typescript/ # Types pour NestJS
└── backend-database/ # Contrat Backend ↔ Database (ORM/SQL)
├── schema.prisma # Schéma Prisma (ou TypeORM entities)
└── migrations/ # Migrations SQL versionnées
```
---
## 🔄 Workflow de Génération
### 1. Frontend ↔ Backend
**Source de vérité :** `frontend-backend/openapi.yaml`
**Génération du client Dart (Flutter) :**
```bash
cd api-contracts/frontend-backend
docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli generate \
-i /local/openapi.yaml \
-g dart-dio \
-o /local/generated/dart
```
**Génération des types TypeScript (NestJS) :**
```bash
cd api-contracts/frontend-backend
npx openapi-typescript openapi.yaml --output generated/typescript/api.types.ts
```
---
### 2. Backend ↔ Database
**Source de vérité :** `backend-database/schema.prisma`
**Génération du client Prisma :**
```bash
cd api-contracts/backend-database
npx prisma generate
```
**Génération des migrations SQL :**
```bash
cd api-contracts/backend-database
npx prisma migrate dev --name <nom_migration>
```
---
## ✅ Avantages
- **Frontend interchangeable** : React, Vue, Angular → il suffit de régénérer le client API
- **Backend interchangeable** : Python, Go, Java → tant qu'il respecte `openapi.yaml`
- **Database read-only en prod** : User PostgreSQL `app_user` (pas de DDL)
- **Cohérence garantie** : Types générés = pas d'erreur de typage
- **Documentation auto** : OpenAPI = documentation interactive (Swagger UI)
---
## 📚 Documentation
- [OpenAPI 3.0 Spec](https://swagger.io/specification/)
- [Prisma Schema](https://www.prisma.io/docs/concepts/components/prisma-schema)
- [openapi-generator](https://openapi-generator.tech/)
- [openapi-typescript](https://github.com/drwpow/openapi-typescript)

View File

@ -0,0 +1,27 @@
# 💾 Backend ↔ Database Contract
Ce dossier contient le **contrat de données** entre le Backend et la Base de Données.
## 📋 Contenu
- **`schema.prisma`** : Schéma de base de données (à créer)
- **`migrations/`** : Migrations SQL versionnées (actuellement dans `/database/migrations/`)
## 🔄 Migration Future
À terme, les migrations SQL de `/database/migrations/` seront gérées ici avec Prisma :
```bash
# Générer une migration
npx prisma migrate dev --name add_user_phone
# Appliquer en production
npx prisma migrate deploy
```
## 📚 Référence
- [Prisma Migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate)

View File

@ -0,0 +1,179 @@
openapi: 3.0.0
info:
title: PtitsPas API
version: 1.0.0
description: |
API REST pour l'application PtitsPas.
Ce contrat définit l'interface entre le Frontend (Flutter) et le Backend (NestJS).
contact:
name: PtitsPas Team
email: admin@ptits-pas.fr
servers:
- url: https://app.ptits-pas.fr/api
description: Production
- url: http://localhost:3000/api
description: Développement local
paths:
/auth/login:
post:
summary: Authentification utilisateur
operationId: loginUser
tags:
- Authentication
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- password
properties:
email:
type: string
format: email
example: admin@ptits-pas.fr
password:
type: string
format: password
example: "4dm1n1strateur"
responses:
'200':
description: Authentification réussie
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
description: Token JWT d'accès
refresh_token:
type: string
description: Token JWT de rafraîchissement
user:
$ref: '#/components/schemas/User'
'401':
description: Identifiants invalides
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/users:
get:
summary: Liste des utilisateurs
operationId: listUsers
tags:
- Users
security:
- bearerAuth: []
parameters:
- name: role
in: query
schema:
$ref: '#/components/schemas/RoleType'
- name: statut
in: query
schema:
$ref: '#/components/schemas/StatutUtilisateurType'
responses:
'200':
description: Liste des utilisateurs
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
'401':
description: Non authentifié
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
User:
type: object
required:
- id
- email
- role
- statut
properties:
id:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
email:
type: string
format: email
example: "parent@ptits-pas.fr"
prenom:
type: string
example: "Jean"
nom:
type: string
example: "Dupont"
role:
$ref: '#/components/schemas/RoleType'
statut:
$ref: '#/components/schemas/StatutUtilisateurType'
telephone:
type: string
example: "0612345678"
adresse:
type: string
photo_url:
type: string
format: uri
cree_le:
type: string
format: date-time
RoleType:
type: string
enum:
- parent
- assistante_maternelle
- gestionnaire
- administrateur
- super_admin
StatutUtilisateurType:
type: string
enum:
- en_attente
- actif
- suspendu
Error:
type: object
required:
- message
- statusCode
properties:
message:
type: string
example: "Identifiants invalides"
statusCode:
type: integer
example: 401
error:
type: string
example: "Unauthorized"
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Token JWT obtenu via /auth/login

23
backend/.env.example Normal file
View File

@ -0,0 +1,23 @@
# Fichier: .env.example
# Copier ce fichier vers .env et adapter les valeurs selon votre environnement
# Configuration de la base de données PostgreSQL
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=admin
POSTGRES_PASSWORD=admin123
POSTGRES_DB=ptitpas_db
# Configuration PgAdmin (accessible sur http://localhost:8080)
PGADMIN_DEFAULT_EMAIL=admin@localhost
PGADMIN_DEFAULT_PASSWORD=admin123
# Configuration de l'API
API_PORT=3000
# Secrets pour l'authentification JWT
JWT_SECRET=dev-jwt-secret-key-change-me
JWT_EXPIRATION_TIME=7d
# Environnement
NODE_ENV=development

64
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.env
# Tests bdd
.vscode/
BDD.sql
migrations/
src/seed/

4
backend/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

42
backend/Dockerfile Normal file
View File

@ -0,0 +1,42 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copier les fichiers de configuration
COPY package*.json ./
COPY tsconfig*.json ./
COPY nest-cli.json ./
# Installer TOUTES les dépendances (dev + prod pour le build)
RUN npm install && npm cache clean --force
# Copier le code source
COPY src ./src
# Builder l'application
RUN npm run build
# Stage production
FROM node:22-alpine AS production
WORKDIR /app
# Installer seulement les dépendances de production
COPY package*.json ./
RUN npm install --only=production && npm cache clean --force
# Copier le build depuis le stage builder
COPY --from=builder /app/dist ./dist
# Créer un utilisateur non-root
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nestjs -u 1001
# Créer le dossier uploads et donner les permissions
RUN mkdir -p /app/uploads/photos && chown -R nestjs:nodejs /app/uploads
USER nestjs
EXPOSE 3000
CMD ["node", "dist/main"]

63
backend/README-DEV.md Normal file
View File

@ -0,0 +1,63 @@
# 🚀 Guide de développement local
## Prérequis
- Docker et Docker Compose installés
- Git
## 🏃‍♂️ Démarrage rapide
### 1. Cloner le projet
```bash
git clone <url-du-depot-backend>
cd ptitspas-backend
```
### 2. Configuration de l'environnement
```bash
# Copier le fichier d'exemple
cp .env.example .env
# Optionnel : adapter les valeurs dans .env selon vos besoins
```
### 3. Lancer l'application
```bash
# Démarrer tous les services (PostgreSQL + PgAdmin + Backend)
docker compose -f docker-compose.dev.yml up -d
# Voir les logs
docker compose -f docker-compose.dev.yml logs -f
```
## 🌐 Accès aux services
- **Backend API** : http://localhost:3000
- **PgAdmin** : http://localhost:8080
- Email : admin@localhost
- Mot de passe : admin123
- **PostgreSQL** : localhost:5432
- Utilisateur : admin
- Mot de passe : admin123
- Base : ptitpas_db
## 🛠️ Commandes utiles
```bash
# Arrêter les services
docker compose -f docker-compose.dev.yml down
# Rebuild le backend après modification du Dockerfile
docker compose -f docker-compose.dev.yml up --build backend
# Voir l'état des services
docker compose -f docker-compose.dev.yml ps
# Accéder au container backend
docker exec -it ptitspas-backend-dev sh
```
## 📝 Notes de développement
- Les modifications du code source sont automatiquement prises en compte (hot reload)
- Les données PostgreSQL sont persistantes via le volume `postgres_dev_data`
- Le fichier `.env` n'est pas versionné pour des raisons de sécurité

158
backend/README.md Normal file
View File

@ -0,0 +1,158 @@
# P'titsPas API Backend ✨
Ce dépôt contient le code source de l'API backend pour la plateforme **P'titsPas**. L'API est construite avec NestJS et est responsable de toute la logique métier, de la gestion des données et de l'authentification des utilisateurs.
---
## 📚 Table des matières
- [Technologies utilisées](#-technologies-utilisées)
- [Prérequis](#-prérequis)
- [Installation](#-installation)
- [Lancement de l'application](#-lancement-de-lapplication)
- [Scripts principaux](#-scripts-principaux)
- [Tests](#-tests)
- [Gestion des migrations](#-gestion-des-migrations)
- [Documentation de l'API](#-documentation-de-lapi)
---
## 🛠️ Technologies utilisées
- **Framework**: [NestJS](https://nestjs.com/) (TypeScript)
- **Base de données**: [PostgreSQL](https://www.postgresql.org/)
- **ORM**: [TypeORM](https://typeorm.io/)
- **Authentification**: JWT avec [Passport.js](http://www.passportjs.org/)
- **Stockage Fichiers**: [MinIO](https://min.io/) (Compatible S3)
- **Tâches Asynchrones**: [Redis](https://redis.io/) avec [BullMQ](https://bullmq.io/)
- **Conteneurisation**: [Docker](https://www.docker.com/)
---
## 📋 Prérequis
Avant de commencer, assurez-vous d'avoir installé les outils suivants sur votre machine :
- [Node.js](https://nodejs.org/) (v18 ou supérieure)
- [npm](https://www.npmjs.com/) ou [pnpm](https://pnpm.io/)
- [Docker](https://www.docker.com/products/docker-desktop/) et Docker Compose
- [Git](https://git-scm.com/)
---
## 🚀 Installation
1. **Clonez le dépôt :**
```bash
git clone https://github.com/votre-username/ptitspas-backend.git
cd ptitspas-backend
```
2. **Créez le fichier d'environnement :**
Copiez le fichier d'exemple `.env.example` et renommez-le en `.env`. Ce fichier est ignoré par Git et contiendra vos secrets locaux.
```bash
cp .env.example .env
```
➡️ **Important :** Ouvrez le fichier `.env` et remplissez les variables (identifiants de la base de données, secrets JWT, etc.).
3. **Installez les dépendances du projet :**
```bash
npm install
```
---
## ▶️ Lancement de l'application
### Méthode recommandée : avec Docker
Cette méthode lance l'ensemble des services nécessaires (API, base de données, MinIO, Redis) dans des conteneurs isolés.
```bash
docker-compose up --build
```
L'API sera accessible à l'adresse `http://localhost:3000` (ou le port que vous avez configuré dans votre `.env`).
### Méthode locale (pour le développement)
Cette méthode ne lance que le serveur NestJS. Assurez-vous que les autres services (PostgreSQL, Redis, etc.) sont déjà en cours d'exécution (par exemple, via Docker).
```
npm run start:dev
```
Le serveur redémarrera automatiquement à chaque modification de fichier.
---
## ⚙️ Scripts principaux
| Commande | Description |
| :------------------ | :-------------------------------------------------------------------- |
| `npm run start:dev` | Lance le serveur en mode développement avec rechargement automatique. |
| `npm run build` | Compile le projet TypeScript en JavaScript. |
| `npm start` | Lance l'application depuis les fichiers compilés (mode production). |
| `npm run lint` | Analyse le code pour détecter les erreurs de style et de syntaxe. |
---
## 🧪 Tests
Pour lancer les tests, utilisez les commandes suivantes :
| Commande | Description |
| :----------------- | :--------------------------------------------------------------- |
| `npm test` | Lance les tests unitaires. |
| `npm run test:e2e` | Lance les tests de bout en bout (end-to-end). |
| `npm run test:cov` | Lance tous les tests et génère un rapport de couverture de code. |
---
## 🗄️ Gestion des migrations
La structure de la base de données est gérée par des fichiers de migration TypeORM.
1. **Générer une nouvelle migration :**
Après avoir modifié une entité TypeORM, générez automatiquement le fichier de migration correspondant.
```bash
npm run migration:generate -- src/database/migrations/NomDeLaMigration
```
2. **Appliquer les migrations :**
Pour mettre à jour le schéma de votre base de données avec les nouvelles migrations.
```bash
npm run migration:run
```
---
## 📖 Documentation de l'API
Une fois l'application lancée, la documentation de l'API générée avec **Swagger (OpenAPI)** est disponible à l'adresse suivante :
➡️ **[http://localhost:3000/api-docs](http://localhost:3000/api-docs)**
Cette interface vous permet d'explorer et de tester toutes les routes de l'API directement depuis votre navigateur.
Excellente idée. C'est un élément crucial qui définit les droits et les devoirs liés à votre code.
En me basant sur la section `10.5 Propriété intellectuelle et licence de lapplication` de votre cahier des charges, j'ai rédigé une section "Licence" qui reflète précisément le statut propriétaire de votre projet.
Voici le `README.md` complet et mis à jour.
---
## 📜 Licence
Ce projet est distribué sous une **licence propriétaire**.
Le code source, la marque "P'titsPas" et la documentation associée sont la propriété exclusive de l'éditeur, Julien MARTIN.
Toute reproduction, distribution, modification ou utilisation du code source est strictement interdite sans un accord écrit préalable de l'auteur. Les clients et partenaires autorisés disposent d'une licence d'utilisation non-exclusive et non-transférable, conformément aux termes de leur contrat.
Pour toute question relative à l'utilisation ou à l'acquisition d'une licence, veuillez contacter l'auteur.

View File

@ -0,0 +1,72 @@
# Docker Compose pour développement local
# Usage: docker compose -f docker-compose.dev.yml up -d
services:
# Base de données PostgreSQL
postgres:
image: postgres:17
container_name: ptitspas-postgres-dev
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin123}
POSTGRES_DB: ${POSTGRES_DB:-ptitpas_db}
ports:
- "5433:5432"
volumes:
# Si le fichier d'init existe dans le dépôt database
- ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
- postgres_dev_data:/var/lib/postgresql/data
networks:
- ptitspas_dev
# Interface d'administration DB
pgadmin:
image: dpage/pgadmin4
container_name: ptitspas-pgadmin-dev
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@ptits-pas.fr}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin123}
ports:
- "8080:80"
depends_on:
- postgres
networks:
- ptitspas_dev
# Backend NestJS
backend:
build:
context: .
dockerfile: Dockerfile
container_name: ptitspas-backend-dev
restart: unless-stopped
environment:
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin123}
POSTGRES_DB: ${POSTGRES_DB:-ptitpas_db}
API_PORT: ${API_PORT:-3000}
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-key}
JWT_EXPIRATION_TIME: ${JWT_EXPIRATION_TIME:-7d}
NODE_ENV: ${NODE_ENV:-development}
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
# Pour le hot reload en développement
- ./src:/app/src
- /app/node_modules
networks:
- ptitspas_dev
volumes:
postgres_dev_data:
networks:
ptitspas_dev:
driver: bridge

34
backend/eslint.config.mjs Normal file
View File

@ -0,0 +1,34 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

8
backend/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11571
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +1,95 @@
{ {
"name": "petitspas-backend", "name": "ptitspas-ynov-back",
"version": "1.0.0", "version": "0.0.1",
"description": "Backend pour l'application P'titsPas", "description": "",
"main": "dist/index.js", "author": "",
"private": true,
"license": "UNLICENSED",
"scripts": { "scripts": {
"start": "node dist/index.js", "typeorm": "typeorm-ts-node-commonjs",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts", "migration:run": "npm run typeorm migration:run",
"build": "tsc", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"init-admin": "ts-node src/scripts/initAdmin.ts" "test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.1.0", "@nestjs/common": "^11.1.6",
"@prisma/client": "^6.7.0", "@nestjs/config": "^4.0.2",
"@types/jsonwebtoken": "^9.0.9", "@nestjs/core": "^11.0.1",
"bcrypt": "^5.1.1", "@nestjs/jwt": "^11.0.0",
"cors": "^2.8.5", "@nestjs/mapped-types": "^2.1.0",
"express": "^4.18.2", "@nestjs/platform-express": "^11.0.1",
"helmet": "^7.1.0", "@nestjs/swagger": "^11.2.0",
"jsonwebtoken": "^9.0.2", "@nestjs/typeorm": "^11.0.0",
"morgan": "^1.10.0" "@sentry/nestjs": "^10.10.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"joi": "^18.0.0",
"mapped-types": "^0.0.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.26"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@eslint/eslintrc": "^3.2.0",
"@types/cors": "^2.8.17", "@eslint/js": "^9.18.0",
"@types/express": "^4.17.21", "@nestjs/cli": "^11.0.10",
"@types/helmet": "^4.0.0", "@nestjs/schematics": "^11.0.0",
"@types/morgan": "^1.9.9", "@nestjs/testing": "^11.0.1",
"@types/node": "^20.11.19", "@types/bcrypt": "^6.0.0",
"prisma": "^6.7.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3" "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
} }
} }

View File

@ -1,108 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Modèle pour les parents
model Parent {
id String @id @default(uuid())
email String @unique
password String
firstName String
lastName String
phoneNumber String?
address String?
status AccountStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
children Child[]
contracts Contract[]
}
// Modèle pour les enfants
model Child {
id String @id @default(uuid())
firstName String
dateOfBirth DateTime
photoUrl String?
photoConsent Boolean @default(false)
isMultiple Boolean @default(false)
isUnborn Boolean @default(false)
parentId String
parent Parent @relation(fields: [parentId], references: [id])
contracts Contract[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Modèle pour les contrats
model Contract {
id String @id @default(uuid())
parentId String
childId String
startDate DateTime
endDate DateTime?
status ContractStatus @default(ACTIVE)
parent Parent @relation(fields: [parentId], references: [id])
child Child @relation(fields: [childId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Modèle pour les thèmes
model Theme {
id String @id @default(uuid())
name String @unique
primaryColor String
secondaryColor String
backgroundColor String
textColor String
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
appSettings AppSettings[]
}
// Modèle pour les paramètres de l'application
model AppSettings {
id String @id @default(uuid())
currentThemeId String
currentTheme Theme @relation(fields: [currentThemeId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([currentThemeId])
}
// Modèle pour les administrateurs
model Admin {
id String @id @default(uuid())
email String @unique
password String
firstName String
lastName String
passwordChanged Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Enums
enum AccountStatus {
PENDING
VALIDATED
REJECTED
SUSPENDED
}
enum ContractStatus {
ACTIVE
ENDED
CANCELLED
}

View File

@ -1,18 +0,0 @@
import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';
import { AdminService } from './admin.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Post('change-password')
@UseGuards(JwtAuthGuard)
async changePassword(
@Req() req,
@Body('oldPassword') oldPassword: string,
@Body('newPassword') newPassword: string,
) {
return this.adminService.changePassword(req.user.id, oldPassword, newPassword);
}
}

View File

@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PrismaModule } from '../prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
PrismaModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '1d' },
}),
],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}

View File

@ -1,40 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async changePassword(adminId: string, oldPassword: string, newPassword: string) {
// Récupérer l'administrateur
const admin = await this.prisma.admin.findUnique({
where: { id: adminId },
});
if (!admin) {
throw new UnauthorizedException('Administrateur non trouvé');
}
// Vérifier l'ancien mot de passe
const isPasswordValid = await bcrypt.compare(oldPassword, admin.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Ancien mot de passe incorrect');
}
// Hasher le nouveau mot de passe
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Mettre à jour le mot de passe
await this.prisma.admin.update({
where: { id: adminId },
data: { password: hashedPassword },
});
return { message: 'Mot de passe modifié avec succès' };
}
}

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getOverView() {
return this.appService.getOverView();
}
@Get('hello')
getHello(): string {
return this.appService.getHello();
}
}

View File

@ -1,17 +1,71 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AppService } from './app.service';
import { AdminModule } from './admin/admin.module'; import appConfig from './config/app.config';
import databaseConfig from './config/database.config';
import jwtConfig from './config/jwt.config';
import { configValidationSchema } from './config/validation.schema';
import { UserModule } from './routes/user/user.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_FILTER } from '@nestjs/core';
import { ParentsModule } from './routes/parents/parents.module';
import { AuthModule } from './routes/auth/auth.module';
import { SentryGlobalFilter } from '@sentry/nestjs/setup';
import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
// Gestion dynamique des fichiers .env
envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
// envFilePath: '.env',
// Chargement de configurations typées
load: [appConfig, databaseConfig, jwtConfig],
isGlobal: true, isGlobal: true,
validationSchema: configValidationSchema,
}), }),
PrismaModule, TypeOrmModule.forRootAsync({
imports: [ConfigModule,
],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('database.host'),
port: config.get<number>('database.port'),
username: config.get('database.username'),
password: config.get('database.password'),
database: config.get('database.database'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
logging: true,
}),
}),
UserModule,
ParentsModule,
EnfantsModule,
AuthModule, AuthModule,
AdminModule, AppConfigModule,
DocumentsLegauxModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_FILTER,
useClass: SentryGlobalFilter
},
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
}
], ],
}) })
export class AppModule {} export class AppModule { }

View File

@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello Test!!!';
}
getOverView() {
return {
name: "P'titsPas API",
version: "1.0",
description: "Documentation rapide des endpoints disponibles",
authentication: "JWT Bearer Token requis",
endpoints: [
{
method: "GET",
path: "/parents",
description: "Liste tous les parents",
roles: ["SUPER_ADMIN", "GESTIONNAIRE"]
},
{
method: "GET",
path: "/parents/:id",
description: "Récupère un parent par ID utilisateur",
roles: ["SUPER_ADMIN", "GESTIONNAIRE"],
params: ["id (UUID)"]
},
{
method: "POST",
path: "/parents",
description: "Crée un parent",
roles: ["SUPER_ADMIN", "GESTIONNAIRE"],
body: {
user_id: "UUID",
co_parent_id: "UUID (optionnel)"
}
},
{
method: "PATCH",
path: "/parents/:id",
description: "Met à jour un parent",
roles: ["SUPER_ADMIN", "GESTIONNAIRE"],
params: ["id (UUID)"],
body: {
user_id: "UUID (optionnel)",
co_parent_id: "UUID (optionnel)"
}
},
{
method: "DELETE",
path: "/parents/:id",
description: "Supprime un parent",
roles: ["SUPER_ADMIN"],
params: ["id (UUID)"]
}
],
docs: "/api/docs"
};
}
}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,3 @@
import { SetMetadata } from "@nestjs/common";
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);

View File

@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const User = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
});

View File

@ -0,0 +1,11 @@
import { IsDateString, IsOptional } from "class-validator";
export class DateRangeQueryDto {
@IsOptional()
@IsDateString()
start?: string;
@IsOptional()
@IsDateString()
end?: string;
}

View File

@ -0,0 +1,6 @@
import { IsUUID } from "class-validator";
export class IdParamDto {
@IsUUID()
id: string;
}

View File

@ -0,0 +1,11 @@
import { IsOptional, IsPositive } from "class-validator";
export class PaginationQueryDto {
@IsOptional()
@IsPositive()
offset?: number;
@IsOptional()
@IsPositive()
limit?: number;
}

View File

@ -0,0 +1,8 @@
import { IsOptional, IsString, MinLength } from "class-validator";
export class SearchQueryDto {
@IsOptional()
@IsString()
@MinLength(2)
q?: string;
}

View File

@ -0,0 +1,27 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.getResponse()
: { message: 'Internal server error' };
response.status(status).json({
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message,
});
}
}

View File

@ -0,0 +1,49 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from 'express';
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<Request>();
if (request.path.startsWith('/api-docs')) {
return true;
}
const authHeader = request.headers['authorization'] as string | undefined;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Token manquant ou invalide');
}
const token = authHeader.split(' ')[1];
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.accessSecret'),
});
request.user = {
...payload,
id: payload.sub,
};
return true;
} catch (error) {
throw new UnauthorizedException('Token invalide ou expiré');
}
}
}

View File

@ -0,0 +1,26 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Observable } from "rxjs";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.role) {
return false;
}
return requiredRoles.includes(user.role);
}
}

View File

@ -0,0 +1,15 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { map, Observable, timestamp } from "rxjs";
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
timestamp: new Date().toISOString(),
data
})),
);
}
}

View File

@ -0,0 +1,6 @@
import { registerAs } from '@nestjs/config';
export default registerAs('', () => ({
port: process.env.PORT,
env: process.env.NODE_ENV,
}));

View File

@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
host: process.env.POSTGRES_HOST,
port: process.env.POSTGRES_PORT,
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
}));

View File

@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
accessSecret: process.env.JWT_ACCESS_SECRET,
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES,
refreshSecret: process.env.JWT_REFRESH_SECRET,
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES,
}));

View File

@ -0,0 +1,21 @@
import * as Joi from 'joi';
export const configValidationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().optional(),
// Base de données
POSTGRES_HOST: Joi.string().required(),
POSTGRES_PORT: Joi.number().required(),
POSTGRES_USER: Joi.string().required(),
POSTGRES_PASSWORD: Joi.string().required(),
POSTGRES_DB: Joi.string().required(),
// JWT
JWT_ACCESS_SECRET: Joi.string().required(),
JWT_ACCESS_EXPIRES: Joi.string().required(),
JWT_REFRESH_SECRET: Joi.string().required(),
JWT_REFRESH_EXPIRES: Joi.string().required(),
});

View File

@ -1,72 +0,0 @@
import { Request, Response } from 'express';
import { ThemeService, ThemeData } from '../services/theme.service';
export class ThemeController {
// Créer un nouveau thème
static async createTheme(req: Request, res: Response) {
try {
const themeData: ThemeData = req.body;
const theme = await ThemeService.createTheme(themeData);
res.status(201).json(theme);
} catch (error) {
res.status(500).json({ error: 'Erreur lors de la création du thème' });
}
}
// Récupérer tous les thèmes
static async getAllThemes(req: Request, res: Response) {
try {
const themes = await ThemeService.getAllThemes();
res.json(themes);
} catch (error) {
res.status(500).json({ error: 'Erreur lors de la récupération des thèmes' });
}
}
// Récupérer le thème actif
static async getActiveTheme(req: Request, res: Response) {
try {
const theme = await ThemeService.getActiveTheme();
if (!theme) {
return res.status(404).json({ error: 'Aucun thème actif trouvé' });
}
res.json(theme);
} catch (error) {
res.status(500).json({ error: 'Erreur lors de la récupération du thème actif' });
}
}
// Activer un thème
static async activateTheme(req: Request, res: Response) {
try {
const { themeId } = req.params;
const theme = await ThemeService.activateTheme(themeId);
res.json(theme);
} catch (error) {
res.status(500).json({ error: 'Erreur lors de l\'activation du thème' });
}
}
// Mettre à jour un thème
static async updateTheme(req: Request, res: Response) {
try {
const { themeId } = req.params;
const themeData: Partial<ThemeData> = req.body;
const theme = await ThemeService.updateTheme(themeId, themeData);
res.json(theme);
} catch (error) {
res.status(500).json({ error: 'Erreur lors de la mise à jour du thème' });
}
}
// Supprimer un thème
static async deleteTheme(req: Request, res: Response) {
try {
const { themeId } = req.params;
await ThemeService.deleteTheme(themeId);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Erreur lors de la suppression du thème' });
}
}
}

View File

@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Users } from './users.entity';
import { DocumentLegal } from './document-legal.entity';
@Entity('acceptations_documents')
export class AcceptationDocument {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Users, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_utilisateur' })
utilisateur: Users;
@ManyToOne(() => DocumentLegal, { nullable: true })
@JoinColumn({ name: 'id_document' })
document: DocumentLegal | null;
@Column({ type: 'varchar', length: 50, nullable: false })
type_document: 'cgu' | 'privacy';
@Column({ type: 'integer', nullable: false })
version_document: number;
@CreateDateColumn({ name: 'accepte_le', type: 'timestamptz' })
accepteLe: Date;
@Column({ type: 'inet', nullable: true })
ip_address: string | null;
@Column({ type: 'text', nullable: true })
user_agent: string | null;
}

View File

@ -0,0 +1,51 @@
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { Users } from './users.entity';
@Entity('assistantes_maternelles')
export class AssistanteMaternelle {
// PK = FK vers utilisateurs.id
@PrimaryColumn('uuid', { name: 'id_utilisateur' })
user_id: string;
@OneToOne(() => Users, (user) => user.assistanteMaternelle, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user: Users;
@Column({ name: 'numero_agrement', length: 50, nullable: true })
approval_number?: string;
@Column({ name: 'nir_chiffre', length: 15, nullable: true })
nir?: string;
@Column({ name: 'nb_max_enfants', type: 'int', nullable: true })
max_children?: number;
@Column({ name: 'biographie', type: 'text', nullable: true })
biography?: string;
@Column({
name: 'disponible',
type: 'boolean',
default: true,
nullable: true,
})
available?: boolean;
@Column({ name: 'ville_residence', length: 100, nullable: true })
residence_city?: string;
@Column( { name: 'date_agrement', type: 'date', nullable: true })
agreement_date?: Date;
@Column( { name: 'annee_experience', type: 'smallint', nullable: true })
years_experience?: number;
@Column( { name: 'specialite', length: 100, nullable: true })
specialty?: string;
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
places_available?: number;
}

View File

@ -0,0 +1,42 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Contrat } from "./contrats.entity";
import { Users } from "./users.entity";
export enum StatutAvenantType {
PROPOSE = 'propose',
ACCEPTE = 'accepte',
REFUSE = 'refuse',
}
@Entity('avenants_contrats')
export class AvenantContrat {
// Define your columns and relationships here
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Contrat, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_contrat' })
contrat: Contrat;
@Column({ type: 'jsonb', nullable: true, name: 'modifications' })
modifications?: any;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'initie_par', referencedColumnName: 'id' })
initiator?: Users;
@Column({
type: 'enum',
enum: StatutAvenantType,
enumName: 'statut_avenant_type',
default: StatutAvenantType.PROPOSE,
name: 'statut'
})
statut: StatutAvenantType;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,74 @@
import {
Entity, PrimaryGeneratedColumn, Column,
OneToMany, ManyToMany, CreateDateColumn, JoinTable
} from 'typeorm';
import { Parents } from './parents.entity';
import { ParentsChildren } from './parents_children.entity';
import { Dossier } from './dossiers.entity';
export enum StatutEnfantType {
A_NAITRE = 'a_naitre',
ACTIF = 'actif',
SCOLARISE = 'scolarise',
}
export enum GenreType {
H = 'H',
F = 'F',
AUTRE = 'Autre',
}
@Entity('enfants')
export class Children {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: StatutEnfantType,
enumName: 'statut_enfant_type',
name: 'statut'
})
status: StatutEnfantType;
@Column({ name: 'prenom', length: 100, nullable: true })
first_name?: string;
@Column({ name: 'nom', length: 100, nullable: true })
last_name?: string;
@Column({
type: 'enum',
enum: GenreType,
enumName: 'genre_type',
nullable: true,
name: 'genre'
})
gender?: GenreType;
@Column({ type: 'date', nullable: true, name: 'date_naissance' })
birth_date?: Date;
@Column({ type: 'date', nullable: true, name: 'date_prevue_naissance' })
due_date?: Date;
@Column({ nullable: true, name: 'photo_url', type: 'text' })
photo_url?: string;
@Column({ default: false, name: 'consentement_photo', type: 'boolean' })
consent_photo: boolean;
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
consent_photo_at?: Date;
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
is_multiple: boolean;
// Lien via table de jointure enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[];
// Relation avec Dossier
@OneToMany(() => Dossier, d => d.child)
dossiers: Dossier[];
}

View File

@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { Users } from './users.entity';
@Entity('configuration')
export class Configuration {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100, unique: true, nullable: false })
cle: string;
@Column({ type: 'text', nullable: true })
valeur: string | null;
@Column({ type: 'varchar', length: 50, nullable: false })
type: 'string' | 'number' | 'boolean' | 'json' | 'encrypted';
@Column({ type: 'varchar', length: 50, nullable: true })
categorie: 'email' | 'app' | 'security' | null;
@Column({ type: 'text', nullable: true })
description: string | null;
@UpdateDateColumn({ name: 'modifie_le' })
modifieLe: Date;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'modifie_par' })
modifiePar: Users | null;
}

View File

@ -0,0 +1,57 @@
import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Dossier } from "./dossiers.entity";
export enum StatutContratType {
BROUILLON = 'brouillon',
EN_ATTENTE_SIGNATURE = 'en_attente_signature',
VALIDE = 'valide',
RESILIE = 'resilie',
}
@Entity('contrats')
export class Contrat {
// Define your columns and relationships here
@PrimaryGeneratedColumn('uuid')
id: string;
@OneToOne(() => Dossier, {onDelete: 'CASCADE'} )
@JoinColumn({ name: 'id_dossier'})
dossier: Dossier;
@Column({type: 'jsonb', nullable: true, name: 'planning'})
planning?: any;
@Column({type: 'numeric', precision: 6, scale: 2, nullable: true, name: 'tarif_horaire'})
hourly_rate?: string;
@Column({type: 'numeric', precision: 6, scale: 2, nullable: true, name: 'indemnites_repas'})
meal_indemnity?: string;
@Column( { name: 'date_debut', type: 'date', nullable: true })
start_date?: Date;
@Column({
type: 'enum',
enum: StatutContratType,
enumName: 'statut_contrat_type',
default: StatutContratType.BROUILLON,
name: 'statut'
})
statut: StatutContratType;
@Column({type: 'boolean', default: false, name: 'signe_parent'})
signed_by_parent: boolean;
@Column({type: 'boolean', default: false, name: 'signe_am'})
signed_by_am: boolean;
@Column({type: 'timestamptz', nullable: true, name: 'finalise_le'})
finalized_at?: Date;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
updated_at: Date;
}

View File

@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Users } from './users.entity';
@Entity('documents_legaux')
export class DocumentLegal {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50, nullable: false })
type: 'cgu' | 'privacy';
@Column({ type: 'integer', nullable: false })
version: number;
@Column({ type: 'varchar', length: 255, nullable: false })
fichier_nom: string;
@Column({ type: 'varchar', length: 500, nullable: false })
fichier_path: string;
@Column({ type: 'varchar', length: 64, nullable: false })
fichier_hash: string;
@Column({ type: 'boolean', default: false })
actif: boolean;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'televerse_par' })
televersePar: Users | null;
@CreateDateColumn({ name: 'televerse_le', type: 'timestamptz' })
televerseLe: Date;
@Column({ name: 'active_le', type: 'timestamptz', nullable: true })
activeLe: Date | null;
}

View File

@ -0,0 +1,61 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn, JoinColumn
} from 'typeorm';
import { Parents } from './parents.entity';
import { Children } from './children.entity';
import { Message } from './messages.entity';
export enum StatutDossierType {
ENVOYE = 'envoye',
ACCEPTE = 'accepte',
REFUSE = 'refuse',
CLOTURE = 'cloture',
}
@Entity('dossiers')
export class Dossier {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Parents, p => p.dossiers, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
parent: Parents;
@ManyToOne(() => Children, c => c.dossiers, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
child: Children;
@Column({ type: 'text', nullable: true, name: 'presentation' })
presentation?: string;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'type_contrat' })
type_contrat?: string;
@Column({ type: 'boolean', default: false, name: 'repas' })
meals: boolean;
@Column({ type: 'numeric', precision: 10, scale: 2, nullable: true, name: 'budget' })
budget?: number;
@Column({ type: 'jsonb', nullable: true, name: 'planning_souhaite' })
desired_schedule?: any;
@Column({
type: 'enum',
enum: StatutDossierType,
enumName: 'statut_dossier_type',
default: StatutDossierType.ENVOYE,
name: 'statut'
})
status: StatutDossierType;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
updated_at: Date;
@OneToMany(() => Message, m => m.dossier)
messages: Message[];
}

View File

@ -0,0 +1,79 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Children } from "./children.entity";
import { Users } from "./users.entity";
import { Parents } from "./parents.entity";
export enum TypeEvenementType {
ABSENCE_ENFANT = 'absence_enfant',
CONGE_AM = 'conge_am',
CONGE_PARENT = 'conge_parent',
ARRET_MALADIE_AM = 'arret_maladie_am',
EVENEMENT_RPE = 'evenement_rpe',
}
export enum StatutEvenementType {
PROPOSE = 'propose',
VALIDE = 'valide',
REFUSE = 'refuse',
}
@Entity('evenements')
export class Evenement {
// Define your columns and relationships here
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: TypeEvenementType,
enumName: 'type_evenement_type',
name: 'type'
})
type: TypeEvenementType;
@ManyToOne(() => Children, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
child?: Children;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'id_am', referencedColumnName: 'id' })
assistanteMaternelle?: Users;
@ManyToOne(() => Parents, { nullable: true })
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
parent?: Parents;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
created_by?: Users;
@Column({ type: 'timestamptz', nullable: true, name: 'date_debut' })
start_date?: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'date_fin' })
end_date?: Date;
@Column({ type: 'text', nullable: true, name: 'commentaires' })
comments?: string;
@Column({
type: 'enum',
enum: StatutEvenementType,
enumName: 'statut_evenement_type',
name: 'statut',
default: StatutEvenementType.PROPOSE
})
status: StatutEvenementType;
@Column({type: 'timestamptz', nullable: true, name: 'delai_grace'})
grace_deadline?: Date;
@Column({type: 'boolean', default: false, name: 'urgent'})
urgent: boolean;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
updated_at: Date;
}

View File

@ -0,0 +1,29 @@
import {
Entity, PrimaryGeneratedColumn, Column,
ManyToOne, JoinColumn, CreateDateColumn
} from 'typeorm';
import { Dossier } from './dossiers.entity';
import { Users } from './users.entity';
@Entity('messages')
export class Message {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Dossier, d => d.messages, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'id_dossier' })
dossier: Dossier;
@ManyToOne(() => Users, u => u.messages, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'id_expediteur' })
sender: Users;
@Column({ type: 'text', name: 'contenu' })
content: string;
@Column({ type: 'boolean', name: 're_redige_par_ia', default: false })
reRedigeParIA: boolean;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
}

View File

@ -0,0 +1,23 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Users } from "./users.entity";
@Entity('notifications')
export class Notification {
// Define your columns and relationships here
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Users, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user: Users;
@Column({ type: 'text', name: 'contenu' })
content: string;
@Column({type: 'boolean', name: 'lu', default: false})
read: boolean;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
}

View File

@ -0,0 +1,31 @@
import {
Entity, PrimaryColumn, OneToOne, JoinColumn,
ManyToOne, OneToMany
} from 'typeorm';
import { Users } from './users.entity';
import { ParentsChildren } from './parents_children.entity';
import { Dossier } from './dossiers.entity';
@Entity('parents', { schema: 'public' })
export class Parents {
// PK = FK vers utilisateurs.id
@PrimaryColumn('uuid', { name: 'id_utilisateur' })
user_id: string;
@OneToOne(() => Users, user => user.parent, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user: Users;
// Co-parent (nullable) → FK vers utilisateurs.id
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
co_parent?: Users;
// Lien vers enfants via la table enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.parent)
parentChildren: ParentsChildren[];
// Lien vers les dossiers de ce parent
@OneToMany(() => Dossier, d => d.parent)
dossiers: Dossier[];
}

View File

@ -0,0 +1,22 @@
import {
Entity, ManyToOne, JoinColumn, PrimaryColumn
} from 'typeorm';
import { Parents } from './parents.entity';
import { Children } from './children.entity';
@Entity('enfants_parents', { schema: 'public' })
export class ParentsChildren {
@PrimaryColumn('uuid', { name: 'id_parent' })
parentId: string;
@PrimaryColumn('uuid', { name: 'id_enfant' })
enfantId: string;
@ManyToOne(() => Parents, p => p.parentChildren, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
parent: Parents;
@ManyToOne(() => Children, c => c.parentLinks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
child: Children;
}

View File

@ -0,0 +1,19 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Users } from "./users.entity";
@Entity('signalements_bugs')
export class SignalementBug {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Users, {nullable: true})
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user?: Users;
@Column({ type: 'text', name: 'description'})
description: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
}

View File

@ -0,0 +1,21 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Users } from "./users.entity";
@Entity('uploads')
export class Upload {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Users, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user?: Users;
@Column({ type: 'text', name: 'fichier_url' })
file_url: string;
@Column({type: 'varchar', length: 50, nullable: true, name: 'type'})
type?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
}

View File

@ -0,0 +1,150 @@
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn,
OneToOne, OneToMany
} from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Parents } from './parents.entity';
import { Message } from './messages.entity';
// Enums alignés avec la BDD PostgreSQL
export enum RoleType {
PARENT = 'parent',
GESTIONNAIRE = 'gestionnaire',
SUPER_ADMIN = 'super_admin',
ASSISTANTE_MATERNELLE = 'assistante_maternelle',
ADMINISTRATEUR = 'administrateur',
}
//Enum pour definir le genre
export enum GenreType {
H = 'H',
F = 'F',
AUTRE = 'Autre',
}
//Enum pour definir le statut utilisateur
export enum StatutUtilisateurType {
EN_ATTENTE = 'en_attente',
ACTIF = 'actif',
SUSPENDU = 'suspendu',
}
export enum SituationFamilialeType {
CELIBATAIRE = 'celibataire',
MARIE = 'marie',
DIVORCE = 'divorce',
VEUF = 'veuf',
PACSE = 'pacse',
SEPARE = 'separe',
PARENT_ISOLE = 'parent_isole',
CONCUBINAGE = 'concubinage',
}
//Declaration de l'entite utilisateur
@Entity('utilisateurs', { schema: 'public' })
export class Users {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, name: 'email' })
email: string;
@Column({ name: 'password', nullable: true })
password?: string;
@Column({ name: 'prenom', nullable: true })
prenom?: string;
@Column({ name: 'nom', nullable: true })
nom?: string;
@Column({
type: 'enum',
enum: GenreType,
enumName: 'genre_type', // correspond à l'enum de la db psql
nullable: true,
name: 'genre'
})
genre?: GenreType;
@Column({
type: 'enum',
enum: RoleType,
enumName: 'role_type', // correspond à l'enum de la db psql
name: 'role'
})
role: RoleType;
@Column({
type: 'enum',
enum: StatutUtilisateurType,
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
default: StatutUtilisateurType.EN_ATTENTE,
name: 'statut'
})
statut: StatutUtilisateurType;
@Column({ type: 'enum',
enum: SituationFamilialeType,
enumName: 'situation_familiale_type',
nullable: true,
name: 'situation_familiale'
})
situation_familiale?: SituationFamilialeType;
@Column({ nullable: true, name: 'telephone' })
telephone?: string;
@Column({ nullable: true, name: 'adresse' })
adresse?: string;
@Column({ nullable: true, name: 'photo_url' })
photo_url?: string;
@Column({ default: false, name: 'consentement_photo' })
consentement_photo: boolean;
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
date_consentement_photo?: Date;
@Column({ default: false, name: 'changement_mdp_obligatoire' })
changement_mdp_obligatoire: boolean;
@Column({ nullable: true, name: 'token_creation_mdp', length: 255 })
token_creation_mdp?: string;
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
token_creation_mdp_expire_le?: Date;
@Column({ nullable: true, name: 'ville' })
ville?: string;
@Column({ nullable: true, name: 'code_postal' })
code_postal?: string;
@Column({ nullable: true, name: 'profession' })
profession?: string;
@Column({ name: 'date_naissance', type: 'date', nullable: true })
date_naissance?: Date;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
// Relations
@OneToOne(() => AssistanteMaternelle, a => a.user)
assistanteMaternelle?: AssistanteMaternelle;
@OneToOne(() => Parents, p => p.user)
parent?: Parents;
@OneToMany(() => Message, m => m.sender)
messages?: Message[];
@OneToMany(() => Parents, parent => parent.co_parent)
co_parent_in?: Parents[];
}

View File

@ -0,0 +1,44 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Users } from "./users.entity";
export enum StatutValidationType {
EN_ATTENTE = 'en_attente',
VALIDE = 'valide',
REFUSE = 'refuse',
}
@Entity('validations')
export class Validation {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
user?: Users;
@Column({ type: 'varchar', length: 50, name: 'type', nullable: true })
type: string;
@Column({
type: 'enum',
enum: StatutValidationType,
enumName: 'statut_validation_type',
name: 'statut',
default: StatutValidationType.EN_ATTENTE
})
status: StatutValidationType;
@ManyToOne(() => Users, { nullable: true })
@JoinColumn({ name: 'valide_par', referencedColumnName: 'id' })
validated_by?: Users;
@Column( { name: 'commentaire', type: 'text', nullable: true })
comment?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
updated_at: Date;
}

View File

@ -1,28 +0,0 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import themeRoutes from './routes/theme.routes';
const app = express();
const port = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(helmet());
app.use(morgan('dev'));
app.use(express.json());
// Routes
app.use('/api/themes', themeRoutes);
// Gestion des erreurs
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Une erreur est survenue' });
});
// Démarrage du serveur
app.listen(port, () => {
console.log(`Serveur démarré sur le port ${port}`);
});

59
backend/src/main.ts Normal file
View File

@ -0,0 +1,59 @@
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
import { DocumentBuilder } from '@nestjs/swagger';
import { AuthGuard } from './common/guards/auth.guard';
import { JwtService } from '@nestjs/jwt';
import { RolesGuard } from './common/guards/roles.guard';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule,
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
app.enableCors();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port', 3000);
app.setGlobalPrefix('api/v1');
const config = new DocumentBuilder()
.setTitle("P'titsPas API")
.setDescription("API pour l'application P'titsPas")
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'Bearer',
bearerFormat: 'JWT',
name: 'Authorization',
description: 'Enter JWT token',
in: 'header',
},
'access-token',
)
//.addServer('/api/v1')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/v1/swagger', app, document);
await app.listen(port);
console.log(`✅ P'titsPas API is running on: ${await app.getUrl()}`);
}
bootstrap().catch((err) => {
console.error('❌ Error starting the application:', err);
process.exit(1);
});

View File

@ -0,0 +1,232 @@
import {
Controller,
Get,
Patch,
Post,
Body,
Param,
UseGuards,
Request,
HttpStatus,
HttpException,
} from '@nestjs/common';
import { AppConfigService } from './config.service';
import { UpdateConfigDto } from './dto/update-config.dto';
import { TestSmtpDto } from './dto/test-smtp.dto';
@Controller('configuration')
export class ConfigController {
constructor(private readonly configService: AppConfigService) {}
/**
* Vérifier si la configuration initiale est terminée
* GET /api/v1/configuration/setup/status
*/
@Get('setup/status')
async getSetupStatus() {
try {
const isCompleted = this.configService.isSetupCompleted();
return {
success: true,
data: {
setupCompleted: isCompleted,
},
};
} catch (error) {
throw new HttpException(
{
success: false,
message: 'Erreur lors de la vérification du statut de configuration',
error: error.message,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Marquer la configuration initiale comme terminée
* POST /api/v1/configuration/setup/complete
*/
@Post('setup/complete')
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin')
async completeSetup(@Request() req: any) {
try {
// TODO: Récupérer l'ID utilisateur depuis le JWT
const userId = req.user?.id || 'system';
await this.configService.markSetupCompleted(userId);
return {
success: true,
message: 'Configuration initiale terminée avec succès',
};
} catch (error) {
throw new HttpException(
{
success: false,
message: 'Erreur lors de la finalisation de la configuration',
error: error.message,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Test de la connexion SMTP
* POST /api/v1/configuration/test-smtp
*/
@Post('test-smtp')
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin')
async testSmtp(@Body() testSmtpDto: TestSmtpDto) {
try {
const result = await this.configService.testSmtpConnection(testSmtpDto.testEmail);
if (result.success) {
return {
success: true,
message: 'Connexion SMTP réussie. Email de test envoyé.',
};
} else {
return {
success: false,
message: 'Échec du test SMTP',
error: result.error,
};
}
} catch (error) {
throw new HttpException(
{
success: false,
message: 'Erreur lors du test SMTP',
error: error.message,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Mise à jour multiple des configurations
* PATCH /api/v1/configuration/bulk
*/
@Patch('bulk')
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin')
async updateBulk(@Body() updateConfigDto: UpdateConfigDto, @Request() req: any) {
try {
// TODO: Récupérer l'ID utilisateur depuis le JWT
const userId = req.user?.id || null;
let updated = 0;
const errors: string[] = [];
// Parcourir toutes les clés du DTO
for (const [key, value] of Object.entries(updateConfigDto)) {
if (value !== undefined) {
try {
await this.configService.set(key, value, userId);
updated++;
} catch (error) {
errors.push(`${key}: ${error.message}`);
}
}
}
// Recharger le cache après les modifications
await this.configService.loadCache();
if (errors.length > 0) {
return {
success: false,
message: 'Certaines configurations n\'ont pas pu être mises à jour',
updated,
errors,
};
}
return {
success: true,
message: 'Configuration mise à jour avec succès',
updated,
};
} catch (error) {
throw new HttpException(
{
success: false,
message: 'Erreur lors de la mise à jour des configurations',
error: error.message,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Récupérer toutes les configurations (pour l'admin)
* GET /api/v1/configuration
*/
@Get()
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin')
async getAll() {
try {
const configs = await this.configService.getAll();
return {
success: true,
data: configs,
};
} catch (error) {
throw new HttpException(
{
success: false,
message: 'Erreur lors de la récupération des configurations',
error: error.message,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Récupérer les configurations par catégorie
* GET /api/v1/configuration/:category
*/
@Get(':category')
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin')
async getByCategory(@Param('category') category: string) {
try {
if (!['email', 'app', 'security'].includes(category)) {
throw new HttpException(
{
success: false,
message: 'Catégorie invalide. Valeurs acceptées: email, app, security',
},
HttpStatus.BAD_REQUEST,
);
}
const configs = await this.configService.getByCategory(category);
return {
success: true,
data: configs,
};
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
{
success: false,
message: 'Erreur lors de la récupération des configurations',
error: error.message,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Configuration } from '../../entities/configuration.entity';
import { AppConfigService } from './config.service';
import { ConfigController } from './config.controller';
@Module({
imports: [TypeOrmModule.forFeature([Configuration])],
controllers: [ConfigController],
providers: [AppConfigService],
exports: [AppConfigService],
})
export class AppConfigModule {}

View File

@ -0,0 +1,338 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Configuration } from '../../entities/configuration.entity';
import * as crypto from 'crypto';
@Injectable()
export class AppConfigService implements OnModuleInit {
private readonly logger = new Logger(AppConfigService.name);
private cache: Map<string, any> = new Map();
private readonly ENCRYPTION_KEY: string;
private readonly ENCRYPTION_ALGORITHM = 'aes-256-cbc';
private readonly IV_LENGTH = 16;
constructor(
@InjectRepository(Configuration)
private configRepo: Repository<Configuration>,
) {
// Clé de chiffrement depuis les variables d'environnement
// En production, cette clé doit être générée de manière sécurisée
this.ENCRYPTION_KEY =
process.env.CONFIG_ENCRYPTION_KEY ||
crypto.randomBytes(32).toString('hex');
if (!process.env.CONFIG_ENCRYPTION_KEY) {
this.logger.warn(
'⚠️ CONFIG_ENCRYPTION_KEY non définie. Utilisation d\'une clé temporaire (NON RECOMMANDÉ EN PRODUCTION)',
);
}
}
/**
* Chargement du cache au démarrage de l'application
*/
async onModuleInit() {
await this.loadCache();
}
/**
* Chargement de toutes les configurations en cache
*/
async loadCache(): Promise<void> {
try {
const configs = await this.configRepo.find();
this.logger.log(`📦 Chargement de ${configs.length} configurations en cache`);
for (const config of configs) {
let value = config.valeur;
// Déchiffrement si nécessaire
if (config.type === 'encrypted' && value) {
try {
value = this.decrypt(value);
} catch (error) {
this.logger.error(
`❌ Erreur de déchiffrement pour la clé '${config.cle}'`,
error,
);
value = null;
}
}
// Conversion de type
const convertedValue = this.convertType(value, config.type);
this.cache.set(config.cle, convertedValue);
}
this.logger.log('✅ Cache de configuration chargé avec succès');
} catch (error) {
this.logger.error('❌ Erreur lors du chargement du cache', error);
throw error;
}
}
/**
* Récupération d'une valeur de configuration
* @param key Clé de configuration
* @param defaultValue Valeur par défaut si la clé n'existe pas
* @returns Valeur de configuration
*/
get<T = any>(key: string, defaultValue?: T): T {
if (this.cache.has(key)) {
return this.cache.get(key) as T;
}
if (defaultValue !== undefined) {
return defaultValue;
}
this.logger.warn(`⚠️ Configuration '${key}' non trouvée et aucune valeur par défaut fournie`);
return undefined as T;
}
/**
* Mise à jour d'une valeur de configuration
* @param key Clé de configuration
* @param value Nouvelle valeur
* @param userId ID de l'utilisateur qui modifie
*/
async set(key: string, value: any, userId?: string): Promise<void> {
const config = await this.configRepo.findOne({ where: { cle: key } });
if (!config) {
throw new Error(`Configuration '${key}' non trouvée`);
}
let valueToStore = value !== null && value !== undefined ? String(value) : null;
// Chiffrement si nécessaire
if (config.type === 'encrypted' && valueToStore) {
valueToStore = this.encrypt(valueToStore);
}
config.valeur = valueToStore;
config.modifieLe = new Date();
if (userId) {
config.modifiePar = { id: userId } as any;
}
await this.configRepo.save(config);
// Mise à jour du cache (avec la valeur déchiffrée)
this.cache.set(key, value);
this.logger.log(`✅ Configuration '${key}' mise à jour`);
}
/**
* Récupération de toutes les configurations par catégorie
* @param category Catégorie de configuration
* @returns Objet clé/valeur des configurations
*/
async getByCategory(category: string): Promise<Record<string, any>> {
const configs = await this.configRepo.find({
where: { categorie: category as any },
});
const result: Record<string, any> = {};
for (const config of configs) {
let value = config.valeur;
// Masquer les mots de passe
if (config.type === 'encrypted') {
value = value ? '***********' : null;
} else {
value = this.convertType(value, config.type);
}
result[config.cle] = {
value,
type: config.type,
description: config.description,
};
}
return result;
}
/**
* Récupération de toutes les configurations (pour l'admin)
* @returns Liste de toutes les configurations
*/
async getAll(): Promise<Configuration[]> {
const configs = await this.configRepo.find({
order: { categorie: 'ASC', cle: 'ASC' },
});
// Masquer les valeurs chiffrées
return configs.map((config) => ({
...config,
valeur: config.type === 'encrypted' && config.valeur ? '***********' : config.valeur,
}));
}
/**
* Test de la configuration SMTP
* @param testEmail Email de destination pour le test
* @returns Objet avec success et error éventuel
*/
async testSmtpConnection(testEmail?: string): Promise<{ success: boolean; error?: string }> {
try {
this.logger.log('🧪 Test de connexion SMTP...');
// Récupération de la configuration SMTP
const smtpHost = this.get<string>('smtp_host');
const smtpPort = this.get<number>('smtp_port');
const smtpSecure = this.get<boolean>('smtp_secure');
const smtpAuthRequired = this.get<boolean>('smtp_auth_required');
const smtpUser = this.get<string>('smtp_user');
const smtpPassword = this.get<string>('smtp_password');
const emailFromName = this.get<string>('email_from_name');
const emailFromAddress = this.get<string>('email_from_address');
// Import dynamique de nodemailer
const nodemailer = await import('nodemailer');
// Configuration du transporteur
const transportConfig: any = {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
};
if (smtpAuthRequired && smtpUser && smtpPassword) {
transportConfig.auth = {
user: smtpUser,
pass: smtpPassword,
};
}
const transporter = nodemailer.createTransport(transportConfig);
// Vérification de la connexion
await transporter.verify();
this.logger.log('✅ Connexion SMTP vérifiée');
// Si un email de test est fourni, on envoie un email
if (testEmail) {
await transporter.sendMail({
from: `"${emailFromName}" <${emailFromAddress}>`,
to: testEmail,
subject: '🧪 Test de configuration SMTP - P\'titsPas',
text: 'Ceci est un email de test pour vérifier la configuration SMTP de votre application P\'titsPas.',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;"> Test de configuration SMTP réussi !</h2>
<p>Ceci est un email de test pour vérifier la configuration SMTP de votre application <strong>P'titsPas</strong>.</p>
<p>Si vous recevez cet email, cela signifie que votre configuration SMTP fonctionne correctement.</p>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">
Cet email a é envoyé automatiquement depuis votre application P'titsPas.<br>
Configuration testée le ${new Date().toLocaleString('fr-FR')}
</p>
</div>
`,
});
this.logger.log(`📧 Email de test envoyé à ${testEmail}`);
}
return { success: true };
} catch (error) {
this.logger.error('❌ Échec du test SMTP', error);
return {
success: false,
error: error.message || 'Erreur inconnue lors du test SMTP',
};
}
}
/**
* Vérification si la configuration initiale est terminée
* @returns true si la configuration est terminée
*/
isSetupCompleted(): boolean {
return this.get<boolean>('setup_completed', false);
}
/**
* Marquer la configuration initiale comme terminée
* @param userId ID de l'utilisateur qui termine la configuration
*/
async markSetupCompleted(userId: string): Promise<void> {
await this.set('setup_completed', 'true', userId);
this.logger.log('✅ Configuration initiale marquée comme terminée');
}
/**
* Conversion de type selon le type de configuration
* @param value Valeur à convertir
* @param type Type cible
* @returns Valeur convertie
*/
private convertType(value: string | null, type: string): any {
if (value === null || value === undefined) {
return null;
}
switch (type) {
case 'number':
return parseFloat(value);
case 'boolean':
return value === 'true' || value === '1';
case 'json':
try {
return JSON.parse(value);
} catch {
return null;
}
case 'string':
case 'encrypted':
default:
return value;
}
}
/**
* Chiffrement AES-256-CBC
* @param text Texte à chiffrer
* @returns Texte chiffré (format: iv:encrypted)
*/
private encrypt(text: string): string {
const iv = crypto.randomBytes(this.IV_LENGTH);
const key = Buffer.from(this.ENCRYPTION_KEY, 'hex');
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Format: iv:encrypted
return `${iv.toString('hex')}:${encrypted}`;
}
/**
* Déchiffrement AES-256-CBC
* @param encryptedText Texte chiffré (format: iv:encrypted)
* @returns Texte déchiffré
*/
private decrypt(encryptedText: string): string {
const parts = encryptedText.split(':');
if (parts.length !== 2) {
throw new Error('Format de chiffrement invalide');
}
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const key = Buffer.from(this.ENCRYPTION_KEY, 'hex');
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}

View File

@ -0,0 +1,7 @@
import { IsEmail } from 'class-validator';
export class TestSmtpDto {
@IsEmail()
testEmail: string;
}

View File

@ -0,0 +1,67 @@
import { IsString, IsOptional, IsNumber, IsBoolean, IsEmail, IsUrl } from 'class-validator';
export class UpdateConfigDto {
// Configuration Email (SMTP)
@IsOptional()
@IsString()
smtp_host?: string;
@IsOptional()
@IsNumber()
smtp_port?: number;
@IsOptional()
@IsBoolean()
smtp_secure?: boolean;
@IsOptional()
@IsBoolean()
smtp_auth_required?: boolean;
@IsOptional()
@IsString()
smtp_user?: string;
@IsOptional()
@IsString()
smtp_password?: string;
@IsOptional()
@IsString()
email_from_name?: string;
@IsOptional()
@IsEmail()
email_from_address?: string;
// Configuration Application
@IsOptional()
@IsString()
app_name?: string;
@IsOptional()
@IsUrl()
app_url?: string;
@IsOptional()
@IsString()
app_logo_url?: string;
// Configuration Sécurité
@IsOptional()
@IsNumber()
password_reset_token_expiry_days?: number;
@IsOptional()
@IsNumber()
jwt_expiry_hours?: number;
@IsOptional()
@IsNumber()
max_upload_size_mb?: number;
@IsOptional()
@IsNumber()
bcrypt_rounds?: number;
}

View File

@ -0,0 +1,3 @@
export * from './config.module';
export * from './config.service';

View File

@ -0,0 +1,202 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
UseInterceptors,
UploadedFile,
Res,
HttpStatus,
BadRequestException,
ParseUUIDPipe,
StreamableFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { DocumentsLegauxService } from './documents-legaux.service';
import { UploadDocumentDto } from './dto/upload-document.dto';
import { DocumentsActifsResponseDto } from './dto/documents-actifs.dto';
import { DocumentVersionDto } from './dto/document-version.dto';
@Controller('documents-legaux')
export class DocumentsLegauxController {
constructor(private readonly documentsService: DocumentsLegauxService) {}
/**
* GET /api/v1/documents-legaux/actifs
* Récupérer les documents actifs (CGU + Privacy)
* PUBLIC
*/
@Get('actifs')
async getDocumentsActifs(): Promise<DocumentsActifsResponseDto> {
const { cgu, privacy } = await this.documentsService.getDocumentsActifs();
return {
cgu: {
id: cgu.id,
type: cgu.type,
version: cgu.version,
url: `/api/v1/documents-legaux/${cgu.id}/download`,
activeLe: cgu.activeLe,
},
privacy: {
id: privacy.id,
type: privacy.type,
version: privacy.version,
url: `/api/v1/documents-legaux/${privacy.id}/download`,
activeLe: privacy.activeLe,
},
};
}
/**
* GET /api/v1/documents-legaux/:type/versions
* Lister toutes les versions d'un type de document
* ADMIN ONLY
*/
@Get(':type/versions')
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin', 'administrateur')
async listerVersions(@Param('type') type: string): Promise<DocumentVersionDto[]> {
if (type !== 'cgu' && type !== 'privacy') {
throw new BadRequestException('Le type doit être "cgu" ou "privacy"');
}
const documents = await this.documentsService.listerVersions(type as 'cgu' | 'privacy');
return documents.map((doc) => ({
id: doc.id,
version: doc.version,
fichier_nom: doc.fichier_nom,
actif: doc.actif,
televersePar: doc.televersePar
? {
id: doc.televersePar.id,
prenom: doc.televersePar.prenom,
nom: doc.televersePar.nom,
}
: null,
televerseLe: doc.televerseLe,
activeLe: doc.activeLe,
}));
}
/**
* POST /api/v1/documents-legaux
* Upload une nouvelle version d'un document
* ADMIN ONLY
*/
@Post()
@UseInterceptors(FileInterceptor('file'))
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin', 'administrateur')
async uploadDocument(
@Body() uploadDto: UploadDocumentDto,
@UploadedFile() file: Express.Multer.File,
// @CurrentUser() user: any, // TODO: Décommenter quand le guard sera implémenté
) {
if (!file) {
throw new BadRequestException('Aucun fichier fourni');
}
// TODO: Récupérer l'ID utilisateur depuis le guard
const userId = '00000000-0000-0000-0000-000000000000'; // Temporaire
const document = await this.documentsService.uploadNouvelleVersion(
uploadDto.type,
file,
userId,
);
return {
id: document.id,
type: document.type,
version: document.version,
fichier_nom: document.fichier_nom,
actif: document.actif,
televerseLe: document.televerseLe,
};
}
/**
* PATCH /api/v1/documents-legaux/:id/activer
* Activer une version d'un document
* ADMIN ONLY
*/
@Patch(':id/activer')
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin', 'administrateur')
async activerVersion(@Param('id', ParseUUIDPipe) documentId: string) {
await this.documentsService.activerVersion(documentId);
// Récupérer le document pour retourner les infos
const documents = await this.documentsService.listerVersions('cgu');
const document = documents.find((d) => d.id === documentId);
if (!document) {
const documentsPrivacy = await this.documentsService.listerVersions('privacy');
const docPrivacy = documentsPrivacy.find((d) => d.id === documentId);
if (!docPrivacy) {
throw new BadRequestException('Document non trouvé');
}
return {
message: 'Document activé avec succès',
documentId: docPrivacy.id,
type: docPrivacy.type,
version: docPrivacy.version,
};
}
return {
message: 'Document activé avec succès',
documentId: document.id,
type: document.type,
version: document.version,
};
}
/**
* GET /api/v1/documents-legaux/:id/download
* Télécharger un document
* PUBLIC
*/
@Get(':id/download')
async telechargerDocument(
@Param('id', ParseUUIDPipe) documentId: string,
@Res() res: Response,
) {
const { stream, filename } = await this.documentsService.telechargerDocument(documentId);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.status(HttpStatus.OK).send(stream);
}
/**
* GET /api/v1/documents-legaux/:id/verifier-integrite
* Vérifier l'intégrité d'un document (hash SHA-256)
* ADMIN ONLY
*/
@Get(':id/verifier-integrite')
// @UseGuards(JwtAuthGuard, RolesGuard)
// @Roles('super_admin', 'administrateur')
async verifierIntegrite(@Param('id', ParseUUIDPipe) documentId: string) {
const integre = await this.documentsService.verifierIntegrite(documentId);
return {
documentId,
integre,
message: integre
? 'Le document est intègre (hash valide)'
: 'ALERTE : Le document a été modifié (hash invalide)',
};
}
}

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DocumentLegal } from '../../entities/document-legal.entity';
import { AcceptationDocument } from '../../entities/acceptation-document.entity';
import { DocumentsLegauxService } from './documents-legaux.service';
import { DocumentsLegauxController } from './documents-legaux.controller';
@Module({
imports: [TypeOrmModule.forFeature([DocumentLegal, AcceptationDocument])],
providers: [DocumentsLegauxService],
controllers: [DocumentsLegauxController],
exports: [DocumentsLegauxService],
})
export class DocumentsLegauxModule {}

Some files were not shown because too many files have changed in this diff Show More