Compare commits
162 Commits
migration/
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 060e610a75 | |||
| 7e32eef0a7 | |||
| aa4e240ad1 | |||
| a92447aaf0 | |||
| 94c8a0d97a | |||
| af489f39b4 | |||
| aefe590d2c | |||
| f749484731 | |||
| ca98821b3e | |||
| b1a80f85c9 | |||
| e713c05da1 | |||
| 51d279e341 | |||
| fffe8cd202 | |||
| 619e39219f | |||
| 6749f2025a | |||
| 119edbcfb4 | |||
| 33cc7a9191 | |||
| 10ebc77ba1 | |||
| f9477d3fbe | |||
| 4d37131301 | |||
| 04b910295c | |||
| c136f28f12 | |||
| 4b176b7083 | |||
| 00c42c7bee | |||
| 42c569e491 | |||
| d32d956b0e | |||
| 1fca0cf132 | |||
| b16dd4b55c | |||
| 8682421453 | |||
| 31bd8c3175 | |||
| c94f2cf0d5 | |||
| dfe7daed14 | |||
| 111935e451 | |||
| ae3292a7fc | |||
| 8e8c6d79b1 | |||
| 6752dc97b4 | |||
| 31857ec891 | |||
| ca7ef862da | |||
| 11aa66feff | |||
| 358eefdab3 | |||
| d23f3c9f4f | |||
| 1834eb8c79 | |||
| 0386785f81 | |||
| c43f55bed6 | |||
| 0c48a5c06f | |||
| be8b1f23ed | |||
| 18b270eaa3 | |||
| 68e4f54814 | |||
| 6794190916 | |||
| 790761d576 | |||
| 930097f87d | |||
| 18af5c9034 | |||
| 10bf2553e7 | |||
| 678f4219b5 | |||
| 5295e8ec72 | |||
| 480f4a9396 | |||
| 6bf0932da8 | |||
| 2f1740b35f | |||
| fd97e68dd9 | |||
| 813fdb8449 | |||
| 155c6ca4d5 | |||
| 030ef81038 | |||
| 0d88597bb6 | |||
| 9b007fe490 | |||
| 7ecb99963c | |||
| 39814c76b1 | |||
| b956f94ad2 | |||
| b18d5c8a9e | |||
| 6ad88cbbc6 | |||
| dfe91ed772 | |||
| 08612c455d | |||
| 6452706680 | |||
| eea94769bf | |||
| bdecbc2c1d | |||
| f8bd911c02 | |||
| b79f8c7e64 | |||
| a57993a90f | |||
| 1d774f29eb | |||
| 890619ff59 | |||
| 5d7eb9eb36 | |||
| 45bd8a9ef1 | |||
| acb8e72a7c | |||
| b6c70a52ac | |||
| 96794919a8 | |||
| 271dc713a3 | |||
| 13741b0430 | |||
| 8e3af711e5 | |||
| e700e50924 | |||
| 36ef0f8d5c | |||
| f09deb5efc | |||
| 26a0e31b32 | |||
| 21430dca41 | |||
| dcb81d3feb | |||
| 7c86feeb78 | |||
| df87abbb85 | |||
| bd81561e41 | |||
| cc96ef20e1 | |||
| a4ac65a5db | |||
| 3d13eb5b2e | |||
| 5b37d09fa9 | |||
| 53f3af9794 | |||
| 105cf53e7b | |||
| 29bee9fa80 | |||
|
|
dbd56637e1 | ||
| 264e0d49ae | |||
| fe71fdf28e | |||
| b3ec1b94ea | |||
| 95d1c3741b | |||
| c5028c3b22 | |||
| cef197d133 | |||
| c934466e47 | |||
| 90d8fa8669 | |||
| 90cdf16709 | |||
| bde97c24db | |||
| 9ae6533b4d | |||
| 579b6cae90 | |||
| 9aea26805d | |||
| 40c7f40d12 | |||
| 61b45cd830 | |||
| f53fd903e5 | |||
| 98082187b5 | |||
| 1fb8c33cbf | |||
| 6ceb0f0ea9 | |||
| bebd3c74da | |||
| e1628da9cb | |||
| eb1583b35b | |||
| ec485b5a3e | |||
| 80afe2fa2f | |||
| 4149d0147f | |||
| 47dbe94b02 | |||
| fd4f5e6b12 | |||
| 40b1eb2192 | |||
| 933793aad8 | |||
| 2285069a52 | |||
| 2e3139b5fc | |||
| 93306d287b | |||
| d0827a119e | |||
| a5dae7a017 | |||
| 48b01ed3fe | |||
|
|
1bbdab03d0 | ||
|
|
7f78617561 | ||
|
|
7707b99773 | ||
|
|
760f4feca3 | ||
|
|
03712bd99b | ||
|
|
1496f7f174 | ||
|
|
acb602643a | ||
|
|
0772f83369 | ||
|
|
42d147c273 | ||
|
|
df56ba11df | ||
|
|
bbdacd68aa | ||
|
|
7f831f363e | ||
|
|
009d42ece8 | ||
|
|
e6d3c41ecc | ||
|
|
c7ac3d9ebe | ||
|
|
c8b8ad9318 | ||
|
|
482040ba55 | ||
|
|
2bcb0b1e54 | ||
|
|
30e72242a8 | ||
|
|
aaf7070757 | ||
|
|
f4c211e0dd | ||
|
|
9519fafe3a | ||
|
|
9321430818 |
18
.gitattributes
vendored
Normal file
18
.gitattributes
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Fins de ligne : toujours LF dans le dépôt (évite les conflits Linux/Windows)
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Fichiers binaires : pas de conversion
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.pdf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
|
||||||
|
# Scripts shell : toujours LF
|
||||||
|
*.sh text eol=lf
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -37,6 +37,10 @@ yarn-error.log*
|
|||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
|
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
||||||
|
**/windows/flutter/generated_plugin_registrant.cc
|
||||||
|
**/windows/flutter/generated_plugin_registrant.h
|
||||||
|
**/windows/flutter/generated_plugins.cmake
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
@ -52,3 +56,4 @@ Xcf/**
|
|||||||
# Release notes
|
# Release notes
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
Ressources/
|
Ressources/
|
||||||
|
.gitea-token
|
||||||
|
|||||||
BIN
Xcf/page_login.xcf
Normal file
BIN
Xcf/page_login.xcf
Normal file
Binary file not shown.
@ -83,3 +83,5 @@ npx prisma migrate dev --name <nom_migration>
|
|||||||
- [openapi-generator](https://openapi-generator.tech/)
|
- [openapi-generator](https://openapi-generator.tech/)
|
||||||
- [openapi-typescript](https://github.com/drwpow/openapi-typescript)
|
- [openapi-typescript](https://github.com/drwpow/openapi-typescript)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -23,3 +23,5 @@ npx prisma migrate deploy
|
|||||||
|
|
||||||
- [Prisma Migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate)
|
- [Prisma Migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -175,3 +175,5 @@ components:
|
|||||||
bearerFormat: JWT
|
bearerFormat: JWT
|
||||||
description: Token JWT obtenu via /auth/login
|
description: Token JWT obtenu via /auth/login
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -21,3 +21,6 @@ JWT_EXPIRATION_TIME=7d
|
|||||||
|
|
||||||
# Environnement
|
# Environnement
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
|
||||||
|
# LOG_API_REQUESTS=true
|
||||||
|
|||||||
@ -32,6 +32,9 @@ COPY --from=builder /app/dist ./dist
|
|||||||
RUN addgroup -g 1001 -S nodejs
|
RUN addgroup -g 1001 -S nodejs
|
||||||
RUN adduser -S nestjs -u 1001
|
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
|
USER nestjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@ -37,6 +37,8 @@
|
|||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"joi": "^18.0.0",
|
"joi": "^18.0.0",
|
||||||
"mapped-types": "^0.0.1",
|
"mapped-types": "^0.0.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@ -53,7 +55,9 @@
|
|||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/nodemailer": "^6.4.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|||||||
108
backend/prisma/schema.prisma
Normal file
108
backend/prisma/schema.prisma
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
89
backend/scripts/create-gitea-issue-parent-api.js
Normal file
89
backend/scripts/create-gitea-issue-parent-api.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Crée l'issue Gitea "[Frontend] Inscription Parent – Branchement soumission formulaire à l'API"
|
||||||
|
* Usage: node backend/scripts/create-gitea-issue-parent-api.js
|
||||||
|
* Token : .gitea-token (racine du dépôt), sinon GITEA_TOKEN, sinon docs/BRIEFING-FRONTEND.md (voir PROCEDURE-API-GITEA.md)
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '../..');
|
||||||
|
let token = process.env.GITEA_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||||
|
if (fs.existsSync(tokenFile)) {
|
||||||
|
token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
|
||||||
|
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||||
|
if (m) token = m[1].trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.error('Token non trouvé : créer .gitea-token à la racine ou export GITEA_TOKEN (voir docs/PROCEDURE-API-GITEA.md)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `## Description
|
||||||
|
|
||||||
|
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale puis redirige vers le login.
|
||||||
|
|
||||||
|
**Estimation** : 4h | **Labels** : frontend, p3, auth, cdc
|
||||||
|
|
||||||
|
## Tâches
|
||||||
|
|
||||||
|
- [ ] Créer un service ou méthode (ex. AuthService.registerParent) appelant POST /api/v1/auth/register/parent
|
||||||
|
- [ ] Construire le body (DTO) à partir de UserRegistrationData (parent1, parent2, children, motivationText, CGU) en cohérence avec le backend (#18)
|
||||||
|
- [ ] Dans ParentRegisterStep5Screen, au clic « Soumettre » : appel API puis modale + redirection ou message d'erreur
|
||||||
|
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
|
||||||
|
|
||||||
|
## Référence
|
||||||
|
|
||||||
|
20_WORKFLOW-CREATION-COMPTE.md § Étape 3 – Inscription d'un parent, backend #18`;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: "[Frontend] Inscription Parent – Branchement soumission formulaire à l'API",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: 'git.ptits-pas.fr',
|
||||||
|
path: '/api/v1/repos/jmartin/petitspas/issues',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'token ' + token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(d);
|
||||||
|
if (o.number) {
|
||||||
|
console.log('NUMBER:', o.number);
|
||||||
|
console.log('URL:', o.html_url);
|
||||||
|
} else {
|
||||||
|
console.error('Erreur API:', o.message || d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Réponse:', d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
64
backend/scripts/list-gitea-issues.js
Normal file
64
backend/scripts/list-gitea-issues.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Liste toutes les issues Gitea (ouvertes + fermées) pour jmartin/petitspas.
|
||||||
|
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '../..');
|
||||||
|
let token = process.env.GITEA_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||||
|
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
|
||||||
|
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||||
|
if (m) token = m[1].trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.error('Token non trouvé');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const opts = { hostname: 'git.ptits-pas.fr', path, method: 'GET', headers: { Authorization: 'token ' + token } };
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const seen = new Map();
|
||||||
|
for (const state of ['open', 'closed']) {
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const raw = await get('/api/v1/repos/jmartin/petitspas/issues?state=' + state + '&limit=50&page=' + page + '&type=issues');
|
||||||
|
if (raw && raw.message && !Array.isArray(raw)) {
|
||||||
|
console.error('API:', raw.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const list = Array.isArray(raw) ? raw : [];
|
||||||
|
for (const i of list) {
|
||||||
|
if (!i.pull_request) seen.set(i.number, { number: i.number, title: i.title, state: i.state });
|
||||||
|
}
|
||||||
|
if (list.length < 50) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const all = [...seen.values()].sort((a, b) => a.number - b.number);
|
||||||
|
console.log(JSON.stringify(all, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
27
backend/scripts/test-register-am.sh
Executable file
27
backend/scripts/test-register-am.sh
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test POST /auth/register/am (ticket #90)
|
||||||
|
# Usage: ./scripts/test-register-am.sh [BASE_URL]
|
||||||
|
# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
|
||||||
|
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:3000/api/v1}"
|
||||||
|
echo "Testing POST $BASE_URL/auth/register/am"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "marie.dupont.test@ptits-pas.fr",
|
||||||
|
"prenom": "Marie",
|
||||||
|
"nom": "DUPONT",
|
||||||
|
"telephone": "0612345678",
|
||||||
|
"adresse": "1 rue Test",
|
||||||
|
"code_postal": "75001",
|
||||||
|
"ville": "Paris",
|
||||||
|
"consentement_photo": true,
|
||||||
|
"nir": "123456789012345",
|
||||||
|
"numero_agrement": "AGR-2024-001",
|
||||||
|
"capacite_accueil": 4,
|
||||||
|
"acceptation_cgu": true,
|
||||||
|
"acceptation_privacy": true
|
||||||
|
}'
|
||||||
@ -14,6 +14,9 @@ import { AuthModule } from './routes/auth/auth.module';
|
|||||||
import { SentryGlobalFilter } from '@sentry/nestjs/setup';
|
import { SentryGlobalFilter } from '@sentry/nestjs/setup';
|
||||||
import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
||||||
import { EnfantsModule } from './routes/enfants/enfants.module';
|
import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||||
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -49,6 +52,9 @@ import { EnfantsModule } from './routes/enfants/enfants.module';
|
|||||||
ParentsModule,
|
ParentsModule,
|
||||||
EnfantsModule,
|
EnfantsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
AppConfigModule,
|
||||||
|
DocumentsLegauxModule,
|
||||||
|
RelaisModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
69
backend/src/common/interceptors/log-request.interceptor.ts
Normal file
69
backend/src/common/interceptors/log-request.interceptor.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
/** Clés à masquer dans les logs (corps de requête) */
|
||||||
|
const SENSITIVE_KEYS = [
|
||||||
|
'password',
|
||||||
|
'smtp_password',
|
||||||
|
'token',
|
||||||
|
'accessToken',
|
||||||
|
'refreshToken',
|
||||||
|
'secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
function maskBody(body: unknown): unknown {
|
||||||
|
if (body === null || body === undefined) return body;
|
||||||
|
if (typeof body !== 'object') return body;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
const isSensitive = SENSITIVE_KEYS.some((s) => lower.includes(s));
|
||||||
|
out[key] = isSensitive ? '***' : value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogRequestInterceptor implements NestInterceptor {
|
||||||
|
private readonly enabled: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.enabled =
|
||||||
|
process.env.LOG_API_REQUESTS === 'true' ||
|
||||||
|
process.env.LOG_API_REQUESTS === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
if (!this.enabled) return next.handle();
|
||||||
|
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const req = http.getRequest<Request>();
|
||||||
|
const { method, url, body, query } = req;
|
||||||
|
const hasBody = body && Object.keys(body).length > 0;
|
||||||
|
|
||||||
|
const logLine = [
|
||||||
|
`[API] ${method} ${url}`,
|
||||||
|
Object.keys(query || {}).length ? `query=${JSON.stringify(query)}` : '',
|
||||||
|
hasBody ? `body=${JSON.stringify(maskBody(body))}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
console.log(logLine);
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap({
|
||||||
|
next: () => {
|
||||||
|
// Optionnel: log du statut en fin de requête (si besoin plus tard)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
backend/src/common/utils/nir.util.ts
Normal file
109
backend/src/common/utils/nir.util.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Utilitaire de validation du NIR (numéro de sécurité sociale français).
|
||||||
|
* - Format 15 caractères (chiffres ou 2A/2B pour la Corse).
|
||||||
|
* - Clé de contrôle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 2A→19, 2B→20).
|
||||||
|
* - En cas d'incohérence avec les données (sexe, date, lieu) : warning uniquement, pas de rejet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NIR_CORSE_2A = '19';
|
||||||
|
const NIR_CORSE_2B = '20';
|
||||||
|
|
||||||
|
/** Regex 15 caractères : sexe (1-3) + 4 chiffres + (2A|2B|2 chiffres) + 6 chiffres + 2 chiffres clé */
|
||||||
|
const NIR_FORMAT = /^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit le NIR en chaîne de 13 chiffres pour le calcul de la clé (2A→19, 2B→20).
|
||||||
|
*/
|
||||||
|
export function nirTo13Digits(nir: string): string {
|
||||||
|
const n = nir.toUpperCase().replace(/\s/g, '');
|
||||||
|
if (n.length !== 15) return '';
|
||||||
|
const dept = n.slice(5, 7);
|
||||||
|
let deptNum: string;
|
||||||
|
if (dept === '2A') deptNum = NIR_CORSE_2A;
|
||||||
|
else if (dept === '2B') deptNum = NIR_CORSE_2B;
|
||||||
|
else deptNum = dept;
|
||||||
|
return n.slice(0, 5) + deptNum + n.slice(7, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie que le format NIR est valide (15 caractères, 2A/2B acceptés).
|
||||||
|
*/
|
||||||
|
export function isNirFormatValid(nir: string): boolean {
|
||||||
|
if (!nir || typeof nir !== 'string') return false;
|
||||||
|
const n = nir.replace(/\s/g, '').toUpperCase();
|
||||||
|
return NIR_FORMAT.test(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule la clé de contrôle attendue (97 - (NIR13 mod 97)).
|
||||||
|
* Retourne un nombre entre 1 et 97.
|
||||||
|
*/
|
||||||
|
export function computeNirKey(nir13: string): number {
|
||||||
|
const num = parseInt(nir13, 10);
|
||||||
|
if (Number.isNaN(num) || nir13.length !== 13) return -1;
|
||||||
|
return 97 - (num % 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie la clé de contrôle du NIR (15 caractères).
|
||||||
|
* Retourne true si le NIR est valide (format + clé).
|
||||||
|
*/
|
||||||
|
export function isNirKeyValid(nir: string): boolean {
|
||||||
|
const n = nir.replace(/\s/g, '').toUpperCase();
|
||||||
|
if (n.length !== 15) return false;
|
||||||
|
const nir13 = nirTo13Digits(n);
|
||||||
|
if (nir13.length !== 13) return false;
|
||||||
|
const expectedKey = computeNirKey(nir13);
|
||||||
|
const actualKey = parseInt(n.slice(13, 15), 10);
|
||||||
|
return expectedKey === actualKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NirValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide le NIR (format + clé). En cas d'incohérence avec date de naissance ou sexe, ajoute un warning sans invalider.
|
||||||
|
*/
|
||||||
|
export function validateNir(
|
||||||
|
nir: string,
|
||||||
|
options?: { dateNaissance?: string; genre?: 'H' | 'F' },
|
||||||
|
): NirValidationResult {
|
||||||
|
const n = (nir || '').replace(/\s/g, '').toUpperCase();
|
||||||
|
if (n.length === 0) return { valid: false, error: 'Le NIR est requis' };
|
||||||
|
if (!isNirFormatValid(n)) {
|
||||||
|
return { valid: false, error: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)' };
|
||||||
|
}
|
||||||
|
if (!isNirKeyValid(n)) {
|
||||||
|
return { valid: false, error: 'Clé de contrôle du NIR invalide' };
|
||||||
|
}
|
||||||
|
let warning: string | undefined;
|
||||||
|
if (options?.genre) {
|
||||||
|
const sexNir = n[0];
|
||||||
|
const expectedSex = options.genre === 'F' ? '2' : '1';
|
||||||
|
if (sexNir !== expectedSex) {
|
||||||
|
warning = 'Le NIR ne correspond pas au genre indiqué (position 1 du NIR).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options?.dateNaissance) {
|
||||||
|
try {
|
||||||
|
const d = new Date(options.dateNaissance);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const year2 = d.getFullYear() % 100;
|
||||||
|
const month = d.getMonth() + 1;
|
||||||
|
const nirYear = parseInt(n.slice(1, 3), 10);
|
||||||
|
const nirMonth = parseInt(n.slice(3, 5), 10);
|
||||||
|
if (nirYear !== year2 || nirMonth !== month) {
|
||||||
|
warning = warning
|
||||||
|
? `${warning} Le NIR ne correspond pas à la date de naissance (positions 2-5).`
|
||||||
|
: 'Le NIR ne correspond pas à la date de naissance indiquée (positions 2-5).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true, warning };
|
||||||
|
}
|
||||||
15
backend/src/config/typeorm.config.ts
Normal file
15
backend/src/config/typeorm.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
export default new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
host: process.env.DATABASE_HOST,
|
||||||
|
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
|
||||||
|
username: process.env.DATABASE_USERNAME,
|
||||||
|
password: process.env.DATABASE_PASSWORD,
|
||||||
|
database: process.env.DATABASE_NAME,
|
||||||
|
entities: ['src/**/*.entity.ts'],
|
||||||
|
migrations: ['src/migrations/*.ts'],
|
||||||
|
});
|
||||||
40
backend/src/entities/acceptation-document.entity.ts
Normal file
40
backend/src/entities/acceptation-document.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -48,4 +48,7 @@ export class AssistanteMaternelle {
|
|||||||
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
||||||
places_available?: number;
|
places_available?: number;
|
||||||
|
|
||||||
|
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
|
||||||
|
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||||
|
numero_dossier?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
39
backend/src/entities/configuration.entity.ts
Normal file
39
backend/src/entities/configuration.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
44
backend/src/entities/document-legal.entity.ts
Normal file
44
backend/src/entities/document-legal.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Entity, PrimaryColumn, OneToOne, JoinColumn,
|
Entity, PrimaryColumn, Column, OneToOne, JoinColumn,
|
||||||
ManyToOne, OneToMany
|
ManyToOne, OneToMany
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Users } from './users.entity';
|
import { Users } from './users.entity';
|
||||||
@ -21,6 +21,10 @@ export class Parents {
|
|||||||
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
|
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
|
||||||
co_parent?: Users;
|
co_parent?: Users;
|
||||||
|
|
||||||
|
/** Numéro de dossier famille (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
|
||||||
|
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||||
|
numero_dossier?: string;
|
||||||
|
|
||||||
// Lien vers enfants via la table enfants_parents
|
// Lien vers enfants via la table enfants_parents
|
||||||
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
||||||
parentChildren: ParentsChildren[];
|
parentChildren: ParentsChildren[];
|
||||||
|
|||||||
35
backend/src/entities/relais.entity.ts
Normal file
35
backend/src/entities/relais.entity.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
|
||||||
|
@Entity('relais', { schema: 'public' })
|
||||||
|
export class Relais {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'nom' })
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@Column({ name: 'adresse' })
|
||||||
|
adresse: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true })
|
||||||
|
horaires_ouverture?: any;
|
||||||
|
|
||||||
|
@Column({ name: 'ligne_fixe', nullable: true })
|
||||||
|
ligne_fixe?: string;
|
||||||
|
|
||||||
|
@Column({ default: true, name: 'actif' })
|
||||||
|
actif: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'notes', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => Users, user => user.relais)
|
||||||
|
gestionnaires: Users[];
|
||||||
|
}
|
||||||
@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Entity, PrimaryGeneratedColumn, Column,
|
Entity, PrimaryGeneratedColumn, Column,
|
||||||
CreateDateColumn, UpdateDateColumn,
|
CreateDateColumn, UpdateDateColumn,
|
||||||
OneToOne, OneToMany
|
OneToOne, OneToMany, ManyToOne, JoinColumn
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
||||||
import { Parents } from './parents.entity';
|
import { Parents } from './parents.entity';
|
||||||
import { Message } from './messages.entity';
|
import { Message } from './messages.entity';
|
||||||
|
import { Relais } from './relais.entity';
|
||||||
|
|
||||||
// Enums alignés avec la BDD PostgreSQL
|
// Enums alignés avec la BDD PostgreSQL
|
||||||
export enum RoleType {
|
export enum RoleType {
|
||||||
@ -28,6 +29,7 @@ export enum StatutUtilisateurType {
|
|||||||
EN_ATTENTE = 'en_attente',
|
EN_ATTENTE = 'en_attente',
|
||||||
ACTIF = 'actif',
|
ACTIF = 'actif',
|
||||||
SUSPENDU = 'suspendu',
|
SUSPENDU = 'suspendu',
|
||||||
|
REFUSE = 'refuse',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SituationFamilialeType {
|
export enum SituationFamilialeType {
|
||||||
@ -50,8 +52,8 @@ export class Users {
|
|||||||
@Column({ unique: true, name: 'email' })
|
@Column({ unique: true, name: 'email' })
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@Column({ name: 'password' })
|
@Column({ name: 'password', nullable: true })
|
||||||
password: string;
|
password?: string;
|
||||||
|
|
||||||
@Column({ name: 'prenom', nullable: true })
|
@Column({ name: 'prenom', nullable: true })
|
||||||
prenom?: string;
|
prenom?: string;
|
||||||
@ -80,7 +82,7 @@ export class Users {
|
|||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: StatutUtilisateurType,
|
enum: StatutUtilisateurType,
|
||||||
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
|
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
|
||||||
default: StatutUtilisateurType.EN_ATTENTE,
|
default: StatutUtilisateurType.ACTIF,
|
||||||
name: 'statut'
|
name: 'statut'
|
||||||
})
|
})
|
||||||
statut: StatutUtilisateurType;
|
statut: StatutUtilisateurType;
|
||||||
@ -96,12 +98,6 @@ export class Users {
|
|||||||
@Column({ nullable: true, name: 'telephone' })
|
@Column({ nullable: true, name: 'telephone' })
|
||||||
telephone?: string;
|
telephone?: string;
|
||||||
|
|
||||||
@Column({ name: 'mobile', nullable: true })
|
|
||||||
mobile?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'telephone_fixe', nullable: true })
|
|
||||||
telephone_fixe?: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true, name: 'adresse' })
|
@Column({ nullable: true, name: 'adresse' })
|
||||||
adresse?: string;
|
adresse?: string;
|
||||||
|
|
||||||
@ -117,6 +113,19 @@ export class Users {
|
|||||||
@Column({ default: false, name: 'changement_mdp_obligatoire' })
|
@Column({ default: false, name: 'changement_mdp_obligatoire' })
|
||||||
changement_mdp_obligatoire: boolean;
|
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;
|
||||||
|
|
||||||
|
/** Token pour reprise après refus (lien email), ticket #110 */
|
||||||
|
@Column({ nullable: true, name: 'token_reprise', length: 255 })
|
||||||
|
token_reprise?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'token_reprise_expire_le' })
|
||||||
|
token_reprise_expire_le?: Date;
|
||||||
|
|
||||||
@Column({ nullable: true, name: 'ville' })
|
@Column({ nullable: true, name: 'ville' })
|
||||||
ville?: string;
|
ville?: string;
|
||||||
|
|
||||||
@ -147,4 +156,15 @@ export class Users {
|
|||||||
|
|
||||||
@OneToMany(() => Parents, parent => parent.co_parent)
|
@OneToMany(() => Parents, parent => parent.co_parent)
|
||||||
co_parent_in?: Parents[];
|
co_parent_in?: Parents[];
|
||||||
|
|
||||||
|
@Column({ nullable: true, name: 'relais_id' })
|
||||||
|
relaisId?: string;
|
||||||
|
|
||||||
|
/** Numéro de dossier (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
|
||||||
|
@Column({ nullable: true, name: 'numero_dossier', length: 20 })
|
||||||
|
numero_dossier?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'relais_id' })
|
||||||
|
relais?: Relais;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,25 @@
|
|||||||
import { NestFactory, Reflector } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
|
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
|
||||||
import { DocumentBuilder } from '@nestjs/swagger';
|
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';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule,
|
const app = await NestFactory.create(AppModule,
|
||||||
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
|
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
|
||||||
app.enableCors();
|
|
||||||
|
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
|
||||||
|
app.useGlobalInterceptors(new LogRequestInterceptor());
|
||||||
|
|
||||||
|
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
|
||||||
|
app.enableCors({
|
||||||
|
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
|
|||||||
231
backend/src/modules/config/config.controller.ts
Normal file
231
backend/src/modules/config/config.controller.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
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 {
|
||||||
|
const userId = req.user?.id ?? null;
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/config/config.module.ts
Normal file
14
backend/src/modules/config/config.module.ts
Normal 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 {}
|
||||||
|
|
||||||
338
backend/src/modules/config/config.service.ts
Normal file
338
backend/src/modules/config/config.service.ts
Normal 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 été 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 (null si non authentifié)
|
||||||
|
*/
|
||||||
|
async markSetupCompleted(userId: string | null): Promise<void> {
|
||||||
|
await this.set('setup_completed', 'true', userId ?? undefined);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
backend/src/modules/config/dto/test-smtp.dto.ts
Normal file
7
backend/src/modules/config/dto/test-smtp.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsEmail } from 'class-validator';
|
||||||
|
|
||||||
|
export class TestSmtpDto {
|
||||||
|
@IsEmail()
|
||||||
|
testEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
67
backend/src/modules/config/dto/update-config.dto.ts
Normal file
67
backend/src/modules/config/dto/update-config.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
3
backend/src/modules/config/index.ts
Normal file
3
backend/src/modules/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './config.module';
|
||||||
|
export * from './config.service';
|
||||||
|
|
||||||
@ -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)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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 {}
|
||||||
|
|
||||||
209
backend/src/modules/documents-legaux/documents-legaux.service.ts
Normal file
209
backend/src/modules/documents-legaux/documents-legaux.service.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { DocumentLegal } from '../../entities/document-legal.entity';
|
||||||
|
import { AcceptationDocument } from '../../entities/acceptation-document.entity';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocumentsLegauxService {
|
||||||
|
private readonly UPLOAD_DIR = '/app/documents/legaux';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DocumentLegal)
|
||||||
|
private docRepo: Repository<DocumentLegal>,
|
||||||
|
@InjectRepository(AcceptationDocument)
|
||||||
|
private acceptationRepo: Repository<AcceptationDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer les documents actifs (CGU + Privacy)
|
||||||
|
*/
|
||||||
|
async getDocumentsActifs(): Promise<{ cgu: DocumentLegal; privacy: DocumentLegal }> {
|
||||||
|
const cgu = await this.docRepo.findOne({
|
||||||
|
where: { type: 'cgu', actif: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const privacy = await this.docRepo.findOne({
|
||||||
|
where: { type: 'privacy', actif: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cgu || !privacy) {
|
||||||
|
throw new NotFoundException('Documents légaux manquants');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cgu, privacy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploader une nouvelle version d'un document
|
||||||
|
*/
|
||||||
|
async uploadNouvelleVersion(
|
||||||
|
type: 'cgu' | 'privacy',
|
||||||
|
file: Express.Multer.File,
|
||||||
|
userId: string,
|
||||||
|
): Promise<DocumentLegal> {
|
||||||
|
// Validation du type de fichier
|
||||||
|
if (file.mimetype !== 'application/pdf') {
|
||||||
|
throw new BadRequestException('Seuls les fichiers PDF sont acceptés');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation de la taille (max 10MB)
|
||||||
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
throw new BadRequestException('Le fichier ne doit pas dépasser 10MB');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Calculer la prochaine version
|
||||||
|
const lastDoc = await this.docRepo.findOne({
|
||||||
|
where: { type },
|
||||||
|
order: { version: 'DESC' },
|
||||||
|
});
|
||||||
|
const nouvelleVersion = (lastDoc?.version || 0) + 1;
|
||||||
|
|
||||||
|
// 2. Calculer le hash SHA-256 du fichier
|
||||||
|
const fileBuffer = file.buffer;
|
||||||
|
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
|
||||||
|
// 3. Générer le nom de fichier unique
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const fileName = `${type}_v${nouvelleVersion}_${timestamp}.pdf`;
|
||||||
|
const filePath = path.join(this.UPLOAD_DIR, fileName);
|
||||||
|
|
||||||
|
// 4. Créer le répertoire si nécessaire et sauvegarder le fichier
|
||||||
|
await fs.mkdir(this.UPLOAD_DIR, { recursive: true });
|
||||||
|
await fs.writeFile(filePath, fileBuffer);
|
||||||
|
|
||||||
|
// 5. Créer l'entrée en BDD
|
||||||
|
const document = this.docRepo.create({
|
||||||
|
type,
|
||||||
|
version: nouvelleVersion,
|
||||||
|
fichier_nom: file.originalname,
|
||||||
|
fichier_path: filePath,
|
||||||
|
fichier_hash: hash,
|
||||||
|
actif: false, // Pas actif par défaut
|
||||||
|
televersePar: { id: userId } as any,
|
||||||
|
televerseLe: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.docRepo.save(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activer une version (désactive automatiquement l'ancienne)
|
||||||
|
*/
|
||||||
|
async activerVersion(documentId: string): Promise<void> {
|
||||||
|
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new NotFoundException('Document non trouvé');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction : désactiver l'ancienne version, activer la nouvelle
|
||||||
|
await this.docRepo.manager.transaction(async (manager) => {
|
||||||
|
// Désactiver toutes les versions de ce type
|
||||||
|
await manager.update(
|
||||||
|
DocumentLegal,
|
||||||
|
{ type: document.type, actif: true },
|
||||||
|
{ actif: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Activer la nouvelle version
|
||||||
|
await manager.update(
|
||||||
|
DocumentLegal,
|
||||||
|
{ id: documentId },
|
||||||
|
{ actif: true, activeLe: new Date() },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lister toutes les versions d'un type de document
|
||||||
|
*/
|
||||||
|
async listerVersions(type: 'cgu' | 'privacy'): Promise<DocumentLegal[]> {
|
||||||
|
return await this.docRepo.find({
|
||||||
|
where: { type },
|
||||||
|
order: { version: 'DESC' },
|
||||||
|
relations: ['televersePar'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharger un document (retourne le buffer et le nom)
|
||||||
|
*/
|
||||||
|
async telechargerDocument(documentId: string): Promise<{ stream: Buffer; filename: string }> {
|
||||||
|
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new NotFoundException('Document non trouvé');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = await fs.readFile(document.fichier_path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: fileBuffer,
|
||||||
|
filename: document.fichier_nom,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new NotFoundException('Fichier introuvable sur le système de fichiers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier l'intégrité d'un document (hash SHA-256)
|
||||||
|
*/
|
||||||
|
async verifierIntegrite(documentId: string): Promise<boolean> {
|
||||||
|
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new NotFoundException('Document non trouvé');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = await fs.readFile(document.fichier_path);
|
||||||
|
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
|
||||||
|
return hash === document.fichier_hash;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistrer une acceptation de document (lors de l'inscription)
|
||||||
|
*/
|
||||||
|
async enregistrerAcceptation(
|
||||||
|
userId: string,
|
||||||
|
documentId: string,
|
||||||
|
typeDocument: 'cgu' | 'privacy',
|
||||||
|
versionDocument: number,
|
||||||
|
ipAddress: string | null,
|
||||||
|
userAgent: string | null,
|
||||||
|
): Promise<AcceptationDocument> {
|
||||||
|
const acceptation = this.acceptationRepo.create({
|
||||||
|
utilisateur: { id: userId } as any,
|
||||||
|
document: { id: documentId } as any,
|
||||||
|
type_document: typeDocument,
|
||||||
|
version_document: versionDocument,
|
||||||
|
ip_address: ipAddress,
|
||||||
|
user_agent: userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.acceptationRepo.save(acceptation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer l'historique des acceptations d'un utilisateur
|
||||||
|
*/
|
||||||
|
async getAcceptationsUtilisateur(userId: string): Promise<AcceptationDocument[]> {
|
||||||
|
return await this.acceptationRepo.find({
|
||||||
|
where: { utilisateur: { id: userId } },
|
||||||
|
order: { accepteLe: 'DESC' },
|
||||||
|
relations: ['document'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
export class DocumentVersionDto {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
fichier_nom: string;
|
||||||
|
actif: boolean;
|
||||||
|
televersePar: {
|
||||||
|
id: string;
|
||||||
|
prenom?: string;
|
||||||
|
nom?: string;
|
||||||
|
} | null;
|
||||||
|
televerseLe: Date;
|
||||||
|
activeLe: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
export class DocumentActifDto {
|
||||||
|
id: string;
|
||||||
|
type: 'cgu' | 'privacy';
|
||||||
|
version: number;
|
||||||
|
url: string;
|
||||||
|
activeLe: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentsActifsResponseDto {
|
||||||
|
cgu: DocumentActifDto;
|
||||||
|
privacy: DocumentActifDto;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class UploadDocumentDto {
|
||||||
|
@IsEnum(['cgu', 'privacy'], { message: 'Le type doit être "cgu" ou "privacy"' })
|
||||||
|
@IsNotEmpty({ message: 'Le type est requis' })
|
||||||
|
type: 'cgu' | 'privacy';
|
||||||
|
}
|
||||||
|
|
||||||
3
backend/src/modules/documents-legaux/index.ts
Normal file
3
backend/src/modules/documents-legaux/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './documents-legaux.module';
|
||||||
|
export * from './documents-legaux.service';
|
||||||
|
|
||||||
10
backend/src/modules/mail/mail.module.ts
Normal file
10
backend/src/modules/mail/mail.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
import { AppConfigModule } from '../config/config.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
providers: [MailService],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
137
backend/src/modules/mail/mail.service.ts
Normal file
137
backend/src/modules/mail/mail.service.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
private readonly logger = new Logger(MailService.name);
|
||||||
|
|
||||||
|
constructor(private readonly configService: AppConfigService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoi d'un email générique
|
||||||
|
* @param to Destinataire
|
||||||
|
* @param subject Sujet
|
||||||
|
* @param html Contenu HTML
|
||||||
|
* @param text Contenu texte (optionnel)
|
||||||
|
*/
|
||||||
|
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Récupération de la configuration SMTP
|
||||||
|
const smtpHost = this.configService.get<string>('smtp_host');
|
||||||
|
const smtpPort = this.configService.get<number>('smtp_port');
|
||||||
|
const smtpSecure = this.configService.get<boolean>('smtp_secure');
|
||||||
|
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
|
||||||
|
const smtpUser = this.configService.get<string>('smtp_user');
|
||||||
|
const smtpPassword = this.configService.get<string>('smtp_password');
|
||||||
|
const emailFromName = this.configService.get<string>('email_from_name');
|
||||||
|
const emailFromAddress = this.configService.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);
|
||||||
|
|
||||||
|
// Envoi de l'email
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${emailFromName}" <${emailFromAddress}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoi de l'email de bienvenue pour un gestionnaire
|
||||||
|
* @param to Email du gestionnaire
|
||||||
|
* @param prenom Prénom
|
||||||
|
* @param nom Nom
|
||||||
|
* @param token Token de création de mot de passe (si applicable) ou mot de passe temporaire (si applicable)
|
||||||
|
* @note Pour l'instant, on suppose que le gestionnaire doit définir son mot de passe via "Mot de passe oublié" ou un lien d'activation
|
||||||
|
* Mais le ticket #17 parle de "Flag changement_mdp_obligatoire = TRUE", ce qui implique qu'on lui donne un mot de passe temporaire ou qu'on lui envoie un lien.
|
||||||
|
* Le ticket #24 parle de "API Création mot de passe" via token.
|
||||||
|
* Pour le ticket #17, on crée le gestionnaire avec un mot de passe (hashé).
|
||||||
|
* Si on suit le ticket #35 (Frontend), on saisit un mot de passe.
|
||||||
|
* Donc on envoie juste un email de confirmation de création de compte.
|
||||||
|
*/
|
||||||
|
async sendGestionnaireWelcomeEmail(to: string, prenom: string, nom: string): Promise<void> {
|
||||||
|
const appName = this.configService.get<string>('app_name', 'P\'titsPas');
|
||||||
|
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||||||
|
|
||||||
|
const subject = `Bienvenue sur ${appName}`;
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #4CAF50;">Bienvenue ${prenom} ${nom} !</h2>
|
||||||
|
<p>Votre compte gestionnaire sur <strong>${appName}</strong> a été créé avec succès.</p>
|
||||||
|
<p>Vous pouvez dès à présent vous connecter avec l'adresse email <strong>${to}</strong> et le mot de passe qui vous a été communiqué.</p>
|
||||||
|
<p>Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder à l'application</a>
|
||||||
|
</div>
|
||||||
|
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Cet email a été envoyé automatiquement. Merci de ne pas y répondre.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email de refus de dossier avec lien reprise (token).
|
||||||
|
* Ticket #110 – Refus sans suppression
|
||||||
|
*/
|
||||||
|
async sendRefusEmail(
|
||||||
|
to: string,
|
||||||
|
prenom: string,
|
||||||
|
nom: string,
|
||||||
|
comment: string | undefined,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||||
|
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||||||
|
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
const subject = `Votre dossier – compléments demandés`;
|
||||||
|
const commentBlock = comment
|
||||||
|
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '<').replace(/>/g, '>')}</p>`
|
||||||
|
: '';
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
|
||||||
|
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
|
||||||
|
${commentBlock}
|
||||||
|
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${repriseLink}" style="background-color: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
|
||||||
|
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NumeroDossierService } from './numero-dossier.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [NumeroDossierService],
|
||||||
|
exports: [NumeroDossierService],
|
||||||
|
})
|
||||||
|
export class NumeroDossierModule {}
|
||||||
55
backend/src/modules/numero-dossier/numero-dossier.service.ts
Normal file
55
backend/src/modules/numero-dossier/numero-dossier.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
const FORMAT_MAX_SEQUENCE = 990000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de génération du numéro de dossier (ticket #103).
|
||||||
|
* Format AAAA-NNNNNN (année + 6 chiffres), séquence par année.
|
||||||
|
* Si séquence >= 990000, overflowWarning est true (alerte gestionnaire).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NumeroDossierService {
|
||||||
|
/**
|
||||||
|
* Génère le prochain numéro de dossier dans le cadre d'une transaction.
|
||||||
|
* À appeler avec le manager de la transaction pour garantir l'unicité.
|
||||||
|
*/
|
||||||
|
async getNextNumeroDossier(manager: EntityManager): Promise<{
|
||||||
|
numero: string;
|
||||||
|
overflowWarning: boolean;
|
||||||
|
}> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Garantir l'existence de la ligne pour l'année
|
||||||
|
await manager.query(
|
||||||
|
`INSERT INTO numero_dossier_sequence (annee, prochain)
|
||||||
|
VALUES ($1, 1)
|
||||||
|
ON CONFLICT (annee) DO NOTHING`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prendre le prochain numéro et incrémenter (FOR UPDATE pour concurrence)
|
||||||
|
const selectRows = await manager.query(
|
||||||
|
`SELECT prochain FROM numero_dossier_sequence WHERE annee = $1 FOR UPDATE`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
const currentVal = selectRows?.[0]?.prochain ?? 1;
|
||||||
|
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE numero_dossier_sequence SET prochain = prochain + 1 WHERE annee = $1`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextVal = currentVal;
|
||||||
|
const overflowWarning = nextVal >= FORMAT_MAX_SEQUENCE;
|
||||||
|
if (overflowWarning) {
|
||||||
|
// Log pour alerte gestionnaire (ticket #103)
|
||||||
|
console.warn(
|
||||||
|
`[NumeroDossierService] Séquence année ${year} >= ${FORMAT_MAX_SEQUENCE} (valeur ${nextVal}). Prévoir renouvellement ou format.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numero = `${year}-${String(nextVal).padStart(6, '0')}`;
|
||||||
|
return { numero, overflowWarning };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,7 +35,7 @@ export class AssistantesMaternellesController {
|
|||||||
return this.assistantesMaternellesService.create(dto);
|
return this.assistantesMaternellesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
|
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
|
||||||
@ApiResponse({ status: 200, description: 'Liste des nounous' })
|
@ApiResponse({ status: 200, description: 'Liste des nounous' })
|
||||||
|
|||||||
@ -1,16 +1,22 @@
|
|||||||
import { Body, Controller, Get, Post, Req, UnauthorizedException, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Public } from 'src/common/decorators/public.decorator';
|
import { Public } from 'src/common/decorators/public.decorator';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||||
|
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||||
|
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { ProfileResponseDto } from './dto/profile_response.dto';
|
import { ProfileResponseDto } from './dto/profile_response.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh_token.dto';
|
import { RefreshTokenDto } from './dto/refresh_token.dto';
|
||||||
|
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
|
||||||
|
import { RepriseIdentifyBodyDto } from './dto/reprise-identify.dto';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||||
|
|
||||||
@ApiTags('Authentification')
|
@ApiTags('Authentification')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -30,11 +36,67 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Inscription' })
|
@ApiOperation({ summary: 'Inscription (OBSOLÈTE - utiliser /register/parent)' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
|
||||||
async register(@Body() dto: RegisterDto) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register/parent')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Inscription Parent COMPLÈTE - Workflow 6 étapes',
|
||||||
|
description: 'Crée Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU en une transaction'
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
|
||||||
|
async inscrireParentComplet(@Body() dto: RegisterParentCompletDto) {
|
||||||
|
return this.authService.inscrireParentComplet(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register/am')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Inscription Assistante Maternelle COMPLÈTE',
|
||||||
|
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
|
||||||
|
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
|
||||||
|
return this.authService.inscrireAMComplet(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('reprise-dossier')
|
||||||
|
@ApiOperation({ summary: 'Dossier pour reprise (token seul)' })
|
||||||
|
@ApiQuery({ name: 'token', required: true, description: 'Token reprise (lien email)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Données dossier pour préremplir', type: RepriseDossierDto })
|
||||||
|
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
|
||||||
|
async getRepriseDossier(@Query('token') token: string): Promise<RepriseDossierDto> {
|
||||||
|
return this.authService.getRepriseDossier(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Patch('reprise-resoumettre')
|
||||||
|
@ApiOperation({ summary: 'Resoumettre le dossier (mise à jour + statut en_attente, invalide le token)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Dossier resoumis' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
|
||||||
|
async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) {
|
||||||
|
const { token, ...fields } = dto;
|
||||||
|
return this.authService.resoumettreReprise(token, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('reprise-identify')
|
||||||
|
@ApiOperation({ summary: 'Modale reprise : numéro + email → type + token' })
|
||||||
|
@ApiResponse({ status: 201, description: 'type (parent/AM) + token pour GET reprise-dossier / PUT reprise-resoumettre' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Aucun dossier en reprise pour ce numéro et email' })
|
||||||
|
async repriseIdentify(@Body() dto: RepriseIdentifyBodyDto) {
|
||||||
|
return this.authService.identifyReprise(dto.numero_dossier, dto.email);
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@ApiBearerAuth('refresh_token')
|
@ApiBearerAuth('refresh_token')
|
||||||
@ -62,6 +124,7 @@ export class AuthController {
|
|||||||
prenom: user.prenom ?? '',
|
prenom: user.prenom ?? '',
|
||||||
nom: user.nom ?? '',
|
nom: user.nom ?? '',
|
||||||
statut: user.statut,
|
statut: user.statut,
|
||||||
|
changement_mdp_obligatoire: user.changement_mdp_obligatoire,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,5 +134,31 @@ export class AuthController {
|
|||||||
logout(@User() currentUser: Users) {
|
logout(@User() currentUser: Users) {
|
||||||
return this.authService.logout(currentUser.id);
|
return this.authService.logout(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('change-password-required')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Changement de mot de passe obligatoire',
|
||||||
|
description: 'Permet de changer le mot de passe lors de la première connexion (flag changement_mdp_obligatoire)'
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'Mot de passe changé avec succès' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Mot de passe actuel incorrect ou confirmation non correspondante' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Changement de mot de passe non requis pour cet utilisateur' })
|
||||||
|
async changePasswordRequired(
|
||||||
|
@User() currentUser: Users,
|
||||||
|
@Body() dto: ChangePasswordRequiredDto,
|
||||||
|
) {
|
||||||
|
// Vérifier que les mots de passe correspondent
|
||||||
|
if (dto.nouveau_mot_de_passe !== dto.confirmation_mot_de_passe) {
|
||||||
|
throw new BadRequestException('Les mots de passe ne correspondent pas');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authService.changePasswordRequired(
|
||||||
|
currentUser.id,
|
||||||
|
dto.mot_de_passe_actuel,
|
||||||
|
dto.nouveau_mot_de_passe,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,23 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { Children } from 'src/entities/children.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { AppConfigModule } from 'src/modules/config';
|
||||||
|
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
|
AppConfigModule,
|
||||||
|
NumeroDossierModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@ -1,15 +1,33 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||||
|
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||||
|
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
||||||
|
import { AppConfigService } from 'src/modules/config/config.service';
|
||||||
|
import { validateNir } from 'src/common/utils/nir.util';
|
||||||
|
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -17,6 +35,14 @@ export class AuthService {
|
|||||||
private readonly usersService: UserService,
|
private readonly usersService: UserService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
private readonly appConfigService: AppConfigService,
|
||||||
|
private readonly numeroDossierService: NumeroDossierService,
|
||||||
|
@InjectRepository(Parents)
|
||||||
|
private readonly parentsRepo: Repository<Parents>,
|
||||||
|
@InjectRepository(Users)
|
||||||
|
private readonly usersRepo: Repository<Users>,
|
||||||
|
@InjectRepository(Children)
|
||||||
|
private readonly childrenRepo: Repository<Children>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,29 +69,43 @@ export class AuthService {
|
|||||||
* Connexion utilisateur
|
* Connexion utilisateur
|
||||||
*/
|
*/
|
||||||
async login(dto: LoginDto) {
|
async login(dto: LoginDto) {
|
||||||
try {
|
|
||||||
const user = await this.usersService.findByEmailOrNull(dto.email);
|
const user = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Email invalide');
|
|
||||||
}
|
|
||||||
console.log("Tentative login:", dto.email, JSON.stringify(dto.password));
|
|
||||||
console.log("Utilisateur trouvé:", user.email, user.password);
|
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(dto.password, user.password);
|
|
||||||
console.log("Résultat bcrypt.compare:", isMatch);
|
|
||||||
if (!isMatch) {
|
|
||||||
throw new UnauthorizedException('Mot de passe invalide');
|
|
||||||
}
|
|
||||||
// if (user.password !== dto.password) {
|
|
||||||
// throw new UnauthorizedException('Mot de passe invalide');
|
|
||||||
// }
|
|
||||||
|
|
||||||
return this.generateTokens(user.id, user.email, user.role);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur de connexion :', error);
|
|
||||||
throw new UnauthorizedException('Identifiants invalides');
|
throw new UnauthorizedException('Identifiants invalides');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifier que le mot de passe existe (compte activé)
|
||||||
|
if (!user.password) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Compte non activé. Veuillez créer votre mot de passe via le lien reçu par email.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le mot de passe
|
||||||
|
const isMatch = await bcrypt.compare(dto.password, user.password);
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new UnauthorizedException('Identifiants invalides');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le statut du compte
|
||||||
|
if (user.statut === StatutUtilisateurType.EN_ATTENTE) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Votre compte est en attente de validation par un gestionnaire.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.statut === StatutUtilisateurType.SUSPENDU) {
|
||||||
|
throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.statut === StatutUtilisateurType.REFUSE) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(user.id, user.email, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,7 +129,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inscription utilisateur lambda (parent ou assistante maternelle)
|
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
|
||||||
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
async register(registerDto: RegisterDto) {
|
async register(registerDto: RegisterDto) {
|
||||||
const exists = await this.usersService.findByEmailOrNull(registerDto.email);
|
const exists = await this.usersService.findByEmailOrNull(registerDto.email);
|
||||||
@ -129,9 +170,381 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(userId: string) {
|
/**
|
||||||
// Pour le moment envoyer un message clair
|
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
||||||
return { success: true, message: 'Deconnexion'}
|
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
||||||
|
*/
|
||||||
|
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||||
|
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||||
|
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.enfants || dto.enfants.length === 0) {
|
||||||
|
throw new BadRequestException('Au moins un enfant est requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
|
if (existe) {
|
||||||
|
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.co_parent_email) {
|
||||||
|
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||||||
|
if (coParentExiste) {
|
||||||
|
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenCreationMdp = crypto.randomUUID();
|
||||||
|
const dateExpiration = new Date();
|
||||||
|
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
|
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
|
const parent1 = manager.create(Users, {
|
||||||
|
email: dto.email,
|
||||||
|
prenom: dto.prenom,
|
||||||
|
nom: dto.nom,
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
telephone: dto.telephone,
|
||||||
|
adresse: dto.adresse,
|
||||||
|
code_postal: dto.code_postal,
|
||||||
|
ville: dto.ville,
|
||||||
|
token_creation_mdp: tokenCreationMdp,
|
||||||
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent1Enregistre = await manager.save(Users, parent1);
|
||||||
|
|
||||||
|
let parent2Enregistre: Users | null = null;
|
||||||
|
let tokenCoParent: string | null = null;
|
||||||
|
|
||||||
|
if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) {
|
||||||
|
tokenCoParent = crypto.randomUUID();
|
||||||
|
const dateExpirationCoParent = new Date();
|
||||||
|
dateExpirationCoParent.setDate(dateExpirationCoParent.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
|
const parent2 = manager.create(Users, {
|
||||||
|
email: dto.co_parent_email,
|
||||||
|
prenom: dto.co_parent_prenom,
|
||||||
|
nom: dto.co_parent_nom,
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
telephone: dto.co_parent_telephone,
|
||||||
|
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
|
||||||
|
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
|
||||||
|
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
|
||||||
|
token_creation_mdp: tokenCoParent,
|
||||||
|
token_creation_mdp_expire_le: dateExpirationCoParent,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
|
||||||
|
parent2Enregistre = await manager.save(Users, parent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entiteParent = manager.create(Parents, {
|
||||||
|
user_id: parent1Enregistre.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
entiteParent.user = parent1Enregistre;
|
||||||
|
if (parent2Enregistre) {
|
||||||
|
entiteParent.co_parent = parent2Enregistre;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.save(Parents, entiteParent);
|
||||||
|
|
||||||
|
if (parent2Enregistre) {
|
||||||
|
const entiteCoParent = manager.create(Parents, {
|
||||||
|
user_id: parent2Enregistre.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
entiteCoParent.user = parent2Enregistre;
|
||||||
|
entiteCoParent.co_parent = parent1Enregistre;
|
||||||
|
|
||||||
|
await manager.save(Parents, entiteCoParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enfantsEnregistres: Children[] = [];
|
||||||
|
for (const enfantDto of dto.enfants) {
|
||||||
|
let urlPhoto: string | null = null;
|
||||||
|
|
||||||
|
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||||||
|
urlPhoto = await this.sauvegarderPhotoDepuisBase64(
|
||||||
|
enfantDto.photo_base64,
|
||||||
|
enfantDto.photo_filename,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enfant = new Children();
|
||||||
|
enfant.first_name = enfantDto.prenom;
|
||||||
|
enfant.last_name = enfantDto.nom || dto.nom;
|
||||||
|
enfant.gender = enfantDto.genre;
|
||||||
|
enfant.birth_date = enfantDto.date_naissance ? new Date(enfantDto.date_naissance) : undefined;
|
||||||
|
enfant.due_date = enfantDto.date_previsionnelle_naissance
|
||||||
|
? new Date(enfantDto.date_previsionnelle_naissance)
|
||||||
|
: undefined;
|
||||||
|
enfant.photo_url = urlPhoto || undefined;
|
||||||
|
enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE;
|
||||||
|
enfant.consent_photo = false;
|
||||||
|
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
||||||
|
|
||||||
|
const enfantEnregistre = await manager.save(Children, enfant);
|
||||||
|
enfantsEnregistres.push(enfantEnregistre);
|
||||||
|
|
||||||
|
const lienParentEnfant1 = manager.create(ParentsChildren, {
|
||||||
|
parentId: parent1Enregistre.id,
|
||||||
|
enfantId: enfantEnregistre.id,
|
||||||
|
});
|
||||||
|
await manager.save(ParentsChildren, lienParentEnfant1);
|
||||||
|
|
||||||
|
if (parent2Enregistre) {
|
||||||
|
const lienParentEnfant2 = manager.create(ParentsChildren, {
|
||||||
|
parentId: parent2Enregistre.id,
|
||||||
|
enfantId: enfantEnregistre.id,
|
||||||
|
});
|
||||||
|
await manager.save(ParentsChildren, lienParentEnfant2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parent1: parent1Enregistre,
|
||||||
|
parent2: parent2Enregistre,
|
||||||
|
enfants: enfantsEnregistres,
|
||||||
|
tokenCreationMdp,
|
||||||
|
tokenCoParent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||||
|
parent_id: resultat.parent1.id,
|
||||||
|
co_parent_id: resultat.parent2?.id,
|
||||||
|
enfants_ids: resultat.enfants.map(e => e.id),
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||||
|
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||||
|
*/
|
||||||
|
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||||
|
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
|
||||||
|
const nirValidation = validateNir(nirNormalized, {
|
||||||
|
dateNaissance: dto.date_naissance,
|
||||||
|
});
|
||||||
|
if (!nirValidation.valid) {
|
||||||
|
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||||
|
}
|
||||||
|
if (nirValidation.warning) {
|
||||||
|
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
||||||
|
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
|
if (existe) {
|
||||||
|
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||||
|
}
|
||||||
|
|
||||||
|
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
const tokenCreationMdp = crypto.randomUUID();
|
||||||
|
const dateExpiration = new Date();
|
||||||
|
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
|
let urlPhoto: string | null = null;
|
||||||
|
if (dto.photo_base64 && dto.photo_filename) {
|
||||||
|
urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateConsentementPhoto =
|
||||||
|
dto.consentement_photo ? new Date() : undefined;
|
||||||
|
|
||||||
|
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
|
const user = manager.create(Users, {
|
||||||
|
email: dto.email,
|
||||||
|
prenom: dto.prenom,
|
||||||
|
nom: dto.nom,
|
||||||
|
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
telephone: dto.telephone,
|
||||||
|
adresse: dto.adresse,
|
||||||
|
code_postal: dto.code_postal,
|
||||||
|
ville: dto.ville,
|
||||||
|
token_creation_mdp: tokenCreationMdp,
|
||||||
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
|
photo_url: urlPhoto ?? undefined,
|
||||||
|
consentement_photo: dto.consentement_photo,
|
||||||
|
date_consentement_photo: dateConsentementPhoto,
|
||||||
|
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
const userEnregistre = await manager.save(Users, user);
|
||||||
|
|
||||||
|
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||||
|
const am = amRepo.create({
|
||||||
|
user_id: userEnregistre.id,
|
||||||
|
approval_number: dto.numero_agrement,
|
||||||
|
nir: nirNormalized,
|
||||||
|
max_children: dto.capacite_accueil,
|
||||||
|
biography: dto.biographie,
|
||||||
|
residence_city: dto.ville ?? undefined,
|
||||||
|
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||||
|
available: true,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
await amRepo.save(am);
|
||||||
|
|
||||||
|
return { user: userEnregistre };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||||
|
user_id: resultat.user.id,
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||||
|
*/
|
||||||
|
private async sauvegarderPhotoDepuisBase64(donneesBase64: string, nomFichier: string): Promise<string> {
|
||||||
|
const correspondances = donneesBase64.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||||
|
if (!correspondances) {
|
||||||
|
throw new BadRequestException('Format de photo invalide (doit être base64)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = correspondances[1];
|
||||||
|
const tamponImage = Buffer.from(correspondances[2], 'base64');
|
||||||
|
|
||||||
|
const dossierUpload = '/app/uploads/photos';
|
||||||
|
await fs.mkdir(dossierUpload, { recursive: true });
|
||||||
|
|
||||||
|
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
|
||||||
|
const cheminFichier = path.join(dossierUpload, nomFichierUnique);
|
||||||
|
|
||||||
|
await fs.writeFile(cheminFichier, tamponImage);
|
||||||
|
|
||||||
|
return `/uploads/photos/${nomFichierUnique}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changement de mot de passe obligatoire (première connexion)
|
||||||
|
*/
|
||||||
|
async changePasswordRequired(
|
||||||
|
userId: string,
|
||||||
|
motDePasseActuel: string,
|
||||||
|
nouveauMotDePasse: string,
|
||||||
|
) {
|
||||||
|
const user = await this.usersRepo.findOne({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Utilisateur introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que le changement est bien obligatoire
|
||||||
|
if (!user.changement_mdp_obligatoire) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Le changement de mot de passe n\'est pas requis pour cet utilisateur',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que l'utilisateur a un mot de passe
|
||||||
|
if (!user.password) {
|
||||||
|
throw new BadRequestException('Compte non activé');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le mot de passe actuel
|
||||||
|
const motDePasseValide = await bcrypt.compare(motDePasseActuel, user.password);
|
||||||
|
if (!motDePasseValide) {
|
||||||
|
throw new BadRequestException('Mot de passe actuel incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que le nouveau mot de passe est différent de l'ancien
|
||||||
|
const memeMotDePasse = await bcrypt.compare(nouveauMotDePasse, user.password);
|
||||||
|
if (memeMotDePasse) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Le nouveau mot de passe doit être différent de l\'ancien',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hasher et sauvegarder le nouveau mot de passe
|
||||||
|
const sel = await bcrypt.genSalt(12);
|
||||||
|
user.password = await bcrypt.hash(nouveauMotDePasse, sel);
|
||||||
|
user.changement_mdp_obligatoire = false;
|
||||||
|
user.modifie_le = new Date();
|
||||||
|
|
||||||
|
await this.usersRepo.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Mot de passe changé avec succès',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(userId: string) {
|
||||||
|
return { success: true, message: 'Deconnexion'}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET dossier reprise – token seul. Ticket #111 */
|
||||||
|
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
|
||||||
|
const user = await this.usersService.findByTokenReprise(token);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
prenom: user.prenom,
|
||||||
|
nom: user.nom,
|
||||||
|
telephone: user.telephone,
|
||||||
|
adresse: user.adresse,
|
||||||
|
ville: user.ville,
|
||||||
|
code_postal: user.code_postal,
|
||||||
|
numero_dossier: user.numero_dossier,
|
||||||
|
role: user.role,
|
||||||
|
photo_url: user.photo_url,
|
||||||
|
genre: user.genre,
|
||||||
|
situation_familiale: user.situation_familiale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT resoumission reprise. Ticket #111 */
|
||||||
|
async resoumettreReprise(
|
||||||
|
token: string,
|
||||||
|
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
|
||||||
|
): Promise<Users> {
|
||||||
|
return this.usersService.resoumettreReprise(token, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
|
||||||
|
async identifyReprise(numero_dossier: string, email: string): Promise<RepriseIdentifyResponseDto> {
|
||||||
|
const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email);
|
||||||
|
if (!user || !user.token_reprise) {
|
||||||
|
throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle',
|
||||||
|
token: user.token_reprise,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
backend/src/routes/auth/dto/change-password.dto.ts
Normal file
23
backend/src/routes/auth/dto/change-password.dto.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MinLength, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class ChangePasswordRequiredDto {
|
||||||
|
@ApiProperty({ description: 'Mot de passe actuel' })
|
||||||
|
@IsString()
|
||||||
|
mot_de_passe_actuel: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)',
|
||||||
|
minLength: 8
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
|
||||||
|
@Matches(/^(?=.*[A-Z])(?=.*\d)/, {
|
||||||
|
message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre'
|
||||||
|
})
|
||||||
|
nouveau_mot_de_passe: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Confirmation du nouveau mot de passe' })
|
||||||
|
@IsString()
|
||||||
|
confirmation_mot_de_passe: string;
|
||||||
|
}
|
||||||
63
backend/src/routes/auth/dto/enfant-inscription.dto.ts
Normal file
63
backend/src/routes/auth/dto/enfant-inscription.dto.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsDateString,
|
||||||
|
IsBoolean,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { GenreType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
|
export class EnfantInscriptionDto {
|
||||||
|
@ApiProperty({ example: 'Emma', required: false, description: 'Prénom de l\'enfant (obligatoire si déjà né)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
|
||||||
|
@MaxLength(100, { message: 'Le prénom ne peut pas dépasser 100 caractères' })
|
||||||
|
prenom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MARTIN', required: false, description: 'Nom de l\'enfant (hérité des parents si non fourni)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
|
||||||
|
@MaxLength(100, { message: 'Le nom ne peut pas dépasser 100 caractères' })
|
||||||
|
nom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2023-02-15', required: false, description: 'Date de naissance (si enfant déjà né)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_naissance?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2025-06-15', required: false, description: 'Date prévisionnelle de naissance (si enfant à naître)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_previsionnelle_naissance?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: GenreType, example: GenreType.F })
|
||||||
|
@IsEnum(GenreType, { message: 'Le genre doit être H, F ou Autre' })
|
||||||
|
@IsNotEmpty({ message: 'Le genre est requis' })
|
||||||
|
genre: GenreType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'data:image/jpeg;base64,/9j/4AAQSkZJRg...',
|
||||||
|
required: false,
|
||||||
|
description: 'Photo de l\'enfant en base64 (obligatoire si déjà né)'
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
photo_base64?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'emma_martin.jpg', required: false, description: 'Nom du fichier photo' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
photo_filename?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
grossesse_multiple?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@ -19,4 +19,7 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({ enum: StatutUtilisateurType })
|
@ApiProperty({ enum: StatutUtilisateurType })
|
||||||
statut: StatutUtilisateurType;
|
statut: StatutUtilisateurType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Indique si le changement de mot de passe est obligatoire à la première connexion' })
|
||||||
|
changement_mdp_obligatoire: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
158
backend/src/routes/auth/dto/register-am-complet.dto.ts
Normal file
158
backend/src/routes/auth/dto/register-am-complet.dto.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
IsDateString,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterAMCompletDto {
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 1 : IDENTITÉ (Obligatoire)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'marie.dupont@ptits-pas.fr' })
|
||||||
|
@IsEmail({}, { message: 'Email invalide' })
|
||||||
|
@IsNotEmpty({ message: "L'email est requis" })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Marie' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||||
|
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
|
||||||
|
@MaxLength(100)
|
||||||
|
prenom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'DUPONT' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||||
|
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
|
||||||
|
@MaxLength(100)
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0689567890' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||||
|
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||||
|
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||||
|
})
|
||||||
|
telephone: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '95870', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Bezons', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
ville?: string;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 2 : PHOTO + INFOS PRO
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'data:image/jpeg;base64,/9j/4AAQ...',
|
||||||
|
required: false,
|
||||||
|
description: 'Photo de profil en base64',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
photo_base64?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'photo_profil.jpg', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
photo_filename?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true, description: 'Consentement utilisation photo' })
|
||||||
|
@IsBoolean()
|
||||||
|
@IsNotEmpty({ message: 'Le consentement photo est requis' })
|
||||||
|
consentement_photo: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2024-01-15', required: false, description: 'Date de naissance' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_naissance?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
lieu_naissance_ville?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
lieu_naissance_pays?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le NIR est requis' })
|
||||||
|
@Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, {
|
||||||
|
message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)',
|
||||||
|
})
|
||||||
|
nir: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: "Le numéro d'agrément est requis" })
|
||||||
|
@MaxLength(50)
|
||||||
|
numero_agrement: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2024-06-01', required: false, description: "Date d'obtention de l'agrément" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_agrement?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 4, description: 'Capacité d\'accueil (nombre d\'enfants)', minimum: 1, maximum: 10 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1, { message: 'La capacité doit être au moins 1' })
|
||||||
|
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
|
||||||
|
capacite_accueil: number;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Assistante maternelle expérimentée, accueil bienveillant...',
|
||||||
|
required: false,
|
||||||
|
description: 'Présentation / biographie (max 2000 caractères)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000, { message: 'La présentation ne peut pas dépasser 2000 caractères' })
|
||||||
|
biographie?: string;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 4 : ACCEPTATION CGU (Obligatoire)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({ example: true, description: "Acceptation des CGU" })
|
||||||
|
@IsBoolean()
|
||||||
|
@IsNotEmpty({ message: "L'acceptation des CGU est requise" })
|
||||||
|
acceptation_cgu: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true, description: 'Acceptation de la Politique de confidentialité' })
|
||||||
|
@IsBoolean()
|
||||||
|
@IsNotEmpty({ message: "L'acceptation de la politique de confidentialité est requise" })
|
||||||
|
acceptation_privacy: boolean;
|
||||||
|
}
|
||||||
166
backend/src/routes/auth/dto/register-parent-complet.dto.ts
Normal file
166
backend/src/routes/auth/dto/register-parent-complet.dto.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsBoolean,
|
||||||
|
IsArray,
|
||||||
|
ValidateNested,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { SituationFamilialeType } from 'src/entities/users.entity';
|
||||||
|
import { EnfantInscriptionDto } from './enfant-inscription.dto';
|
||||||
|
|
||||||
|
export class RegisterParentCompletDto {
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 1 : PARENT 1 (Obligatoire)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'claire.martin@ptits-pas.fr' })
|
||||||
|
@IsEmail({}, { message: 'Email invalide' })
|
||||||
|
@IsNotEmpty({ message: 'L\'email est requis' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Claire' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||||
|
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
|
||||||
|
@MaxLength(100, { message: 'Le prénom ne peut pas dépasser 100 caractères' })
|
||||||
|
prenom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MARTIN' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||||
|
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
|
||||||
|
@MaxLength(100, { message: 'Le nom ne peut pas dépasser 100 caractères' })
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0689567890' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||||
|
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||||
|
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||||
|
})
|
||||||
|
telephone: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '95870', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Bezons', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
ville?: string;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 2 : PARENT 2 / CO-PARENT (Optionnel)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail({}, { message: 'Email du co-parent invalide' })
|
||||||
|
co_parent_email?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Thomas', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
co_parent_prenom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MARTIN', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
co_parent_nom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0678456789', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||||
|
message: 'Le numéro de téléphone du co-parent doit être valide',
|
||||||
|
})
|
||||||
|
co_parent_telephone?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true, description: 'Le co-parent habite à la même adresse', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
co_parent_meme_adresse?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
co_parent_adresse?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
co_parent_code_postal?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
co_parent_ville?: string;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 3 : ENFANT(S) (Au moins 1 requis)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [EnfantInscriptionDto],
|
||||||
|
description: 'Liste des enfants (au moins 1 requis)',
|
||||||
|
example: [{
|
||||||
|
prenom: 'Emma',
|
||||||
|
nom: 'MARTIN',
|
||||||
|
date_naissance: '2023-02-15',
|
||||||
|
genre: 'F',
|
||||||
|
photo_base64: 'data:image/jpeg;base64,...',
|
||||||
|
photo_filename: 'emma_martin.jpg'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
@IsArray({ message: 'La liste des enfants doit être un tableau' })
|
||||||
|
@IsNotEmpty({ message: 'Au moins un enfant est requis' })
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => EnfantInscriptionDto)
|
||||||
|
enfants: EnfantInscriptionDto[];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 4 : PRÉSENTATION DU DOSSIER (Optionnel)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Nous recherchons une assistante maternelle bienveillante pour nos triplés...',
|
||||||
|
required: false,
|
||||||
|
description: 'Présentation du dossier (max 2000 caractères)'
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000, { message: 'La présentation ne peut pas dépasser 2000 caractères' })
|
||||||
|
presentation_dossier?: string;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ÉTAPE 5 : ACCEPTATION CGU (Obligatoire)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@ApiProperty({ example: true, description: 'Acceptation des Conditions Générales d\'Utilisation' })
|
||||||
|
@IsBoolean()
|
||||||
|
@IsNotEmpty({ message: 'L\'acceptation des CGU est requise' })
|
||||||
|
acceptation_cgu: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true, description: 'Acceptation de la Politique de confidentialité' })
|
||||||
|
@IsBoolean()
|
||||||
|
@IsNotEmpty({ message: 'L\'acceptation de la politique de confidentialité est requise' })
|
||||||
|
acceptation_privacy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
44
backend/src/routes/auth/dto/reprise-dossier.dto.ts
Normal file
44
backend/src/routes/auth/dto/reprise-dossier.dto.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */
|
||||||
|
export class RepriseDossierDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
prenom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
telephone?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
ville?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
numero_dossier?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: RoleType })
|
||||||
|
role: RoleType;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Pour AM' })
|
||||||
|
photo_url?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
genre?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
situation_familiale?: string;
|
||||||
|
}
|
||||||
23
backend/src/routes/auth/dto/reprise-identify.dto.ts
Normal file
23
backend/src/routes/auth/dto/reprise-identify.dto.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
/** Body POST /auth/reprise-identify – numéro + email pour obtenir token reprise. Ticket #111 */
|
||||||
|
export class RepriseIdentifyBodyDto {
|
||||||
|
@ApiProperty({ example: '2026-000001' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
numero_dossier: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'parent@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse POST /auth/reprise-identify */
|
||||||
|
export class RepriseIdentifyResponseDto {
|
||||||
|
@ApiProperty({ enum: ['parent', 'assistante_maternelle'] })
|
||||||
|
type: 'parent' | 'assistante_maternelle';
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Token à utiliser pour GET reprise-dossier et PUT reprise-resoumettre' })
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
49
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
Normal file
49
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */
|
||||||
|
export class ResoumettreRepriseDto {
|
||||||
|
@ApiProperty({ description: 'Token reprise (reçu par email)' })
|
||||||
|
@IsUUID()
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
prenom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
nom?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
telephone?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
ville?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Pour AM' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
photo_url?: string;
|
||||||
|
}
|
||||||
@ -29,10 +29,10 @@ export class CreateEnfantsDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: GenreType, required: false })
|
@ApiProperty({ enum: GenreType })
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(GenreType)
|
@IsEnum(GenreType)
|
||||||
gender?: GenreType;
|
@IsNotEmpty()
|
||||||
|
gender: GenreType;
|
||||||
|
|
||||||
@ApiProperty({ example: '2018-06-24', required: false })
|
@ApiProperty({ example: '2018-06-24', required: false })
|
||||||
@ValidateIf(o => o.status !== StatutEnfantType.A_NAITRE)
|
@ValidateIf(o => o.status !== StatutEnfantType.A_NAITRE)
|
||||||
|
|||||||
@ -8,8 +8,13 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||||
|
import { diskStorage } from 'multer';
|
||||||
|
import { extname } from 'path';
|
||||||
import { EnfantsService } from './enfants.service';
|
import { EnfantsService } from './enfants.service';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||||
@ -28,8 +33,34 @@ export class EnfantsController {
|
|||||||
|
|
||||||
@Roles(RoleType.PARENT)
|
@Roles(RoleType.PARENT)
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateEnfantsDto, @User() currentUser: Users) {
|
@ApiConsumes('multipart/form-data')
|
||||||
return this.enfantsService.create(dto, currentUser);
|
@UseInterceptors(
|
||||||
|
FileInterceptor('photo', {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: './uploads/photos',
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const ext = extname(file.originalname);
|
||||||
|
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||||
|
return cb(new Error('Seules les images sont autorisées'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateEnfantsDto,
|
||||||
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
|
@User() currentUser: Users,
|
||||||
|
) {
|
||||||
|
return this.enfantsService.create(dto, currentUser, photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
|
@Roles(RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
|
||||||
|
|||||||
@ -24,10 +24,11 @@ export class EnfantsService {
|
|||||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d’un enfant
|
// Création d'un enfant
|
||||||
async create(dto: CreateEnfantsDto, currentUser: Users): Promise<Children> {
|
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
||||||
const parent = await this.parentsRepository.findOne({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id: currentUser.id },
|
where: { user_id: currentUser.id },
|
||||||
|
relations: ['co_parent'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||||
|
|
||||||
@ -46,17 +47,34 @@ export class EnfantsService {
|
|||||||
});
|
});
|
||||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||||
|
|
||||||
|
// Gestion de la photo uploadée
|
||||||
|
if (photoFile) {
|
||||||
|
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||||
|
if (dto.consent_photo) {
|
||||||
|
dto.consent_photo_at = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Création
|
// Création
|
||||||
const child = this.childrenRepository.create(dto);
|
const child = this.childrenRepository.create(dto);
|
||||||
await this.childrenRepository.save(child);
|
await this.childrenRepository.save(child);
|
||||||
|
|
||||||
// Lien parent-enfant
|
// Lien parent-enfant (Parent 1)
|
||||||
const parentLink = this.parentsChildrenRepository.create({
|
const parentLink = this.parentsChildrenRepository.create({
|
||||||
parentId: parent.user_id,
|
parentId: parent.user_id,
|
||||||
enfantId: child.id,
|
enfantId: child.id,
|
||||||
});
|
});
|
||||||
await this.parentsChildrenRepository.save(parentLink);
|
await this.parentsChildrenRepository.save(parentLink);
|
||||||
|
|
||||||
|
// Rattachement automatique au co-parent s'il existe
|
||||||
|
if (parent.co_parent) {
|
||||||
|
const coParentLink = this.parentsChildrenRepository.create({
|
||||||
|
parentId: parent.co_parent.id,
|
||||||
|
enfantId: child.id,
|
||||||
|
});
|
||||||
|
await this.parentsChildrenRepository.save(coParentLink);
|
||||||
|
}
|
||||||
|
|
||||||
return this.findOne(child.id, currentUser);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
backend/src/routes/parents/dto/pending-family.dto.ts
Normal file
20
backend/src/routes/parents/dto/pending-family.dto.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class PendingFamilyDto {
|
||||||
|
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||||
|
libelle: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
example: ['uuid-parent-1', 'uuid-parent-2'],
|
||||||
|
description: 'IDs utilisateur des parents de la famille',
|
||||||
|
})
|
||||||
|
parentIds: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
nullable: true,
|
||||||
|
example: '2026-000001',
|
||||||
|
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
|
||||||
|
})
|
||||||
|
numero_dossier: string | null;
|
||||||
|
}
|
||||||
@ -1,26 +1,68 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RoleType } from 'src/entities/users.entity';
|
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
|
|
||||||
@ApiTags('Parents')
|
@ApiTags('Parents')
|
||||||
@Controller('parents')
|
@Controller('parents')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class ParentsController {
|
export class ParentsController {
|
||||||
constructor(private readonly parentsService: ParentsService) {}
|
constructor(
|
||||||
|
private readonly parentsService: ParentsService,
|
||||||
|
private readonly userService: UserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Get('pending-families')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||||
|
return this.parentsService.getPendingFamilies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':parentId/valider-dossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
|
||||||
|
@ApiParam({ name: 'parentId', description: "UUID d'un des parents (user_id)" })
|
||||||
|
@ApiResponse({ status: 200, description: 'Utilisateurs validés (famille)' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
async validerDossierFamille(
|
||||||
|
@Param('parentId') parentId: string,
|
||||||
|
@User() currentUser: Users,
|
||||||
|
@Body('comment') comment?: string,
|
||||||
|
): Promise<Users[]> {
|
||||||
|
const familyIds = await this.parentsService.getFamilyUserIds(parentId);
|
||||||
|
const validated: Users[] = [];
|
||||||
|
for (const userId of familyIds) {
|
||||||
|
const user = await this.userService.findOne(userId);
|
||||||
|
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) continue;
|
||||||
|
const saved = await this.userService.validateUser(userId, currentUser, comment);
|
||||||
|
validated.push(saved);
|
||||||
|
}
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get()
|
@Get()
|
||||||
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
|
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { ParentsController } from './parents.controller';
|
import { ParentsController } from './parents.controller';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Parents, Users])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Parents, Users]),
|
||||||
|
forwardRef(() => UserModule),
|
||||||
|
],
|
||||||
controllers: [ParentsController],
|
controllers: [ParentsController],
|
||||||
providers: [ParentsService],
|
providers: [ParentsService],
|
||||||
exports: [ParentsService,
|
exports: [ParentsService,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||||
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ParentsService {
|
export class ParentsService {
|
||||||
@ -71,4 +72,96 @@ export class ParentsService {
|
|||||||
await this.parentsRepository.update(id, dto);
|
await this.parentsRepository.update(id, dto);
|
||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des familles en attente (une entrée par famille).
|
||||||
|
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
||||||
|
* Uniquement les parents dont l'utilisateur a statut = en_attente.
|
||||||
|
*/
|
||||||
|
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||||
|
const raw = await this.parentsRepository.query(`
|
||||||
|
WITH RECURSIVE
|
||||||
|
links AS (
|
||||||
|
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
),
|
||||||
|
rec AS (
|
||||||
|
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||||
|
UNION
|
||||||
|
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||||
|
),
|
||||||
|
family_rep AS (
|
||||||
|
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||||
|
array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds",
|
||||||
|
(array_agg(p.numero_dossier))[1] AS numero_dossier
|
||||||
|
FROM family_rep fr
|
||||||
|
JOIN parents p ON p.id_utilisateur = fr.id
|
||||||
|
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
||||||
|
WHERE u.role = 'parent' AND u.statut = 'en_attente'
|
||||||
|
GROUP BY fr.rep
|
||||||
|
ORDER BY libelle
|
||||||
|
`);
|
||||||
|
return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({
|
||||||
|
libelle: r.libelle,
|
||||||
|
parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [],
|
||||||
|
numero_dossier: r.numero_dossier ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
|
||||||
|
* @throws NotFoundException si parentId n'est pas un parent
|
||||||
|
*/
|
||||||
|
async getFamilyUserIds(parentId: string): Promise<string[]> {
|
||||||
|
const raw = await this.parentsRepository.query(
|
||||||
|
`
|
||||||
|
WITH RECURSIVE
|
||||||
|
links AS (
|
||||||
|
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
),
|
||||||
|
rec AS (
|
||||||
|
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||||
|
UNION
|
||||||
|
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||||
|
),
|
||||||
|
family_rep AS (
|
||||||
|
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||||
|
),
|
||||||
|
input_rep AS (
|
||||||
|
SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1
|
||||||
|
)
|
||||||
|
SELECT fr.id::text AS id
|
||||||
|
FROM family_rep fr
|
||||||
|
CROSS JOIN input_rep ir
|
||||||
|
WHERE fr.rep = ir.rep
|
||||||
|
`,
|
||||||
|
[parentId],
|
||||||
|
);
|
||||||
|
if (!raw || raw.length === 0) {
|
||||||
|
throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.');
|
||||||
|
}
|
||||||
|
return raw.map((r: { id: string }) => r.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
backend/src/routes/relais/dto/create-relais.dto.ts
Normal file
34
backend/src/routes/relais/dto/create-relais.dto.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRelaisDto {
|
||||||
|
@ApiProperty({ example: 'Relais Petite Enfance Centre' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '12 rue de la Mairie, 75000 Paris' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
adresse: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: { lundi: '09:00-17:00' }, required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
horaires_ouverture?: any;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0123456789', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ligne_fixe?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ default: true, required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
actif?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Notes internes...', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
4
backend/src/routes/relais/dto/update-relais.dto.ts
Normal file
4
backend/src/routes/relais/dto/update-relais.dto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateRelaisDto } from './create-relais.dto';
|
||||||
|
|
||||||
|
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
|
||||||
57
backend/src/routes/relais/relais.controller.ts
Normal file
57
backend/src/routes/relais/relais.controller.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
|
||||||
|
import { RelaisService } from './relais.service';
|
||||||
|
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||||
|
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||||
|
import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
@ApiTags('Relais')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Controller('relais')
|
||||||
|
export class RelaisController {
|
||||||
|
constructor(private readonly relaisService: RelaisService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Créer un relais' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Le relais a été créé.' })
|
||||||
|
create(@Body() createRelaisDto: CreateRelaisDto) {
|
||||||
|
return this.relaisService.create(createRelaisDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||||
|
findAll() {
|
||||||
|
return this.relaisService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.relaisService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Mettre à jour un relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais a été mis à jour.' })
|
||||||
|
update(@Param('id') id: string, @Body() updateRelaisDto: UpdateRelaisDto) {
|
||||||
|
return this.relaisService.update(id, updateRelaisDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Supprimer un relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais a été supprimé.' })
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.relaisService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/src/routes/relais/relais.module.ts
Normal file
17
backend/src/routes/relais/relais.module.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { RelaisService } from './relais.service';
|
||||||
|
import { RelaisController } from './relais.controller';
|
||||||
|
import { Relais } from 'src/entities/relais.entity';
|
||||||
|
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Relais]),
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
|
controllers: [RelaisController],
|
||||||
|
providers: [RelaisService],
|
||||||
|
exports: [RelaisService],
|
||||||
|
})
|
||||||
|
export class RelaisModule {}
|
||||||
42
backend/src/routes/relais/relais.service.ts
Normal file
42
backend/src/routes/relais/relais.service.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Relais } from 'src/entities/relais.entity';
|
||||||
|
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||||
|
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RelaisService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Relais)
|
||||||
|
private readonly relaisRepository: Repository<Relais>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(createRelaisDto: CreateRelaisDto) {
|
||||||
|
const relais = this.relaisRepository.create(createRelaisDto);
|
||||||
|
return this.relaisRepository.save(relais);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll() {
|
||||||
|
return this.relaisRepository.find({ order: { nom: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const relais = await this.relaisRepository.findOne({ where: { id } });
|
||||||
|
if (!relais) {
|
||||||
|
throw new NotFoundException(`Relais #${id} not found`);
|
||||||
|
}
|
||||||
|
return relais;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updateRelaisDto: UpdateRelaisDto) {
|
||||||
|
const relais = await this.findOne(id);
|
||||||
|
Object.assign(relais, updateRelaisDto);
|
||||||
|
return this.relaisRepository.save(relais);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const relais = await this.findOne(id);
|
||||||
|
return this.relaisRepository.remove(relais);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/routes/user/dto/affecter-numero-dossier.dto.ts
Normal file
14
backend/src/routes/user/dto/affecter-numero-dossier.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
/** Format AAAA-NNNNNN (année + 6 chiffres) */
|
||||||
|
const NUMERO_DOSSIER_REGEX = /^\d{4}-\d{6}$/;
|
||||||
|
|
||||||
|
export class AffecterNumeroDossierDto {
|
||||||
|
@ApiProperty({ example: '2026-000004', description: 'Numéro de dossier (AAAA-NNNNNN)' })
|
||||||
|
@IsNotEmpty({ message: 'Le numéro de dossier est requis' })
|
||||||
|
@Matches(NUMERO_DOSSIER_REGEX, {
|
||||||
|
message: 'Le numéro de dossier doit être au format AAAA-NNNNNN (ex: 2026-000001)',
|
||||||
|
})
|
||||||
|
numero_dossier: string;
|
||||||
|
}
|
||||||
@ -1,4 +1,10 @@
|
|||||||
import { OmitType } from "@nestjs/swagger";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { CreateUserDto } from "./create_user.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
|
||||||
export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
export class CreateAdminDto extends PickType(CreateUserDto, [
|
||||||
|
'nom',
|
||||||
|
'prenom',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'telephone'
|
||||||
|
] as const) {}
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { OmitType } from "@nestjs/swagger";
|
import { ApiProperty, OmitType } from "@nestjs/swagger";
|
||||||
import { CreateUserDto } from "./create_user.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
import { IsOptional, IsUUID } from "class-validator";
|
||||||
|
|
||||||
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) {
|
||||||
|
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
relaisId?: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -36,10 +36,10 @@ export class CreateUserDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
nom: string;
|
nom: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE })
|
@ApiProperty({ enum: GenreType, required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(GenreType)
|
@IsEnum(GenreType)
|
||||||
genre?: GenreType = GenreType.AUTRE;
|
genre?: GenreType;
|
||||||
|
|
||||||
@ApiProperty({ enum: RoleType })
|
@ApiProperty({ enum: RoleType })
|
||||||
@IsEnum(RoleType)
|
@IsEnum(RoleType)
|
||||||
@ -86,7 +86,7 @@ export class CreateUserDto {
|
|||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consentement_photo?: boolean = false;
|
consentement_photo?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -96,7 +96,7 @@ export class CreateUserDto {
|
|||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
changement_mdp_obligatoire?: boolean = false;
|
changement_mdp_obligatoire?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ example: true })
|
@ApiProperty({ example: true })
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.create(dto);
|
return this.gestionnairesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Liste des gestionnaires' })
|
@ApiOperation({ summary: 'Liste des gestionnaires' })
|
||||||
@ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] })
|
@ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] })
|
||||||
@Get()
|
@Get()
|
||||||
|
|||||||
@ -3,9 +3,15 @@ import { GestionnairesService } from './gestionnaires.service';
|
|||||||
import { GestionnairesController } from './gestionnaires.controller';
|
import { GestionnairesController } from './gestionnaires.controller';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Users])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Users]),
|
||||||
|
AuthModule,
|
||||||
|
MailModule,
|
||||||
|
],
|
||||||
controllers: [GestionnairesController],
|
controllers: [GestionnairesController],
|
||||||
providers: [GestionnairesService],
|
providers: [GestionnairesService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,16 +5,18 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
|
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
|
||||||
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
|
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { MailService } from 'src/modules/mail/mail.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GestionnairesService {
|
export class GestionnairesService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly gestionnaireRepository: Repository<Users>,
|
private readonly gestionnaireRepository: Repository<Users>,
|
||||||
|
private readonly mailService: MailService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d’un gestionnaire
|
// Création d’un gestionnaire
|
||||||
@ -30,30 +32,51 @@ export class GestionnairesService {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
genre: dto.genre,
|
// genre: dto.genre, // Retiré
|
||||||
statut: dto.statut,
|
// statut: dto.statut, // Retiré
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
// adresse: dto.adresse, // Retiré
|
||||||
photo_url: dto.photo_url,
|
// photo_url: dto.photo_url, // Retiré
|
||||||
consentement_photo: dto.consentement_photo ?? false,
|
// consentement_photo: dto.consentement_photo ?? false, // Retiré
|
||||||
date_consentement_photo: dto.date_consentement_photo
|
// date_consentement_photo: dto.date_consentement_photo // Retiré
|
||||||
? new Date(dto.date_consentement_photo)
|
// ? new Date(dto.date_consentement_photo)
|
||||||
: undefined,
|
// : undefined,
|
||||||
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
|
changement_mdp_obligatoire: true,
|
||||||
role: RoleType.GESTIONNAIRE,
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
relaisId: dto.relaisId,
|
||||||
});
|
});
|
||||||
return this.gestionnaireRepository.save(entity);
|
|
||||||
|
const savedUser = await this.gestionnaireRepository.save(entity);
|
||||||
|
|
||||||
|
// Envoi de l'email de bienvenue
|
||||||
|
try {
|
||||||
|
await this.mailService.sendGestionnaireWelcomeEmail(
|
||||||
|
savedUser.email,
|
||||||
|
savedUser.prenom || '',
|
||||||
|
savedUser.nom || '',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// On ne bloque pas la création si l'envoi d'email échoue, mais on log l'erreur
|
||||||
|
console.error('Erreur lors de l\'envoi de l\'email de bienvenue au gestionnaire', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des gestionnaires
|
// Liste des gestionnaires
|
||||||
async findAll(): Promise<Users[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } });
|
return this.gestionnaireRepository.find({
|
||||||
|
where: { role: RoleType.GESTIONNAIRE },
|
||||||
|
relations: ['relais'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer un gestionnaire par ID
|
// Récupérer un gestionnaire par ID
|
||||||
async findOne(id: string): Promise<Users> {
|
async findOne(id: string): Promise<Users> {
|
||||||
const gestionnaire = await this.gestionnaireRepository.findOne({
|
const gestionnaire = await this.gestionnaireRepository.findOne({
|
||||||
where: { id, role: RoleType.GESTIONNAIRE },
|
where: { id, role: RoleType.GESTIONNAIRE },
|
||||||
|
relations: ['relais'],
|
||||||
});
|
});
|
||||||
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
|
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
|
||||||
return gestionnaire;
|
return gestionnaire;
|
||||||
@ -68,13 +91,7 @@ export class GestionnairesService {
|
|||||||
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.date_consentement_photo !== undefined) {
|
const { password, ...rest } = dto;
|
||||||
gestionnaire.date_consentement_photo = dto.date_consentement_photo
|
|
||||||
? new Date(dto.date_consentement_photo)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { password, date_consentement_photo, ...rest } = dto;
|
|
||||||
Object.entries(rest).forEach(([key, value]) => {
|
Object.entries(rest).forEach(([key, value]) => {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
(gestionnaire as any)[key] = value;
|
(gestionnaire as any)[key] = value;
|
||||||
|
|||||||
@ -1,20 +1,34 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { CreateUserDto } from './dto/create_user.dto';
|
import { CreateUserDto } from './dto/create_user.dto';
|
||||||
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
|
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) { }
|
constructor(private readonly userService: UserService) { }
|
||||||
|
|
||||||
|
// Création d'un administrateur (réservée aux super admins)
|
||||||
|
@Post('admin')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
||||||
|
createAdmin(
|
||||||
|
@Body() dto: CreateAdminDto,
|
||||||
|
@User() currentUser: Users
|
||||||
|
) {
|
||||||
|
return this.userService.createAdmin(dto, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
// Création d'un utilisateur (réservée aux super admins)
|
// Création d'un utilisateur (réservée aux super admins)
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
@ -26,9 +40,29 @@ export class UserController {
|
|||||||
return this.userService.createUser(dto, currentUser);
|
return this.userService.createUser(dto, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lister les utilisateurs en attente de validation
|
||||||
|
@Get('pending')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Lister les utilisateurs en attente de validation' })
|
||||||
|
findPendingUsers(
|
||||||
|
@Query('role') role?: RoleType
|
||||||
|
) {
|
||||||
|
return this.userService.findPendingUsers(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lister les comptes refusés (à corriger / reprise)
|
||||||
|
@Get('reprise')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Lister les comptes refusés (reprise)' })
|
||||||
|
findRefusedUsers(
|
||||||
|
@Query('role') role?: RoleType
|
||||||
|
) {
|
||||||
|
return this.userService.findRefusedUsers(role);
|
||||||
|
}
|
||||||
|
|
||||||
// Lister tous les utilisateurs (super_admin uniquement)
|
// Lister tous les utilisateurs (super_admin uniquement)
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Lister tous les utilisateurs' })
|
@ApiOperation({ summary: 'Lister tous les utilisateurs' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.userService.findAll();
|
return this.userService.findAll();
|
||||||
@ -43,9 +77,9 @@ export class UserController {
|
|||||||
return this.userService.findOne(id);
|
return this.userService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modifier un utilisateur (réservé super_admin)
|
// Modifier un utilisateur (réservé super_admin et admin)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
|
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
|
||||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
updateUser(
|
updateUser(
|
||||||
@ -56,6 +90,23 @@ export class UserController {
|
|||||||
return this.userService.updateUser(id, dto, currentUser);
|
return this.userService.updateUser(id, dto, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/numero-dossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Affecter un numéro de dossier à un utilisateur',
|
||||||
|
description: 'Permet de rapprocher deux dossiers ou d’attribuer un numéro existant à un parent/AM. Réservé aux gestionnaires et administrateurs.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur (parent ou AM)" })
|
||||||
|
@ApiResponse({ status: 200, description: 'Numéro de dossier affecté' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Format invalide, rôle non éligible, ou dossier déjà associé à 2 parents' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Utilisateur introuvable' })
|
||||||
|
affecterNumeroDossier(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: AffecterNumeroDossierDto,
|
||||||
|
) {
|
||||||
|
return this.userService.affecterNumeroDossier(id, dto.numero_dossier);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/valider')
|
@Patch(':id/valider')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Valider un compte utilisateur' })
|
@ApiOperation({ summary: 'Valider un compte utilisateur' })
|
||||||
@ -71,6 +122,18 @@ export class UserController {
|
|||||||
return this.userService.validateUser(id, currentUser, comment);
|
return this.userService.validateUser(id, currentUser, comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/refuser')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Refuser un compte (à corriger)' })
|
||||||
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
|
refuse(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@User() currentUser: Users,
|
||||||
|
@Body('comment') comment?: string,
|
||||||
|
) {
|
||||||
|
return this.userService.refuseUser(id, currentUser, comment);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/suspendre')
|
@Patch(':id/suspendre')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })
|
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { ParentsModule } from '../parents/parents.module';
|
|||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@ -20,6 +22,8 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
]), forwardRef(() => AuthModule),
|
]), forwardRef(() => AuthModule),
|
||||||
ParentsModule,
|
ParentsModule,
|
||||||
AssistantesMaternellesModule,
|
AssistantesMaternellesModule,
|
||||||
|
GestionnairesModule,
|
||||||
|
MailModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
||||||
import { In, Repository } from "typeorm";
|
import { In, MoreThan, Repository } from "typeorm";
|
||||||
import { CreateUserDto } from "./dto/create_user.dto";
|
import { CreateUserDto } from "./dto/create_user.dto";
|
||||||
|
import { CreateAdminDto } from "./dto/create_admin.dto";
|
||||||
import { UpdateUserDto } from "./dto/update_user.dto";
|
import { UpdateUserDto } from "./dto/update_user.dto";
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||||
import { Parents } from "src/entities/parents.entity";
|
import { Parents } from "src/entities/parents.entity";
|
||||||
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
||||||
|
import { MailService } from "src/modules/mail/mail.service";
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private readonly logger = new Logger(UserService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
@ -22,7 +27,9 @@ export class UserService {
|
|||||||
private readonly parentsRepository: Repository<Parents>,
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
|
||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>
|
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
|
||||||
|
|
||||||
|
private readonly mailService: MailService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
||||||
@ -106,6 +113,48 @@ export class UserService {
|
|||||||
return this.findOne(saved.id);
|
return this.findOne(saved.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||||
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||||
|
if (exist) throw new BadRequestException('Email déjà utilisé');
|
||||||
|
|
||||||
|
const salt = await bcrypt.genSalt();
|
||||||
|
const hashedPassword = await bcrypt.hash(dto.password, salt);
|
||||||
|
|
||||||
|
const entity = this.usersRepository.create({
|
||||||
|
email: dto.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
prenom: dto.prenom,
|
||||||
|
nom: dto.nom,
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
telephone: dto.telephone,
|
||||||
|
changement_mdp_obligatoire: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.usersRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPendingUsers(role?: RoleType): Promise<Users[]> {
|
||||||
|
const where: any = { statut: StatutUtilisateurType.EN_ATTENTE };
|
||||||
|
if (role) {
|
||||||
|
where.role = role;
|
||||||
|
}
|
||||||
|
return this.usersRepository.find({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */
|
||||||
|
async findRefusedUsers(role?: RoleType): Promise<Users[]> {
|
||||||
|
const where: any = { statut: StatutUtilisateurType.REFUSE };
|
||||||
|
if (role) {
|
||||||
|
where.role = role;
|
||||||
|
}
|
||||||
|
return this.usersRepository.find({ where });
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Users[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.usersRepository.find();
|
return this.usersRepository.find();
|
||||||
}
|
}
|
||||||
@ -129,11 +178,26 @@ export class UserService {
|
|||||||
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
|
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
|
||||||
const user = await this.findOne(id);
|
const user = await this.findOne(id);
|
||||||
|
|
||||||
|
// Le super administrateur conserve une identité figée.
|
||||||
|
if (
|
||||||
|
user.role === RoleType.SUPER_ADMIN &&
|
||||||
|
(dto.nom !== undefined || dto.prenom !== undefined)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Interdire changement de rôle si pas super admin
|
// Interdire changement de rôle si pas super admin
|
||||||
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un admin ne peut pas modifier un super admin
|
||||||
|
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
|
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
|
||||||
if (
|
if (
|
||||||
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
|
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
|
||||||
@ -165,7 +229,7 @@ export class UserService {
|
|||||||
return this.usersRepository.save(user);
|
return this.usersRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valider un compte utilisateur
|
// Valider un compte utilisateur (en_attente ou refuse -> actif)
|
||||||
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
||||||
@ -174,6 +238,10 @@ export class UserService {
|
|||||||
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
|
||||||
|
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) {
|
||||||
|
throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.');
|
||||||
|
}
|
||||||
|
|
||||||
user.statut = StatutUtilisateurType.ACTIF;
|
user.statut = StatutUtilisateurType.ACTIF;
|
||||||
const savedUser = await this.usersRepository.save(user);
|
const savedUser = await this.usersRepository.save(user);
|
||||||
if (user.role === RoleType.PARENT) {
|
if (user.role === RoleType.PARENT) {
|
||||||
@ -221,10 +289,165 @@ export class UserService {
|
|||||||
await this.validationRepository.save(suspend);
|
await this.validationRepository.save(suspend);
|
||||||
return savedUser;
|
return savedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
|
||||||
|
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||||
|
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||||
|
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
||||||
|
}
|
||||||
|
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||||
|
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
|
||||||
|
throw new BadRequestException('Seul un compte en attente peut être refusé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenReprise = crypto.randomUUID();
|
||||||
|
const expireLe = new Date();
|
||||||
|
expireLe.setDate(expireLe.getDate() + 7);
|
||||||
|
|
||||||
|
user.statut = StatutUtilisateurType.REFUSE;
|
||||||
|
user.token_reprise = tokenReprise;
|
||||||
|
user.token_reprise_expire_le = expireLe;
|
||||||
|
const savedUser = await this.usersRepository.save(user);
|
||||||
|
|
||||||
|
const validation = this.validationRepository.create({
|
||||||
|
user: savedUser,
|
||||||
|
type: 'refus_compte',
|
||||||
|
status: StatutValidationType.REFUSE,
|
||||||
|
validated_by: currentUser,
|
||||||
|
comment,
|
||||||
|
});
|
||||||
|
await this.validationRepository.save(validation);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailService.sendRefusEmail(
|
||||||
|
savedUser.email,
|
||||||
|
savedUser.prenom ?? '',
|
||||||
|
savedUser.nom ?? '',
|
||||||
|
comment,
|
||||||
|
tokenReprise,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM).
|
||||||
|
* Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes).
|
||||||
|
* Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents).
|
||||||
|
*/
|
||||||
|
async affecterNumeroDossier(userId: string, numeroDossier: string): Promise<Users> {
|
||||||
|
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||||
|
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
|
||||||
|
if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === RoleType.PARENT) {
|
||||||
|
const uneAMALe = await this.assistantesRepository.count({
|
||||||
|
where: { numero_dossier: numeroDossier },
|
||||||
|
});
|
||||||
|
if (uneAMALe > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parentsAvecCeNumero = await this.parentsRepository.count({
|
||||||
|
where: { numero_dossier: numeroDossier },
|
||||||
|
});
|
||||||
|
const userADejaCeNumero = user.numero_dossier === numeroDossier;
|
||||||
|
if (!userADejaCeNumero && parentsAvecCeNumero >= 2) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
const unParentLA = await this.parentsRepository.count({
|
||||||
|
where: { numero_dossier: numeroDossier },
|
||||||
|
});
|
||||||
|
if (unParentLA > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.numero_dossier = numeroDossier;
|
||||||
|
const savedUser = await this.usersRepository.save(user);
|
||||||
|
|
||||||
|
if (user.role === RoleType.PARENT) {
|
||||||
|
await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
|
||||||
|
} else {
|
||||||
|
await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trouve un user par token reprise valide (non expiré). Ticket #111 */
|
||||||
|
async findByTokenReprise(token: string): Promise<Users | null> {
|
||||||
|
return this.usersRepository.findOne({
|
||||||
|
where: {
|
||||||
|
token_reprise: token,
|
||||||
|
statut: StatutUtilisateurType.REFUSE,
|
||||||
|
token_reprise_expire_le: MoreThan(new Date()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */
|
||||||
|
async resoumettreReprise(
|
||||||
|
token: string,
|
||||||
|
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
|
||||||
|
): Promise<Users> {
|
||||||
|
const user = await this.findByTokenReprise(token);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||||||
|
}
|
||||||
|
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||||
|
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||||
|
if (dto.telephone !== undefined) user.telephone = dto.telephone;
|
||||||
|
if (dto.adresse !== undefined) user.adresse = dto.adresse;
|
||||||
|
if (dto.ville !== undefined) user.ville = dto.ville;
|
||||||
|
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
|
||||||
|
if (dto.photo_url !== undefined) user.photo_url = dto.photo_url;
|
||||||
|
user.statut = StatutUtilisateurType.EN_ATTENTE;
|
||||||
|
user.token_reprise = undefined;
|
||||||
|
user.token_reprise_expire_le = undefined;
|
||||||
|
return this.usersRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */
|
||||||
|
async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise<Users | null> {
|
||||||
|
const user = await this.usersRepository.findOne({
|
||||||
|
where: {
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
numero_dossier: numero_dossier.trim(),
|
||||||
|
statut: StatutUtilisateurType.REFUSE,
|
||||||
|
token_reprise_expire_le: MoreThan(new Date()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return user ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: string, currentUser: Users): Promise<void> {
|
async remove(id: string, currentUser: Users): Promise<void> {
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
const user = await this.findOne(id);
|
||||||
|
if (user.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le super administrateur ne peut pas être supprimé',
|
||||||
|
);
|
||||||
|
}
|
||||||
const result = await this.usersRepository.delete(id);
|
const result = await this.usersRepository.delete(id);
|
||||||
if (result.affected === 0) {
|
if (result.affected === 0) {
|
||||||
throw new NotFoundException('Utilisateur introuvable');
|
throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
|||||||
7
check_hash.js
Normal file
7
check_hash.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const pass = '!Bezons2014';
|
||||||
|
|
||||||
|
bcrypt.hash(pass, 10).then(hash => {
|
||||||
|
console.log('New Hash:', hash);
|
||||||
|
}).catch(err => console.error(err));
|
||||||
188
database/BDD.sql
188
database/BDD.sql
@ -11,7 +11,7 @@ DO $$ BEGIN
|
|||||||
CREATE TYPE genre_type AS ENUM ('H', 'F');
|
CREATE TYPE genre_type AS ENUM ('H', 'F');
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
||||||
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu');
|
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
||||||
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
|
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
|
||||||
@ -46,19 +46,20 @@ CREATE TABLE utilisateurs (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
||||||
password TEXT NOT NULL,
|
password TEXT, -- NULL avant création via token
|
||||||
prenom VARCHAR(100),
|
prenom VARCHAR(100),
|
||||||
nom VARCHAR(100),
|
nom VARCHAR(100),
|
||||||
genre genre_type,
|
genre genre_type,
|
||||||
role role_type NOT NULL,
|
role role_type NOT NULL,
|
||||||
statut statut_utilisateur_type DEFAULT 'en_attente',
|
statut statut_utilisateur_type DEFAULT 'en_attente',
|
||||||
mobile VARCHAR(20),
|
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
||||||
telephone_fixe VARCHAR(20),
|
|
||||||
adresse TEXT,
|
adresse TEXT,
|
||||||
date_naissance DATE,
|
date_naissance DATE,
|
||||||
photo_url TEXT,
|
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
|
||||||
consentement_photo BOOLEAN DEFAULT false,
|
consentement_photo BOOLEAN DEFAULT false,
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
date_consentement_photo TIMESTAMPTZ,
|
||||||
|
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
|
||||||
|
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
|
||||||
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
modifie_le TIMESTAMPTZ DEFAULT now(),
|
||||||
@ -68,20 +69,26 @@ CREATE TABLE utilisateurs (
|
|||||||
situation_familiale situation_familiale_type
|
situation_familiale situation_familiale_type
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Index pour recherche par token
|
||||||
|
CREATE INDEX idx_utilisateurs_token_creation_mdp
|
||||||
|
ON utilisateurs(token_creation_mdp)
|
||||||
|
WHERE token_creation_mdp IS NOT NULL;
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : assistantes_maternelles
|
-- Table : assistantes_maternelles
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE assistantes_maternelles (
|
CREATE TABLE assistantes_maternelles (
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
numero_agrement VARCHAR(50),
|
numero_agrement VARCHAR(50),
|
||||||
|
nir_chiffre CHAR(15) NOT NULL,
|
||||||
|
nb_max_enfants INT,
|
||||||
|
biographie TEXT,
|
||||||
|
disponible BOOLEAN DEFAULT true,
|
||||||
|
ville_residence VARCHAR(100),
|
||||||
date_agrement DATE,
|
date_agrement DATE,
|
||||||
nir_chiffre CHAR(15),
|
|
||||||
annee_experience SMALLINT,
|
annee_experience SMALLINT,
|
||||||
specialite VARCHAR(100),
|
specialite VARCHAR(100),
|
||||||
nb_max_enfants INT,
|
place_disponible INT
|
||||||
place_disponible INT,
|
|
||||||
biographie TEXT,
|
|
||||||
disponible BOOLEAN DEFAULT true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@ -100,7 +107,7 @@ CREATE TABLE enfants (
|
|||||||
statut statut_enfant_type,
|
statut statut_enfant_type,
|
||||||
prenom VARCHAR(100),
|
prenom VARCHAR(100),
|
||||||
nom VARCHAR(100),
|
nom VARCHAR(100),
|
||||||
genre genre_type,
|
genre genre_type NOT NULL, -- Obligatoire selon CDC
|
||||||
date_naissance DATE,
|
date_naissance DATE,
|
||||||
date_prevue_naissance DATE,
|
date_prevue_naissance DATE,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
@ -241,3 +248,162 @@ CREATE TABLE validations (
|
|||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Table : configuration
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE configuration (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
cle VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
valeur TEXT,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
categorie VARCHAR(50),
|
||||||
|
description TEXT,
|
||||||
|
modifie_le TIMESTAMPTZ DEFAULT now(),
|
||||||
|
modifie_par UUID REFERENCES utilisateurs(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index pour performance
|
||||||
|
CREATE INDEX idx_configuration_cle ON configuration(cle);
|
||||||
|
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
|
||||||
|
|
||||||
|
-- Seed initial de configuration
|
||||||
|
INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
||||||
|
-- === Configuration Email (SMTP) ===
|
||||||
|
('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'),
|
||||||
|
('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'),
|
||||||
|
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
|
||||||
|
('smtp_auth_required', 'false', 'boolean', 'email', 'Authentification SMTP requise'),
|
||||||
|
('smtp_user', '', 'string', 'email', 'Utilisateur SMTP (si authentification requise)'),
|
||||||
|
('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'),
|
||||||
|
('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'),
|
||||||
|
('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'),
|
||||||
|
|
||||||
|
-- === Configuration Application ===
|
||||||
|
('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'),
|
||||||
|
('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'),
|
||||||
|
('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'),
|
||||||
|
('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'),
|
||||||
|
|
||||||
|
-- === Configuration Sécurité ===
|
||||||
|
('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'),
|
||||||
|
('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'),
|
||||||
|
('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'),
|
||||||
|
('bcrypt_rounds', '12', 'number', 'security', 'Nombre de rounds bcrypt pour le hachage des mots de passe');
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Table : documents_legaux
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE documents_legaux (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
||||||
|
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
|
||||||
|
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
|
||||||
|
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
|
||||||
|
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
|
||||||
|
actif BOOLEAN DEFAULT false, -- Version actuellement active
|
||||||
|
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
|
||||||
|
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
|
||||||
|
active_le TIMESTAMPTZ, -- Date d'activation
|
||||||
|
UNIQUE(type, version) -- Pas de doublon version
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index pour performance
|
||||||
|
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
|
||||||
|
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Table : acceptations_documents
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE acceptations_documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
|
id_document UUID REFERENCES documents_legaux(id),
|
||||||
|
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
||||||
|
version_document INTEGER NOT NULL, -- Version acceptée
|
||||||
|
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
|
||||||
|
ip_address INET, -- IP de l'utilisateur (RGPD)
|
||||||
|
user_agent TEXT -- Navigateur (preuve)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index pour performance
|
||||||
|
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
||||||
|
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Table : relais
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE relais (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
nom VARCHAR(255) NOT NULL,
|
||||||
|
adresse TEXT NOT NULL,
|
||||||
|
horaires_ouverture JSONB,
|
||||||
|
ligne_fixe VARCHAR(20),
|
||||||
|
actif BOOLEAN DEFAULT true,
|
||||||
|
notes TEXT,
|
||||||
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
|
||||||
|
-- ==========================================================
|
||||||
|
ALTER TABLE utilisateurs
|
||||||
|
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
|
||||||
|
annee INT PRIMARY KEY,
|
||||||
|
prochain INT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||||
|
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||||
|
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Ticket #110 : Token reprise après refus (lien email)
|
||||||
|
-- ==========================================================
|
||||||
|
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
|
||||||
|
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Seed : Documents légaux génériques v1
|
||||||
|
-- ==========================================================
|
||||||
|
INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_hash, actif, televerse_le, active_le) VALUES
|
||||||
|
('cgu', 1, 'cgu_v1_default.pdf', '/documents/legaux/cgu_v1_default.pdf', 'a3f8b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2', true, now(), now()),
|
||||||
|
('privacy', 1, 'privacy_v1_default.pdf', '/documents/legaux/privacy_v1_default.pdf', 'b4f9c3d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4', true, now(), now());
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Seed : Super Administrateur par défaut
|
||||||
|
-- ==========================================================
|
||||||
|
-- Email: admin@ptits-pas.fr
|
||||||
|
-- Mot de passe: 4dm1n1strateur (hashé bcrypt)
|
||||||
|
-- IMPORTANT: Changer ce mot de passe en production !
|
||||||
|
-- ==========================================================
|
||||||
|
INSERT INTO utilisateurs (
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
prenom,
|
||||||
|
nom,
|
||||||
|
role,
|
||||||
|
statut,
|
||||||
|
changement_mdp_obligatoire
|
||||||
|
) VALUES (
|
||||||
|
'admin@ptits-pas.fr',
|
||||||
|
'$2b$12$plOZCW7lzLFkWgDPcE6p6u10EA4yErQt6Xcp5nyH3Sp/2.6EpNW.6',
|
||||||
|
'Super',
|
||||||
|
'Administrateur',
|
||||||
|
'super_admin',
|
||||||
|
'actif',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|||||||
@ -41,6 +41,16 @@ docker compose -f docker-compose.dev.yml down -v
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Réinitialiser la BDD et charger les données de test (dashboard admin)
|
||||||
|
|
||||||
|
Depuis la **racine du projet** (ptitspas-app, où se trouve `docker-compose.yml`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/reset-and-seed-db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script : arrête les conteneurs, supprime le volume Postgres, redémarre la base (le schéma est recréé via `BDD.sql`), puis exécute `database/seed/03_seed_test_data.sql`. Tu obtiens un super_admin (`admin@ptits-pas.fr`) plus 9 comptes de test (1 admin, 1 gestionnaire, 2 AM, 5 parents) avec **mot de passe : `password`**. Idéal pour développer le ticket #92 (dashboard admin).
|
||||||
|
|
||||||
## Importation automatique des données de test
|
## Importation automatique des données de test
|
||||||
|
|
||||||
Les données de test (CSV) sont automatiquement importées dans la base au démarrage du conteneur Docker grâce aux scripts présents dans le dossier `migrations/`.
|
Les données de test (CSV) sont automatiquement importées dans la base au démarrage du conteneur Docker grâce aux scripts présents dans le dossier `migrations/`.
|
||||||
|
|||||||
@ -1,277 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- ENUMS
|
|
||||||
-- ==========================================================
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
|
|
||||||
CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'assistante_maternelle','administrateur');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
|
|
||||||
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
|
||||||
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
|
||||||
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN
|
|
||||||
CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
|
|
||||||
CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
|
|
||||||
CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
|
|
||||||
CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
|
|
||||||
CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse');
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
|
|
||||||
CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse');
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : utilisateurs
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE utilisateurs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
|
||||||
password TEXT NOT NULL,
|
|
||||||
prenom VARCHAR(100),
|
|
||||||
nom VARCHAR(100),
|
|
||||||
genre genre_type,
|
|
||||||
role role_type NOT NULL,
|
|
||||||
statut statut_utilisateur_type DEFAULT 'en_attente',
|
|
||||||
telephone VARCHAR(20),
|
|
||||||
adresse TEXT,
|
|
||||||
photo_url TEXT,
|
|
||||||
consentement_photo BOOLEAN DEFAULT false,
|
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
|
||||||
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
ville VARCHAR(150),
|
|
||||||
code_postal VARCHAR(10),
|
|
||||||
mobile VARCHAR(20),
|
|
||||||
telephone_fixe VARCHAR(20),
|
|
||||||
profession VARCHAR(150),
|
|
||||||
situation_familiale VARCHAR(50),
|
|
||||||
date_naissance DATE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : assistantes_maternelles
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE assistantes_maternelles (
|
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
|
||||||
numero_agrement VARCHAR(50),
|
|
||||||
nir_chiffre CHAR(15),
|
|
||||||
nb_max_enfants INT,
|
|
||||||
biographie TEXT,
|
|
||||||
disponible BOOLEAN DEFAULT true,
|
|
||||||
ville_residence VARCHAR(100),
|
|
||||||
date_agrement DATE,
|
|
||||||
annee_experience SMALLINT,
|
|
||||||
specialite VARCHAR(100),
|
|
||||||
place_disponible INT
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : parentschange les donnée de init
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE parents (
|
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
|
||||||
id_co_parent UUID REFERENCES utilisateurs(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : enfants
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE enfants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
statut statut_enfant_type,
|
|
||||||
prenom VARCHAR(100),
|
|
||||||
nom VARCHAR(100),
|
|
||||||
genre genre_type,
|
|
||||||
date_naissance DATE,
|
|
||||||
date_prevue_naissance DATE,
|
|
||||||
photo_url TEXT,
|
|
||||||
consentement_photo BOOLEAN DEFAULT false,
|
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
|
||||||
est_multiple BOOLEAN DEFAULT false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : enfants_parents
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE enfants_parents (
|
|
||||||
id_parent UUID REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
|
|
||||||
id_enfant UUID REFERENCES enfants(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (id_parent, id_enfant)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : dossiers
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE dossiers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_parent UUID REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
|
|
||||||
id_enfant UUID REFERENCES enfants(id) ON DELETE CASCADE,
|
|
||||||
presentation TEXT,
|
|
||||||
type_contrat VARCHAR(50),
|
|
||||||
repas BOOLEAN DEFAULT false,
|
|
||||||
budget NUMERIC(10,2),
|
|
||||||
planning_souhaite JSONB,
|
|
||||||
statut statut_dossier_type DEFAULT 'envoye',
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : messages
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE messages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_dossier UUID REFERENCES dossiers(id) ON DELETE CASCADE,
|
|
||||||
id_expediteur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
|
||||||
contenu TEXT,
|
|
||||||
re_redige_par_ia BOOLEAN DEFAULT false,
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : contrats
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE contrats (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_dossier UUID UNIQUE REFERENCES dossiers(id) ON DELETE CASCADE,
|
|
||||||
planning JSONB,
|
|
||||||
tarif_horaire NUMERIC(6,2),
|
|
||||||
indemnites_repas NUMERIC(6,2),
|
|
||||||
date_debut DATE,
|
|
||||||
statut statut_contrat_type DEFAULT 'brouillon',
|
|
||||||
signe_parent BOOLEAN DEFAULT false,
|
|
||||||
signe_am BOOLEAN DEFAULT false,
|
|
||||||
finalise_le TIMESTAMPTZ,
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : avenants_contrats
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE avenants_contrats (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_contrat UUID REFERENCES contrats(id) ON DELETE CASCADE,
|
|
||||||
modifications JSONB,
|
|
||||||
initie_par UUID REFERENCES utilisateurs(id),
|
|
||||||
statut statut_avenant_type DEFAULT 'propose',
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : evenements
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE evenements (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
type type_evenement_type,
|
|
||||||
id_enfant UUID REFERENCES enfants(id) ON DELETE CASCADE,
|
|
||||||
id_am UUID REFERENCES utilisateurs(id),
|
|
||||||
id_parent UUID REFERENCES parents(id_utilisateur),
|
|
||||||
cree_par UUID REFERENCES utilisateurs(id),
|
|
||||||
date_debut TIMESTAMPTZ,
|
|
||||||
date_fin TIMESTAMPTZ,
|
|
||||||
commentaires TEXT,
|
|
||||||
statut statut_evenement_type DEFAULT 'propose',
|
|
||||||
delai_grace TIMESTAMPTZ,
|
|
||||||
urgent BOOLEAN DEFAULT false,
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : signalements_bugs
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE signalements_bugs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id),
|
|
||||||
description TEXT,
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : uploads
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE uploads (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
|
||||||
fichier_url TEXT NOT NULL,
|
|
||||||
type VARCHAR(50),
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : notifications
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE notifications (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
|
||||||
contenu TEXT,
|
|
||||||
lu BOOLEAN DEFAULT false,
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Table : validations
|
|
||||||
-- ==========================================================
|
|
||||||
CREATE TABLE validations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id),
|
|
||||||
type VARCHAR(50),
|
|
||||||
statut statut_validation_type DEFAULT 'en_attente',
|
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
|
||||||
valide_par UUID REFERENCES utilisateurs(id),
|
|
||||||
commentaire TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- Initialisation d'un administrateur par défaut
|
|
||||||
-- ==========================================================
|
|
||||||
|
|
||||||
-- ==========================================================
|
|
||||||
-- SEED: Super Administrateur par défaut
|
|
||||||
-- ==========================================================
|
|
||||||
-- Mot de passe: 4dm1n1strateur
|
|
||||||
INSERT INTO utilisateurs (
|
|
||||||
id,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
prenom,
|
|
||||||
nom,
|
|
||||||
role,
|
|
||||||
statut,
|
|
||||||
cree_le,
|
|
||||||
modifie_le
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
gen_random_uuid(),
|
|
||||||
'admin@ptits-pas.fr',
|
|
||||||
'$2b$12$Fo5ly1YlTj3O6lXf.IUgoeUqEebBGpmoM5zLbzZx.CueorSE7z2E2',
|
|
||||||
'Admin',
|
|
||||||
'Système',
|
|
||||||
'super_admin',
|
|
||||||
'actif',
|
|
||||||
now(),
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
ON CONFLICT (email) DO NOTHING;
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
-- =============================================
|
|
||||||
-- 02_indexes.sql : Index FK + colonnes critiques
|
|
||||||
-- =============================================
|
|
||||||
|
|
||||||
-- Recommandation : exécuter après 01_init.sql
|
|
||||||
|
|
||||||
-- ===========
|
|
||||||
-- UTILISATEURS
|
|
||||||
-- ===========
|
|
||||||
-- Recherche par email (insensibilité à la casse pour lookup)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_lower_courriel
|
|
||||||
ON utilisateurs (LOWER(courriel));
|
|
||||||
|
|
||||||
-- ===========
|
|
||||||
-- ASSISTANTES_MATERNELLES
|
|
||||||
-- ===========
|
|
||||||
-- FK -> utilisateurs(id)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_am_id_utilisateur
|
|
||||||
ON assistantes_maternelles (id_utilisateur);
|
|
||||||
|
|
||||||
-- =======
|
|
||||||
-- PARENTS
|
|
||||||
-- =======
|
|
||||||
-- FK -> utilisateurs(id)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_parents_id_utilisateur
|
|
||||||
ON parents (id_utilisateur);
|
|
||||||
|
|
||||||
-- Co-parent (nullable)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_parents_id_co_parent
|
|
||||||
ON parents (id_co_parent);
|
|
||||||
|
|
||||||
-- =======
|
|
||||||
-- ENFANTS
|
|
||||||
-- =======
|
|
||||||
-- (souvent filtrés par statut / date_naissance ? à activer si besoin)
|
|
||||||
-- CREATE INDEX IF NOT EXISTS idx_enfants_statut ON enfants (statut);
|
|
||||||
-- CREATE INDEX IF NOT EXISTS idx_enfants_date_naissance ON enfants (date_naissance);
|
|
||||||
|
|
||||||
-- ================
|
|
||||||
-- ENFANTS_PARENTS (N:N)
|
|
||||||
-- ================
|
|
||||||
-- PK composite déjà en place (id_parent, id_enfant), ajouter index individuels si jointures unilatérales fréquentes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_enfants_parents_id_parent
|
|
||||||
ON enfants_parents (id_parent);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_enfants_parents_id_enfant
|
|
||||||
ON enfants_parents (id_enfant);
|
|
||||||
|
|
||||||
-- ========
|
|
||||||
-- DOSSIERS
|
|
||||||
-- ========
|
|
||||||
-- FK -> parent / enfant
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dossiers_id_parent
|
|
||||||
ON dossiers (id_parent);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dossiers_id_enfant
|
|
||||||
ON dossiers (id_enfant);
|
|
||||||
|
|
||||||
-- Statut (filtrages "à traiter", "envoyé", etc.)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dossiers_statut
|
|
||||||
ON dossiers (statut);
|
|
||||||
|
|
||||||
-- JSONB : si on fait des requêtes @> sur le planning souhaité
|
|
||||||
-- CREATE INDEX IF NOT EXISTS idx_dossiers_planning_souhaite_gin
|
|
||||||
-- ON dossiers USING GIN (planning_souhaite);
|
|
||||||
|
|
||||||
-- ========
|
|
||||||
-- MESSAGES
|
|
||||||
-- ========
|
|
||||||
-- Filtrage par dossier + tri chronologique
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_id_dossier_cree_le
|
|
||||||
ON messages (id_dossier, cree_le);
|
|
||||||
|
|
||||||
-- Recherche par expéditeur
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_id_expediteur_cree_le
|
|
||||||
ON messages (id_expediteur, cree_le);
|
|
||||||
|
|
||||||
-- =========
|
|
||||||
-- CONTRATS
|
|
||||||
-- =========
|
|
||||||
-- UNIQUE(id_dossier) existe déjà -> index implicite
|
|
||||||
-- Tri / filtres fréquents
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contrats_statut
|
|
||||||
ON contrats (statut);
|
|
||||||
|
|
||||||
-- JSONB planning : activer si on requête par clé
|
|
||||||
-- CREATE INDEX IF NOT EXISTS idx_contrats_planning_gin
|
|
||||||
-- ON contrats USING GIN (planning);
|
|
||||||
|
|
||||||
-- ==================
|
|
||||||
-- AVENANTS_CONTRATS
|
|
||||||
-- ==================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_avenants_contrats_id_contrat_cree_le
|
|
||||||
ON avenants_contrats (id_contrat, cree_le);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_avenants_contrats_initie_par
|
|
||||||
ON avenants_contrats (initie_par);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_avenants_contrats_statut
|
|
||||||
ON avenants_contrats (statut);
|
|
||||||
|
|
||||||
-- =========
|
|
||||||
-- EVENEMENTS
|
|
||||||
-- =========
|
|
||||||
-- Accès par enfant + période
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_evenements_id_enfant_date_debut
|
|
||||||
ON evenements (id_enfant, date_debut);
|
|
||||||
|
|
||||||
-- Filtrage par auteur / AM / parent
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_evenements_cree_par
|
|
||||||
ON evenements (cree_par);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_evenements_id_am
|
|
||||||
ON evenements (id_am);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_evenements_id_parent
|
|
||||||
ON evenements (id_parent);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_evenements_type
|
|
||||||
ON evenements (type);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_evenements_statut
|
|
||||||
ON evenements (statut);
|
|
||||||
|
|
||||||
-- =================
|
|
||||||
-- SIGNALEMENTS_BUGS
|
|
||||||
-- =================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_signalements_bugs_id_utilisateur_cree_le
|
|
||||||
ON signalements_bugs (id_utilisateur, cree_le);
|
|
||||||
|
|
||||||
-- =======
|
|
||||||
-- UPLOADS
|
|
||||||
-- =======
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_uploads_id_utilisateur_cree_le
|
|
||||||
ON uploads (id_utilisateur, cree_le);
|
|
||||||
|
|
||||||
-- =============
|
|
||||||
-- NOTIFICATIONS
|
|
||||||
-- =============
|
|
||||||
-- Requêtes fréquentes : non lues + ordre chrono
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_lu_cree_le
|
|
||||||
ON notifications (id_utilisateur, lu, cree_le);
|
|
||||||
|
|
||||||
-- Option : index partiel pour "non lues"
|
|
||||||
-- CREATE INDEX IF NOT EXISTS idx_notifications_non_lues
|
|
||||||
-- ON notifications (id_utilisateur, cree_le)
|
|
||||||
-- WHERE lu = false;
|
|
||||||
|
|
||||||
-- ===========
|
|
||||||
-- VALIDATIONS
|
|
||||||
-- ===========
|
|
||||||
-- Requêtes par utilisateur validé, par statut et par date
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_validations_id_utilisateur_cree_le
|
|
||||||
ON validations (id_utilisateur, cree_le);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_validations_statut
|
|
||||||
ON validations (statut);
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
-- =============================================
|
|
||||||
-- 03_checks.sql : Contraintes CHECK & NOT NULL
|
|
||||||
-- A exécuter après 01_init.sql (et 02_indexes.sql)
|
|
||||||
-- =============================================
|
|
||||||
|
|
||||||
-- ==============
|
|
||||||
-- UTILISATEURS
|
|
||||||
-- ==============
|
|
||||||
-- (Regex email déjà présente dans 01_init.sql)
|
|
||||||
-- Optionnel : forcer prenom/nom non vides si fournis
|
|
||||||
ALTER TABLE utilisateurs
|
|
||||||
ADD CONSTRAINT chk_utilisateurs_prenom_non_vide
|
|
||||||
CHECK (prenom IS NULL OR btrim(prenom) <> ''),
|
|
||||||
ADD CONSTRAINT chk_utilisateurs_nom_non_vide
|
|
||||||
CHECK (nom IS NULL OR btrim(nom) <> '');
|
|
||||||
|
|
||||||
-- =========================
|
|
||||||
-- ASSISTANTES_MATERNELLES
|
|
||||||
-- =========================
|
|
||||||
-- NIR : aujourd’hui en 15 chiffres (Sprint 2 : chiffrement)
|
|
||||||
ALTER TABLE assistantes_maternelles
|
|
||||||
ADD CONSTRAINT chk_am_nir_format
|
|
||||||
CHECK (nir_chiffre IS NULL OR nir_chiffre ~ '^[0-9]{15}$'),
|
|
||||||
ADD CONSTRAINT chk_am_nb_max_enfants
|
|
||||||
CHECK (nb_max_enfants IS NULL OR nb_max_enfants BETWEEN 0 AND 10),
|
|
||||||
ADD CONSTRAINT chk_am_ville_non_vide
|
|
||||||
CHECK (ville_residence IS NULL OR btrim(ville_residence) <> '');
|
|
||||||
|
|
||||||
-- =========
|
|
||||||
-- PARENTS
|
|
||||||
-- =========
|
|
||||||
-- Interdiction d’être co-parent de soi-même
|
|
||||||
ALTER TABLE parents
|
|
||||||
ADD CONSTRAINT chk_parents_co_parent_diff
|
|
||||||
CHECK (id_co_parent IS NULL OR id_co_parent <> id_utilisateur);
|
|
||||||
|
|
||||||
-- =========
|
|
||||||
-- ENFANTS
|
|
||||||
-- =========
|
|
||||||
-- Cohérence statut / dates de naissance
|
|
||||||
ALTER TABLE enfants
|
|
||||||
ADD CONSTRAINT chk_enfants_dates_exclusives
|
|
||||||
CHECK (NOT (date_naissance IS NOT NULL AND date_prevue_naissance IS NOT NULL)),
|
|
||||||
ADD CONSTRAINT chk_enfants_statut_dates
|
|
||||||
CHECK (
|
|
||||||
-- a_naitre => date_prevue_naissance requise
|
|
||||||
(statut = 'a_naitre' AND date_prevue_naissance IS NOT NULL)
|
|
||||||
OR
|
|
||||||
-- actif/scolarise => date_naissance requise
|
|
||||||
(statut IN ('actif','scolarise') AND date_naissance IS NOT NULL)
|
|
||||||
OR statut IS NULL -- si statut non encore fixé
|
|
||||||
),
|
|
||||||
ADD CONSTRAINT chk_enfants_consentement_coherent
|
|
||||||
CHECK (
|
|
||||||
(consentement_photo = true AND date_consentement_photo IS NOT NULL)
|
|
||||||
OR
|
|
||||||
(consentement_photo = false AND date_consentement_photo IS NULL)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =================
|
|
||||||
-- ENFANTS_PARENTS
|
|
||||||
-- =================
|
|
||||||
-- (PK composite déjà en place, rien à ajouter ici)
|
|
||||||
|
|
||||||
-- ========
|
|
||||||
-- DOSSIERS
|
|
||||||
-- ========
|
|
||||||
ALTER TABLE dossiers
|
|
||||||
ADD CONSTRAINT chk_dossiers_budget_nonneg
|
|
||||||
CHECK (budget IS NULL OR budget >= 0),
|
|
||||||
ADD CONSTRAINT chk_dossiers_type_contrat_non_vide
|
|
||||||
CHECK (type_contrat IS NULL OR btrim(type_contrat) <> ''),
|
|
||||||
ADD CONSTRAINT chk_dossiers_planning_json
|
|
||||||
CHECK (planning_souhaite IS NULL OR jsonb_typeof(planning_souhaite) = 'object');
|
|
||||||
|
|
||||||
-- ========
|
|
||||||
-- MESSAGES
|
|
||||||
-- ========
|
|
||||||
-- Contenu obligatoire, non vide
|
|
||||||
ALTER TABLE messages
|
|
||||||
ALTER COLUMN contenu SET NOT NULL;
|
|
||||||
ALTER TABLE messages
|
|
||||||
ADD CONSTRAINT chk_messages_contenu_non_vide
|
|
||||||
CHECK (btrim(contenu) <> '');
|
|
||||||
|
|
||||||
-- =========
|
|
||||||
-- CONTRATS
|
|
||||||
-- =========
|
|
||||||
ALTER TABLE contrats
|
|
||||||
ADD CONSTRAINT chk_contrats_tarif_nonneg
|
|
||||||
CHECK (tarif_horaire IS NULL OR tarif_horaire >= 0),
|
|
||||||
ADD CONSTRAINT chk_contrats_indemnites_nonneg
|
|
||||||
CHECK (indemnites_repas IS NULL OR indemnites_repas >= 0);
|
|
||||||
|
|
||||||
-- ==================
|
|
||||||
-- AVENANTS_CONTRATS
|
|
||||||
-- ==================
|
|
||||||
-- Rien de spécifique (statut enum déjà en place)
|
|
||||||
|
|
||||||
-- =========
|
|
||||||
-- EVENEMENTS
|
|
||||||
-- =========
|
|
||||||
ALTER TABLE evenements
|
|
||||||
ADD CONSTRAINT chk_evenements_dates_coherentes
|
|
||||||
CHECK (date_fin IS NULL OR date_debut IS NULL OR date_fin >= date_debut);
|
|
||||||
|
|
||||||
-- =================
|
|
||||||
-- SIGNALEMENTS_BUGS
|
|
||||||
-- =================
|
|
||||||
-- Description obligatoire, non vide
|
|
||||||
ALTER TABLE signalements_bugs
|
|
||||||
ALTER COLUMN description SET NOT NULL;
|
|
||||||
ALTER TABLE signalements_bugs
|
|
||||||
ADD CONSTRAINT chk_bugs_description_non_vide
|
|
||||||
CHECK (btrim(description) <> '');
|
|
||||||
|
|
||||||
-- =======
|
|
||||||
-- UPLOADS
|
|
||||||
-- =======
|
|
||||||
-- URL obligatoire + format basique (chemin absolu ou http(s))
|
|
||||||
ALTER TABLE uploads
|
|
||||||
ALTER COLUMN fichier_url SET NOT NULL;
|
|
||||||
ALTER TABLE uploads
|
|
||||||
ADD CONSTRAINT chk_uploads_url_format
|
|
||||||
CHECK (fichier_url ~ '^(https?://.+|/[^\\s]+)$');
|
|
||||||
|
|
||||||
-- =============
|
|
||||||
-- NOTIFICATIONS
|
|
||||||
-- =============
|
|
||||||
-- Contenu obligatoire, non vide
|
|
||||||
ALTER TABLE notifications
|
|
||||||
ALTER COLUMN contenu SET NOT NULL;
|
|
||||||
ALTER TABLE notifications
|
|
||||||
ADD CONSTRAINT chk_notifications_contenu_non_vide
|
|
||||||
CHECK (btrim(contenu) <> '');
|
|
||||||
|
|
||||||
-- ===========
|
|
||||||
-- VALIDATIONS
|
|
||||||
-- ===========
|
|
||||||
-- Rien de plus ici (Sprint 1 Ticket 8 enrichira la table)
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
-- ==========================================================
|
|
||||||
-- 04_fk_policies.sql : normalisation des politiques ON DELETE
|
|
||||||
-- A exécuter après 01_init.sql et 03_checks.sql
|
|
||||||
-- ==========================================================
|
|
||||||
|
|
||||||
-- Helper: Drop FK d'une table/colonne si elle existe (par son/leurs noms de colonne)
|
|
||||||
-- puis recrée la contrainte avec la clause fournie
|
|
||||||
-- Utilise information_schema pour retrouver le nom de contrainte auto-généré
|
|
||||||
-- NB: schema = public
|
|
||||||
|
|
||||||
-- ========== messages.id_expediteur -> utilisateurs.id : SET NULL (au lieu de CASCADE)
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='messages'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='id_expediteur';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.messages DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.messages
|
|
||||||
ADD CONSTRAINT fk_messages_id_expediteur
|
|
||||||
FOREIGN KEY (id_expediteur) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========== parents.id_co_parent -> utilisateurs.id : SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='parents'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='id_co_parent';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.parents DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.parents
|
|
||||||
ADD CONSTRAINT fk_parents_id_co_parent
|
|
||||||
FOREIGN KEY (id_co_parent) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========== avenants_contrats.initie_par -> utilisateurs.id : SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='avenants_contrats'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='initie_par';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.avenants_contrats DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.avenants_contrats
|
|
||||||
ADD CONSTRAINT fk_avenants_contrats_initie_par
|
|
||||||
FOREIGN KEY (initie_par) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========== evenements.id_am -> utilisateurs.id : SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='evenements'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='id_am';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.evenements DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.evenements
|
|
||||||
ADD CONSTRAINT fk_evenements_id_am
|
|
||||||
FOREIGN KEY (id_am) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========== evenements.id_parent -> parents.id_utilisateur : SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='evenements'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='id_parent';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.evenements DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.evenements
|
|
||||||
ADD CONSTRAINT fk_evenements_id_parent
|
|
||||||
FOREIGN KEY (id_parent) REFERENCES public.parents(id_utilisateur) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========== evenements.cree_par -> utilisateurs.id : SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='evenements'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='cree_par';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.evenements DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.evenements
|
|
||||||
ADD CONSTRAINT fk_evenements_cree_par
|
|
||||||
FOREIGN KEY (cree_par) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========== signalements_bugs.id_utilisateur -> utilisateurs.id : SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='signalements_bugs'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='id_utilisateur';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.signalements_bugs DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.signalements_bugs
|
|
||||||
ADD CONSTRAINT fk_signalements_bugs_id_utilisateur
|
|
||||||
FOREIGN KEY (id_utilisateur) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========== validations.id_utilisateur -> utilisateurs.id : SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema='public'
|
|
||||||
AND tc.table_name='validations'
|
|
||||||
AND tc.constraint_type='FOREIGN KEY'
|
|
||||||
AND kcu.column_name='id_utilisateur';
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.validations DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.validations
|
|
||||||
ADD CONSTRAINT fk_validations_id_utilisateur
|
|
||||||
FOREIGN KEY (id_utilisateur) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- NB:
|
|
||||||
-- D'autres FK déjà correctes : CASCADE (assistantes_maternelles, parents, enfants_parents, dossiers, messages.id_dossier, contrats, avenants_contrats.id_contrat, evenements.id_enfant), SET NULL (uploads).
|
|
||||||
-- On laisse ON UPDATE par défaut (NO ACTION), car les UUID ne changent pas.
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
-- =============================================
|
|
||||||
-- 05_triggers.sql : Timestamps automatiques
|
|
||||||
-- - Ajoute (si absent) cree_le DEFAULT now() et modifie_le DEFAULT now()
|
|
||||||
-- - Crée un trigger BEFORE UPDATE pour mettre à jour modifie_le
|
|
||||||
-- - Idempotent (DROP TRIGGER IF EXISTS / IF NOT EXISTS)
|
|
||||||
-- A exécuter après 01_init.sql, 02_indexes.sql, 03_checks.sql
|
|
||||||
-- =============================================
|
|
||||||
|
|
||||||
-- 1) Fonction unique de mise à jour du timestamp
|
|
||||||
CREATE OR REPLACE FUNCTION set_modifie_le()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.modifie_le := NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Helper macro-like: pour chaque table, s'assurer des colonnes & trigger
|
|
||||||
-- (on ne peut pas faire de macro, donc on répète pour chaque table)
|
|
||||||
|
|
||||||
-- Liste des tables concernées :
|
|
||||||
-- utilisateurs, assistantes_maternelles, parents, enfants, enfants_parents,
|
|
||||||
-- dossiers, messages, contrats, avenants_contrats, evenements,
|
|
||||||
-- signalements_bugs, uploads, notifications, validations
|
|
||||||
|
|
||||||
-- ========== UTILISATEURS
|
|
||||||
ALTER TABLE utilisateurs
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_utilisateurs_set_modifie_le ON utilisateurs;
|
|
||||||
CREATE TRIGGER trg_utilisateurs_set_modifie_le
|
|
||||||
BEFORE UPDATE ON utilisateurs
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== ASSISTANTES_MATERNELLES
|
|
||||||
ALTER TABLE assistantes_maternelles
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_am_set_modifie_le ON assistantes_maternelles;
|
|
||||||
CREATE TRIGGER trg_am_set_modifie_le
|
|
||||||
BEFORE UPDATE ON assistantes_maternelles
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== PARENTS
|
|
||||||
ALTER TABLE parents
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_parents_set_modifie_le ON parents;
|
|
||||||
CREATE TRIGGER trg_parents_set_modifie_le
|
|
||||||
BEFORE UPDATE ON parents
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== ENFANTS
|
|
||||||
ALTER TABLE enfants
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_enfants_set_modifie_le ON enfants;
|
|
||||||
CREATE TRIGGER trg_enfants_set_modifie_le
|
|
||||||
BEFORE UPDATE ON enfants
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== ENFANTS_PARENTS (table de liaison)
|
|
||||||
ALTER TABLE enfants_parents
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_enfants_parents_set_modifie_le ON enfants_parents;
|
|
||||||
CREATE TRIGGER trg_enfants_parents_set_modifie_le
|
|
||||||
BEFORE UPDATE ON enfants_parents
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== DOSSIERS
|
|
||||||
ALTER TABLE dossiers
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_dossiers_set_modifie_le ON dossiers;
|
|
||||||
CREATE TRIGGER trg_dossiers_set_modifie_le
|
|
||||||
BEFORE UPDATE ON dossiers
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== MESSAGES
|
|
||||||
ALTER TABLE messages
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_messages_set_modifie_le ON messages;
|
|
||||||
CREATE TRIGGER trg_messages_set_modifie_le
|
|
||||||
BEFORE UPDATE ON messages
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== CONTRATS
|
|
||||||
ALTER TABLE contrats
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_contrats_set_modifie_le ON contrats;
|
|
||||||
CREATE TRIGGER trg_contrats_set_modifie_le
|
|
||||||
BEFORE UPDATE ON contrats
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== AVENANTS_CONTRATS
|
|
||||||
ALTER TABLE avenants_contrats
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_avenants_contrats_set_modifie_le ON avenants_contrats;
|
|
||||||
CREATE TRIGGER trg_avenants_contrats_set_modifie_le
|
|
||||||
BEFORE UPDATE ON avenants_contrats
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== EVENEMENTS
|
|
||||||
ALTER TABLE evenements
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_evenements_set_modifie_le ON evenements;
|
|
||||||
CREATE TRIGGER trg_evenements_set_modifie_le
|
|
||||||
BEFORE UPDATE ON evenements
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== SIGNALEMENTS_BUGS
|
|
||||||
ALTER TABLE signalements_bugs
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_signalements_bugs_set_modifie_le ON signalements_bugs;
|
|
||||||
CREATE TRIGGER trg_signalements_bugs_set_modifie_le
|
|
||||||
BEFORE UPDATE ON signalements_bugs
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== UPLOADS
|
|
||||||
ALTER TABLE uploads
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_uploads_set_modifie_le ON uploads;
|
|
||||||
CREATE TRIGGER trg_uploads_set_modifie_le
|
|
||||||
BEFORE UPDATE ON uploads
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== NOTIFICATIONS
|
|
||||||
ALTER TABLE notifications
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_notifications_set_modifie_le ON notifications;
|
|
||||||
CREATE TRIGGER trg_notifications_set_modifie_le
|
|
||||||
BEFORE UPDATE ON notifications
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
|
|
||||||
-- ========== VALIDATIONS
|
|
||||||
ALTER TABLE validations
|
|
||||||
ADD COLUMN IF NOT EXISTS cree_le TIMESTAMP DEFAULT NOW(),
|
|
||||||
ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMP DEFAULT NOW();
|
|
||||||
DROP TRIGGER IF EXISTS trg_validations_set_modifie_le ON validations;
|
|
||||||
CREATE TRIGGER trg_validations_set_modifie_le
|
|
||||||
BEFORE UPDATE ON validations
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_modifie_le();
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
-- ==========================================================
|
|
||||||
-- 06_validations_enrich.sql : Traçabilité complète des validations
|
|
||||||
-- - Ajoute la colonne 'valide_par' (FK -> utilisateurs.id)
|
|
||||||
-- - ON DELETE SET NULL pour conserver l'historique
|
|
||||||
-- - Ajoute index utiles pour les requêtes (valideur, statut, date)
|
|
||||||
-- A exécuter après : 01_init.sql, 02_indexes.sql, 03_checks.sql, 04_fk_policies.sql, 05_triggers.sql
|
|
||||||
-- ==========================================================
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- 1) Colonne 'valide_par' si absente
|
|
||||||
ALTER TABLE validations
|
|
||||||
ADD COLUMN IF NOT EXISTS valide_par UUID NULL;
|
|
||||||
|
|
||||||
-- 2) FK vers utilisateurs(id), ON DELETE SET NULL
|
|
||||||
DO $$
|
|
||||||
DECLARE conname text;
|
|
||||||
BEGIN
|
|
||||||
SELECT tc.constraint_name INTO conname
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.key_column_usage kcu
|
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
|
||||||
AND tc.table_schema = kcu.table_schema
|
|
||||||
WHERE tc.table_schema = 'public'
|
|
||||||
AND tc.table_name = 'validations'
|
|
||||||
AND tc.constraint_type= 'FOREIGN KEY'
|
|
||||||
AND kcu.column_name = 'valide_par';
|
|
||||||
|
|
||||||
IF conname IS NOT NULL THEN
|
|
||||||
EXECUTE format('ALTER TABLE public.validations DROP CONSTRAINT %I', conname);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
EXECUTE $sql$
|
|
||||||
ALTER TABLE public.validations
|
|
||||||
ADD CONSTRAINT fk_validations_valide_par
|
|
||||||
FOREIGN KEY (valide_par) REFERENCES public.utilisateurs(id) ON DELETE SET NULL
|
|
||||||
$sql$;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 3) Index pour accélérer les recherches
|
|
||||||
-- - qui a validé quoi récemment ?
|
|
||||||
-- - toutes les validations par statut / par date
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_validations_valide_par_cree_le
|
|
||||||
ON validations (valide_par, cree_le);
|
|
||||||
|
|
||||||
-- Certains existent peut-être déjà : on sécurise
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_validations_id_utilisateur_cree_le
|
|
||||||
ON validations (id_utilisateur, cree_le);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_validations_statut
|
|
||||||
ON validations (statut);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
-- Script d'importation des données CSV dans la base Postgres du docker dev
|
|
||||||
-- À exécuter dans le conteneur ou via psql connecté à la base
|
|
||||||
-- psql -U admin -d ptitpas_db -f /docker-entrypoint-initdb.d/07_import.sql
|
|
||||||
-- Exemple d'utilisation :
|
|
||||||
|
|
||||||
|
|
||||||
-- Import utilisateurs
|
|
||||||
\copy utilisateurs FROM 'bdd/data_test/utilisateurs.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import assistantes_maternelles
|
|
||||||
\copy assistantes_maternelles FROM 'bdd/data_test/assistantes_maternelles.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import parents
|
|
||||||
\copy parents FROM 'bdd/data_test/parents.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import enfants
|
|
||||||
\copy enfants FROM 'bdd/data_test/enfants.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import enfants_parents
|
|
||||||
\copy enfants_parents FROM 'bdd/data_test/enfants_parents.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import dossiers
|
|
||||||
\copy dossiers FROM 'bdd/data_test/dossiers.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import contrats
|
|
||||||
\copy contrats FROM 'bdd/data_test/contrats.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import validations
|
|
||||||
\copy validations FROM 'bdd/data_test/validations.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import notifications
|
|
||||||
\copy notifications FROM 'bdd/data_test/notifications.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import uploads
|
|
||||||
\copy uploads FROM 'bdd/data_test/uploads.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Import evenements
|
|
||||||
\copy evenements FROM 'bdd/data_test/evenements.csv' DELIMITER ',' CSV HEADER;
|
|
||||||
|
|
||||||
-- Remarque :
|
|
||||||
-- Les chemins doivent être accessibles depuis le conteneur Docker (monter le dossier si besoin)
|
|
||||||
-- Adapter l'utilisateur, la base et le chemin si nécessaire
|
|
||||||
16
database/migrations/2026_nir_chiffre_not_null.sql
Normal file
16
database/migrations/2026_nir_chiffre_not_null.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- Migration : rendre nir_chiffre NOT NULL (ticket #102)
|
||||||
|
-- À exécuter sur les bases existantes avant déploiement du schéma avec nir_chiffre NOT NULL.
|
||||||
|
-- Les lignes sans NIR reçoivent un NIR de test valide (format + clé) pour satisfaire la contrainte.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Renseigner un NIR de test valide pour toute ligne où nir_chiffre est NULL
|
||||||
|
UPDATE assistantes_maternelles
|
||||||
|
SET nir_chiffre = '275119900100102'
|
||||||
|
WHERE nir_chiffre IS NULL;
|
||||||
|
|
||||||
|
-- Appliquer la contrainte NOT NULL
|
||||||
|
ALTER TABLE assistantes_maternelles
|
||||||
|
ALTER COLUMN nir_chiffre SET NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
33
database/migrations/2026_numero_dossier.sql
Normal file
33
database/migrations/2026_numero_dossier.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
-- Migration #103 : Numéro de dossier (format AAAA-NNNNNN, séquence par année)
|
||||||
|
-- Colonnes sur utilisateurs, assistantes_maternelles, parents.
|
||||||
|
-- Table de séquence par année pour génération unique.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Table de séquence : une ligne par année, prochain = prochain numéro à attribuer (1..999999)
|
||||||
|
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
|
||||||
|
annee INT PRIMARY KEY,
|
||||||
|
prochain INT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Colonne sur utilisateurs (AM et parents : numéro attribué à la soumission)
|
||||||
|
ALTER TABLE utilisateurs
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||||
|
|
||||||
|
-- Colonne sur assistantes_maternelles (redondant avec users pour accès direct)
|
||||||
|
ALTER TABLE assistantes_maternelles
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||||
|
|
||||||
|
-- Colonne sur parents (un numéro par famille, même valeur sur les deux lignes si co-parent)
|
||||||
|
ALTER TABLE parents
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||||
|
|
||||||
|
-- Index pour recherche par numéro
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier
|
||||||
|
ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier
|
||||||
|
ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier
|
||||||
|
ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
122
database/migrations/2026_numero_dossier_backfill.sql
Normal file
122
database/migrations/2026_numero_dossier_backfill.sql
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
-- Backfill #103 : attribuer un numero_dossier aux entrées existantes (NULL)
|
||||||
|
-- Famille = lien co_parent OU partage d'au moins un enfant (même dossier).
|
||||||
|
-- Ordre : par année, AM puis familles (une entrée par famille), séquence 000001, 000002...
|
||||||
|
-- À exécuter après 2026_numero_dossier.sql
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
yr INT;
|
||||||
|
seq INT;
|
||||||
|
num TEXT;
|
||||||
|
r RECORD;
|
||||||
|
family_user_ids UUID[];
|
||||||
|
BEGIN
|
||||||
|
-- Réinitialiser pour rejouer le backfill (cohérence AM + familles)
|
||||||
|
UPDATE parents SET numero_dossier = NULL;
|
||||||
|
UPDATE utilisateurs SET numero_dossier = NULL
|
||||||
|
WHERE role IN ('parent', 'assistante_maternelle');
|
||||||
|
UPDATE assistantes_maternelles SET numero_dossier = NULL;
|
||||||
|
|
||||||
|
FOR yr IN
|
||||||
|
SELECT DISTINCT EXTRACT(YEAR FROM u.cree_le)::INT
|
||||||
|
FROM utilisateurs u
|
||||||
|
WHERE (
|
||||||
|
(u.role = 'assistante_maternelle' AND u.numero_dossier IS NULL)
|
||||||
|
OR EXISTS (SELECT 1 FROM parents p WHERE p.id_utilisateur = u.id AND p.numero_dossier IS NULL)
|
||||||
|
)
|
||||||
|
ORDER BY 1
|
||||||
|
LOOP
|
||||||
|
seq := 0;
|
||||||
|
|
||||||
|
-- 1) AM : par ordre de création
|
||||||
|
FOR r IN
|
||||||
|
SELECT u.id
|
||||||
|
FROM utilisateurs u
|
||||||
|
WHERE u.role = 'assistante_maternelle'
|
||||||
|
AND u.numero_dossier IS NULL
|
||||||
|
AND EXTRACT(YEAR FROM u.cree_le) = yr
|
||||||
|
ORDER BY u.cree_le
|
||||||
|
LOOP
|
||||||
|
seq := seq + 1;
|
||||||
|
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
|
||||||
|
UPDATE utilisateurs SET numero_dossier = num WHERE id = r.id;
|
||||||
|
UPDATE assistantes_maternelles SET numero_dossier = num WHERE id_utilisateur = r.id;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 2) Familles : une entrée par "dossier" (co_parent OU enfants partagés)
|
||||||
|
-- family_rep = min(id) de la composante connexe (lien co_parent + partage d'enfants)
|
||||||
|
FOR r IN
|
||||||
|
WITH RECURSIVE
|
||||||
|
links AS (
|
||||||
|
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
),
|
||||||
|
rec AS (
|
||||||
|
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||||
|
UNION
|
||||||
|
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||||
|
),
|
||||||
|
family_rep AS (
|
||||||
|
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
|
||||||
|
),
|
||||||
|
fam_ordered AS (
|
||||||
|
SELECT fr.rep AS family_rep, MIN(u.cree_le) AS cree_le
|
||||||
|
FROM family_rep fr
|
||||||
|
JOIN parents p ON p.id_utilisateur = fr.id
|
||||||
|
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
||||||
|
WHERE p.numero_dossier IS NULL
|
||||||
|
AND EXTRACT(YEAR FROM u.cree_le) = yr
|
||||||
|
GROUP BY fr.rep
|
||||||
|
ORDER BY MIN(u.cree_le)
|
||||||
|
)
|
||||||
|
SELECT fo.family_rep
|
||||||
|
FROM fam_ordered fo
|
||||||
|
LOOP
|
||||||
|
seq := seq + 1;
|
||||||
|
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
|
||||||
|
|
||||||
|
WITH RECURSIVE
|
||||||
|
links AS (
|
||||||
|
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
UNION ALL
|
||||||
|
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||||
|
FROM enfants_parents ep1
|
||||||
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
|
),
|
||||||
|
rec AS (
|
||||||
|
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||||
|
UNION
|
||||||
|
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||||
|
),
|
||||||
|
family_rep AS (
|
||||||
|
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
|
||||||
|
)
|
||||||
|
SELECT array_agg(DISTINCT fr.id) INTO family_user_ids
|
||||||
|
FROM family_rep fr
|
||||||
|
WHERE fr.rep = r.family_rep;
|
||||||
|
|
||||||
|
UPDATE utilisateurs SET numero_dossier = num WHERE id = ANY(family_user_ids);
|
||||||
|
UPDATE parents SET numero_dossier = num WHERE id_utilisateur = ANY(family_user_ids);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
INSERT INTO numero_dossier_sequence (annee, prochain)
|
||||||
|
VALUES (yr, seq + 1)
|
||||||
|
ON CONFLICT (annee) DO UPDATE
|
||||||
|
SET prochain = GREATEST(numero_dossier_sequence.prochain, seq + 1);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
4
database/migrations/2026_statut_utilisateur_refuse.sql
Normal file
4
database/migrations/2026_statut_utilisateur_refuse.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- Migration #105 : Statut utilisateur « refusé » (à corriger)
|
||||||
|
-- Ajout de la valeur 'refuse' à l'enum statut_utilisateur_type.
|
||||||
|
|
||||||
|
ALTER TYPE statut_utilisateur_type ADD VALUE IF NOT EXISTS 'refuse';
|
||||||
10
database/migrations/2026_token_reprise_refus.sql
Normal file
10
database/migrations/2026_token_reprise_refus.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Migration #110 : Token reprise après refus (lien email)
|
||||||
|
-- Permet à l'utilisateur refusé de corriger et resoumettre via un lien sécurisé.
|
||||||
|
|
||||||
|
ALTER TABLE utilisateurs
|
||||||
|
ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise
|
||||||
|
ON utilisateurs(token_reprise)
|
||||||
|
WHERE token_reprise IS NOT NULL;
|
||||||
@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent)
|
|||||||
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
|
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- assistantes_maternelles
|
-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102)
|
||||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence)
|
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence)
|
||||||
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille')
|
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille')
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
78
database/seed/03_seed_test_data.sql
Normal file
78
database/seed/03_seed_test_data.sql
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
|
||||||
|
-- Aligné sur utilisateurs-test-complet.json
|
||||||
|
-- Mot de passe universel : password (bcrypt)
|
||||||
|
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
|
||||||
|
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
|
||||||
|
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
|
||||||
|
-- À exécuter après BDD.sql (init DB)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Hash bcrypt pour "password" (10 rounds)
|
||||||
|
|
||||||
|
-- ========== UTILISATEURS (1 admin + 1 gestionnaire + 2 AM + 5 parents) ==========
|
||||||
|
-- On garde admin@ptits-pas.fr (super_admin) déjà créé par BDD.sql
|
||||||
|
|
||||||
|
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
|
||||||
|
VALUES
|
||||||
|
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
|
||||||
|
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
|
||||||
|
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
||||||
|
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
|
||||||
|
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
|
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
||||||
|
INSERT INTO parents (id_utilisateur, id_co_parent)
|
||||||
|
VALUES
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'a0000006-0006-0006-0006-000000000006'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'a0000005-0005-0005-0005-000000000005'),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', NULL),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', NULL),
|
||||||
|
('a0000009-0009-0009-0009-000000000009', NULL)
|
||||||
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
|
-- ========== ASSISTANTES MATERNELLES ==========
|
||||||
|
-- Marie Dubois (a0000003) : née en Corse à Ajaccio – NIR 2A pour test exception Corse (1980-06-08, F).
|
||||||
|
-- Fatima El Mansouri (a0000004) : née à l'étranger – NIR 99 pour test (1975-11-12, F).
|
||||||
|
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
|
||||||
|
VALUES
|
||||||
|
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280062A00100191', 4, 'Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
||||||
|
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119900100102', 3, 'Assistante maternelle expérimentée. Née à l''étranger. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
||||||
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
|
-- ========== ENFANTS ==========
|
||||||
|
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||||
|
VALUES
|
||||||
|
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||||
|
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
|
||||||
|
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||||
|
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
|
||||||
|
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
|
||||||
|
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||||
|
-- Martin (Claire + Thomas) -> Emma, Noah, Léa
|
||||||
|
INSERT INTO enfants_parents (id_parent, id_enfant)
|
||||||
|
VALUES
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'e0000001-0001-0001-0001-000000000001'),
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'e0000002-0002-0002-0002-000000000002'),
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'e0000003-0003-0003-0003-000000000003'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'e0000001-0001-0001-0001-000000000001'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'e0000002-0002-0002-0002-000000000002'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'e0000003-0003-0003-0003-000000000003'),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', 'e0000004-0004-0004-0004-000000000004'),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', 'e0000005-0005-0005-0005-000000000005'),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', 'e0000004-0004-0004-0004-000000000004'),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', 'e0000005-0005-0005-0005-000000000005'),
|
||||||
|
('a0000009-0009-0009-0009-000000000009', 'e0000006-0006-0006-0006-000000000006')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -9,7 +9,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
volumes:
|
volumes:
|
||||||
- ./database/migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
|
- ./database/BDD.sql:/docker-entrypoint-initdb.d/01_init.sql
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- ptitspas_network
|
- ptitspas_network
|
||||||
@ -55,6 +55,8 @@ services:
|
|||||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
||||||
JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES}
|
JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES}
|
||||||
NODE_ENV: ${NODE_ENV}
|
NODE_ENV: ${NODE_ENV}
|
||||||
|
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
|
||||||
|
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
- database
|
- database
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
69
docs/00_INDEX.md
Normal file
69
docs/00_INDEX.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# 📚 Index de la Documentation - PtitsPas App
|
||||||
|
|
||||||
|
Bienvenue dans la documentation complète de l'application PtitsPas.
|
||||||
|
|
||||||
|
Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
|
||||||
|
|
||||||
|
## 📖 Table des matières
|
||||||
|
|
||||||
|
### 📋 Cahier des Charges
|
||||||
|
- [**01 - Cahier des Charges**](./01_CAHIER-DES-CHARGES.md) - Cahier des charges complet du projet P'titsPas (V1.3 - 24/11/2025)
|
||||||
|
|
||||||
|
### Architecture & Infrastructure
|
||||||
|
- [**02 - Architecture**](./02_ARCHITECTURE.md) - Vue d'ensemble de l'architecture mono-repo et multi-conteneurs
|
||||||
|
- [**03 - Déploiement**](./03_DEPLOYMENT.md) - Guide complet de déploiement et configuration CI/CD
|
||||||
|
|
||||||
|
### Planification
|
||||||
|
- [**04 - Roadmap Générale**](./04_ROADMAP-GENERALE.md) - Roadmap complète du projet (Phases 1 à 5+)
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
- [**10 - Database Schema**](./10_DATABASE.md) - Schéma de la base de données et modèles
|
||||||
|
- [**11 - API Documentation**](./11_API.md) - Documentation complète des endpoints REST
|
||||||
|
|
||||||
|
### Workflows Fonctionnels
|
||||||
|
- [**20 - Workflow Création de Compte**](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow complet de création et validation des comptes utilisateurs
|
||||||
|
- [**21 - Configuration Système**](./21_CONFIGURATION-SYSTEME.md) - Configuration on-premise dynamique
|
||||||
|
- [**22 - Documents Légaux**](./22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning
|
||||||
|
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
|
||||||
|
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
|
||||||
|
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
|
||||||
|
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
|
||||||
|
|
||||||
|
### Administration (À créer)
|
||||||
|
- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs
|
||||||
|
- [**31 - Troubleshooting**](./31_TROUBLESHOOTING.md) - Résolution des problèmes courants
|
||||||
|
|
||||||
|
### Frontend (À créer)
|
||||||
|
- [**40 - Frontend Flutter**](./40_FRONTEND.md) - Structure de l'application mobile/web
|
||||||
|
|
||||||
|
### Audit & Analyse
|
||||||
|
- [**90 - Audit du projet YNOV**](./90_AUDIT.md) - Analyse complète du code étudiant et fonctionnalités
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cloner le projet
|
||||||
|
git clone ssh://gitea-jmartin/jmartin/app.git ptitspas-app
|
||||||
|
|
||||||
|
# Lancer l'environnement de développement
|
||||||
|
cd ptitspas-app
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Accéder aux services
|
||||||
|
Frontend: https://app.ptits-pas.fr
|
||||||
|
API: https://app.ptits-pas.fr/api
|
||||||
|
PgAdmin: https://app.ptits-pas.fr/pgadmin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 Liens utiles
|
||||||
|
|
||||||
|
- **Gitea** : https://git.ptits-pas.fr
|
||||||
|
- **Production** : https://app.ptits-pas.fr
|
||||||
|
- **Mail** : https://mail.ptits-pas.fr
|
||||||
|
|
||||||
|
## 📝 Maintenance
|
||||||
|
|
||||||
|
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
|
||||||
|
|
||||||
|
Dernière mise à jour : Novembre 2025
|
||||||
|
|
||||||
1225
docs/01_CAHIER-DES-CHARGES.md
Normal file
1225
docs/01_CAHIER-DES-CHARGES.md
Normal file
File diff suppressed because it is too large
Load Diff
330
docs/04_ROADMAP-GENERALE.md
Normal file
330
docs/04_ROADMAP-GENERALE.md
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
# 🗺️ Roadmap Générale - Projet P'titsPas
|
||||||
|
|
||||||
|
**Version** : 1.0
|
||||||
|
**Date** : 28 Novembre 2025
|
||||||
|
**Auteur** : Équipe PtitsPas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Avertissement
|
||||||
|
|
||||||
|
Les **Phases 2, 3, 4+** sont des **ébauches indicatives** qui seront affinées au fur et à mesure du développement et des retours utilisateurs. Certaines fonctionnalités mentionnées (comme la facturation) ne seront peut-être pas développées ou seront remplacées par d'autres priorités.
|
||||||
|
|
||||||
|
**Seule la Phase 1 est détaillée et validée.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Vue d'ensemble
|
||||||
|
|
||||||
|
| Phase | Focus | Estimation | Statut |
|
||||||
|
|-------|-------|------------|--------|
|
||||||
|
| **Phase 1** | Comptes & Auth | ~173h | ✅ Détaillée (61 tickets) |
|
||||||
|
| **Phase 2** | Recherche & Contact | ~100h | 📋 Ébauche |
|
||||||
|
| **Phase 3** | Contrats & Planning | ~120h | 📋 Ébauche |
|
||||||
|
| **Phase 4** | Suivi & Avancé | ~140h+ | 📋 Ébauche |
|
||||||
|
| **Phase 5+** | Optimisations | ~200h+ | 📋 Ébauche |
|
||||||
|
| **TOTAL** | | **~733h+** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Phase 1 (v1.0.0) - 🔐 Création de comptes & Authentification
|
||||||
|
|
||||||
|
**Objectif** : MVP fonctionnel avec gestion des utilisateurs
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- ✅ Configuration système (on-premise)
|
||||||
|
- ✅ Authentification & Sécurité
|
||||||
|
- ✅ Inscription Parents (workflow 6 étapes)
|
||||||
|
- ✅ Inscription Assistantes Maternelles (workflow 5 panneaux)
|
||||||
|
- ✅ Validation par Gestionnaires (dashboard 2 onglets)
|
||||||
|
- ✅ Documents légaux (CGU/Privacy avec versioning)
|
||||||
|
- ✅ Upload photos (enfants, AM)
|
||||||
|
- ✅ Notifications email (validation, refus, création MDP)
|
||||||
|
- ✅ Logging & Monitoring
|
||||||
|
- ✅ Tests & Documentation
|
||||||
|
|
||||||
|
### Versions incrémentales
|
||||||
|
|
||||||
|
| Version | Objectif | Tickets | Estimation |
|
||||||
|
|---------|----------|---------|------------|
|
||||||
|
| **0.1.0** | MVP Fonctionnel | ~21 | ~45h |
|
||||||
|
| **0.2.0** | Sécurité & RGPD | ~10 | ~35h |
|
||||||
|
| **0.3.0** | Interfaces Complètes | ~17 | ~52h |
|
||||||
|
| **0.4.0** | Tests & Documentation | ~6 | ~24h |
|
||||||
|
| **0.5.0** | Monitoring & Optimisations | ~7 | ~17h |
|
||||||
|
| **1.0.0** | 🎉 **Release Phase 1** | **61** | **~173h** |
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
Application installable avec création et validation de comptes utilisateurs.
|
||||||
|
|
||||||
|
**Référence** : [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Phase 2 (v2.0.0) - 🤝 Mise en relation & Communication
|
||||||
|
|
||||||
|
**Objectif** : Permettre aux parents de trouver et contacter des assistantes maternelles
|
||||||
|
|
||||||
|
### Fonctionnalités (ébauche)
|
||||||
|
|
||||||
|
- 🔍 **Recherche d'AM**
|
||||||
|
- Recherche par critères (ville, capacité, disponibilité, tarifs)
|
||||||
|
- Filtres avancés
|
||||||
|
- Géolocalisation (optionnel)
|
||||||
|
|
||||||
|
- 👤 **Profils détaillés AM**
|
||||||
|
- Présentation complète
|
||||||
|
- Photos du lieu de garde
|
||||||
|
- Expérience et qualifications
|
||||||
|
- Avis/Témoignages (optionnel)
|
||||||
|
|
||||||
|
- 💬 **Messagerie interne**
|
||||||
|
- Conversations sécurisées Parent ↔ AM
|
||||||
|
- Pièces jointes
|
||||||
|
- Historique des échanges
|
||||||
|
|
||||||
|
- 📨 **Demandes de contact**
|
||||||
|
- Workflow de demande Parent → AM
|
||||||
|
- Validation/Refus par AM
|
||||||
|
- Notifications
|
||||||
|
|
||||||
|
- ⭐ **Favoris/Shortlist**
|
||||||
|
- AM sauvegardées par parents
|
||||||
|
- Comparaison de profils
|
||||||
|
|
||||||
|
### Estimation
|
||||||
|
|
||||||
|
~100h (à affiner)
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
Parents peuvent trouver, consulter et contacter des assistantes maternelles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Phase 3 (v3.0.0) - 📄 Contrats & Planning
|
||||||
|
|
||||||
|
**Objectif** : Formaliser les gardes et gérer les plannings
|
||||||
|
|
||||||
|
### Fonctionnalités (ébauche)
|
||||||
|
|
||||||
|
- 📄 **Gestion des contrats**
|
||||||
|
- Création contrats (modèle type personnalisable)
|
||||||
|
- Signature électronique ou validation
|
||||||
|
- Stockage documents contractuels (PDF)
|
||||||
|
- Historique des contrats
|
||||||
|
- Renouvellement/Modification
|
||||||
|
|
||||||
|
- 📅 **Planning & Disponibilités**
|
||||||
|
- Calendrier AM (disponibilités, absences, congés)
|
||||||
|
- Réservations/Demandes de garde
|
||||||
|
- Validation/Refus par AM
|
||||||
|
- Vue planning Parent (enfants gardés)
|
||||||
|
- Alertes conflits de planning
|
||||||
|
- Export calendrier (iCal)
|
||||||
|
|
||||||
|
### Estimation
|
||||||
|
|
||||||
|
~120h (à affiner)
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
Contrats formalisés + Planning opérationnel pour gérer les gardes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Phase 4 (v4.0.0) - 📊 Suivi & Fonctionnalités avancées
|
||||||
|
|
||||||
|
**Objectif** : Suivi quotidien des enfants et fonctionnalités complémentaires
|
||||||
|
|
||||||
|
### Fonctionnalités (ébauche)
|
||||||
|
|
||||||
|
- 📔 **Suivi des Enfants (Carnet de liaison numérique)**
|
||||||
|
- Activités quotidiennes (repas, sieste, jeux)
|
||||||
|
- Photos/Vidéos sécurisées (partage Parent ↔ AM)
|
||||||
|
- Notes/Observations
|
||||||
|
- Suivi médical (médicaments, allergies, vaccins)
|
||||||
|
- Historique complet par enfant
|
||||||
|
- Export PDF (bilan mensuel)
|
||||||
|
|
||||||
|
- 🎯 **Autres fonctionnalités à définir**
|
||||||
|
- ⚠️ **Pas de facturation** (décision validée)
|
||||||
|
- Fonctionnalités à déterminer selon retours utilisateurs Phase 2 et 3
|
||||||
|
|
||||||
|
### Estimation
|
||||||
|
|
||||||
|
~140h+ (à affiner)
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
Suivi quotidien des enfants + Fonctionnalités complémentaires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Phase 5+ (v5.0.0+) - 🚀 Optimisations & Améliorations
|
||||||
|
|
||||||
|
**Objectif** : Optimisations, monitoring, et fonctionnalités premium
|
||||||
|
|
||||||
|
### Fonctionnalités (ébauche)
|
||||||
|
|
||||||
|
#### 📊 Statistiques & Reporting
|
||||||
|
- Dashboard gestionnaire (stats inscriptions, validations)
|
||||||
|
- Rapports collectivité (CSV/PDF)
|
||||||
|
- Graphiques évolution
|
||||||
|
- Tableaux de bord personnalisés
|
||||||
|
|
||||||
|
#### 🔒 RGPD avancé
|
||||||
|
- Droit à l'oubli (suppression compte)
|
||||||
|
- Export données personnelles (portabilité)
|
||||||
|
- Anonymisation automatique comptes inactifs
|
||||||
|
- Audit trail complet
|
||||||
|
|
||||||
|
#### 📈 Monitoring & Infrastructure
|
||||||
|
- Métriques système (CPU, RAM, BDD)
|
||||||
|
- Dashboard monitoring admin
|
||||||
|
- Sauvegarde automatique BDD (cron)
|
||||||
|
- Procédures de restauration
|
||||||
|
- Alertes automatiques
|
||||||
|
|
||||||
|
#### 📚 Documentation & Formation
|
||||||
|
- Guides utilisateur (Gestionnaire, Parent, AM)
|
||||||
|
- Vidéos tutoriels
|
||||||
|
- FAQ interactive
|
||||||
|
- Base de connaissances
|
||||||
|
|
||||||
|
#### 🎨 Améliorations UX
|
||||||
|
- Mode sombre
|
||||||
|
- Notifications push (PWA)
|
||||||
|
- Accessibilité (WCAG 2.1)
|
||||||
|
- Multi-langue (i18n)
|
||||||
|
- Responsive avancé
|
||||||
|
|
||||||
|
#### 🌟 Fonctionnalités Premium (optionnel)
|
||||||
|
- Géolocalisation AM (carte interactive)
|
||||||
|
- Système d'avis/notation
|
||||||
|
- Badges/Certifications AM
|
||||||
|
- Intégrations tierces (CAF, etc.)
|
||||||
|
- Application mobile native
|
||||||
|
|
||||||
|
### Estimation
|
||||||
|
|
||||||
|
~200h+ (à affiner)
|
||||||
|
|
||||||
|
### Livrable
|
||||||
|
|
||||||
|
Application mature, optimisée et riche en fonctionnalités.
|
||||||
|
|
||||||
|
**Référence** : [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) (anciennes fonctionnalités techniques)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Logique de progression
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 : "Je peux créer un compte"
|
||||||
|
↓
|
||||||
|
Phase 2 : "Je peux trouver et contacter une AM"
|
||||||
|
↓
|
||||||
|
Phase 3 : "Je peux signer un contrat et gérer le planning"
|
||||||
|
↓
|
||||||
|
Phase 4 : "Je peux suivre mon enfant au quotidien"
|
||||||
|
↓
|
||||||
|
Phase 5+ : "L'application est optimisée et riche en fonctionnalités"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔢 Schéma de versioning
|
||||||
|
|
||||||
|
```
|
||||||
|
X.Y.Z
|
||||||
|
|
||||||
|
X = Phase majeure (0 = dev Phase 1, 1 = Phase 1 livrée, 2 = Phase 2 livrée, etc.)
|
||||||
|
Y = Version incrémentale dans la phase (0.1, 0.2, 0.3... → 1.0)
|
||||||
|
Z = Patch/Hotfix (0 par défaut, incrémenté pour corrections)
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
- 0.1.0 → Phase 1 en dev, Version 1 (MVP)
|
||||||
|
- 0.1.1 → Phase 1 en dev, Version 1, Patch 1 (correction bug)
|
||||||
|
- 0.2.0 → Phase 1 en dev, Version 2 (Sécurité)
|
||||||
|
- 1.0.0 → Livraison finale Phase 1
|
||||||
|
- 1.0.1 → Patch Phase 1
|
||||||
|
- 2.0.0 → Livraison finale Phase 2
|
||||||
|
- 3.0.0 → Livraison finale Phase 3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Critères de passage entre phases
|
||||||
|
|
||||||
|
### Phase 1 → Phase 2
|
||||||
|
- ✅ Phase 1 terminée (61 tickets)
|
||||||
|
- ✅ Application déployée en production (au moins 1 collectivité)
|
||||||
|
- ✅ Utilisateurs réels (au moins 10 comptes validés)
|
||||||
|
- ✅ Feedback terrain collecté
|
||||||
|
- ✅ Bugs critiques corrigés
|
||||||
|
|
||||||
|
### Phase 2 → Phase 3
|
||||||
|
- ✅ Phase 2 terminée
|
||||||
|
- ✅ Recherche et messagerie utilisées activement
|
||||||
|
- ✅ Au moins 5 mises en relation réussies
|
||||||
|
- ✅ Feedback utilisateurs positif
|
||||||
|
- ✅ Besoin de formalisation des contrats exprimé
|
||||||
|
|
||||||
|
### Phase 3 → Phase 4
|
||||||
|
- ✅ Phase 3 terminée
|
||||||
|
- ✅ Contrats et planning utilisés activement
|
||||||
|
- ✅ Au moins 10 contrats signés
|
||||||
|
- ✅ Feedback utilisateurs positif
|
||||||
|
- ✅ Besoin de suivi quotidien exprimé
|
||||||
|
|
||||||
|
### Phase 4 → Phase 5+
|
||||||
|
- ✅ Phase 4 terminée
|
||||||
|
- ✅ Application stable en production
|
||||||
|
- ✅ Base utilisateurs significative (50+ comptes actifs)
|
||||||
|
- ✅ Demandes d'optimisations et fonctionnalités avancées
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes importantes
|
||||||
|
|
||||||
|
1. **Flexibilité** : Cette roadmap est indicative et sera ajustée en fonction :
|
||||||
|
- Des retours utilisateurs
|
||||||
|
- Des priorités des collectivités
|
||||||
|
- Des contraintes techniques découvertes
|
||||||
|
- Des évolutions réglementaires
|
||||||
|
|
||||||
|
2. **Priorisation** : Les fonctionnalités de chaque phase peuvent être réorganisées selon :
|
||||||
|
- L'urgence métier
|
||||||
|
- La valeur ajoutée
|
||||||
|
- La complexité technique
|
||||||
|
- Les dépendances
|
||||||
|
|
||||||
|
3. **Décisions actées** :
|
||||||
|
- ❌ Pas de facturation automatique (gestion externe)
|
||||||
|
- ❌ Pas de SMS (email uniquement)
|
||||||
|
- ✅ Application on-premise (auto-hébergée)
|
||||||
|
- ✅ Configuration dynamique (pas de hardcoding)
|
||||||
|
|
||||||
|
4. **Documentation** : Chaque phase aura sa propre documentation détaillée avant démarrage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documents de référence
|
||||||
|
|
||||||
|
- [00_INDEX.md](./00_INDEX.md) - Index général de la documentation
|
||||||
|
- [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) - Cahier des charges v1.3
|
||||||
|
- [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow création de comptes
|
||||||
|
- [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) - Liste des 61 tickets Phase 1
|
||||||
|
- [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md) - Décisions architecturales
|
||||||
|
- [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) - Anciennes fonctionnalités techniques
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 28 Novembre 2025
|
||||||
|
**Version** : 1.0
|
||||||
|
**Statut** : 📋 Roadmap indicative - Phase 1 détaillée et validée
|
||||||
|
|
||||||
|
|
||||||
421
docs/10_DATABASE.md
Normal file
421
docs/10_DATABASE.md
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
# 🗄️ Documentation Base de Données
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
L'application PtitsPas utilise **PostgreSQL 14** avec l'extension **pgcrypto** pour la gestion des UUID.
|
||||||
|
|
||||||
|
**Nom de la base** : `ptitpas_db`
|
||||||
|
**Port** : `5432`
|
||||||
|
**Conteneur Docker** : `ptitspas-postgres`
|
||||||
|
|
||||||
|
## Schéma de la base de données
|
||||||
|
|
||||||
|
### Types ENUM
|
||||||
|
|
||||||
|
La base de données utilise plusieurs types énumérés PostgreSQL :
|
||||||
|
|
||||||
|
| Type ENUM | Valeurs possibles | Usage |
|
||||||
|
|-----------|------------------|-------|
|
||||||
|
| `role_type` | `parent`, `gestionnaire`, `super_admin`, `assistante_maternelle`, `administrateur` | Rôles des utilisateurs |
|
||||||
|
| `genre_type` | `H`, `F`, `Autre` | Genre des utilisateurs et enfants |
|
||||||
|
| `statut_utilisateur_type` | `en_attente`, `actif`, `suspendu` | Statut du compte utilisateur |
|
||||||
|
| `statut_enfant_type` | `a_naitre`, `actif`, `scolarise` | Statut de l'enfant |
|
||||||
|
| `statut_dossier_type` | `envoye`, `accepte`, `refuse` | Statut de la candidature |
|
||||||
|
| `statut_contrat_type` | `brouillon`, `en_attente_signature`, `valide`, `resilie` | Statut du contrat |
|
||||||
|
| `statut_avenant_type` | `propose`, `accepte`, `refuse` | Statut des avenants au contrat |
|
||||||
|
| `type_evenement_type` | `absence_enfant`, `conge_am`, `conge_parent`, `arret_maladie_am`, `evenement_rpe` | Type d'événement |
|
||||||
|
| `statut_evenement_type` | `propose`, `valide`, `refuse` | Statut de l'événement |
|
||||||
|
| `statut_validation_type` | `en_attente`, `valide`, `refuse` | Statut de validation générique |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### 1. `utilisateurs`
|
||||||
|
|
||||||
|
Table centrale pour tous les types d'utilisateurs.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `email` | VARCHAR(255) | NOT NULL, UNIQUE | Email (avec validation regex) |
|
||||||
|
| `password` | TEXT | NOT NULL | Mot de passe hashé (bcrypt) |
|
||||||
|
| `prenom` | VARCHAR(100) | | Prénom |
|
||||||
|
| `nom` | VARCHAR(100) | | Nom de famille |
|
||||||
|
| `genre` | genre_type | | Genre de l'utilisateur |
|
||||||
|
| `role` | role_type | NOT NULL | Rôle de l'utilisateur |
|
||||||
|
| `statut` | statut_utilisateur_type | DEFAULT 'en_attente' | Statut du compte |
|
||||||
|
| `telephone` | VARCHAR(20) | | Téléphone principal |
|
||||||
|
| `adresse` | TEXT | | Adresse complète |
|
||||||
|
| `photo_url` | TEXT | | URL de la photo de profil |
|
||||||
|
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
||||||
|
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
||||||
|
| `changement_mdp_obligatoire` | BOOLEAN | DEFAULT false | Force changement de MDP |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date de création |
|
||||||
|
| `modifie_le` | TIMESTAMPTZ | DEFAULT now() | Dernière modification |
|
||||||
|
| `ville` | VARCHAR(150) | | Ville |
|
||||||
|
| `code_postal` | VARCHAR(10) | | Code postal |
|
||||||
|
| `mobile` | VARCHAR(20) | | Téléphone mobile |
|
||||||
|
| `telephone_fixe` | VARCHAR(20) | | Téléphone fixe |
|
||||||
|
| `profession` | VARCHAR(150) | | Profession |
|
||||||
|
| `situation_familiale` | VARCHAR(50) | | Situation familiale |
|
||||||
|
| `date_naissance` | DATE | | Date de naissance |
|
||||||
|
|
||||||
|
**Contraintes** :
|
||||||
|
- Email validé par regex : `^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `assistantes_maternelles`
|
||||||
|
|
||||||
|
Extension de la table `utilisateurs` pour les assistantes maternelles.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id_utilisateur` | UUID | PRIMARY KEY, FK → utilisateurs(id) | Référence à l'utilisateur |
|
||||||
|
| `numero_agrement` | VARCHAR(50) | | Numéro d'agrément |
|
||||||
|
| `nir_chiffre` | CHAR(15) | | NIR (Sécurité sociale) |
|
||||||
|
| `nb_max_enfants` | INT | | Capacité maximale d'accueil |
|
||||||
|
| `biographie` | TEXT | | Présentation |
|
||||||
|
| `disponible` | BOOLEAN | DEFAULT true | Disponibilité |
|
||||||
|
| `ville_residence` | VARCHAR(100) | | Ville de résidence |
|
||||||
|
| `date_agrement` | DATE | | Date d'obtention de l'agrément |
|
||||||
|
| `annee_experience` | SMALLINT | | Années d'expérience |
|
||||||
|
| `specialite` | VARCHAR(100) | | Spécialités |
|
||||||
|
| `place_disponible` | INT | | Nombre de places disponibles |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE` (suppression si utilisateur supprimé)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `parents`
|
||||||
|
|
||||||
|
Extension de la table `utilisateurs` pour les parents.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id_utilisateur` | UUID | PRIMARY KEY, FK → utilisateurs(id) | Référence à l'utilisateur |
|
||||||
|
| `id_co_parent` | UUID | FK → utilisateurs(id) | Référence au co-parent (optionnel) |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `enfants`
|
||||||
|
|
||||||
|
Table des enfants pris en charge.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `statut` | statut_enfant_type | | Statut de l'enfant |
|
||||||
|
| `prenom` | VARCHAR(100) | | Prénom |
|
||||||
|
| `nom` | VARCHAR(100) | | Nom |
|
||||||
|
| `genre` | genre_type | | Genre |
|
||||||
|
| `date_naissance` | DATE | | Date de naissance |
|
||||||
|
| `date_prevue_naissance` | DATE | | Date prévue (si à naître) |
|
||||||
|
| `photo_url` | TEXT | | URL de la photo |
|
||||||
|
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
||||||
|
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
||||||
|
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `enfants_parents`
|
||||||
|
|
||||||
|
Table de liaison entre enfants et parents (relation N:N).
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id_parent` | UUID | FK → parents(id_utilisateur) | Référence au parent |
|
||||||
|
| `id_enfant` | UUID | FK → enfants(id) | Référence à l'enfant |
|
||||||
|
|
||||||
|
**Clé primaire composite** : `(id_parent, id_enfant)`
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `dossiers`
|
||||||
|
|
||||||
|
Dossiers de candidature des parents pour une assistante maternelle.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_parent` | UUID | FK → parents(id_utilisateur) | Parent demandeur |
|
||||||
|
| `id_enfant` | UUID | FK → enfants(id) | Enfant concerné |
|
||||||
|
| `presentation` | TEXT | | Présentation de la demande |
|
||||||
|
| `type_contrat` | VARCHAR(50) | | Type de contrat souhaité |
|
||||||
|
| `repas` | BOOLEAN | DEFAULT false | Demande de repas |
|
||||||
|
| `budget` | NUMERIC(10,2) | | Budget disponible |
|
||||||
|
| `planning_souhaite` | JSONB | | Planning souhaité (format JSON) |
|
||||||
|
| `statut` | statut_dossier_type | DEFAULT 'envoye' | Statut du dossier |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date de création |
|
||||||
|
| `modifie_le` | TIMESTAMPTZ | DEFAULT now() | Dernière modification |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `messages`
|
||||||
|
|
||||||
|
Messages échangés dans le cadre d'un dossier.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_dossier` | UUID | FK → dossiers(id) | Dossier lié |
|
||||||
|
| `id_expediteur` | UUID | FK → utilisateurs(id) | Expéditeur |
|
||||||
|
| `contenu` | TEXT | | Contenu du message |
|
||||||
|
| `re_redige_par_ia` | BOOLEAN | DEFAULT false | Message réécrit par IA |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date d'envoi |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. `contrats`
|
||||||
|
|
||||||
|
Contrats conclus entre parents et assistantes maternelles.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_dossier` | UUID | UNIQUE, FK → dossiers(id) | Dossier source (1:1) |
|
||||||
|
| `planning` | JSONB | | Planning défini (format JSON) |
|
||||||
|
| `tarif_horaire` | NUMERIC(6,2) | | Tarif horaire |
|
||||||
|
| `indemnites_repas` | NUMERIC(6,2) | | Indemnités repas |
|
||||||
|
| `date_debut` | DATE | | Date de début du contrat |
|
||||||
|
| `statut` | statut_contrat_type | DEFAULT 'brouillon' | Statut du contrat |
|
||||||
|
| `signe_parent` | BOOLEAN | DEFAULT false | Signature parent |
|
||||||
|
| `signe_am` | BOOLEAN | DEFAULT false | Signature assistante maternelle |
|
||||||
|
| `finalise_le` | TIMESTAMPTZ | | Date de finalisation |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date de création |
|
||||||
|
| `modifie_le` | TIMESTAMPTZ | DEFAULT now() | Dernière modification |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. `avenants_contrats`
|
||||||
|
|
||||||
|
Modifications apportées aux contrats existants.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_contrat` | UUID | FK → contrats(id) | Contrat modifié |
|
||||||
|
| `modifications` | JSONB | | Détails des modifications (JSON) |
|
||||||
|
| `initie_par` | UUID | FK → utilisateurs(id) | Utilisateur initiateur |
|
||||||
|
| `statut` | statut_avenant_type | DEFAULT 'propose' | Statut de l'avenant |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date de création |
|
||||||
|
| `modifie_le` | TIMESTAMPTZ | DEFAULT now() | Dernière modification |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. `evenements`
|
||||||
|
|
||||||
|
Événements liés au planning (absences, congés, etc.).
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `type` | type_evenement_type | | Type d'événement |
|
||||||
|
| `id_enfant` | UUID | FK → enfants(id) | Enfant concerné |
|
||||||
|
| `id_am` | UUID | FK → utilisateurs(id) | Assistante maternelle |
|
||||||
|
| `id_parent` | UUID | FK → parents(id_utilisateur) | Parent |
|
||||||
|
| `cree_par` | UUID | FK → utilisateurs(id) | Créateur de l'événement |
|
||||||
|
| `date_debut` | TIMESTAMPTZ | | Date de début |
|
||||||
|
| `date_fin` | TIMESTAMPTZ | | Date de fin |
|
||||||
|
| `commentaires` | TEXT | | Commentaires |
|
||||||
|
| `statut` | statut_evenement_type | DEFAULT 'propose' | Statut de l'événement |
|
||||||
|
| `delai_grace` | TIMESTAMPTZ | | Délai de grâce |
|
||||||
|
| `urgent` | BOOLEAN | DEFAULT false | Événement urgent |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date de création |
|
||||||
|
| `modifie_le` | TIMESTAMPTZ | DEFAULT now() | Dernière modification |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. `signalements_bugs`
|
||||||
|
|
||||||
|
Signalements de bugs par les utilisateurs.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_utilisateur` | UUID | FK → utilisateurs(id) | Utilisateur signalant |
|
||||||
|
| `description` | TEXT | | Description du bug |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date du signalement |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. `uploads`
|
||||||
|
|
||||||
|
Fichiers téléversés par les utilisateurs.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_utilisateur` | UUID | FK → utilisateurs(id), ON DELETE SET NULL | Utilisateur |
|
||||||
|
| `fichier_url` | TEXT | NOT NULL | URL du fichier |
|
||||||
|
| `type` | VARCHAR(50) | | Type de fichier |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date d'upload |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. `notifications`
|
||||||
|
|
||||||
|
Notifications envoyées aux utilisateurs.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_utilisateur` | UUID | FK → utilisateurs(id) | Destinataire |
|
||||||
|
| `contenu` | TEXT | | Contenu de la notification |
|
||||||
|
| `lu` | BOOLEAN | DEFAULT false | Statut de lecture |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date de création |
|
||||||
|
|
||||||
|
**Cascade** : `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. `validations`
|
||||||
|
|
||||||
|
Validations génériques de données utilisateur.
|
||||||
|
|
||||||
|
| Colonne | Type | Contraintes | Description |
|
||||||
|
|---------|------|-------------|-------------|
|
||||||
|
| `id` | UUID | PRIMARY KEY | Identifiant unique |
|
||||||
|
| `id_utilisateur` | UUID | FK → utilisateurs(id) | Utilisateur à valider |
|
||||||
|
| `type` | VARCHAR(50) | | Type de validation |
|
||||||
|
| `statut` | statut_validation_type | DEFAULT 'en_attente' | Statut |
|
||||||
|
| `cree_le` | TIMESTAMPTZ | DEFAULT now() | Date de demande |
|
||||||
|
| `modifie_le` | TIMESTAMPTZ | DEFAULT now() | Dernière modification |
|
||||||
|
| `valide_par` | UUID | FK → utilisateurs(id) | Validateur |
|
||||||
|
| `commentaire` | TEXT | | Commentaire du validateur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relations principales
|
||||||
|
|
||||||
|
```
|
||||||
|
utilisateurs (1) ──┬──> (1) assistantes_maternelles
|
||||||
|
├──> (1) parents
|
||||||
|
└──> (N) messages
|
||||||
|
|
||||||
|
parents (1) ───> (N) enfants_parents <─── (N) enfants
|
||||||
|
|
||||||
|
parents (1) ───> (N) dossiers <─── (1) enfants
|
||||||
|
dossiers (1) ───> (N) messages
|
||||||
|
dossiers (1) ───> (1) contrats
|
||||||
|
contrats (1) ───> (N) avenants_contrats
|
||||||
|
|
||||||
|
enfants (1) ───> (N) evenements
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Données initiales (SEED)
|
||||||
|
|
||||||
|
### Super Administrateur par défaut
|
||||||
|
|
||||||
|
**Email** : `admin@ptits-pas.fr`
|
||||||
|
**Mot de passe** : `4dm1n1strateur`
|
||||||
|
**Rôle** : `super_admin`
|
||||||
|
**Statut** : `actif`
|
||||||
|
|
||||||
|
> ⚠️ **Sécurité** : Le mot de passe est hashé avec bcrypt (`$2b$12$...`).
|
||||||
|
> Il est **impératif** de changer ce mot de passe en production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Les migrations sont gérées manuellement via le fichier SQL :
|
||||||
|
|
||||||
|
**Fichier** : `/database/migrations/01_init.sql`
|
||||||
|
|
||||||
|
### Appliquer les migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis le conteneur backend
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Ou manuellement depuis psql
|
||||||
|
psql -U admin -d ptitpas_db -f /database/migrations/01_init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accès à la base de données
|
||||||
|
|
||||||
|
### Via PgAdmin
|
||||||
|
|
||||||
|
**URL** : `https://app.ptits-pas.fr/pgadmin`
|
||||||
|
**Email** : `admin@ptits-pas.fr`
|
||||||
|
**Mot de passe** : `admin123`
|
||||||
|
|
||||||
|
**Configuration serveur** :
|
||||||
|
- Host : `ptitspas-postgres`
|
||||||
|
- Port : `5432`
|
||||||
|
- Database : `ptitpas_db`
|
||||||
|
- Username : `admin`
|
||||||
|
- Password : `admin123`
|
||||||
|
|
||||||
|
### Via terminal (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connexion au conteneur PostgreSQL
|
||||||
|
docker exec -it ptitspas-postgres psql -U admin -d ptitpas_db
|
||||||
|
|
||||||
|
# Lister les tables
|
||||||
|
\dt
|
||||||
|
|
||||||
|
# Voir le schéma d'une table
|
||||||
|
\d utilisateurs
|
||||||
|
|
||||||
|
# Quitter
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommandations de sécurité
|
||||||
|
|
||||||
|
1. ✅ **Mots de passe hashés** avec bcrypt
|
||||||
|
2. ✅ **Validation email** via regex
|
||||||
|
3. ⚠️ **Changer les credentials par défaut en production**
|
||||||
|
4. ⚠️ **Créer un utilisateur read-only pour les analytics**
|
||||||
|
5. ⚠️ **Activer SSL/TLS pour les connexions PostgreSQL**
|
||||||
|
6. ✅ **Utiliser des UUID** plutôt que des identifiants séquentiels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Backup de la base
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec ptitspas-postgres pg_dump -U admin ptitpas_db > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restauration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i ptitspas-postgres psql -U admin ptitpas_db < backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérifier la taille de la base
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT pg_size_pretty(pg_database_size('ptitpas_db'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : Novembre 2025
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user