Compare commits

..
Author SHA1 Message Date
jmartin 90d984ef92 docs(#117): CDC V1.4 + SRS gestion utilisateurs (depuis develop). 2026-09-15 10:22:33 +02:00
jmartinandCursor 89f65356d1 docs(#117): CDC V1.4 (users à jour, reste conservé) + SRS utilisateurs.
- CDC complet conservé ; sections gestion utilisateurs / dossiers alignées v0.1.0
- Archive V1.3 + EVOLUTIONS_CDC ; nouvelle 12_SRS-GESTION-UTILISATEURS
- INDEX / versions / bilan mis à jour

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 10:22:32 +02:00
jmartin aae2beeac8 docs: rationalisation post-0.1.0 + bilan (depuis develop). 2026-09-14 17:45:52 +02:00
jmartinandCursor 71b1897678 docs: rationalisation post-0.1.0 (bilan, purge tmp, archives).
- Bilan 29 + index versions 05 ; suivi tickets pointeur Gitea
- Purge docs/tmp et archive/temporaires livrés
- Archive SuperNounou, liste tickets figée, backlog Phase 2, notes 14/92
- Suppression stubs PROCEDURE/22 ; INDEX et roadmap alignés

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:45:43 +02:00
jmartinandCursor f745079f0a feat(#164): uniformisation modale staff gestionnaire / admin (squash develop).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:34:25 +02:00
jmartin 3218daa12e merge(#164): uniformisation modale staff gestionnaire / admin. 2026-09-14 17:29:31 +02:00
jmartinandCursor 1d6261b312 feat(#164): uniformise la modale staff (gestionnaire / admin).
Shell 930 + Validation*Field, dirty-state, StaffUserFormModal sous
widgets/dashboard/ ; retire AdminUserFormDialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 17:29:13 +02:00
jmartin 5b83102a59 feat(#152/#155): cleanup est_multiple + dashboard sans préfixe Admin (squash develop).
Suppression complète grossesse multiple / est_multiple (BDD, API, front).
Rename option C : widgets partagés et panels staff sous widgets/dashboard/,
AdminManagementWidget seul restant dans widgets/admin/.
2026-09-14 12:52:22 +02:00
jmartin 9557cf9947 merge(#155): rename Admin* + panels sous widgets/dashboard/. 2026-09-14 12:46:46 +02:00
jmartinandCursor 939e7777ac docs(#155): mini-spec phase 2 — move panels admin → dashboard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:47 +02:00
jmartinandCursor b474842e19 refactor(#155): phase 2 — panels staff sous widgets/dashboard/.
Déplace les panels/wizards/validation/common partagés hors de
widgets/admin/ ; ne conserve que AdminManagementWidget (variante A).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:23 +02:00
jmartinandCursor a312d9c0fa refactor(#155): option C — widgets partagés sans préfixe Admin.
Déplace les composants Admin* du dashboard partagé vers
widgets/dashboard/ (ChildDetailModal, AmEditModal, UserCard, Select*, …).
Conserve AdminManagementWidget et screens/administrateurs. Rename mécanique,
aucun changement de comportement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 10:57:58 +02:00
jmartin b5b32062b3 merge(#152): suppression grossesse multiple / est_multiple. 2026-09-14 10:57:28 +02:00
jmartinandCursor 946d8edcd2 feat(#152): suppression complète grossesse multiple / est_multiple.
Retire le champ partout : BDD (migration + seed), entity/DTO/services Nest,
modèles et payloads Flutter, scripts de test et docs. Back et front alignés
(forbidNonWhitelisted). Aucun fantôme de compat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 23:08:36 +02:00
jmartin eb5e4aa915 feat(#161): admin peut créer gestionnaire et administrateur (squash develop).
Ouvre POST/PATCH /gestionnaires et POST /users/admin aux administrateurs
(en plus du super_admin). Garde-fou front : pas de création staff sur le
dashboard gestionnaire.
2026-09-10 23:00:35 +02:00
jmartin c8cb82dd24 merge(#161): admin peut créer gestionnaire et administrateur. 2026-09-10 23:00:28 +02:00
jmartinandCursor 9cd180bf6a feat(#161): admin peut créer gestionnaire et administrateur.
Assouplit POST /gestionnaires, PATCH /gestionnaires/:id et POST /users/admin
(+ check createAdmin) pour ADMINISTRATEUR. Masque « Ajouter » staff sur le
dashboard gestionnaire. Tests droits Roles + createAdmin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:54:49 +02:00
jmartin d6d8b299dd feat(#159/#160): suppressions métier dashboard — API + UI (squash develop).
Matrice PO #154 : supprimer dossiers famille/AM, parents, enfants et comptes
staff avec règles métier (placements clôturés, sans_enfant, cascade foyer,
droits gestionnaire/admin). Poubelle en liste, dialogues de confirmation,
garde-fou gestionnaire→gestionnaire. Inclut polish hauteur modale fiche AM.
2026-09-10 22:43:33 +02:00
jmartin ca07d2e111 merge(#160): suppressions dashboard front + API #159. 2026-09-10 22:41:02 +02:00
jmartinandCursor 67336c64fe fix(#160): un gestionnaire ne peut plus supprimer un autre depuis la fiche.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:38:57 +02:00
jmartinandCursor 97afbbcf9a fix(am): hauteur modale fiche AM par onglet (sans scroll inutile).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:06 +02:00
jmartinandCursor e235b30140 feat(#160): suppressions dashboard — poubelle, confirmations et sans_enfant.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:05 +02:00
jmartinandCursor 7f23356273 feat(#159): suppressions métier dossiers/parents/enfants/AM/staff.
SuppressionService + DELETE /dossiers/:numero, cascades DELETE /users et
DELETE /enfants?deleteDossier, flag sans_enfant, specs front #160.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 18:01:44 +02:00
jmartinandCursor ea0e97d930 feat(#135): mode édition dossier + ajout co-parent (squash develop).
Wizard edit famille/AM, POST co-parent, PATCH enfants avec photo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 17:32:06 +02:00
jmartinandCursor c8c9cfbc4d feat(#135): mode édition dossier (PATCH fiche, co-parent, enfants).
Wizard edit famille/AM depuis la liste Dossiers ; save parents +
co-parent ; enfants existants en PATCH (photo multipart) sans POST doublon.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:07:23 +02:00
jmartinandCursor 530e896b66 feat(#135): POST /parents/:id/co-parent — ajout co-parent foyer.
API staff pour foyer mono-parent : compte actif, liens + enfants,
mail création MDP. Mini-specs front/back dans docs/tmp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 23:34:19 +02:00
jmartinandCursor 84e46162fd feat(#153): onglet permanent Dossiers — liste unifiée + pending.
Remplace l’onglet conditionnel « À valider » par un onglet Dossiers
(pending en haut, familles + AM en dessous), avec GET /dossiers,
recherche, et libellés pending NOM Prénom.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 19:41:44 +02:00
jmartinandCursor 0029c5ab86 fix(#153): peaufiner cartes dossiers et recherche.
Cartes neutres avec accent icône, photo AM si dispo, hint court
et infobulle de critères sur la barre de recherche.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 19:39:55 +02:00
jmartinandCursor 2ce9e9215f fix(#153): libellé pending-families en NOM Prénom.
Inclut le prénom dans string_agg et expose nom/prenom dans parents[].

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:41:25 +02:00
jmartinandCursor 3fdd913367 feat(#153): onglet permanent Dossiers (pending + liste unifiée).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:40:00 +02:00
jmartinandCursor 6708f73b06 feat(#153): GET /dossiers — liste unifiée familles + AM.
Endpoint staff pour l’onglet Dossiers : type, n°, libellé, emails,
statut, a_valider, filtre q. Complète GET /dossiers/:numero (#119).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:13:32 +02:00
jmartinandCursor f596f062a6 docs(#153): mini-spec front onglet Dossiers permanent.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:10:20 +02:00
jmartinandCursor 14580c34e0 feat(#129): création dossier famille staff — API + wizard dashboard.
Permet au dashboard de créer un dossier parent complet (pivot, co-parent
optionnel, enfants, n° dossier) déjà actif, avec e-mail de création de
mot de passe — miroir du parcours AM (#156), sans passer par l’inscription
publique en attente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:16:02 +02:00
jmartinandCursor 86701731e3 fix(#129): resserrer les champs enfant pour éviter le scroll.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:13:21 +02:00
jmartinandCursor b4abb7d6de fix(#129): peaufiner le wizard famille (co-parent, hauteurs, à naître).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:01:54 +02:00
jmartinandCursor cb5c1a5518 feat(#129): wizard admin création dossier famille (create/review).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 12:34:24 +02:00
jmartinandCursor 30ca99fb65 feat(#129): POST /parents/dossier — création dossier famille staff.
Factorise createParentDossier (actif + mail MDP) depuis l’inscription
publique qui reste en_attente + mail pending. Miroir #156 AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:59:09 +02:00
jmartinandCursor ae610733cc feat(#156): création dossier AM staff — API + wizard dashboard.
Squash depuis develop : POST /assistantes-maternelles/dossier (actif +
mail MDP), AmDossierWizard create/review, validations inscription.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:53:18 +02:00
jmartinandCursor 846afed86c feat(#158): affiliation enfant au foyer — attach/detach pivot + co-parent.
Squash depuis develop : un POST/DELETE propage les liens à tout le foyer ;
front un seul appel API ; doc empreinte.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:31:15 +02:00
jmartinandCursor 99a6c17c23 feat(#157): enfants sans responsable — alerte liste et détach dernier parent.
Squash depuis develop : détachement dernier parent autorisé, flag
sans_responsable, vigilance liste Enfants, rattachement foyer depuis
la fiche, polish détach et compteur foyer interim (#158 à suivre).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:43:58 +02:00
jmartinandCursor 04f49cb62f feat(#132): création enfant staff (onglet Enfants) — front + back.
Squash depuis develop : POST /enfants staff + parent_user_id, photo
multipart, modale création + sélection famille, fixes UX/birth_date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:00:12 +02:00
jmartinandCursor 3c7f4f6e16 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
Permet au gestionnaire de peupler la liste Relais (création/édition).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:54:17 +02:00
jmartinandCursor dcd407a3da feat(#146–#149): sélection enfant/AM, capacité max et case libre.
Modales de rattachement partagées, filtre Libre/Sans garde, recalcul des places à l'attach, désactivation si capacité pleine, clic sur case libre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:25:42 +02:00
jmartinandCursor fde63f8e72 feat(#145): lien co-parent cliquable dans la fiche parent.
Squash merge develop → master.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:56:45 +02:00
jmartinandCursor 1f8f1b9507 feat(#138,#143,#144): fiche enfant admin, consentement photo et fix gestionnaire.
Squash merge develop → master.
- #138 : modale enfant paysage, zone AM/scolarisation, liens responsables
- #144 : persistance consent_photo à l'inscription enfant
- #143 : masquer Supprimer sur la propre fiche gestionnaire

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:21:43 +02:00
147 changed files with 11040 additions and 3725 deletions
-1
View File
@@ -33,7 +33,6 @@ model Child {
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])
+2
View File
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module';
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
@Module({
imports: [
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
DocumentsLegauxModule,
RelaisModule,
DossiersModule,
SuppressionsModule,
],
controllers: [AppController],
providers: [
-3
View File
@@ -63,9 +63,6 @@ export class Children {
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
consent_photo_at?: Date;
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
is_multiple: boolean;
// Lien via table de jointure enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[];
+1 -1
View File
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
ParentsChildren,
]),
forwardRef(() => UserModule),
ParentsModule,
forwardRef(() => ParentsModule),
DossiersModule,
AppConfigModule,
MailModule,
+259 -27
View File
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service';
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
@Injectable()
export class AuthService {
@@ -416,11 +417,20 @@ export class AuthService {
}
/**
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
* Cœur partagé création dossier parent (#129).
* - public : statut en_attente + mails pending
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
*/
async inscrireParentComplet(dto: RegisterParentCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
async createParentDossier(
dto: RegisterParentCompletDto,
options: {
statut: StatutUtilisateurType;
sendPendingEmail: boolean;
sendPasswordSetupEmail: boolean;
requireCgu: boolean;
},
) {
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
}
@@ -471,7 +481,7 @@ export class AuthService {
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
statut: options.statut,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
@@ -496,7 +506,7 @@ export class AuthService {
prenom: dto.co_parent_prenom,
nom: dto.co_parent_nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
statut: options.statut,
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,
@@ -554,7 +564,6 @@ export class AuthService {
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant);
enfantsEnregistres.push(enfantEnregistre);
@@ -612,35 +621,261 @@ export class AuthService {
const numeroDossier = resultat.parent1.numero_dossier ?? '';
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
if (options.sendPendingEmail) {
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
numeroDossier,
);
}
} catch (err) {
this.logger.error(
"[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
}
if (options.sendPasswordSetupEmail) {
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.parent1.email,
prenom: resultat.parent1.prenom ?? '',
nom: resultat.parent1.nom ?? '',
token: resultat.tokenCreationMdp,
numeroDossier,
},
'parent',
);
if (resultat.parent2 && resultat.tokenCoParent) {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.parent2.email,
prenom: resultat.parent2.prenom ?? '',
nom: resultat.parent2.nom ?? '',
token: resultat.tokenCoParent,
numeroDossier,
},
'parent',
);
}
} catch (err) {
this.logger.error(
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
err instanceof Error ? err.stack : String(err),
);
}
}
const message = options.sendPasswordSetupEmail
? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.'
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
return {
message,
parent_id: resultat.parent1.id,
parent_user_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id ?? null,
co_parent_user_id: resultat.parent2?.id ?? null,
enfants_ids: resultat.enfants.map(e => e.id),
enfant_ids: resultat.enfants.map(e => e.id),
statut: options.statut,
numero_dossier: numeroDossier,
};
}
/**
* Inscription Parent publique — CDC (statut en_attente + mail pending).
*/
async inscrireParentComplet(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.EN_ATTENTE,
sendPendingEmail: true,
sendPasswordSetupEmail: false,
requireCgu: true,
});
}
/**
* Création dossier parent par le staff (#129) — actif + mail création MDP.
*/
async createParentDossierStaff(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.ACTIF,
sendPendingEmail: false,
sendPasswordSetupEmail: true,
requireCgu: false,
});
}
/**
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
*/
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
const pivotParent = await this.parentsRepo.findOne({
where: { user_id: pivotUserId },
relations: ['user', 'co_parent', 'parentChildren'],
});
if (!pivotParent?.user) {
throw new NotFoundException('Parent introuvable');
}
if (pivotParent.co_parent) {
throw new BadRequestException('Ce foyer a déjà un co-parent.');
}
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
if (!numeroDossier) {
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
}
const sameDossierCount = await this.parentsRepo.count({
where: { numero_dossier: numeroDossier },
});
if (sameDossierCount >= 2) {
throw new BadRequestException('Ce dossier a déjà deux responsables.');
}
const email = dto.email.trim().toLowerCase();
if (pivotParent.user.email.trim().toLowerCase() === email) {
throw new BadRequestException(
"L'email du co-parent doit être différent de celui du parent principal.",
);
}
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
if (emailExiste) {
throw new ConflictException("L'email du co-parent est déjà utilisé");
}
const memeAdresse = dto.meme_adresse ?? true;
if (!memeAdresse) {
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
throw new BadRequestException(
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
);
}
}
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 coParent: Users;
try {
coParent = await this.usersRepo.manager.transaction(async (manager) => {
const pivotUser = await manager.findOne(Users, {
where: { id: pivotUserId },
});
if (!pivotUser) {
throw new NotFoundException('Parent introuvable');
}
const pivotEntite = await manager.findOne(Parents, {
where: { user_id: pivotUserId },
relations: ['parentChildren'],
});
if (!pivotEntite) {
throw new NotFoundException('Parent introuvable');
}
const coUser = manager.create(Users, {
email: dto.email.trim(),
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
ville: memeAdresse ? pivotUser.ville : dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
});
const coUserSaved = await manager.save(Users, coUser);
pivotEntite.co_parent = coUserSaved;
pivotEntite.numero_dossier = numeroDossier;
await manager.save(Parents, pivotEntite);
const coEntite = manager.create(Parents, {
user_id: coUserSaved.id,
numero_dossier: numeroDossier,
});
coEntite.user = coUserSaved;
coEntite.co_parent = pivotUser;
await manager.save(Parents, coEntite);
const enfantIds = (pivotEntite.parentChildren ?? [])
.map((pc) => pc.enfantId)
.filter(Boolean);
for (const enfantId of enfantIds) {
const existing = await manager.findOne(ParentsChildren, {
where: { parentId: coUserSaved.id, enfantId },
});
if (existing) continue;
await manager.save(
ParentsChildren,
manager.create(ParentsChildren, {
parentId: coUserSaved.id,
enfantId,
}),
);
}
return coUserSaved;
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: coParent.email,
prenom: coParent.prenom ?? '',
nom: coParent.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'parent',
);
} catch (err) {
this.logger.error(
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
err instanceof Error ? err.stack : String(err),
);
}
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,
message:
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
numero_dossier: numeroDossier,
parent_user_id: pivotUserId,
co_parent_user_id: coParent.id,
statut: StatutUtilisateurType.ACTIF,
};
}
@@ -1151,9 +1386,6 @@ export class AuthService {
enfant.status = StatutEnfantType.A_NAITRE;
}
}
if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple;
}
if (enfantDto.consent_photo !== undefined) {
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo
@@ -55,11 +55,6 @@ export class EnfantInscriptionDto {
@IsString()
photo_filename?: string;
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
@IsOptional()
@IsBoolean()
grossesse_multiple?: boolean;
@ApiProperty({
example: true,
required: false,
@@ -0,0 +1,85 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersController', () => {
let controller: DossiersController;
const dossiersServiceMock = {
listDossiers: jest.fn(),
getDossierByNumero: jest.fn(),
};
const suppressionServiceMock = {
deleteDossier: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DossiersController],
providers: [
{ provide: DossiersService, useValue: dossiersServiceMock },
{ provide: SuppressionService, useValue: suppressionServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<DossiersController>(DossiersController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('list delegates to dossiersService.listDossiers with q', async () => {
dossiersServiceMock.listDossiers.mockResolvedValue([
{
type: 'famille',
numero_dossier: '2026-000043',
libelle: 'Claire MARTIN',
emails: ['claire@test.fr'],
user_ids: ['u1'],
statut: StatutUtilisateurType.ACTIF,
a_valider: false,
date_reference: null,
},
]);
const res = await controller.list('martin');
expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin');
expect(res).toHaveLength(1);
expect(res[0].numero_dossier).toBe('2026-000043');
});
it('getDossier delegates to getDossierByNumero', async () => {
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
type: 'family',
dossier: { numero_dossier: '2026-000001' },
});
const res = await controller.getDossier('2026-000001');
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
expect(res.type).toBe('family');
});
it('remove delegates to suppressionService.deleteDossier', async () => {
const user = { id: 'u1', role: 'gestionnaire' } as never;
suppressionServiceMock.deleteDossier.mockResolvedValue({
type: 'famille',
deleted_user_ids: [],
deleted_enfant_ids: [],
message: 'ok',
});
await controller.remove('2026-000001', user);
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
'2026-000001',
user,
);
});
});
@@ -1,17 +1,58 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
Controller,
Delete,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
import { RoleType, Users } from 'src/entities/users.entity';
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 { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
@ApiTags('Dossiers')
@ApiBearerAuth('access-token')
@Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard)
export class DossiersController {
constructor(private readonly dossiersService: DossiersService) {}
constructor(
private readonly dossiersService: DossiersService,
private readonly suppressionService: SuppressionService,
) {}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
description:
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
'Filtre optionnel `q` (n°, nom, email). Tri : à valider dabord, puis n° décroissant. ' +
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
})
@ApiQuery({
name: 'q',
required: false,
description: 'Recherche libre : n° dossier, libellé, email…',
})
@ApiResponse({ status: 200, type: [DossierListItemDto] })
@ApiResponse({ status: 403, description: 'Accès refusé' })
list(@Query('q') q?: string): Promise<DossierListItemDto[]> {
return this.dossiersService.listDossiers(q);
}
@Get(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@@ -23,4 +64,21 @@ export class DossiersController {
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
return this.dossiersService.getDossierByNumero(numeroDossier);
}
@Delete(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Supprimer un dossier (famille ou AM) — #159',
description:
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
})
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
remove(
@Param('numeroDossier') numeroDossier: string,
@User() currentUser: Users,
) {
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
}
}
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsModule } from '../parents/parents.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule,
SuppressionsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
@@ -0,0 +1,148 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DossiersService } from './dossiers.service';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersService.listDossiers', () => {
let service: DossiersService;
const parentsQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const amQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const parentsRepo = {
createQueryBuilder: jest.fn(() => parentsQb),
findOne: jest.fn(),
};
const amRepo = {
createQueryBuilder: jest.fn(() => amQb),
findOne: jest.fn(),
};
const parentsService = {
getDossierFamilleByNumero: jest.fn(),
};
const suppressionService = {
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DossiersService,
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: ParentsService, useValue: parentsService },
{ provide: SuppressionService, useValue: suppressionService },
],
}).compile();
service = module.get(DossiersService);
jest.clearAllMocks();
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
amRepo.createQueryBuilder.mockReturnValue(amQb);
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
});
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
parentsQb.getMany.mockResolvedValue([
{
user_id: 'p1',
numero_dossier: '2026-000010',
user: {
id: 'p1',
email: 'claire@test.fr',
prenom: 'Claire',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-01'),
},
co_parent: {
id: 'p2',
email: 'thomas@test.fr',
prenom: 'Thomas',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-02'),
},
},
{
user_id: 'p3',
numero_dossier: '2026-000020',
user: {
id: 'p3',
email: 'pending@test.fr',
prenom: 'Paul',
nom: 'Pending',
statut: StatutUtilisateurType.EN_ATTENTE,
cree_le: new Date('2026-02-01'),
},
co_parent: undefined,
},
]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const list = await service.listDossiers();
expect(list).toHaveLength(3);
expect(list[0].a_valider).toBe(true);
expect(list[0].type).toBe('famille');
expect(list[0].numero_dossier).toBe('2026-000020');
const famille = list.find((i) => i.numero_dossier === '2026-000010')!;
expect(famille.type).toBe('famille');
expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2']));
expect(famille.emails).toHaveLength(2);
expect(famille.libelle).toContain('MARTIN');
const am = list.find((i) => i.type === 'assistante_maternelle')!;
expect(am.numero_dossier).toBe('2026-000015');
expect(am.libelle).toContain('Marie');
});
it('filters with q', async () => {
parentsQb.getMany.mockResolvedValue([]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const hit = await service.listDossiers('dupont');
expect(hit).toHaveLength(1);
const miss = await service.listDossiers('zzz');
expect(miss).toHaveLength(0);
});
});
+169 -1
View File
@@ -3,12 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
/**
* Endpoint unifié GET /dossiers/:numeroDossier AM ou famille. Ticket #119.
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
*/
@Injectable()
export class DossiersService {
@@ -18,8 +21,173 @@ export class DossiersService {
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService,
private readonly suppressionService: SuppressionService,
) {}
/**
* Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier.
* Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur).
*/
async listDossiers(q?: string): Promise<DossierListItemDto[]> {
const items: DossierListItemDto[] = [
...(await this.listFamilleItems()),
...(await this.listAmItems()),
];
const needle = (q ?? '').trim().toLowerCase();
const filtered = needle
? items.filter((item) => this.matchesQuery(item, needle))
: items;
filtered.sort((a, b) => {
// À valider d'abord, puis n° dossier décroissant
if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1;
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
});
for (const item of filtered) {
if (item.type === 'famille') {
const n = await this.suppressionService.countEnfantsForNumero(
item.numero_dossier,
);
item.sans_enfant = n === 0;
} else {
item.sans_enfant = false;
}
}
return filtered;
}
private async listFamilleItems(): Promise<DossierListItemDto[]> {
const parents = await this.parentsRepository
.createQueryBuilder('p')
.innerJoinAndSelect('p.user', 'u')
.leftJoinAndSelect('p.co_parent', 'cp')
.where('p.numero_dossier IS NOT NULL')
.andWhere("TRIM(p.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, Parents[]>();
for (const p of parents) {
const num = (p.numero_dossier ?? '').trim();
if (!num) continue;
const group = byNum.get(num) ?? [];
group.push(p);
byNum.set(num, group);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, group] of byNum) {
const usersMap = new Map<string, Users>();
for (const p of group) {
if (p.user) usersMap.set(p.user.id, p.user);
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
}
const users = [...usersMap.values()].sort((a, b) => {
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
});
if (users.length === 0) continue;
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
const libelle =
names.length === 0
? `Dossier ${numero_dossier}`
: names.length === 1
? names[0]
: names.join(' & ');
const emails = users.map((u) => u.email).filter(Boolean);
const user_ids = users.map((u) => u.id);
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
const statut = a_valider
? StatutUtilisateurType.EN_ATTENTE
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
const date_reference = this.minCreeLeIso(users);
items.push({
type: 'famille',
numero_dossier,
libelle,
emails,
user_ids,
statut,
a_valider,
date_reference,
});
}
return items;
}
private async listAmItems(): Promise<DossierListItemDto[]> {
const ams = await this.amRepository
.createQueryBuilder('am')
.innerJoinAndSelect('am.user', 'u')
.where('am.numero_dossier IS NOT NULL')
.andWhere("TRIM(am.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, AssistanteMaternelle>();
for (const am of ams) {
const num = (am.numero_dossier ?? '').trim();
if (!num || !am.user) continue;
// Un n° = une AM ; garder le premier
if (!byNum.has(num)) byNum.set(num, am);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, am] of byNum) {
const u = am.user!;
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
items.push({
type: 'assistante_maternelle',
numero_dossier,
libelle,
emails: u.email ? [u.email] : [],
user_ids: [u.id],
statut: u.statut ?? StatutUtilisateurType.ACTIF,
a_valider,
date_reference: this.minCreeLeIso([u]),
});
}
return items;
}
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
const hay = [
item.numero_dossier,
item.libelle,
...item.emails,
item.statut,
item.type,
]
.join(' ')
.toLowerCase();
return hay.includes(needle);
}
private formatPersonName(u: Users): string {
const prenom = (u.prenom ?? '').trim();
const nom = (u.nom ?? '').trim();
const nomFmt = nom ? nom.toUpperCase() : '';
return [prenom, nomFmt].filter(Boolean).join(' ');
}
private minCreeLeIso(users: Users[]): string | null {
let min: Date | null = null;
for (const u of users) {
const d = u.cree_le;
if (!d) continue;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) continue;
if (!min || date < min) min = date;
}
return min ? min.toISOString() : null;
}
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
const num = numeroDossier?.trim();
if (!num) {
@@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Ligne de liste GET /dossiers (#153). */
export class DossierListItemDto {
@ApiProperty({
enum: ['famille', 'assistante_maternelle'],
description: 'Type de dossier',
})
type: 'famille' | 'assistante_maternelle';
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({
example: 'Claire MARTIN & Thomas MARTIN',
description: 'Libellé affiché (noms)',
})
libelle: string;
@ApiProperty({
type: [String],
example: ['claire@example.com', 'thomas@example.com'],
})
emails: string[];
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)',
})
user_ids: string[];
@ApiProperty({
enum: StatutUtilisateurType,
description:
'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier',
})
statut: StatutUtilisateurType;
@ApiProperty({
description: 'True si le dossier est en attente de validation (section haute UI)',
})
a_valider: boolean;
@ApiPropertyOptional({
nullable: true,
example: '2026-01-12T10:00:00.000Z',
description: 'Date de référence (MIN cree_le des users du dossier)',
})
date_reference: string | null;
@ApiPropertyOptional({
description:
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
})
sans_enfant?: boolean;
}
@@ -74,11 +74,6 @@ export class CreateEnfantsDto {
@IsDateString()
consent_photo_at?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
is_multiple: boolean;
/**
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132).
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
@ApiProperty({ example: false })
consent_photo: boolean;
@ApiProperty({ example: false })
is_multiple: boolean;
@ApiProperty({ example: 'UUID-parent' })
parent_id: string;
}
@@ -13,6 +13,7 @@ import {
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -23,6 +24,7 @@ import {
ApiBody,
ApiConsumes,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
@@ -36,6 +38,7 @@ import { User } from 'src/common/decorators/user.decorator';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { SuppressionService } from '../suppressions/suppression.service';
const photoMulterOptions = {
storage: diskStorage({
@@ -83,7 +86,10 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor {
@UseGuards(AuthGuard, RolesGuard)
@Controller('enfants')
export class EnfantsController {
constructor(private readonly enfantsService: EnfantsService) { }
constructor(
private readonly enfantsService: EnfantsService,
private readonly suppressionService: SuppressionService,
) { }
@Roles(
RoleType.PARENT,
@@ -141,17 +147,47 @@ export class EnfantsController {
RoleType.GESTIONNAIRE,
)
@Patch(':id')
@ApiOperation({
summary: 'Mettre à jour un enfant',
description:
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
})
@ApiConsumes('application/json', 'multipart/form-data')
@UseInterceptors(OptionalEnfantPhotoInterceptor)
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@User() currentUser: Users,
) {
return this.enfantsService.update(id, dto, currentUser);
return this.enfantsService.update(id, dto, currentUser, photo);
}
@Roles(RoleType.SUPER_ADMIN)
@Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@Delete(':id')
remove(@Param('id', new ParseUUIDPipe()) id: string) {
return this.enfantsService.remove(id);
@ApiOperation({
summary: 'Supprimer un enfant (#159)',
description:
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
})
@ApiQuery({
name: 'deleteDossier',
required: false,
description: 'Si true et dernier enfant : cascade dossier famille',
})
remove(
@Param('id', new ParseUUIDPipe()) id: string,
@Query('deleteDossier') deleteDossier: string | undefined,
@User() currentUser: Users,
) {
const flag =
deleteDossier === 'true' ||
deleteDossier === '1' ||
deleteDossier === 'yes';
return this.suppressionService.deleteEnfant(id, flag, currentUser);
}
}
+7 -4
View File
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
import { Parents } from 'src/entities/parents.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AuthModule } from '../auth/auth.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule
imports: [
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule,
SuppressionsModule,
],
controllers: [EnfantsController],
providers: [EnfantsService]
providers: [EnfantsService],
exports: [EnfantsService],
})
export class EnfantsModule { }
+13 -2
View File
@@ -92,7 +92,6 @@ export class EnfantsService {
photo_url: photoUrl,
consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
});
await this.childrenRepository.save(child);
@@ -195,7 +194,12 @@ export class EnfantsService {
// Mise à jour
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
async update(
id: string,
dto: Partial<CreateEnfantsDto>,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable');
@@ -205,6 +209,13 @@ export class EnfantsService {
patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
}
if (photoFile) {
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo !== false) {
patch.consent_photo = true;
patch.consent_photo_at = new Date();
}
}
await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser);
@@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto {
description: 'Consentement affichage photo (colonne consentement_photo)',
})
consent_photo?: boolean;
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
est_multiple?: boolean;
}
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
@ApiProperty()
email: string;
@ApiPropertyOptional({ nullable: true })
nom?: string | null;
@ApiPropertyOptional({ nullable: true })
prenom?: string | null;
@ApiPropertyOptional({ nullable: true })
telephone?: string | null;
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
}
export class PendingFamilyDto {
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
@ApiProperty({
example: 'MARTIN Claire - MARTIN Thomas',
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
})
libelle: string;
@ApiProperty({
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/:id/co-parent (#135). */
export class StaffAddCoParentResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
parent_user_id: string;
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
co_parent_user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
}
@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
/**
* Ajout dun co-parent sur un foyer existant (staff) — ticket #135.
* Corps sans préfixe `co_parent_*` (lURL cible déjà le pivot).
*/
export class StaffAddCoParentDto {
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Thomas' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2)
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2)
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0678456789' })
@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;
@ApiPropertyOptional({
example: true,
description: 'Si true, copie ladresse du parent pivot',
})
@IsOptional()
@IsBoolean()
meme_adresse?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
}
@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/dossier (#129). */
export class StaffCreateParentDossierResponseDto {
@ApiProperty()
message: string;
@ApiProperty({
example: '2026-000043',
description: 'Numéro de dossier famille attribué',
})
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
parent_user_id: string;
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description: 'UUID user du co-parent, ou null',
})
co_parent_user_id: string | null;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs des enfants créés',
})
enfant_ids: string[];
}
@@ -0,0 +1,29 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
/**
* Création dossier parent/famille par staff (#129).
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
* (acceptées côté serveur pour le compte du gestionnaire).
*/
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
'acceptation_cgu',
'acceptation_privacy',
] as const) {
@ApiPropertyOptional({
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_cgu?: boolean;
@ApiPropertyOptional({
description: 'Ignoré côté staff (privacy acceptée serveur).',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_privacy?: boolean;
}
@@ -1,18 +1,104 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('ParentsController', () => {
let controller: ParentsController;
const authServiceMock = {
createParentDossierStaff: jest.fn(),
addCoParentStaff: jest.fn(),
};
const parentsServiceMock = {};
const userServiceMock = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ParentsController],
}).compile();
providers: [
{ provide: ParentsService, useValue: parentsServiceMock },
{ provide: UserService, useValue: userServiceMock },
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ParentsController>(ParentsController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
authServiceMock.createParentDossierStaff.mockResolvedValue({
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
enfant_ids: ['e1'],
statut: StatutUtilisateurType.ACTIF,
numero_dossier: '2026-000043',
});
const body = {
email: 'parent.staff@test.fr',
prenom: 'Claire',
nom: 'MARTIN',
telephone: '0689567890',
enfants: [
{
prenom: 'Emma',
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
},
],
};
const res = await controller.createDossier(body as any);
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
expect.objectContaining({
email: body.email,
acceptation_cgu: true,
acceptation_privacy: true,
}),
);
expect(res.numero_dossier).toBe('2026-000043');
expect(res.parent_user_id).toBe('p1');
expect(res.co_parent_user_id).toBe('p2');
expect(res.enfant_ids).toEqual(['e1']);
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
it('addCoParent delegates to authService.addCoParentStaff', async () => {
authServiceMock.addCoParentStaff.mockResolvedValue({
message: 'ok',
numero_dossier: '2026-000043',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
statut: StatutUtilisateurType.ACTIF,
});
const body = {
email: 'coparent@test.fr',
prenom: 'Thomas',
nom: 'MARTIN',
telephone: '0678456789',
meme_adresse: true,
};
const res = await controller.addCoParent('p1', body as any);
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
expect(res.co_parent_user_id).toBe('p2');
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
});
@@ -3,6 +3,8 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
@@ -10,14 +12,27 @@ import {
} from '@nestjs/common';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.service';
import { Parents } from 'src/entities/parents.entity';
import { Users } from 'src/entities/users.entity';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.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';
@@ -26,14 +41,50 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
@ApiTags('Parents')
@ApiBearerAuth('access-token')
@Controller('parents')
@UseGuards(AuthGuard, RolesGuard)
export class ParentsController {
constructor(
private readonly parentsService: ParentsService,
private readonly userService: UserService,
private readonly authService: AuthService,
) {}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post('dossier')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
description:
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
'et envoie le-mail de création de mot de passe. ' +
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
})
@ApiBody({ type: StaffCreateParentDossierDto })
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
async createDossier(
@Body() dto: StaffCreateParentDossierDto,
): Promise<StaffCreateParentDossierResponseDto> {
const registerDto = {
...dto,
acceptation_cgu: true,
acceptation_privacy: true,
} as RegisterParentCompletDto;
const result = await this.authService.createParentDossierStaff(registerDto);
return {
message: result.message,
numero_dossier: result.numero_dossier,
parent_user_id: result.parent_user_id,
co_parent_user_id: result.co_parent_user_id ?? null,
statut: result.statut,
enfant_ids: result.enfant_ids,
};
}
@Get('pending-families')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
@@ -127,6 +178,28 @@ export class ParentsController {
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post(':id/co-parent')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
description:
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
})
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
@ApiBody({ type: StaffAddCoParentDto })
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 409, description: 'Email déjà pris' })
async addCoParent(
@Param('id') id: string,
@Body() dto: StaffAddCoParentDto,
): Promise<StaffAddCoParentResponseDto> {
return this.authService.addCoParentStaff(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity';
import { UserModule } from '../user/user.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
+22 -3
View File
@@ -251,7 +251,15 @@ export class ParentsService {
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,
string_agg(
UPPER(TRIM(u.nom))
|| CASE
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
THEN ' ' || INITCAP(TRIM(u.prenom))
ELSE ''
END,
' - ' ORDER BY u.nom, u.prenom, u.id
) AS libelle,
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier,
MIN(u.cree_le) AS date_soumission,
@@ -267,6 +275,8 @@ export class ParentsService {
json_build_object(
'id', u.id::text,
'email', u.email,
'nom', u.nom,
'prenom', u.prenom,
'telephone', u.telephone,
'code_postal', u.code_postal,
'ville', u.ville
@@ -317,11 +327,21 @@ export class ParentsService {
return [];
}
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
private normalizeParents(parents: unknown): {
id: string;
email: string;
nom: string | null;
prenom: string | null;
telephone: string | null;
code_postal: string | null;
ville: string | null;
}[] {
if (Array.isArray(parents)) {
return parents.map((p: any) => ({
id: String(p?.id ?? ''),
email: String(p?.email ?? ''),
nom: p?.nom != null ? String(p.nom) : null,
prenom: p?.prenom != null ? String(p.prenom) : null,
telephone: p?.telephone != null ? String(p.telephone) : null,
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
ville: p?.ville != null ? String(p.ville) : null,
@@ -350,7 +370,6 @@ export class ParentsService {
status: child.status,
photo_url: child.photo_url ?? undefined,
consent_photo: child.consent_photo,
est_multiple: child.is_multiple,
};
}
@@ -0,0 +1,175 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { SuppressionService } from './suppression.service';
import { RoleType } from 'src/entities/users.entity';
describe('SuppressionService (#159)', () => {
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
),
};
const usersRepository = {
findOne: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
};
const parentsRepository = {
findOne: jest.fn(),
find: jest.fn(),
query: jest.fn(),
};
const amRepository = {
findOne: jest.fn(),
};
const childrenRepository = {
findOne: jest.fn(),
delete: jest.fn(),
save: jest.fn(),
};
const parentsChildrenRepository = {
find: jest.fn(),
};
const amChildrenRepository = {
find: jest.fn(),
save: jest.fn(),
count: jest.fn(),
};
let service: SuppressionService;
const staff = {
id: 'staff-1',
role: RoleType.GESTIONNAIRE,
} as never;
const admin = {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never;
const superAdmin = {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never;
beforeEach(() => {
jest.clearAllMocks();
service = new SuppressionService(
dataSource as never,
usersRepository as never,
parentsRepository as never,
amRepository as never,
childrenRepository as never,
parentsChildrenRepository as never,
amChildrenRepository as never,
);
});
it('refuse self-delete', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
});
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('refuse gestionnaire deleting another gestionnaire', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'g2',
role: RoleType.GESTIONNAIRE,
});
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : refus si pas super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : OK pour super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('admin-2', superAdmin);
expect(res.deleted_user_ids).toEqual(['admin-2']);
});
it('delete AM : clos placements, pas denfants deleted', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'am-1',
role: RoleType.ASSISTANTE_MATERNELLE,
});
amRepository.findOne.mockResolvedValue({
user_id: 'am-1',
numero_dossier: '2026-000015',
});
amChildrenRepository.find.mockResolvedValue([
{
amId: 'am-1',
enfantId: 'e1',
child: { id: 'e1', status: 'garde' },
},
]);
amChildrenRepository.count.mockResolvedValue(0);
amChildrenRepository.save.mockImplementation(async (x) => x);
childrenRepository.save.mockResolvedValue({});
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('am-1', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['am-1']);
expect(res.type).toBe('assistante_maternelle');
});
it('delete dossier famille introuvable', async () => {
parentsRepository.findOne.mockResolvedValue(null);
amRepository.findOne.mockResolvedValue(null);
await expect(
service.deleteDossier('2026-999999', staff),
).rejects.toBeInstanceOf(NotFoundException);
});
it('co-parent : delete user seul', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'p2',
role: RoleType.PARENT,
});
parentsRepository.findOne.mockResolvedValue({
user_id: 'p2',
numero_dossier: '2026-000010',
co_parent: { id: 'p1' },
});
parentsRepository.query.mockResolvedValue([
{ id: 'p1' },
{ id: 'p2' },
]);
dataSource.transaction.mockImplementation(async (cb) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
);
const res = await service.deleteUser('p2', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['p2']);
expect(res.message).toMatch(/co-parent/i);
});
});
@@ -0,0 +1,420 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
export type SuppressionResult = {
type?: 'famille' | 'assistante_maternelle';
numero_dossier?: string;
deleted_user_ids: string[];
deleted_enfant_ids: string[];
dossier_supprime?: boolean;
message: string;
};
const STAFF_METIER: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
/**
* Cascades de suppression métier — tickets #154 / #159.
*/
@Injectable()
export class SuppressionService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
) {}
assertStaffMetier(currentUser: Users): void {
if (!STAFF_METIER.includes(currentUser.role)) {
throw new ForbiddenException('Accès refusé');
}
}
async deleteDossier(
numeroDossier: string,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const num = numeroDossier?.trim();
if (!num) {
throw new BadRequestException('Numéro de dossier requis.');
}
const parentHit = await this.parentsRepository.findOne({
where: { numero_dossier: num },
});
if (parentHit) {
return this.deleteFamilleByNumero(num);
}
const amHit = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (amHit?.user) {
return this.deleteAmUser(amHit.user.id);
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
async deleteUser(
id: string,
currentUser: Users,
): Promise<SuppressionResult> {
const target = await this.usersRepository.findOne({ where: { id } });
if (!target) {
throw new NotFoundException('Utilisateur introuvable');
}
if (target.id === currentUser.id) {
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
}
if (target.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
}
if (target.role === RoleType.PARENT) {
this.assertStaffMetier(currentUser);
return this.deleteParentUser(target.id);
}
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
this.assertStaffMetier(currentUser);
return this.deleteAmUser(target.id);
}
if (target.role === RoleType.GESTIONNAIRE) {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un gestionnaire.',
);
}
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Gestionnaire supprimé.',
};
}
if (target.role === RoleType.ADMINISTRATEUR) {
await this.assertCanDeleteAdministrateur(target, currentUser);
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Administrateur supprimé.',
};
}
throw new BadRequestException('Type dutilisateur non supprimable via cet endpoint.');
}
async deleteEnfant(
enfantId: string,
deleteDossier: boolean,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const child = await this.childrenRepository.findOne({
where: { id: enfantId },
relations: ['parentLinks', 'parentLinks.parent'],
});
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const parentIds = (child.parentLinks ?? [])
.map((l) => l.parentId ?? l.parent?.user_id)
.filter(Boolean) as string[];
let numero: string | undefined;
if (parentIds.length > 0) {
const parents = await this.parentsRepository.find({
where: { user_id: In(parentIds) },
});
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
}
if (!numero) {
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
message: 'Enfant supprimé.',
};
}
const siblingIds = await this.listEnfantIdsForNumero(numero);
const isLast = siblingIds.length <= 1;
if (isLast && deleteDossier) {
const result = await this.deleteFamilleByNumero(numero);
return {
...result,
dossier_supprime: true,
message: 'Dernier enfant et dossier famille supprimés.',
};
}
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
numero_dossier: numero,
type: 'famille',
message: isLast
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
: 'Enfant supprimé du dossier famille.',
};
}
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
const ids = await this.listEnfantIdsForNumero(numeroDossier);
return ids.length;
}
private async assertCanDeleteAdministrateur(
target: Users,
currentUser: Users,
): Promise<void> {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un administrateur.',
);
}
const adminCount = await this.usersRepository.count({
where: { role: RoleType.ADMINISTRATEUR },
});
if (adminCount <= 1) {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Seul le super administrateur peut supprimer le dernier administrateur.',
);
}
}
}
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
const parent = await this.parentsRepository.findOne({
where: { user_id: userId },
relations: ['co_parent'],
});
if (!parent) {
// Compte parent sans fiche — hard delete user
await this.usersRepository.delete(userId);
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Parent supprimé.',
};
}
const numero = parent.numero_dossier?.trim();
const foyerIds = numero
? await this.listParentUserIdsForNumero(numero)
: [userId];
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
if (!isLast) {
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
await this.dataSource.transaction(async (manager) => {
await manager.delete(ParentsChildren, { parentId: userId });
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
numero_dossier: numero,
type: 'famille',
message: 'Parent retiré du dossier (co-parent).',
};
}
// Dernier parent : + enfants
const enfantIds = numero
? await this.listEnfantIdsForNumero(numero)
: await this.listEnfantIdsForParent(userId);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: enfantIds,
numero_dossier: numero,
type: 'famille',
message: 'Dernier parent et enfants rattachés supprimés.',
};
}
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (parentIds.length === 0) {
throw new NotFoundException('Aucun parent pour ce dossier.');
}
const enfantIds = await this.listEnfantIdsForNumero(numero);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
[parentIds],
);
await manager.delete(Users, { id: In(parentIds) });
});
return {
type: 'famille',
numero_dossier: numero,
deleted_user_ids: parentIds,
deleted_enfant_ids: enfantIds,
message: 'Dossier famille supprimé.',
};
}
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
const am = await this.amRepository.findOne({ where: { user_id: userId } });
const numero = am?.numero_dossier?.trim();
const active = await this.amChildrenRepository.find({
where: { amId: userId, date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of active) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
await this.usersRepository.delete(userId);
return {
type: 'assistante_maternelle',
numero_dossier: numero,
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Dossier assistante maternelle supprimé.',
};
}
private async applySansGarde(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
const remaining = await this.amChildrenRepository.count({
where: { enfantId: child.id, date_fin: IsNull() },
});
if (remaining === 0) {
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
}
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
if (!enfantIds.length) return;
const links = await this.amChildrenRepository.find({
where: { enfantId: In(enfantIds), date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of links) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
}
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
const rows: Array<{ id: string }> = await this.parentsRepository.query(
`
SELECT DISTINCT x.id::text AS id FROM (
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
UNION
SELECT id_co_parent AS id FROM parents
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
UNION
SELECT p2.id_utilisateur AS id FROM parents p1
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
WHERE TRIM(p1.numero_dossier) = $1
) x WHERE x.id IS NOT NULL
`,
[numero],
);
return rows.map((r) => r.id);
}
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (!parentIds.length) return [];
return this.listEnfantIdsForParents(parentIds);
}
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
return this.listEnfantIdsForParents([parentId]);
}
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
const links = await this.parentsChildrenRepository.find({
where: { parentId: In(parentIds) },
});
return [...new Set(links.map((l) => l.enfantId))];
}
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { SuppressionService } from './suppression.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Users,
Parents,
AssistanteMaternelle,
Children,
ParentsChildren,
AmChildren,
]),
],
providers: [SuppressionService],
exports: [SuppressionService],
})
export class SuppressionsModule {}
@@ -1,20 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import 'reflect-metadata';
import { GestionnairesController } from './gestionnaires.controller';
import { GestionnairesService } from './gestionnaires.service';
import { RoleType } from 'src/entities/users.entity';
describe('GestionnairesController', () => {
let controller: GestionnairesController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GestionnairesController],
providers: [GestionnairesService],
}).compile();
controller = module.get<GestionnairesController>(GestionnairesController);
describe('GestionnairesController roles (#161)', () => {
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
it('should be defined', () => {
expect(controller).toBeDefined();
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
});
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
export class GestionnairesController {
constructor(private readonly gestionnairesService: GestionnairesService) { }
@Roles(RoleType.SUPER_ADMIN)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
@ApiBody({ type: CreateGestionnaireDto })
@Post()
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
@@ -43,7 +43,7 @@ export class GestionnairesController {
return this.gestionnairesService.findAll();
}
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
@ApiResponse({ status: 400, description: 'ID invalide' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -56,8 +56,8 @@ export class GestionnairesController {
return this.gestionnairesService.findOne(id);
}
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -1,20 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing';
import 'reflect-metadata';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { RoleType } from 'src/entities/users.entity';
describe('UserController', () => {
let controller: UserController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
providers: [UserService],
}).compile();
controller = module.get<UserController>(UserController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
describe('UserController roles (#161)', () => {
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
});
+20 -8
View File
@@ -10,18 +10,22 @@ import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
import { SuppressionService } from '../suppressions/suppression.service';
@ApiTags('Utilisateurs')
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) { }
constructor(
private readonly userService: UserService,
private readonly suppressionService: SuppressionService,
) { }
// Création d'un administrateur (réservée aux super admins)
// Création d'un administrateur (admin + super admin) — #161
@Post('admin')
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
createAdmin(
@Body() dto: CreateAdminDto,
@User() currentUser: Users
@@ -146,12 +150,20 @@ export class UserController {
return this.userService.suspendUser(id, currentUser, comment);
}
// Supprimer un utilisateur (super_admin uniquement)
// Supprimer un utilisateur — cascades métier #159
@Delete(':id')
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Supprimer un utilisateur' })
@Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@ApiOperation({
summary: 'Supprimer un utilisateur (cascades métier #159)',
description:
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
})
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
remove(@Param('id') id: string, @User() currentUser: Users) {
return this.userService.remove(id, currentUser);
return this.suppressionService.deleteUser(id, currentUser);
}
}
+2
View File
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [TypeOrmModule.forFeature(
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
GestionnairesModule,
MailModule,
AppConfigModule,
SuppressionsModule,
],
controllers: [UserController],
providers: [UserService],
+78 -9
View File
@@ -1,18 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { UserService } from './user.service';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
describe('UserService.createAdmin (#161)', () => {
const usersRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
describe('UserService', () => {
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UserService],
}).compile();
const dto = {
email: 'nouveau.admin@ptits-pas.fr',
password: 'Password1!',
prenom: 'Nina',
nom: 'Admin',
telephone: '0601020304',
};
service = module.get<UserService>(UserService);
beforeEach(() => {
jest.clearAllMocks();
service = new UserService(
usersRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
it('autorise un administrateur à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
const result = await service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never);
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
expect(usersRepository.save).toHaveBeenCalled();
});
it('autorise un super_admin à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
await expect(
service.createAdmin(dto as never, {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never),
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
});
it('refuse un gestionnaire (403 métier)', async () => {
await expect(
service.createAdmin(dto as never, {
id: 'gest-1',
role: RoleType.GESTIONNAIRE,
} as never),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersRepository.save).not.toHaveBeenCalled();
});
it('refuse un email déjà utilisé', async () => {
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
await expect(
service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+9 -2
View File
@@ -117,8 +117,14 @@ export class UserService {
}
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');
// #161 — admin et super_admin peuvent créer un administrateur
if (
currentUser.role !== RoleType.SUPER_ADMIN &&
currentUser.role !== RoleType.ADMINISTRATEUR
) {
throw new ForbiddenException(
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
);
}
const exist = await this.usersRepository.findOneBy({ email: dto.email });
@@ -520,6 +526,7 @@ export class UserService {
}
async remove(id: string, currentUser: Users): Promise<void> {
// Délégué historiquement ; préférer SuppressionService via controller (#159).
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');
}
+1 -2
View File
@@ -174,8 +174,7 @@ CREATE TABLE enfants (
date_prevue_naissance DATE,
photo_url TEXT,
consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ,
est_multiple BOOLEAN DEFAULT false
date_consentement_photo TIMESTAMPTZ
);
-- ==========================================================
+10 -10
View File
@@ -1,10 +1,10 @@
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,
1 id statut prenom nom genre date_naissance date_prevue_naissance photo_url consentement_photo date_consentement_photo est_multiple
2 5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf sans_garde Emma Dupont F 2020-06-01 False False
3 a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d sans_garde 2020-01-01 2025-01-01 False False
4 e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c sans_garde Emma Martin 2023-02-15 False False
5 e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d sans_garde Noah Martin 2023-02-15 False False
6 e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e sans_garde Léa Martin 2023-02-15 False False
7 e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f sans_garde Chloé Rousseau 2022-04-20 False False
8 e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a sans_garde Hugo Rousseau 2024-03-10 False False
9 e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b sans_garde Maxime Lecomte 2023-04-15 False False
10 edd19cd1-bb67-4f14-8a37-c66b75c94537 scolarise Lucas Durand H 2018-09-15 False False
@@ -0,0 +1,2 @@
-- #152 — Suppression grossesse multiple / est_multiple
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
+4 -4
View File
@@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
-- ------------------------------------------------------------
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
ON CONFLICT (id) DO NOTHING;
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false)
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------
+7 -7
View File
@@ -49,14 +49,14 @@ VALUES
ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
VALUES
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde'),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde'),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde'),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
+53 -70
View File
@@ -1,94 +1,77 @@
# 📚 Index de la Documentation - PtitsPas App
# Index de la documentation P'titsPas
Bienvenue dans la documentation complète de l'application PtitsPas.
Index de navigation du dépôt. Dernière révision : **septembre 2026** (CDC V1.4 + SRS users #117).
Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
## Produit & versions
## 📖 Table des matières
| Doc | Contenu |
|-----|---------|
| [01 — Cahier des charges V1.4](./01_CAHIER-DES-CHARGES.md) | CDC **complet** (cible) ; gestion utilisateurs alignée `v0.1.0` |
| [12 — SRS gestion utilisateurs](./12_SRS-GESTION-UTILISATEURS.md) | Spécification **technique** du domaine users / dossiers |
| [05 — Versions & milestones](./05_VERSIONS-ET-MILESTONES.md) | Semver Gitea + bilans |
| [29 — Bilan version 0.1.0](./29_BILAN-VERSION-0.1.0.md) | Tickets livrés 0.1.0 |
| [04 — Roadmap générale](./04_ROADMAP-GENERALE.md) | Vision phases long terme |
| [28 — Évolution famille / responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) | Limites modèle foyer / contournements |
### 📋 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 & infra
### 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
| Doc | Contenu |
|-----|---------|
| [02 — Architecture](./02_ARCHITECTURE.md) | Mono-repo, conteneurs |
| [03 — Déploiement](./03_DEPLOYMENT.md) | Deploy / CI-CD |
| [10 — Database](./10_DATABASE.md) | Schéma BDD |
| [11 — API](./11_API.md) | Endpoints REST |
| [21 — Configuration système](./21_CONFIGURATION-SYSTEME.md) | Config on-premise |
| [99 — Règles de codage](./99_REGLES-CODAGE.md) | Conventions |
### Planification
- [**04 - Roadmap Générale**](./04_ROADMAP-GENERALE.md) - Roadmap complète du projet (Phases 1 à 5+)
## Workflows & métier
### 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
- [**14 - Note backend config setup**](./14_NOTE-BACKEND-CONFIG-SETUP.md) - Setup configuration
- [**92 - Note backend gestionnaires**](./92_NOTE-BACKEND-GESTIONNAIRES.md) - Gestionnaires
- [**99 - Règles de codage**](./99_REGLES-CODAGE.md) - Conventions de code
| Doc | Contenu |
|-----|---------|
| [20 — Workflow création de compte](./20_WORKFLOW-CREATION-COMPTE.md) | Inscription / validation (détail historique) |
| [juridique/](./juridique/README.md) | CGU / CGC / privacy + [22 technique](./juridique/22_DOCUMENTS-LEGAUX.md) |
| [CHARTE_GRAPHIQUE.md](./CHARTE_GRAPHIQUE.md) | Charte UI |
### 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**](./juridique/22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning
## Projet & outillage
### Juridique (sources & technique)
- [**Dossier juridique**](./juridique/README.md) - Index : CGU/CGC en Markdown,
export PDF, lien vers la doc technique n°22
| Doc | Contenu |
|-----|---------|
| [23 — Suivi tickets](./23_SUIVI-TICKETS.md) | Pointeur Gitea |
| [24 — Décisions projet](./24_DECISIONS-PROJET.md) | ADR / décisions |
| [26 — API Gitea](./26_GITEA-API.md) | Issues, PR, milestones |
| [27 — Briefing frontend](./27_BRIEFING-FRONTEND.md) | Accès Git, priorités |
### Projet & suivi (Gitea / tickets)
- [**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
- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
## Audit
### Archive & convention de nommage
- [**Dossier archive**](./archive/README.md) - Fichiers **sans** `NN_` déplacés
(temporaires, obsolètes) ; règles de rangement et suppression
- Pointeur : [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md) → voir **26**
| Doc | Contenu |
|-----|---------|
| [90 — Audit YNOV](./90_AUDIT.md) | Analyse code étudiant |
### Exceptions de nommage (racine `docs/`)
Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
possible :
- `CHARTE_GRAPHIQUE.md`
- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
- `SuperNounou_SSS-001.md`
## Archive
### 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
| Emplacement | Usage |
|-------------|--------|
| [archive/](./archive/README.md) | Obsolete / temporaires |
| [archive/obsolete/](./archive/obsolete/) | CDC V1.3, EVOLUTIONS_CDC, SuperNounou, listes figées |
### Frontend (À créer)
- [**40 - Frontend Flutter**](./40_FRONTEND.md) - Structure de l'application mobile/web
## Données de test
### Audit & Analyse
- [**90 - Audit du projet YNOV**](./90_AUDIT.md) - Analyse complète du code étudiant et fonctionnalités
| Doc | Contenu |
|-----|---------|
| [test-data/](./test-data/README.md) | Jeux utilisateurs test |
## 🚀 Quick Start
## Quick start
```bash
# Cloner le projet
git clone ssh://gitea-jmartin/jmartin/app.git ptitspas-app
# Lancer l'environnement de développement
git clone … ptitspas-app
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
# Front https://app.ptits-pas.fr — API /api — PgAdmin /pgadmin
```
## 🔗 Liens utiles
## Liens
- **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 : Juin 2026
- Gitea : https://git.ptits-pas.fr/jmartin/petitspas
- Prod : https://app.ptits-pas.fr
Mainteneur : Julien Martin (julien.martin@ptits-pas.fr).
+155 -70
View File
@@ -1,14 +1,19 @@
---
title: "P'titsPas - Cahier des Charges Fonctionnel"
author: "Julien MARTIN"
date: "Novembre 2025"
version: "v1.3"
date: "Septembre 2026"
version: "v1.4"
---
# P'titsPas Cahier des Charges Fonctionnel
> **Objet :** Définir le périmètre fonctionnel, les rôles utilisateurs, les processus métiers et les exigences techniques de la plateforme P'titsPas, destinée à accompagner les collectivités locales dans la gestion de la garde denfants.
> **V1.4 (sept. 2026)** — Mise à jour de la **gestion des utilisateurs / dossiers / validation** pour coller au livré produit **`v0.1.0`**. Le reste du CDC (contrats, messagerie, agenda, paie, recherche AM, etc.) est **conservé** comme cible fonctionnelle.
> Détail technique du domaine utilisateurs : [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md).
> Bilan tickets `v0.1.0` : [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md).
> Archive V1.3 : [archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md](./archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md).
---
## Historique des versions
@@ -19,6 +24,7 @@ version: "v1.3"
| 1.1 | 24/04/2025 | Julien MARTIN | Ajouts : gestion multi-enfants, fin de contrat, tableau de bord étendu |
| 1.2 | 26/05/2025 | Julien MARTIN | Remplacement de "SuperNounou" par "P'titsPas" |
| 1.3 | 24/11/2025 | Julien MARTIN | Correction : retrait photo de profil parent (section 3.1.1) |
| **1.4** | **15/09/2026** | Julien MARTIN | **Gestion utilisateurs** alignée `v0.1.0` (dossiers, validation/refus/reprise, fiches staff, suppressions, retrait naissance multiple & SMS) ; reste du CDC inchangé en cible |
---
@@ -42,6 +48,8 @@ version: "v1.3"
### 3.4 Création dun administrateur
### 3.5 Fiche enfant
### 3.6 Authentification et sécurité
### 3.7 Numéro de dossier, validation, refus et reprise
### 3.8 Suppressions (vue métier)
## 4. Tableaux de bord
### 4.1 Vue densemble
@@ -60,15 +68,15 @@ version: "v1.3"
#### 4.3.5 Heures supplémentaires
#### 4.3.6 Messagerie
### 4.4 Tableau de bord des gestionnaires
#### 4.4.1 Comptes à valider
#### 4.4.2 Liste des utilisateurs
#### 4.4.1 Dossiers à valider
#### 4.4.2 Gestion des utilisateurs (partagée)
#### 4.4.3 Contrats
#### 4.4.4 Messagerie
#### 4.4.5 Événements RPE
#### 4.4.6 Alertes
### 4.5 Tableau de bord des administrateurs
#### 4.5.1 Menu Profil
#### 4.5.2 Gestion des utilisateurs
#### 4.5.2 Gestion des utilisateurs (partagée admin + gestionnaire)
#### 4.5.3 Gestion des enfants
#### 4.5.4 Paramètres de la plateforme
#### 4.5.5 Statistiques et supervision
@@ -193,18 +201,23 @@ Les assistantes maternelles peuvent :
Les gestionnaires (responsables de relais petite enfance) disposent dun tableau de bord de supervision. Ils peuvent :
- Valider ou rejeter les demandes de création de compte
- Suivre les mises en relation et les contrats
- Organiser des événements ou des rendez-vous
- Gérer les conflits ou les fins de contrat
- Valider ou refuser les **dossiers** (inscriptions parent / AM) et accompagner les reprises
- rer les usagers (fiches parent / AM / enfant, rattachements, dossiers) — socle partagé avec ladmin
- Suivre les mises en relation et les contrats (cible CDC)
- Organiser des événements ou des rendez-vous (cible CDC)
- Gérer les conflits ou les fins de contrat (cible CDC)
- Lancer des sondages ou modérer un blog RPE (si activé)
- Voir les historiques et statistiques liés à leur périmètre
Ils **ne créent pas** les comptes gestionnaire / administrateur (réservé à ladmin).
### 2.1.4 Administrateurs
Les administrateurs sont les représentants techniques et institutionnels de la collectivité (DSI ou agents désignés). Ils peuvent :
- Créer ou supprimer des comptes (gestionnaires, parents, assistantes maternelles)
- Tout ce que peut faire un gestionnaire sur les usagers / dossiers
- Créer ou supprimer des comptes **staff** (gestionnaires, administrateurs) selon garde-fous
- Créer ou supprimer des comptes usagers (parents, AM, enfants) selon droits
- Personnaliser linterface (logo, couleurs, nom de la ville)
- Activer ou désactiver des modules complémentaires
- Consulter les statistiques dusage
@@ -245,12 +258,15 @@ Le parcours de création dun compte parent seffectue en plusieurs étapes
**Note** : Le parent 2 ne définit **pas** de mot de passe lors de l'inscription. Il recevra un email avec un lien pour créer son mot de passe après validation du gestionnaire. Cette approche est particulièrement adaptée aux situations de parents séparés ou divorcés où la communication peut être difficile.
### 3.1.3 Informations sur l'enfant
- Un ou **plusieurs** enfants peuvent être ajoutés
- Prénom (facultatif si enfant à naître)
- Nom (hérité des parents)
- Genre (H / F) - obligatoire
- Genre (H / F / Autre) obligatoire
- Date de naissance ou **date prévisionnelle de naissance** (si l'enfant n'est pas encore né, un switch modifie le label)
- Photo obligatoire si l'enfant est né
- Rattachement automatique aux deux parents
- Photo (selon règles dinscription) et **consentement photo**
- Rattachement automatique au parent 1 et au parent 2 sil est renseigné
**Note V1.4** : il nexiste **pas** de champ « naissance multiple / jumeaux » (retiré du produit).
### 3.1.4 Présentation du dossier
- Zone de texte libre permettant aux parents de décrire leur situation
@@ -265,10 +281,12 @@ Le parcours de création dun compte parent seffectue en plusieurs étapes
### 3.1.6 Récapitulatif et validation
- Résumé des données saisies
- Vérification, puis envoi de la demande
- Attribution dun **numéro de dossier** (format type AAAA-NNNNNN)
- Les comptes parent sont soumis à validation par un gestionnaire avant activation
- Une fois validé, chaque parent (Parent 1 et Parent 2 si renseigné) reçoit un e-mail ou un SMS contenant un lien pour créer son mot de passe
- Le lien est valable pendant 7 jours
- Une fois validé, chaque parent (Parent 1 et Parent 2 si renseigné) reçoit un **e-mail** contenant un lien pour créer son mot de passe
- Le lien est valable pendant une durée limitée (jeton à usage unique)
- Une fois le mot de passe créé, le parent peut se connecter à son espace
- En cas de **refus**, le dossier nest pas supprimé : lusager peut **reprendre** sa demande (lien e-mail ou numéro de dossier) — voir §3.7
## 3.2 Création de compte assistante maternelle
@@ -298,7 +316,7 @@ Ce parcours est divisé en deux panneaux.
- Champ libre : message à destination du gestionnaire
- Permet de justifier une demande ou dajouter des précisions
### 3.1.4 Acceptation des CGU
### 3.2.4 Acceptation des CGU
- Les utilisateurs doivent cocher la case
« Jai lu et jaccepte les Conditions Générales dUtilisation et la Politique de confidentialité ».
- Un lien direct ouvre la version PDF des CGU.
@@ -307,23 +325,26 @@ Ce parcours est divisé en deux panneaux.
### 3.2.5 Récapitulatif et validation
- Résumé des données saisies
- Vérification, puis envoi de la demande
- Attribution dun **numéro de dossier**
- Validation par un gestionnaire requise avant activation
- Une fois validé, l'assistante maternelle reçoit un e-mail ou un SMS contenant un lien pour créer son mot de passe
- Le lien est valable pendant 7 jours
- Une fois validé, l'assistante maternelle reçoit un **e-mail** contenant un lien pour créer son mot de passe
- Le lien est valable pendant une durée limitée (jeton à usage unique)
- Une fois le mot de passe créé, l'assistante maternelle peut se connecter à son espace
- En cas de **refus** : reprise possible — voir §3.7
## 3.3 Création dun gestionnaire
- Réalisée par un administrateur
- Champs obligatoires : nom, prénom, adresse e-mail, mot de passe
- Affectation à un ou plusieurs relais petite enfance
- Le mot de passe doit être modifié lors de la première connexion
- Réalisée par un **administrateur** (ou super administrateur) — un gestionnaire ne crée pas de comptes staff
- Champs : nom, prénom, e-mail, téléphone, mot de passe, **relais principal** (optionnel)
- Le mot de passe peut être modifié à la première connexion si la politique lexige
- Formulaire unifié de création / édition (même présentation que pour les administrateurs)
## 3.4 Création dun administrateur
- Seuls les administrateurs existants peuvent créer de nouveaux comptes administrateurs
- Les droits sont équivalents (possibilité de restreindre par périmètre dans une version multi-mairies)
- Obligation de changer le mot de passe à la première connexion
- Seuls les **administrateurs** (et super administrateur) peuvent créer de nouveaux comptes administrateurs
- Champs : nom, prénom, e-mail, téléphone, mot de passe (pas de relais)
- Les droits sont équivalents entre administrateurs (le **super administrateur** est un compte dinstallation non supprimable)
- Obligation de changer le mot de passe à la première connexion si exigé
## 3.5 Fiche enfant
@@ -331,22 +352,68 @@ Chaque enfant est représenté par une fiche :
- Prénom (facultatif si enfant à naître)
- Nom
- Genre (H / F) - obligatoire
- Genre (H / F / Autre) obligatoire
- Date de naissance ou date prévisionnelle
- Photo (obligatoire si l'enfant est né ET si l'option *Photo obligatoire* est activée)
- Consentement photo enregistré : valeur booléenne + horodatage liés à l'accord donné par le parent
- Statut : à naître / actif / scolarisé
- Indication possible : jumeaux, triplés, etc.
- Possibilité de rattacher un enfant à plusieurs parents (garde alternée)
- Photo et consentement photo selon configuration (consentement tracé)
- Statut : à naître / actif / scolarisé (évolutions de libellés possibles ultérieurement)
- Responsables (parents) rattachés — liens vers les fiches parent
- Assistante maternelle de rattachement (le cas échéant)
**Note V1.4** : pas dindication « jumeaux / triplés » comme champ dédié.
Les fiches enfants sont :
- Accessibles depuis la fiche parent, la fiche AM, longlet **Enfants** du dashboard staff
- Créables par le staff pour un foyer existant
- Partagées entre les responsables rattachés
## 3.6 Authentification et sécurité
- Tous les comptes utilisent une combinaison adresse e-mail + mot de passe
- Les gestionnaires et administrateurs doivent modifier leur mot de passe à la première connexion
- Des mécanismes de récupération sont disponibles en cas de perte
- Authentification par e-mail + mot de passe
- Comptes **en attente** ou **suspendus** : connexion refusée
- Les gestionnaires et administrateurs doivent modifier leur mot de passe à la première connexion (si exigé)
- **Création de mot de passe** post-validation : lien e-mail (jeton TTL, usage unique)
- **Mot de passe oublié** : parcours distinct (demande → e-mail → réinitialisation)
- Pas denvoi de lien de mot de passe par SMS (canal **e-mail** uniquement)
Un lien direct vers les **Mentions légales** et la **Politique de confidentialité** est accessible en permanence depuis le pied de page, y compris avant la connexion.
## 3.7 Numéro de dossier, validation, refus et reprise
### Numéro de dossier
- Format type **AAAA-NNNNNN**
- Affiché dans les listes staff, les modales et les e-mails concernés
- Sert de clé métier pour validation, refus et reprise
### Validation
- Le staff (gestionnaire / administrateur) examine le dossier (wizard de revue)
- Action **Valider** : activation du circuit comptes + e-mails de création de mot de passe
### Refus
- Action **Refuser** : le dossier est refusé **sans suppression** des données
- Lusager est informé par e-mail et peut reprendre sa demande
### Reprise
- Via le **lien** reçu par e-mail, ou depuis l’écran de connexion avec le **numéro de dossier**
- Formulaire prérempli / correction des informations
- Nouvelle soumission → retour en file « à valider »
### Création et édition de dossiers par le staff
- En plus de linscription publique, le staff peut **créer** un dossier famille ou AM (wizard)
- Le staff peut **éditer** un dossier existant (y compris ajout dun 2ᵉ parent pour une famille mono-parent)
## 3.8 Suppressions (vue métier)
Sous réserve des droits (détail technique : [SRS gestion utilisateurs](./12_SRS-GESTION-UTILISATEURS.md)) :
| Cible | Principe |
|-------|----------|
| Parent, AM, enfant, dossier | Gestionnaire, administrateur, super admin — avec confirmation |
| Gestionnaire | Administrateur / super admin uniquement |
| Administrateur | Admin / super admin, hors soi-même, hors cible super admin ; garde-fou sur le dernier administrateur |
| Super administrateur | Non supprimable |
Un gestionnaire ne peut pas supprimer **son propre** compte.
# 4. Tableaux de bord
Chaque rôle utilisateur dispose dun tableau de bord personnalisé, adapté à ses fonctions dans la plateforme. Ces interfaces sont pensées pour être lisibles, fonctionnelles et évolutives.
@@ -357,8 +424,8 @@ Chaque rôle utilisateur dispose dun tableau de bord personnalisé, adapté
|------------------------|-------------------------------------------------------------------|
| Parents | Recherche d'assistante maternelle, gestion des enfants, contrat, agenda |
| Assistantes maternelles| Dossiers reçus, enfants accueillis, heures sup, agenda, messagerie|
| Gestionnaires | Validation de comptes, contrats, messagerie, événements RPE |
| Administrateurs | Paramètres globaux, gestion des utilisateurs, statistiques |
| Gestionnaires | Dossiers à valider, gestion utilisateurs/enfants (partagée), contrats, messagerie, événements RPE |
| Administrateurs | Paramètres globaux, même gestion utilisateurs (droits staff élargis), statistiques |
Les tableaux de bord intègrent :
- Une **barre de navigation supérieure** (liens de navigation rapide)
@@ -502,17 +569,24 @@ Lassistante maternelle accède à une interface dédiée à la gestion de ses
Le gestionnaire RPE dispose dune vision transversale sur les utilisateurs, les dossiers et les activités de la structure. Son rôle est d'accompagner, superviser, et arbitrer si nécessaire.
### 4.4.1 Comptes à valider
### 4.4.1 Dossiers à valider
- File dattente des demandes de création de compte
- Détails de chaque demande : parent, assistante maternelle
- Actions possibles : Valider / Refuser / Demander des précisions
- File dattente des **dossiers** (famille / AM) en attente de validation
- Affichage du **numéro de dossier** et des informations essentielles
- Ouverture en **revue** (wizard étapes) : Valider / Refuser
- Le refus nefface pas le dossier : lusager peut reprendre (voir §3.7)
- Longlet **Dossiers** du dashboard regroupe aussi une **liste unifiée** des dossiers (au-delà de la seule file « à valider »)
### 4.4.2 Liste des utilisateurs
### 4.4.2 Gestion des utilisateurs (partagée)
- Filtres par rôle, statut, date dinscription
- Accès rapide aux informations et historiques
- Possibilité de contacter un utilisateur
> **V1.4** — La gestion opérationnelle des usagers (parents, AM, enfants, dossiers) est **partagée** entre gestionnaire et administrateur via le même dashboard. Seule la gestion des **comptes staff** (création / suppression gestionnaire ou admin) est réservée à ladministrateur.
- Onglets : Parents, Assistantes maternelles, Enfants, Gestionnaires (selon droits), Administrateurs (admin)
- Accès aux **fiches** (identité, rattachements, capacité AM, etc.)
- Création de dossiers / enfants par le staff
- Filtres et recherche ; ouverture des fiches depuis les cartes / listes
Le détail des panneaux admin historiques est décrit en §4.5.2 (même socle fonctionnel).
### 4.4.3 Contrats
@@ -566,47 +640,56 @@ Le menu profil de ladministrateur comprend uniquement :
Toutes les fonctionnalités de gestion sont accessibles via des onglets distincts dans le tableau de bord.
### 4.5.2 Gestion des utilisateurs
### 4.5.2 Gestion des utilisateurs (partagée admin + gestionnaire)
Ladministration des utilisateurs est organisée par panneaux distincts pour chaque type de profil.
Ladministration des utilisateurs est organisée par panneaux / onglets. **Admin et gestionnaire** partagent les panneaux usagers ; ladmin dispose en plus des droits staff.
#### a. Gestion des gestionnaires
- Liste des gestionnaires existants
- Création (nom, prénom, e-mail, mot de passe)
- Attribution à un ou plusieurs RPE
- Réinitialisation du mot de passe
- Suppression du compte
- Création / édition (nom, prénom, e-mail, téléphone, mot de passe, relais principal) — **administrateur uniquement** pour la création
- Réinitialisation / nouveau mot de passe en édition
- Suppression du compte — **administrateur uniquement** (pas dauto-suppression)
#### b. Gestion des parents
- Liste complète des parents enregistrés
- Recherche par nom, statut, enfants associés
- Modification des informations
- Suppression dun compte
- Consultation du statut des dossiers liés
- Liste des parents enregistrés
- Ouverture de la **fiche parent** (identité, statut, enfants, lien co-parent)
- Rattacher / détacher un enfant
- Création de dossier famille (wizard staff)
- Suppression dun compte / dossier selon droits (§3.8)
#### c. Gestion des assistantes maternelles
- Liste des assistantes avec numéro dagrément
- Modification ou suppression dun compte
- Filtrage par zone géographique ou capacité
- Liste des AM (agrément, capacité, etc.)
- **Fiche AM** (identité, professionnel, enfants accueillis)
- Respect de la **capacité max** pour les rattachements
- Création de dossier AM (wizard staff)
- Suppression selon droits
#### d. Gestion des administrateurs
- Création de nouveaux comptes administrateurs
- Création de nouveaux comptes administrateurs — **administrateur / super admin**
- Suivi des droits
- Obligation de modification du mot de passe à la première connexion
- Obligation de modification du mot de passe à la première connexion si exigé
- Suppression avec garde-fous (pas soi-même, pas le super admin, dernier admin)
#### e. Onglet Dossiers
- Liste unifiée + dossiers à valider
- Revue / édition des dossiers (wizards)
### 4.5.3 Gestion des enfants
Deux accès possibles à la gestion des enfants :
#### a. Par fiche parent
- Consultation et édition des enfants associés à chaque parent
#### a. Par fiche parent (ou fiche AM)
- Consultation et édition des enfants associés
- Rattacher / détacher depuis la fiche
#### b. Vue globale “Enfants”
- Liste complète avec :
- Nom, prénom, date de naissance ou prévisionnelle
- Statut (à naître, actif, scolarisé)
- Parents associés
- Contrat en cours (si applicable)
- Parents associés (alerte si **sans responsable**)
- AM associée le cas échéant
- Contrat en cours (si applicable — module contrats)
- Création dun enfant rattaché à un **foyer existant**
- Possibilité de modifier ou supprimer une fiche enfant
### 4.5.4 Paramètres de la plateforme
@@ -905,10 +988,10 @@ Certains outils de la plateforme sont accessibles à plusieurs profils et favori
- Le gestionnaire (à titre dinformation)
- Contient :
- Identité
- Photo (obligatoire si né)
- Photo (selon configuration / consentement)
- Statut (à naître / actif / scolarisé)
- Parents associés
- Mention sil sagit de jumeaux, triplés, etc.
- AM associée le cas échéant
## 6.4 Contrats et avenants
@@ -1185,11 +1268,11 @@ P'titsPas sinscrit dans une démarche de service public, avec des fondements
| **Gestionnaires** | Suivi global des situations, outils de médiation, organisation d’événements |
| **Administrateurs** | Supervision complète, gouvernance multi-rôle, contrôle des données |
## 10.3 Périmètre fonctionnel de la V1
## 10.3 Périmètre fonctionnel
Fonctionnalités incluses dès la première version :
- Création de comptes
- Recherche et sélection de nounous
**Cible CDC (plateforme complète)** — fonctionnalités décrites dans ce document :
- Création de comptes et gestion des utilisateurs / dossiers
- Recherche et sélection dassistantes maternelles
- Suivi des dossiers
- Génération de contrat et avenants
- Messagerie
@@ -1198,6 +1281,8 @@ Fonctionnalités incluses dès la première version :
- Gestion des heures supplémentaires
- Suivi RGPD et administration
**Livré produit `v0.1.0`** (voir [bilan](./29_BILAN-VERSION-0.1.0.md)) : cœur **gestion des utilisateurs** — inscription, validation/refus/reprise, auth, dashboard staff (dossiers, fiches, rattachements, suppressions). Les autres modules du CDC restent la **feuille de route**.
## 10.4 Perspectives
La structure du produit permet une montée en charge progressive :
+15 -15
View File
@@ -44,22 +44,21 @@ Les **Phases 2, 3, 4+** sont des **ébauches indicatives** qui seront affinées
- ✅ Logging & Monitoring
- ✅ Tests & Documentation
### Versions incrémentales
### Versions incrémentales (semver / Gitea)
| 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** |
La table historique « ~21 tickets / 0.1.0 » est **obsolète**.
État réel des milestones, bilans et tag : **[05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)**.
### Livrable
| Version | Statut (sept. 2026) |
|---------|---------------------|
| **0.1.0** | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) (48 tickets fermés) |
| **0.2.0+** | Ouvertes — voir Gitea + doc 05 |
Application installable avec création et validation de comptes utilisateurs.
### Livrable Phase 1 (visée)
**Référence** : [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
Application installable avec création et validation de comptes utilisateurs, puis enrichissements dashboard / dossiers (0.1.0 livré).
**Tickets** : Gitea — pointeur [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md).
---
@@ -216,7 +215,7 @@ Suivi quotidien des enfants + Fonctionnalités complémentaires.
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)
**Référence** : [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) (figé) + [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)
---
@@ -317,9 +316,10 @@ Exemples :
- [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
- [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md) / [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) — suivi Gitea + bilan 0.1.0
- [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
- [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md) — milestones Gitea
- [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) — backlog Phase 2 figé
---
+64
View File
@@ -0,0 +1,64 @@
# Versions & milestones — P'titsPas
**Source de vérité tickets** : Gitea [`jmartin/petitspas`](https://git.ptits-pas.fr/jmartin/petitspas)
**Bilans de version** : documents `29_BILAN-…` (et suivants)
Ce fichier remplace, pour le **semver / milestones**, les anciennes tables figées de la roadmap Phase 1.
---
## État des milestones
| Milestone | Rôle | Statut |
|-----------|------|--------|
| **0.1.0** | MVP opérable (auth, inscription, dashboard dossiers/fiches, suppressions, cleanups) | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) |
| **0.2.0** | Suite produit (ex. recherche / échanges — sans contrat) | Ouverte |
| **0.3.0** | Contrat + planning | Ouverte |
| **0.4.0** | Carnet de liaison | Ouverte |
| **0.9.0** | Hors périmètre cleanup 0.1.0 (doublons, upload, tech auth/photos, UX erreurs…) | Ouverte |
| **1.0.0** | Release majeure Phase 1 (critères PO) | Réserve |
| **Backlog transverse** | Doc étendue, CI/tests, RGPD avancé, monitoring — hors semver dédié | Ouverte |
Liens Gitea : [milestones](https://git.ptits-pas.fr/jmartin/petitspas/milestones).
---
## Bilans
| Version | Document |
|---------|----------|
| 0.1.0 | [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) |
## Documents produit de référence (post-0.1.0)
| Doc | Rôle |
|-----|------|
| [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) | CDC **complet** V1.4 (users mis à jour ; reste = cible) |
| [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md) | SRS technique domaine utilisateurs |
| Archive CDC V1.3 | [archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md](./archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md) |
---
## Relation avec la roadmap phases
La vision long terme (Phases 25 : mise en relation, contrats, carnet…) reste dans [04_ROADMAP-GENERALE.md](./04_ROADMAP-GENERALE.md).
Les **jalons livrables** se gèrent ici + dans Gitea.
Ancien backlog « Phase 2 » technique ([archive](./archive/obsolete/25_PHASE-2-BACKLOG.md)) : à croiser avec les milestones ci-dessus ; ne plus maintenir en double.
---
## Suivi des tickets
- **Création / état** : Gitea uniquement.
- **Mémoire dune version livrée** : bilan `29_…` (pas de re-copie exhaustive dans un fichier tickets).
- Ancienne liste figée Phase 1 : [archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md).
- Pointeur court : [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md).
---
## Tag Git
| Tag | Condition |
|-----|-----------|
| `v0.1.0` | Milestone 0.1.0 fermée + bilan mergé sur `master` |
-1
View File
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
| `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 |
---
+250
View File
@@ -0,0 +1,250 @@
# SRS — Gestion des utilisateurs (domaine livré v0.1.0)
**Version** : 1.0
**Date** : 15/09/2026
**Ticket** : [#117](https://git.ptits-pas.fr/jmartin/petitspas/issues/117)
**Niveau** : **technique** (dev / QA)
**CDC associé** : [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) (V1.4 — **CDC complet** ; cette SRS ne couvre que le domaine utilisateurs)
> Périmètre SRS : comptes, auth liée, dossiers famille/AM, enfants, affiliations, dashboard staff, suppressions.
> Le CDC V1.4 conserve **lensemble** des chapitres cibles (contrats, messagerie, etc.) ; ils ne sont **pas** redécrits ici.
> Hors SRS technique : OpenAPI exhaustif, PRA/CI → voir [11_API](./11_API.md), [10_DATABASE](./10_DATABASE.md).
---
## 1. Glossaire
| Terme | Définition |
|-------|------------|
| **User / utilisateur** | Compte authentifiable (`utilisateurs`) avec `role` et `statut` |
| **Parent pivot** | Responsable principal du foyer à linscription / création staff |
| **Co-parent** | Second responsable optionnel (au plus un), lié au pivot |
| **Foyer** | Pivot + co-parent éventuel + enfants affiliés |
| **Dossier** | Unité métier identifiée par `numero_dossier` (format `AAAA-NNNNNN`) — famille ou AM |
| **Pending / en_attente** | Compte ou dossier en attente de validation staff |
| **Refus** | Rejet sans suppression ; ouvre la **reprise** |
| **Relais** | Structure RPE ; rattachement optionnel dun gestionnaire |
| **Staff** | `gestionnaire`, `administrateur`, `super_admin` |
---
## 2. Acteurs et rôles applicatifs
| `role` | Inscription publique | Dashboard staff | Créer staff | Suppressions métier usagers | Supprimer gestionnaire | Supprimer admin |
|--------|---------------------|-----------------|-------------|----------------------------|------------------------|-----------------|
| `parent` | oui | non | non | non | non | non |
| `assistante_maternelle` | oui | non | non | non | non | non |
| `gestionnaire` | non (créé staff) | oui | non | oui* | non | non |
| `administrateur` | non | oui | oui | oui* | oui | oui** |
| `super_admin` | non (install) | oui | oui | oui* | oui | oui** |
\* Sous réserve des garde-fous (pas soi-même pour sa fiche staff, etc.).
\*\* Pas soi-même ; pas de cible `super_admin` ; dernier admin : règles `canDeleteAdministrateur` (front `staff_deletion_rights.dart`).
Réf. front : `frontend/lib/utils/staff_deletion_rights.dart`
- `canCreateStaffAccounts` → admin / super_admin
- `canDeleteMetier` → gestionnaire / admin / super_admin
- `canDeleteGestionnaire` → admin / super_admin
---
## 3. Statuts
### 3.1 Utilisateur (`statut_utilisateur_type`)
| Statut | Signification |
|--------|----------------|
| `en_attente` | Inscrit, non validé ; **login refusé** |
| `actif` | Validé / utilisable |
| `suspendu` | Bloqué ; **login refusé** |
### 3.2 Enfant (`statut_enfant_type`)
Valeurs actuelles : `a_naitre`, `actif`, `scolarise` (évolution métier « gardé / sans garde » **hors** cette SRS — ticket dédié).
### 3.3 Validation dossier
Circuit staff : revue → **valider** ou **refuser**. Refus ≠ delete. Reprise → nouveau passage en attente.
---
## 4. Modèle de données (vue synthétique)
```mermaid
flowchart TB
user[utilisateurs]
parent[parents]
am[assistantes_maternelles]
enfant[enfants]
ep[enfants_parents]
df[dossier_famille]
user --> parent
user --> am
parent --> ep
enfant --> ep
parent --> df
```
| Concept | Tables / liens clés |
|---------|---------------------|
| Compte | `utilisateurs` (role, statut, email, numero_dossier, relais…) |
| Parent | `parents` (+ `id_co_parent` optionnel) |
| AM | `assistantes_maternelles` (agrément, capacité, NIR…) |
| Enfant | `enfants` |
| Affiliation parent | `enfants_parents` (NN) |
| Dossier famille | `dossier_famille` (+ enfants dossier) |
| Tokens MDP | tables / flux tokens création & reset (TTL, usage unique) |
Détail colonnes : [10_DATABASE.md](./10_DATABASE.md).
### Invariants
1. **Pas** de champ `est_multiple` / naissance multiple (#152).
2. Au plus **un** co-parent par fiche parent.
3. Affiliation création enfant : rattache **pivot + co-parent** sil existe (#158).
4. Détachement du **dernier** parent → enfant **sans responsable** possible (#157).
5. Rattachement AM plafonné par **capacité** (#148 / #149).
6. `super_admin` **non supprimable**.
---
## 5. Flux
### 5.1 Inscription → validation → mot de passe
```mermaid
sequenceDiagram
participant U as Usager
participant API as API
participant S as Staff
participant M as Mail
U->>API: register parent/AM
API-->>U: numero_dossier, statut en_attente
S->>API: review / valider
API->>M: mail lien create-password
U->>API: verify token + set password
API-->>U: compte actif, login OK
```
Variante **refus** : staff refuse → mail refus → usager **reprise** (token lien ou n° dossier) → resoumission → à valider.
### 5.2 Mot de passe oublié
Flux distinct de la création post-validation : demande → e-mail → reset (#127). Tokens durcis (#123).
### 5.3 Création dossier staff
- Famille : wizard + `POST` staff parent/dossier (#129).
- AM : wizard + API staff AM (#156).
- Édition : mode `edit` wizards + PATCH fiches ; ajout co-parent `POST …/co-parent` (#135).
### 5.4 Rattachements
| Lien | Opérations |
|------|------------|
| Parent ↔ enfant | attach / detach (fiche parent, fiche enfant) |
| AM ↔ enfant | attach / detach (fiche AM, fiche enfant) ; UI capacité |
API : tickets #115 / #116 ; liste enfants enrichie #136.
---
## 6. Matrice droits × actions (synthèse)
| Action | gestionnaire | administrateur | super_admin |
|--------|:------------:|:--------------:|:-----------:|
| Voir dashboard usagers / dossiers | ✓ | ✓ | ✓ |
| Valider / refuser dossier | ✓ | ✓ | ✓ |
| Créer dossier parent / AM | ✓ | ✓ | ✓ |
| Éditer fiches parent / AM / enfant | ✓ | ✓ | ✓ |
| Rattacher / détacher enfant | ✓ | ✓ | ✓ |
| GET liste relais (combo) | ✓ | ✓ | ✓ |
| Créer gestionnaire / admin | ✗ | ✓ | ✓ |
| Supprimer parent / AM / enfant / dossier | ✓ | ✓ | ✓ |
| Supprimer gestionnaire | ✗ | ✓ | ✓ |
| Supprimer admin (garde-fous) | ✗ | ✓* | ✓* |
| Supprimer super_admin | ✗ | ✗ | ✗ |
| Supprimer son propre compte staff | ✗ | ✗ | ✗ |
\* Voir `canDeleteAdministrateur` / messages `adminDeleteBlockedReason`.
---
## 7. UI staff (points dentrée code)
| Zone | Emplacement typique |
|------|---------------------|
| Panneau gestion users | `widgets/dashboard/user_management_panel.dart` |
| Onglets parents / AM / enfants / dossiers | `widgets/dashboard/*_management_widget.dart` |
| Fiche parent | `parent_edit_modal.dart` |
| Fiche AM | `am_edit_modal.dart` |
| Fiche enfant | `child_detail_modal.dart` |
| Wizards dossier | `parent_dossier_wizard.dart`, `am_dossier_wizard.dart` |
| Modale staff | `staff_user_form_modal.dart` (`StaffUserFormModal`) |
| Confirm suppressions | `widgets/dashboard/common/suppression_confirm_dialog.dart` |
| Champs contrôlés | `validation_detail_section.dart` (`ValidationEmailField`, `ValidationPhoneField`…) |
| Thème primaire modales | `validation_modal_theme.dart` |
Shell fiches / wizards : largeur **930**, labels au-dessus, primaire violet `ValidationModalTheme`.
---
## 8. API — groupes (renvoi)
Ne pas dupliquer OpenAPI ici. Groupes utiles au domaine :
| Groupe | Exemples dusage |
|--------|------------------|
| Auth / register | Inscription parent & AM, login, tokens MDP, oubli MDP |
| Dossiers | `GET /dossiers/:numero`, listes à valider / unifiées, validate/refuse |
| Parents | Fiche PATCH, co-parent POST, dossier staff POST |
| AM | Fiche PATCH, dossier staff POST |
| Enfants | CRUD staff, attach/detach parent & AM |
| Users / staff | CRUD gestionnaires / admins, DELETE métier |
| Relais | `GET /relais` (autorisé gestionnaire — #151) |
| Config | setup / configuration instance |
Référence : [11_API.md](./11_API.md) (à maintenir en parallèle si écart).
---
## 9. Exigences non fonctionnelles (domaine users)
| ID | Exigence |
|----|----------|
| NFR-U1 | Tokens création / reset MDP : TTL strict, usage unique |
| NFR-U2 | Mots de passe : politique min. longueur (création staff / usager selon flux) |
| NFR-U3 | E-mails métier (validation, refus, MDP) via config SMTP instance |
| NFR-U4 | Pas de login si `en_attente` ou `suspendu` |
| NFR-U5 | Confirmations explicites avant toute suppression |
| NFR-U6 | Champs e-mail / téléphone : validation & normalisation côté UI staff (SRS UX #164) |
Hors scope immédiat : normalisation erreurs auth globale (#121), observabilité (#122), audit trail complet (#128).
---
## 10. Limites modèle famille (assumées)
Documentées pour les développeurs — détail produit : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
- Un `numero_dossier` / user ; un seul co-parent.
- Familles recomposées multi-contextes : contournement (2ᵉ compte / composition staff) jusqu’à epic #139.
- La SRS **nexige pas** N responsables en v0.1.0.
---
## 11. Traçabilité livré 0.1.0
Source : [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) — 48 tickets milestone 0.1.0 (auth, inscription, fiches, dossiers, suppressions, cleanups #152/#155/#162/#164).
---
## 12. Hors périmètre de cette SRS
- Contrats, avenants, fin de contrat (restent dans le **CDC** cible)
- Messagerie, agenda, événements RPE (idem CDC)
- Paie / Pajemploi
- Recherche AM côté parent
- PRA, CI/CD, monitoring infra
- Spécification OpenAPI ligne à ligne
- Texte fonctionnel complet → [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md)
-8
View File
@@ -1,8 +0,0 @@
# Fichier déplacé
La documentation **Documents légaux** a été déplacée vers :
**[juridique/22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)**
Voir aussi le dossier **[juridique/](./juridique/)** pour les sources **CGU**
et **CGC** en Markdown.
+12
View File
@@ -0,0 +1,12 @@
# Suivi des tickets — P'titsPas
**Source de vérité** : [Gitea — issues](https://git.ptits-pas.fr/jmartin/petitspas/issues)
**Milestones / versions** : [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)
**API Gitea** : [26_GITEA-API.md](./26_GITEA-API.md)
Ne plus maintenir de catalogue exhaustif des tickets dans le dépôt : l’état (ouvert / fermé / milestone) change dans Gitea.
Pour une **version livrée**, lire le bilan correspondant (ex. [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md)).
Archive historique (liste Phase 1 figée, avril 2026) :
[archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md).
+13 -21
View File
@@ -423,31 +423,23 @@ ptitspas-app/
- Maintenance (tout au même endroit)
- Versioning (Git)
**Structure** :
**Structure** (sept. 2026) :
```
docs/
├── 00_INDEX.md
├── 01_CAHIER-DES-CHARGES.md
├── 02_ARCHITECTURE.md
├── 03_DEPLOYMENT.md
├── 10_DATABASE.md
├── 11_API.md
├── 20_WORKFLOW-CREATION-COMPTE.md
├── 21_CONFIGURATION-SYSTEME.md
├── 22_DOCUMENTS-LEGAUX.md # pointeur → juridique/
├── 27_BRIEFING-FRONTEND.md
├── PROCEDURE-API-GITEA.md # pointeur → 26_GITEA-API.md
├── juridique/
│ ├── README.md
│ ├── cgu.md
│ ├── cgc.md
│ └── 22_DOCUMENTS-LEGAUX.md
├── archive/
│ ├── README.md
│ ├── temporaires/
│ └── obsolete/
├── 23_LISTE-TICKETS.md
├── 01_CAHIER-DES-CHARGES.md # V1.4 fonctionnel
├── 05_VERSIONS-ET-MILESTONES.md
├── 12_SRS-GESTION-UTILISATEURS.md
├── 23_SUIVI-TICKETS.md
├── 24_DECISIONS-PROJET.md (ce document)
├── 26_GITEA-API.md
├── 27_BRIEFING-FRONTEND.md
├── 28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
├── 29_BILAN-VERSION-0.1.0.md
├── 99_REGLES-CODAGE.md
├── CHARTE_GRAPHIQUE.md
├── juridique/ # CGU + 22_DOCUMENTS-LEGAUX.md
├── archive/ # obsolete / temporaires
├── 90_AUDIT.md
└── test-data/
```
+3 -1
View File
@@ -3,7 +3,9 @@
**Version** : 1.1
**Date** : 16 juin 2026
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
**Documents liés** : [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) (V1.4), [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md), [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md)
**Archive** : ancien patch CDC → [archive/obsolete/EVOLUTIONS_CDC.md](./archive/obsolete/EVOLUTIONS_CDC.md)
---
+197
View File
@@ -0,0 +1,197 @@
# Bilan — Version 0.1.0
**Statut** : terminée
**Milestone Gitea** : [0.1.0](https://git.ptits-pas.fr/jmartin/petitspas/milestone/10)
**Dépôt** : `jmartin/petitspas`
**Tag prévu** : `v0.1.0` (sur `master` après merge de cette doc)
Ce document est la **mémoire produit** de la version 0.1.0 : ce qui a été livré, ticket par ticket, et ce qui a été reporté.
---
## 1. Périmètre produit livré
La 0.1.0 couvre le **MVP opérable** pour une collectivité :
- Authentification, création / oubli de mot de passe, e-mails associés
- Inscription parent & AM, validation / refus gestionnaire, reprise après refus
- Dashboard staff (admin + gestionnaire) : listes, fiches, rattachements
- Onglet **Dossiers** + wizards création / édition (famille & AM)
- Suppressions métier (droits, confirms, cascades API)
- Cleanups structurels (préfixe `Admin*`, panels dashboard, modale staff, retrait `est_multiple`)
**Hors 0.1.0** (reporté) : doublons avancés, famille N responsables, statut enfant gardé/sans garde, combobox RPE AM, chantier CDC (#117), tickets tech/observabilité — voir §4 et [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
---
## 2. Thèmes livrés
### 2.1 Auth, mot de passe, e-mails
| # | Titre | Livré |
|---|-------|--------|
| 24 | API Création mot de passe | Endpoints token → création MDP post-validation |
| 28 | Templates Email — Validation | Mails validation avec lien MDP |
| 30 | Connexion — Vérification statut | Blocage comptes pending / suspendus à la connexion |
| 43 | Écran Création Mot de Passe | UI lien e-mail création MDP |
| 47 | Écran Changement MDP Obligatoire | Première connexion staff |
| 50 | Affichage dynamique CGU | CGU/Privacy versionnées à linscription |
| 118 | Page création mot de passe (Front + API) | Alignement front/API du flux lien e-mail |
| 123 | Durcissement token création MDP | TTL, usage unique, contrôles API |
| 127 | Mot de passe oublié | Demande → e-mail → réinitialisation (flux distinct de #24/#43) |
### 2.2 Inscription, numéros de dossier, reprise
| # | Titre | Livré |
|---|-------|--------|
| 104 | Numéro de dossier — frontend | Affichage listes / mails / modales ; format AAAA-… |
| 112 | Reprise après refus — frontend | Lien e-mail `/reprise` + reprise par n° dossier |
| 120 | Inscription AM — photo & UX | Chaîne photo / API / UX alignée parents |
| 144 | Consentement photo enfant | Persistance du consentement à linscription |
### 2.3 Dashboard — fiches, listes, rattachements
| # | Titre | Livré |
|---|-------|--------|
| 115 | Rattachement enfants — backend | Attach/detach parent↔enfant et AM↔enfant |
| 116 | Rattachement enfants — frontend | UI fiches parent / AM |
| 130 | UserService — APIs métier | Branchement parents / AM / enfants côté front |
| 131 | Édition fiche parent + AM | Modales édition dashboard |
| 132 | Création enfant (onglet Enfants) | Création + rattachement foyer |
| 136 | API enfants — droits & liste | Droits gestionnaire + enrichissement liste |
| 137 | Onglet Enfants — liste globale | Panneau liste dashboard |
| 138 | Fiche enfant + liste dans parent | Fiche enfant ; enfants dans fiche parent |
| 140 | Epic fiche parent / affiliation | Livraison regroupée dashboard admin/gestionnaire |
| 142 | Clic carte → modale | Ouverture fiche depuis les listes |
| 145 | Lien co-parent cliquable | Navigation fiche parent → co-parent |
| 146 | Modale sélection enfant | UX rattacher enfant (AM + parent) |
| 147 | Modale sélection AM | UX rattacher AM depuis fiche enfant |
| 148 | Capacité max AM | Désactivation rattachement si capacité atteinte |
| 149 | Case libre AM → rattacher | Clic emplacement vide pour rattacher |
| 151 | GET /relais pour gestionnaire | Combo relais dans modale staff |
| 157 | Enfant sans responsable | Détachement dernier parent + alerte liste |
| 158 | Affiliation foyer (pivot + co-parent) | Attach/detach cohérents sur le foyer |
### 2.4 Dossiers staff (création, édition, liste)
| # | Titre | Livré |
|---|-------|--------|
| 129 | Création dossier parent | Wizard + API staff famille |
| 135 | Édition dossier + 2ᵉ parent | Mode edit wizards + `POST …/co-parent` |
| 153 | Onglet Dossiers | Liste unifiée + à valider (sans création dans longlet) |
| 156 | Création dossier AM | Wizard + API staff AM |
### 2.5 Suppressions & droits staff
| # | Titre | Livré |
|---|-------|--------|
| 133 | Suppression parent + AM (UI) | Première vague UI (complétée par #160) |
| 134 | Droits « Ajouter gestionnaire » | Visibilité / API selon rôle |
| 143 | Bug supprimer sa propre fiche | Masquage / interdiction auto-suppression |
| 154 | Epic suppressions | Cadrage règles métier suppressions |
| 159 | Suppressions métier — backend | Cascades, garde-fous, droits API |
| 160 | Suppressions dashboard — frontend | Poubelles + dialogues de confirmation |
| 161 | Admin création staff 403 | Admin peut créer gestionnaire et administrateur |
### 2.6 Cleanups structure & UX
| # | Titre | Livré |
|---|-------|--------|
| 25 | API Liste comptes en attente | Historique ; couvert par flux dossiers / validation |
| 26 | API Validation / Refus | Historique ; couvert par flux dossiers / validation |
| 152 | Retrait `est_multiple` | Suppression full stack (BDD, API, front, docs) |
| 155 | Rename préfixe `Admin*` | Widgets partagés sans préfixe Admin (option C) |
| 162 | Panels → `widgets/dashboard/` | Suite #155 — panels staff sous dashboard |
| 164 | Modale staff uniformisée | `StaffUserFormModal` (shell 930, champs contrôlés) |
---
## 3. Inventaire exhaustif (48 tickets fermés, milestone 0.1.0)
| # | Titre |
|---|-------|
| 24 | [Backend] API Création mot de passe |
| 25 | [Backend] API Liste comptes en attente |
| 26 | [Backend] API Validation/Refus comptes |
| 28 | [Backend] Templates Email - Validation |
| 30 | [Backend] Connexion - Vérification statut |
| 43 | [Frontend] Écran Création Mot de Passe |
| 47 | [Frontend] Écran Changement MDP Obligatoire |
| 50 | [Frontend] Affichage dynamique CGU lors inscription |
| 104 | Numéro de dossier frontend |
| 112 | Reprise après refus frontend |
| 115 | [Backend] Rattachement enfants — parent et AM |
| 116 | [Frontend] Rattachement enfants — parent et AM |
| 118 | Page création mot de passe (lien email) Front + API |
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents |
| 123 | [Tech] Durcissement token création MDP |
| 127 | [Full-stack] Mot de passe oublié — flux complet |
| 129 | Création dossier parent (wizard + API staff) |
| 130 | [Frontend] UserService — brancher APIs parents, AM et enfants |
| 131 | [Frontend] Édition fiche parent + AM |
| 132 | Création enfant depuis longlet Enfants |
| 133 | [Frontend] Suppression compte parent + AM |
| 134 | Droits bouton « Ajouter gestionnaire » |
| 135 | Mode édition dossier (+ ajout 2ᵉ parent) |
| 136 | [Backend] API enfants — droits gestionnaire + enrichissement liste |
| 137 | [Frontend] Onglet Enfants — liste globale |
| 138 | [Frontend] Fiche enfant + liste enfants dans fiche parent |
| 140 | Dashboard admin — fiche parent, enfants et affiliation |
| 142 | Clic sur carte → ouvrir la modale |
| 143 | Bug — Gestionnaire Supprimer sur sa propre fiche |
| 144 | Bug — Consentement photo enfant non sauvegardé |
| 145 | Lien co-parent cliquable |
| 146 | UX — modale sélection d'enfant |
| 147 | UX — modale sélection d'AM |
| 148 | Bug — capacité max AM |
| 149 | Fiche AM — clic case libre pour rattacher |
| 151 | Bug — GET /relais gestionnaire |
| 152 | Cleanup — supprimer `est_multiple` |
| 153 | Onglet permanent « Dossiers » |
| 154 | Epic — suppressions utilisateurs / dossiers / enfants / AM |
| 155 | Cleanup — renommer préfixe Admin* |
| 156 | Création dossier AM (wizard + API staff) |
| 157 | Enfant sans responsable |
| 158 | Affiliation enfant au foyer (pivot + co-parent) |
| 159 | Backend suppressions métier (#154) |
| 160 | Frontend suppressions dashboard (#154) |
| 161 | Bug — Admin création gestionnaire / administrateur |
| 162 | Cleanup — panels vers `widgets/dashboard/` |
| 164 | Uniformisation modale staff + champs contrôlés |
Issues : https://git.ptits-pas.fr/jmartin/petitspas/issues?q=&type=all&state=closed&labels=&milestone=10&assignee=0
---
## 4. Reporté hors 0.1.0
| # | Titre | Destination typique |
|---|-------|---------------------|
| 113 / 114 | Doublons inscription / alerte gestionnaire | 0.9.0 |
| 117 | Évolution du cahier des charges | Doc (amendement CDC post-0.1.0) |
| 121122, 124125 | Tech auth / photos / DB | 0.9.0 |
| 126 | Upload documents légaux 500 | 0.9.0 |
| 128 | Audit / traçabilité modifications | 0.9.0 |
| 139 | Famille complexe N responsables | Post-0.1.0 / epic |
| 141 | Statut enfant gardé / sans garde | Post-0.1.0 |
| 150 | Combobox rattachement RPE (AM) | 0.2.0 |
Voir aussi [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
---
## 5. Suite documentaire
1. **Amendement CDC (#117)** — CDC V1.4 : mise à jour **gestion utilisateurs** ; le reste du CDC reste la cible. SRS technique : [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md). Intrants historiques archivés : [archive/obsolete/EVOLUTIONS_CDC.md](./archive/obsolete/EVOLUTIONS_CDC.md), [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
2. **Tag** `v0.1.0` sur `master` lorsque milestone fermée + ce bilan mergé.
3. Enchaîner les milestones **0.2.0+** selon [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
---
## 6. Références code (points dentrée)
- Modale staff : `frontend/lib/widgets/dashboard/staff_user_form_modal.dart`
- Fiches : `parent_edit_modal.dart`, `am_edit_modal.dart`, `child_detail_modal.dart`
- Wizards dossiers : `parent_dossier_wizard.dart`, `am_dossier_wizard.dart`
- Règles suppressions : tickets #154 / #159 / #160
- Cleanup `est_multiple` : #152
-3
View File
@@ -276,9 +276,6 @@ export class Enfants {
@Column({ name: 'consentement_photo', type: 'boolean', default: false })
consentementPhoto: boolean;
@Column({ name: 'est_multiple', type: 'boolean', default: false })
estMultiple: boolean;
@Column({
type: 'enum',
enum: StatutEnfantType,
-8
View File
@@ -1,8 +0,0 @@
# Fichier déplacé / fusionné
La procédure **API Gitea** est désormais documentée sous :
**[26_GITEA-API.md](./26_GITEA-API.md)**
Lancienne copie `PROCEDURE-API-GITEA.md` est archivée dans
`docs/archive/obsolete/` (doublon).
+8 -19
View File
@@ -1,30 +1,19 @@
# Archive documentation · P'titsPas
Ce dossier regroupe les fichiers **sans préfixe numérique** à la racine de
`docs/` qui ne sont plus des **références actives**, ou qui sont des
**brouillons / temporaires**.
Fichiers **hors références actives** : brouillons livrés, CDC historiques, listes figées.
## Règle de nommage (racine `docs/`)
- Les documents **normatifs** à la racine portent un préfixe **`NN_`**
(deux chiffres), ex. `23_LISTE-TICKETS.md`.
- **Exceptions** (héritage ou outillage) listées dans
[**00_INDEX.md**](../00_INDEX.md#exceptions-de-nommage) : charte, CDC
historique, évolutions — **cible** : les renommer progressivement en `NN_`
et mettre à jour `.cursorrules` / liens.
- Documents **normatifs** : préfixe **`NN_`**.
- Exceptions héritage listées dans [00_INDEX.md](../00_INDEX.md) (`CHARTE_GRAPHIQUE.md`).
## Sous-dossiers ici
## Sous-dossiers
| Dossier | Usage |
|---------|--------|
| [**temporaires/**](./temporaires/) | Notes jetables, exports de travail.
**Supprimables** quand la tâche associée est close. |
| [**obsolete/**](./obsolete/) | Ancienne doc **remplacée** ou **doublon**
(conservée un temps pour historique). **Supprimer** après bascule confirmée
si plus aucune référence. |
| [**temporaires/**](./temporaires/) | Brouillons jetables. **Vider** dès livraison. |
| [**obsolete/**](./obsolete/) | Doc remplacée (CDC SuperNounou, ancienne liste tickets, notes ponctuelles, backlog Phase 2 figé). |
## Hors `docs/` racine
## Politique `tmp/`
Les dossiers thématiques (**`juridique/`**, **`test-data/`**, etc.) peuvent
contenir des fichiers sans `NN_` : la règle `NN_` sapplique surtout aux
fichiers **directement** sous `docs/`.
Le dossier `docs/tmp/` **nest plus utilisé**. Les mini-specs de tickets livrés sont purgés ; la mémoire produit = bilans de version (`29_…`) + tickets Gitea.
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,9 @@
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
> **Intrant pour #117** (amendement CDC post-0.1.0). Compléter avec le [bilan 0.1.0](./29_BILAN-VERSION-0.1.0.md) et **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
> **Obsolète depuis #152** : ne plus proposer de champ « naissance multiple / `est_multiple` » — retiré de lapp (BDD, API, front).
## 1. Gestion des Enfants
@@ -11,7 +13,6 @@ Ce document liste les modifications à apporter au cahier des charges original p
#### Situation actuelle dans le CDC :
- Mentionne uniquement la collecte d'informations sur l'enfant
- Ne précise pas la possibilité d'ajouter plusieurs enfants
- Ne mentionne pas la gestion des naissances multiples
- Ne mentionne pas la gestion des enfants à naître
#### Modifications proposées :
@@ -22,17 +23,18 @@ Ajouter le paragraphe suivant après la description de la collecte d'information
Les parents peuvent ajouter autant d'enfants que nécessaire. Pour chaque enfant, les informations suivantes sont collectées :
- Prénom
- Date de naissance (ou date prévue pour les enfants à naître)
- Genre
- Photo (optionnelle)
- Consentement pour l'utilisation de la photo
- Indication si l'enfant fait partie d'une naissance multiple (jumeaux, triplés, etc.)
Les parents peuvent :
- Ajouter un nouvel enfant à tout moment
- Supprimer un enfant ajouté
- Modifier les informations d'un enfant existant
- Indiquer si l'enfant est à naître
- Indiquer si l'enfant fait partie d'une naissance multiple
- Donner ou retirer leur consentement pour l'utilisation de la photo de l'enfant
Note : le concept de « naissance multiple » / jumeaux n'est pas géré par un champ dédié (retiré en 0.1.0, #152).
```
### Modifications à apporter dans la section "Workflow de création de compte"
@@ -50,9 +52,9 @@ Remplacer l'étape 3 par :
- Pour chaque enfant :
* Saisie du prénom
* Saisie de la date de naissance (ou date prévue)
* Genre
* Option d'ajout d'une photo
* Option de consentement photo
* Indication si naissance multiple
* Indication si enfant à naître
- Possibilité de modifier ou supprimer un enfant
```
+11 -7
View File
@@ -4,11 +4,15 @@ Ancienne documentation **déplacée** depuis `docs/` :
| Fichier | Motif |
|---------|--------|
| `PROCEDURE-API-GITEA.md` | Doublon fonctionnel de
[**26_GITEA-API.md**](../../26_GITEA-API.md). |
| `ARCHITECTURE_TECHNIQUE.md` | Non référencé ; la vue densemble est dans
[**02_ARCHITECTURE.md**](../../02_ARCHITECTURE.md). |
| `STATUS-APPLICATION.md` | Instantané daté ; non tenu comme doc vivante. |
| `01_CAHIER-DES-CHARGES-v1.3.md` | CDC V1.3 — remplacé par [01 V1.4](../../01_CAHIER-DES-CHARGES.md) |
| `EVOLUTIONS_CDC.md` | Patch CDC — absorbé dans V1.4 + [12_SRS](../../12_SRS-GESTION-UTILISATEURS.md) |
| `PROCEDURE-API-GITEA.md` | Doublon de [26_GITEA-API.md](../../26_GITEA-API.md) |
| `ARCHITECTURE_TECHNIQUE.md` | Remplacé par [02_ARCHITECTURE.md](../../02_ARCHITECTURE.md) |
| `STATUS-APPLICATION.md` | Instantané daté |
| `23_LISTE-TICKETS.md` | Liste Phase 1 figée — suivi = Gitea + bilans |
| `25_PHASE-2-BACKLOG.md` | Backlog technique figé — voir [05_VERSIONS…](../../05_VERSIONS-ET-MILESTONES.md) |
| `SuperNounou_*` | CDC / SSS historiques |
| `14_NOTE-BACKEND-CONFIG-SETUP.md` | Note ticket ponctuelle |
| `92_NOTE-BACKEND-GESTIONNAIRES.md` | Note ticket ponctuelle |
Après vérification quaucun lien externe ne pointe encore vers ces chemins, on
peut **supprimer** ce sous-dossier ou ne garder que des pointeurs minimalistes.
Références actives : [01 CDC V1.4](../../01_CAHIER-DES-CHARGES.md), [12 SRS users](../../12_SRS-GESTION-UTILISATEURS.md), [29 bilan 0.1.0](../../29_BILAN-VERSION-0.1.0.md).
@@ -1,127 +0,0 @@
# #131 — En-tête fiche parent : co-parent (note front → back)
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** livré (en-tête dynamique)
**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles.
---
## 1. Comportement UI (front)
Dans la modale **fiche parent** (`AdminParentEditModal`) :
| Zone | Contenu |
|------|---------|
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
Le sous-titre provient du co-parent **chargé depuis lAPI** (pas saisi à la main dans la modale).
---
## 2. Endpoints consommés
| Méthode | Route | Usage front |
|---------|-------|-------------|
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
---
## 3. Contrat JSON attendu pour `co_parent`
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
### Champs minimum utilisés pour le sous-titre
| Clé JSON | Usage |
|----------|--------|
| `co_parent` | Objet ou absent/`null` |
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
| `co_parent.prenom` | Affichage |
| `co_parent.nom` | Affichage |
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
### Exemple de fragment de réponse (`GET /parents/:id`)
```json
{
"user_id": "33333333-3333-3333-3333-333333333333",
"numero_dossier": "2026-000042",
"user": {
"id": "33333333-3333-3333-3333-333333333333",
"email": "parent1@example.com",
"prenom": "Paul",
"nom": "PARENT",
"statut": "actif",
"telephone": "0601020304"
},
"co_parent": {
"id": "44444444-4444-4444-4444-444444444444",
"email": "coparent1@example.com",
"prenom": "Clara",
"nom": "COPARENT",
"role": "parent",
"statut": "actif"
},
"parentChildren": []
}
```
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est lentité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
---
## 4. État backend
### Relations (déjà en place)
- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ;
- FK : `parents.id_co_parent``utilisateurs.id` ;
- inscription couple : les deux sens renseignés en principe (`auth.service.ts`).
### Livraison back (#131)
- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`.
**Checklist validation :**
- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null
- [x] `GET /parents` (liste) inclut `co_parent`
- [x] `prenom` / `nom` du co-parent présents
- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent`
---
## 5. Points dattention (hors périmètre immédiat)
| Sujet | Détail |
|-------|--------|
| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne saffichera pas** sur la fiche de B. Pas de résolution inverse côté front. |
| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. |
| **Trou AM ↔ enfants en garde** | Pas de lien AMenfant aujourdhui (à documenter / traiter plus tard). |
---
## 6. Fichiers back concernés
| Fichier | Rôle |
|---------|------|
| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations |
| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses |
| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API |
| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets |
| `backend/src/entities/parents.entity.ts` | relation `co_parent` |
---
## 7. Références
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
- Ticket Gitea **#131**
@@ -1,36 +0,0 @@
# Archivé docs/archive/temporaires/ — export jetable, supprimer si inutile.
Point tickets frontend (API Gitea) - 27/01/2026
================================================
Issues avec label "frontend" : 20 (ouvertes: 12, fermees: 8)
Num | Etat | Titre
----+--------+--------------------------------------------------------
35 | open | [Frontend] Écran Création Gestionnaire
36 | closed | [Frontend] Inscription Parent - Étape 1 (Parent 1)
37 | closed | [Frontend] Inscription Parent - Étape 2 (Parent 2)
38 | closed | [Frontend] Inscription Parent - Étape 3 (Enfants)
39 | closed | [Frontend] Inscription Parent - Étapes 4-6 (Finalisatio
40 | closed | [Frontend] Inscription AM - Panneau 1 (Identité)
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
42 | closed | [Frontend] Inscription AM - Finalisation
43 | open | [Frontend] Écran Création Mot de Passe
44 | closed | [Frontend] Dashboard Gestionnaire - Structure
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
47 | open | [Frontend] Écran Changement MDP Obligatoire
48 | open | [Frontend] Gestion Erreurs & Messages
49 | open | [Frontend] Écran Gestion Documents Légaux (Admin)
50 | open | [Frontend] Affichage dynamique CGU lors inscription
51 | open | [Frontend] Écran Logs Admin (optionnel v1.1)
54 | open | [Tests] Tests E2E Frontend
82 | closed | [Frontend] Adapter cran Login pour mobile
83 | closed | [Frontend] Adapter cran Choix Inscription pour mobile
Suivi doc 23_LISTE-TICKETS (Gitea #73,78,79,81,82,83):
#73 closed labels=[]
#78 closed labels=[]
#79 closed labels=[]
#81 closed labels=[]
#82 closed (écran Login mobile)
#83 closed labels=['frontend', 'p3', 'phase-1', 'ux']
+8 -7
View File
@@ -1,10 +1,11 @@
# Temporaires
Fichiers **non numérotés** de travail (brouillons, listes de tickets exportées,
alignements UI en cours, etc.).
Dossier **vide** après clôture 0.1.0 (purge sept. 2026).
- Préfixe conseillé pour les nouveaux fichiers jetables : **`TEMP_`** ou
**`WIP_`** dans ce dossier.
- **Suppression** : dès que la fonctionnalité est livrée ou le sujet clos,
supprimer le fichier (ou le déplacer vers `obsolete/` si une trace utile
reste nécessaire).
Si un brouillon de travail est nécessaire un temps :
- le placer ici avec préfixe `TEMP_` / `WIP_` ;
- le **supprimer** dès livraison (ne pas laisser pourrir) ;
- pour une trace utile durable → bilan de version ou archive `obsolete/`.
Ne plus utiliser `docs/tmp/`.
@@ -1,244 +0,0 @@
# #112 — Alignement front après évolution back (reprise dossier complet)
**Branche déployée :** `feature/112-reprise-apres-refus-front`
**Commit back :** `d70577b1``feat(#112): reprise après refus — dossier complet GET/PATCH`
**Date :** 2026-06-16
Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de lidentité seule).
---
## 1. Endpoints (inchangés côté URL)
| Méthode | Route | Auth |
|---------|-------|------|
| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public |
| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public |
| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) |
> **Note :** le ticket #111 parlait de `PUT` ; limplémentation reste en **`PATCH`** (comme avant).
---
## 2. `GET /auth/reprise-dossier` — réponse enrichie
### Champs communs (toujours présents)
Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`.
### Rôle `parent` (+ champs #119)
Alignés sur `DossierFamilleCompletDto` :
```json
{
"parents": [
{
"user_id": "uuid",
"email": "…",
"prenom": "…",
"nom": "…",
"telephone": "…",
"adresse": "…",
"ville": "…",
"code_postal": "…",
"statut": "refuse",
"co_parent_id": "uuid-parent-entity"
}
],
"enfants": [
{
"id": "uuid-enfant",
"first_name": "Emma",
"last_name": "MARTIN",
"genre": "F",
"status": "actif",
"birth_date": "2023-02-15T00:00:00.000Z",
"due_date": null,
"photo_url": "/uploads/photos/…",
"consent_photo": true,
"est_multiple": false
}
],
"texte_motivation": "Nous recherchons…"
}
```
**Mapping front suggéré :**
| JSON back | Modèle / wizard parent |
|-----------|-------------------------|
| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) |
| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` |
| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) |
| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) |
| `enfants[].status` | `actif` = né, `a_naitre` = à naître |
| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload |
| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) |
| `enfants[].est_multiple` | `grossesse_multiple` si utilisé |
| `texte_motivation` | étape présentation / motivation |
Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule).
### Rôle `assistante_maternelle`
Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) :
```json
{
"consentement_photo": true,
"date_naissance": "1985-03-12T00:00:00.000Z",
"lieu_naissance_ville": "Paris",
"lieu_naissance_pays": "France",
"numero_agrement": "AGR-2024-12345",
"nir": "123456789012345",
"date_agrement": "2024-06-01T00:00:00.000Z",
"nb_max_enfants": 4,
"place_disponible": 2,
"biographie": "…"
}
```
**Mapping `AmRegistrationData` :**
| JSON back | Champ front |
|-----------|-------------|
| `nb_max_enfants` | `capaciteAccueil` |
| `place_disponible` | `placesDisponibles` |
| `numero_agrement` | `numeroAgrement` |
| `biographie` | `biographie` / présentation |
| `photo_url` | déjà géré via `RepriseSession.photoUrl` |
---
## 3. `PATCH /auth/reprise-resoumettre` — body étendu
### Commun
```json
{ "token": "uuid-reprise" }
```
### Parent — champs à envoyer depuis le wizard
| Champ PATCH | Source wizard | Notes |
|-------------|---------------|-------|
| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine |
| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | |
| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | |
| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés |
| `enfants[]` | Liste enfants | Voir ci-dessous |
**Structure `enfants[]` (miroir inscription + `id` obligatoire) :**
```json
{
"id": "uuid-enfant-existant",
"prenom": "Emma",
"nom": "MARTIN",
"date_naissance": "2023-02-15",
"date_previsionnelle_naissance": null,
"genre": "F",
"photo_base64": "data:image/jpeg;base64,…",
"photo_filename": "emma.jpg",
"grossesse_multiple": false
}
```
- **v1 back :** update par `id` uniquement — pas de création/suppression denfant.
- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`.
- Sans nouvelle photo : ne pas envoyer `photo_base64` (lexistant est conservé).
### AM — champs à envoyer
| Champ PATCH | Source |
|-------------|--------|
| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 12 |
| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité |
| `numero_agrement`, `nir`, `date_agrement` | Pro |
| `capacite_accueil`, `places_disponibles` | Pro |
| `biographie` | Présentation |
Validation NIR identique à linscription si `nir` fourni.
### Réponse succès (nouveau format)
```json
{
"message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.",
"statut": "en_attente",
"user_id": "uuid",
"numero_dossier": "2026-000021"
}
```
Code HTTP : **200** (pas de corps `Users` brut comme lancien back).
### Effet métier
- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110).
- **AM :** un seul user.
### E-mail accusé resoumission (parent)
Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) :
- confirmation de resoumission ;
- rappel du **numéro de dossier** ;
- mention « en attente de validation ».
Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale).
---
## 4. Fichiers front à modifier (checklist)
### Modèles
- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM
- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible
### Session / préremplissage
- [ ] `lib/services/reprise_session.dart`
- `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation
- `applyToAm` : remplir tous les champs AM
### API
- [ ] `lib/services/auth_service.dart``resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité
- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile
### Écrans fin de parcours
- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation
- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète
### Hors scope back (inchangé)
RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise.
### Non implémenté front (ticket #112 initial)
- [ ] Modale login « Jai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent)
---
## 5. Tests manuels suggérés
1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation).
2. Ouvrir le lien mail `/reprise?token=…`.
3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`.
4. Après branchement front : wizard prérempli sur toutes les étapes.
5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119).
---
## 6. Références code back
```
backend/src/routes/auth/dto/reprise-dossier.dto.ts
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
backend/src/routes/auth/dto/enfant-reprise.dto.ts
backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise
backend/src/routes/parents/dto/dossier-famille-complet.dto.ts
```
@@ -1,132 +0,0 @@
# #131 — Fiche AM éditable + affiliation enfants (note front → back)
**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter**
---
## 1. Comportement UI (front)
Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) :
| Onglet | Contenu |
|--------|---------|
| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut |
| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher |
En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`.
---
## 2. Endpoints consommés
### Déjà existants (partiels)
| Méthode | Route | Usage |
|---------|-------|-------|
| `GET` | `/api/v1/assistantes-maternelles` | Liste AM |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) |
| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) |
| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) |
### À créer (recommandé — miroir parent #131 / #115)
| Méthode | Route | Rôle |
|---------|-------|------|
| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) |
| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant |
| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) |
Le front appelle déjà ces routes ; en labsence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté).
---
## 3. Modèle de données affiliation AM ↔ enfant
**À définir côté BDD** (pas de table dédiée aujourdhui, contrairement à `enfants_parents`) :
Proposition alignée parent :
```sql
-- Piste : enfants_assistantes_maternelles
CREATE TABLE enfants_assistantes_maternelles (
id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_am, id_enfant)
);
```
Réponse API attendue sur `GET /assistantes-maternelles/:id` :
```json
{
"user_id": "uuid-am",
"user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" },
"approval_number": "AGR-2024-12345",
"residence_city": "Bezons",
"max_children": 4,
"places_available": 2,
"available": true,
"amChildren": [
{
"child": {
"id": "uuid-enfant",
"first_name": "Emma",
"last_name": "MARTIN",
"status": "actif",
"birth_date": "2023-02-15"
}
}
]
}
```
Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`).
---
## 4. Body `PATCH …/fiche` suggéré
```json
{
"nom": "MARTIN",
"prenom": "Claire",
"email": "claire@example.com",
"telephone": "0612345678",
"adresse": "5 place Bellecour",
"ville": "Lyon",
"code_postal": "69002",
"statut": "actif",
"approval_number": "AGR-2024-12345",
"residence_city": "Lyon",
"max_children": 4,
"places_available": 2,
"biography": "…",
"available": true
}
```
NIR et date dagrément : lecture seule dans la modale (modification hors périmètre admin v1).
---
## 5. Fichiers front concernés
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets |
| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM |
| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée |
| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` |
| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` |
| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier |
---
## 6. Références
- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId`
- Ticket Gitea **#131**, **#115**
- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md`
@@ -1,124 +0,0 @@
# #131 — En-tête fiche parent : co-parent (note front → back)
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** livré (en-tête dynamique)
**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que lexistant le couvre.
---
## 1. Comportement UI (front)
Dans la modale **fiche parent** (`AdminParentEditModal`) :
| Zone | Contenu |
|------|---------|
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
Le sous-titre provient du co-parent **chargé depuis lAPI** (pas saisi à la main dans la modale).
---
## 2. Endpoints consommés
| Méthode | Route | Usage front |
|---------|-------|-------------|
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
---
## 3. Contrat JSON attendu pour `co_parent`
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
### Champs minimum utilisés pour le sous-titre
| Clé JSON | Usage |
|----------|--------|
| `co_parent` | Objet ou absent/`null` |
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
| `co_parent.prenom` | Affichage |
| `co_parent.nom` | Affichage |
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
### Exemple de fragment de réponse (`GET /parents/:id`)
```json
{
"user_id": "33333333-3333-3333-3333-333333333333",
"numero_dossier": "2026-000042",
"user": {
"id": "33333333-3333-3333-3333-333333333333",
"email": "parent1@example.com",
"prenom": "Paul",
"nom": "PARENT",
"statut": "actif",
"telephone": "0601020304"
},
"co_parent": {
"id": "44444444-4444-4444-4444-444444444444",
"email": "coparent1@example.com",
"prenom": "Clara",
"nom": "COPARENT",
"role": "parent",
"statut": "actif"
},
"parentChildren": []
}
```
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est lentité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
---
## 4. État backend (à valider, pas à refaire)
Daprès le code actuel (`parents.service.ts`) :
- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ;
- la FK métier est `parents.id_co_parent``utilisateurs.id` ;
- à linscription couple, les deux sens sont en principe renseignés (`auth.service.ts`).
**Checklist validation back :**
- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null
- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès louverture sans re-fetch)
- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON
Si ces trois points passent en recette, **aucun changement backend nest nécessaire** pour cette fonctionnalité.
---
## 5. Points dattention (hors périmètre immédiat)
| Sujet | Détail |
|-------|--------|
| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne saffichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. |
| **Familles > 2 adultes** | Le sous-titre naffiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). |
| **Données sensibles** | Vérifier que la sérialisation de `co_parent` nexpose pas `password` / tokens (même remarque que pour `user`). |
---
## 6. Fichiers front concernés
| Fichier | Rôle |
|---------|------|
| `frontend/lib/models/parent_model.dart` | Parse `co_parent``AppUser? coParent` |
| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre |
| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` |
---
## 7. Références
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
- `backend/src/routes/parents/parents.service.ts``findOne`, `findAll`
- `backend/src/entities/parents.entity.ts` — relation `co_parent`
- Ticket Gitea **#131**
@@ -1,46 +0,0 @@
# TEMP — Alignement front / API (inscription AM & validation gestionnaire)
> **Archivé** (`docs/archive/temporaires/`) — **fichier temporaire** ; à
> **supprimer** une fois le front livré ou le sujet clos (voir
> `docs/archive/temporaires/README.md`).
Ce document décrit les changements **côté API** et ce que **Flutter** doit faire pour rester aligné. Aucune modification front na été faite dans le chantier backend associé.
## 1. `POST /auth/register/am` — lieu de naissance obligatoire
- **`lieu_naissance_ville`** et **`lieu_naissance_pays`** sont **obligatoires** (non vides après trim, min. **2 caractères** chacun, max 100).
- Réponses **400** si manquants ou invalides (messages class-validator).
- **Action front** : champs obligatoires dans le parcours AM (étapes identité / naissance), validation UI avant envoi ; afficher les erreurs renvoyées par lAPI.
## 2. Réponse `GET /dossiers/:numeroDossier` (type `am`)
Sous `dossier.user`, lAPI peut inclure :
| Clé JSON | Description |
|----------|-------------|
| `date_naissance` | Date (si renseignée à linscription) |
| `lieu_naissance_ville` | Ville de naissance |
| `lieu_naissance_pays` | Pays de naissance |
| `consentement_photo` | Booléen (exposé dans `dossier.user`) |
À la **racine** de `dossier` (objet AM), champs déjà renvoyés par le backend : `disponible`, `annees_experience`, `specialite`, `nb_max_enfants`, `place_disponible`, etc.
**Action front** :
- Étendre **`AppUser.fromJson` / `toJson`** (`lib/models/user.dart`) pour mapper `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays`, `consentement_photo`.
- Étendre **`DossierAM.fromJson`** (`lib/models/dossier_unifie.dart`) pour parser `disponible`, `annees_experience`, `specialite` à la racine du dossier (noms snake_case comme dans la réponse JSON Nest).
## 3. `ValidationAmWizard` (admin)
Afficher pour cohérence avec le formulaire dinscription :
- **Informations personnelles** : date de naissance, ville / pays de naissance, consentement photo (Oui/Non).
- **Informations professionnelles** : disponibilité, années dexpérience, spécialité (afficher « » si `null`).
## 4. `place_disponible` à linscription
- Le backend initialise **`place_disponible`** sur la fiche AM à la **même valeur** que **`capacite_accueil`** à la création. Le wizard peut donc afficher une valeur cohérente avec la capacité sans champ séparé côté public.
---
*Dernière mise à jour : alignement backend branche `feature/120-inscription-am-photo-backend`.*
@@ -1,244 +0,0 @@
# #112 — Alignement front après évolution back (reprise dossier complet)
**Branche déployée :** `feature/112-reprise-apres-refus-front`
**Commit back :** `d70577b1``feat(#112): reprise après refus — dossier complet GET/PATCH`
**Date :** 2026-06-16
Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de lidentité seule).
---
## 1. Endpoints (inchangés côté URL)
| Méthode | Route | Auth |
|---------|-------|------|
| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public |
| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public |
| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) |
> **Note :** le ticket #111 parlait de `PUT` ; limplémentation reste en **`PATCH`** (comme avant).
---
## 2. `GET /auth/reprise-dossier` — réponse enrichie
### Champs communs (toujours présents)
Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`.
### Rôle `parent` (+ champs #119)
Alignés sur `DossierFamilleCompletDto` :
```json
{
"parents": [
{
"user_id": "uuid",
"email": "…",
"prenom": "…",
"nom": "…",
"telephone": "…",
"adresse": "…",
"ville": "…",
"code_postal": "…",
"statut": "refuse",
"co_parent_id": "uuid-parent-entity"
}
],
"enfants": [
{
"id": "uuid-enfant",
"first_name": "Emma",
"last_name": "MARTIN",
"genre": "F",
"status": "actif",
"birth_date": "2023-02-15T00:00:00.000Z",
"due_date": null,
"photo_url": "/uploads/photos/…",
"consent_photo": true,
"est_multiple": false
}
],
"texte_motivation": "Nous recherchons…"
}
```
**Mapping front suggéré :**
| JSON back | Modèle / wizard parent |
|-----------|-------------------------|
| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) |
| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` |
| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) |
| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) |
| `enfants[].status` | `actif` = né, `a_naitre` = à naître |
| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload |
| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) |
| `enfants[].est_multiple` | `grossesse_multiple` si utilisé |
| `texte_motivation` | étape présentation / motivation |
Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule).
### Rôle `assistante_maternelle`
Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) :
```json
{
"consentement_photo": true,
"date_naissance": "1985-03-12T00:00:00.000Z",
"lieu_naissance_ville": "Paris",
"lieu_naissance_pays": "France",
"numero_agrement": "AGR-2024-12345",
"nir": "123456789012345",
"date_agrement": "2024-06-01T00:00:00.000Z",
"nb_max_enfants": 4,
"place_disponible": 2,
"biographie": "…"
}
```
**Mapping `AmRegistrationData` :**
| JSON back | Champ front |
|-----------|-------------|
| `nb_max_enfants` | `capaciteAccueil` |
| `place_disponible` | `placesDisponibles` |
| `numero_agrement` | `numeroAgrement` |
| `biographie` | `biographie` / présentation |
| `photo_url` | déjà géré via `RepriseSession.photoUrl` |
---
## 3. `PATCH /auth/reprise-resoumettre` — body étendu
### Commun
```json
{ "token": "uuid-reprise" }
```
### Parent — champs à envoyer depuis le wizard
| Champ PATCH | Source wizard | Notes |
|-------------|---------------|-------|
| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine |
| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | |
| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | |
| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés |
| `enfants[]` | Liste enfants | Voir ci-dessous |
**Structure `enfants[]` (miroir inscription + `id` obligatoire) :**
```json
{
"id": "uuid-enfant-existant",
"prenom": "Emma",
"nom": "MARTIN",
"date_naissance": "2023-02-15",
"date_previsionnelle_naissance": null,
"genre": "F",
"photo_base64": "data:image/jpeg;base64,…",
"photo_filename": "emma.jpg",
"grossesse_multiple": false
}
```
- **v1 back :** update par `id` uniquement — pas de création/suppression denfant.
- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`.
- Sans nouvelle photo : ne pas envoyer `photo_base64` (lexistant est conservé).
### AM — champs à envoyer
| Champ PATCH | Source |
|-------------|--------|
| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 12 |
| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité |
| `numero_agrement`, `nir`, `date_agrement` | Pro |
| `capacite_accueil`, `places_disponibles` | Pro |
| `biographie` | Présentation |
Validation NIR identique à linscription si `nir` fourni.
### Réponse succès (nouveau format)
```json
{
"message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.",
"statut": "en_attente",
"user_id": "uuid",
"numero_dossier": "2026-000021"
}
```
Code HTTP : **200** (pas de corps `Users` brut comme lancien back).
### Effet métier
- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110).
- **AM :** un seul user.
### E-mail accusé resoumission (parent)
Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) :
- confirmation de resoumission ;
- rappel du **numéro de dossier** ;
- mention « en attente de validation ».
Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale).
---
## 4. Fichiers front à modifier (checklist)
### Modèles
- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM
- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible
### Session / préremplissage
- [ ] `lib/services/reprise_session.dart`
- `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation
- `applyToAm` : remplir tous les champs AM
### API
- [ ] `lib/services/auth_service.dart``resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité
- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile
### Écrans fin de parcours
- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation
- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète
### Hors scope back (inchangé)
RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise.
### Non implémenté front (ticket #112 initial)
- [ ] Modale login « Jai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent)
---
## 5. Tests manuels suggérés
1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation).
2. Ouvrir le lien mail `/reprise?token=…`.
3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`.
4. Après branchement front : wizard prérempli sur toutes les étapes.
5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119).
---
## 6. Références code back
```
backend/src/routes/auth/dto/reprise-dossier.dto.ts
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
backend/src/routes/auth/dto/enfant-reprise.dto.ts
backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise
backend/src/routes/parents/dto/dossier-famille-complet.dto.ts
```
+89
View File
@@ -0,0 +1,89 @@
# Mini-spec API — POST /parents/dossier (#129)
Contrat pour le **plan front** (wizard création dossier famille staff).
Miroir de **#156** (`POST /assistantes-maternelles/dossier`).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/parents/dossier` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Content-Type** | `application/json` |
Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard.
## Body (JSON)
Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur).
### Parent 1 (obligatoire)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `adresse` | string | non | |
| `code_postal` | string | non | |
| `ville` | string | non | |
### Co-parent (optionnel)
`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`,
`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`.
Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register.
### Enfants (≥ 1)
| Champ | Type | Notes |
|-------|------|--------|
| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. |
### Présentation
| Champ | Type | Obligatoire |
|-------|------|-------------|
| `presentation_dossier` | string | non (max 2000) |
## Réponses
### 201 Created
```json
{
"message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-ou-null",
"statut": "actif",
"enfant_ids": ["uuid", "..."]
}
```
Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier,
**e-mail création MDP** pour chaque compte sans MDP (pas daccusé « en attente »).
### Erreurs
| Code | Cas |
|------|-----|
| 400 | Validation DTO / métier (enfants vides, dates, etc.) |
| 401 | Token manquant / invalide |
| 403 | Rôle non staff |
| 409 | Conflit e-mail (pivot et/ou co-parent) |
## Front
- `UserService.createParentDossier(body)` → cet endpoint
- Wizard create basé sur `ValidationFamilyWizard`
- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
## Branche
`feature/129-creation-dossier-parent`
@@ -0,0 +1,73 @@
# Mini-spec API — POST /parents/:id/co-parent (#135)
Contrat back pour lajout dun **2ᵉ parent** sur un foyer mono-parent (staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Succès** | **201** |
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
---
## Body (JSON)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
| `adresse` | string | si `meme_adresse=false` | |
| `code_postal` | string | si `meme_adresse=false` | |
| `ville` | string | si `meme_adresse=false` | |
---
## Comportement 201
- User co-parent **actif** + token création MDP
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
- Enfants du foyer rattachés au co-parent
- E-mail **création MDP** (pas mail « en attente »)
```json
{
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-co",
"statut": "actif"
}
```
## Erreurs
| Code | Cas |
|------|-----|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
| 401 | Token invalide |
| 403 | Rôle non staff |
| 404 | Pivot introuvable |
| 409 | Email déjà pris |
## Réemploi édition identité
| Endpoint | Usage |
|----------|--------|
| `GET /dossiers/:numero` | Préremplir wizard edit |
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
## Branche
`feature/135-edition-dossier`
@@ -0,0 +1,83 @@
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
Branche : `feature/135-edition-dossier`
Ticket : **#135** (full-stack)
Prérequis : **#153** (liste Dossiers) livré.
---
## Objectif
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
---
## Modes wizard
| Mode | Famille | AM |
|------|---------|-----|
| `review` | déjà | déjà |
| `create` | déjà (#129) | déjà (#156) |
| **`edit`** | **à faire** | **à faire** |
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
Préremplir via `UserService.getDossierByNumero(numero)`.
---
## APIs
| Action | Endpoint |
|--------|----------|
| Charger | `GET /dossiers/:numero` |
| Sauver parent | `PATCH /parents/:id/fiche` |
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
Body co-parent :
```json
{
"email": "thomas@…",
"prenom": "Thomas",
"nom": "MARTIN",
"telephone": "0678456789",
"meme_adresse": true
}
```
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
---
## UX
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
- Pending : garder validation (review) ; dossiers actifs → edit
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
- Pas de bouton créer dans longlet Dossiers
---
## Hors scope
- Famille N responsables (#139)
- Suppressions (#154)
- Création dossier initial (#129 / #156)
---
## Critères dacceptation
- [ ] Clic dossier actif → wizard edit prérempli
- [ ] PATCH fiche enregistre les modifs
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
- [ ] review / create inchangés
## Branche
`feature/135-edition-dossier`
@@ -0,0 +1,65 @@
# Mini-spec — Suppression complète grossesse multiple / `est_multiple`
**Ticket** : **#152** — https://git.ptits-pas.fr/jmartin/petitspas/issues/152
**Branche** : `feature/152-remove-est-multiple` (depuis `develop`)
**Périmètre** : **full stack** — BDD + back + front + scripts + docs. **Aucun fantôme.**
---
## Décision
On **supprime tout**. Pas de DTO « ignorés », pas de compat payload.
`forbidNonWhitelisted: true` ⇒ back et front **partent ensemble** (même feature / même déploiement).
---
## Alias retirés
`est_multiple` · `is_multiple` · `grossesse_multiple` · `multipleBirth` · `estMultiple` · `isMultiple` · `jumeau_multiple`
---
## Back / BDD (fait sur la branche)
- [x] Migration `database/migrations/2026_drop_enfants_est_multiple.sql`
- [x] `BDD.sql`, seeds, CSV test
- [x] Entity `Children` sans colonne
- [x] DTO create/inscription/réponse/dossier famille **sans** le champ
- [x] Services auth / enfants / parents : plus de mapping
- [x] Prisma legacy `isMultiple` retiré
## Front (fait sur la branche)
- [x] Modèles admin / dossier / inscription
- [x] Payloads inscription + reprise
- [x] Modale enfant + wizard dossier famille
- [x] Step3 inscription parent
## Scripts / docs
- [x] `tests/scripts/register-parent-*.mjs`
- [x] `docs/10_DATABASE.md`, `docs/99_REGLES-CODAGE.md`
- Docs tmp/archive #112 : mentions historiques OK (archive)
---
## Déploiement
1. Appliquer la migration SQL sur la BDD vivante
2. Deploy back **et** front de cette branche
3. Smoke : création enfant staff, inscription parent, reprise, wizard famille
## Vérif
```bash
rg -n 'est_multiple|is_multiple|grossesse_multiple|multipleBirth|estMultiple|isMultiple|jumeau_multiple' \
backend/src frontend/lib database tests/scripts docs/10_DATABASE.md docs/99_REGLES-CODAGE.md
```
**0** hit (hors ce fichier mini-spec / archives).
## Hors scope
- Métier futur « fratrie / jumeaux » → nouveau ticket
- #155 rename Admin*
- Ticket modales staff
@@ -0,0 +1,77 @@
# Mini-spec API — GET /dossiers (#153)
Contrat pour le **plan front** (onglet permanent Dossiers).
## Endpoint
| | |
|--|--|
| **Méthode** | `GET` |
| **URL** | `{base}/api/v1/dossiers` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Query** | `q` (optionnel) — recherche n° / libellé / email |
Complète `GET /dossiers/:numeroDossier` (#119) déjà existant.
---
## Réponse 200
Tableau de lignes (1 entrée = 1 `numero_dossier`) :
```json
[
{
"type": "famille",
"numero_dossier": "2026-000043",
"libelle": "Claire MARTIN & Thomas MARTIN",
"emails": ["claire@test.fr", "thomas@test.fr"],
"user_ids": ["uuid-pivot", "uuid-co"],
"statut": "actif",
"a_valider": false,
"date_reference": "2026-01-12T10:00:00.000Z"
},
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000042",
"libelle": "Marie DUPONT",
"emails": ["marie@test.fr"],
"user_ids": ["uuid-am"],
"statut": "en_attente",
"a_valider": true,
"date_reference": "2026-02-01T08:00:00.000Z"
}
]
```
### Champs
| Champ | Notes |
|-------|--------|
| `type` | `famille` \| `assistante_maternelle` |
| `numero_dossier` | Clé dunité |
| `libelle` | Noms formatés (foyer : `A & B`) |
| `emails` / `user_ids` | Membres du foyer ou AM |
| `statut` | Agrégé : `en_attente` si au moins un user pending |
| `a_valider` | `true` si pending → section haute UI |
| `date_reference` | `MIN(cree_le)` des users |
**Tri** : `a_valider` dabord, puis `numero_dossier` décroissant.
**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne).
---
## Front
- `UserService.getDossiers({ q? })` → cet endpoint
- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes
- Section basse : liste complète (ou hors pending selon règle UX)
- Clic → `GET /dossiers/:numero` (détail) / validation review
Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé.
## Branche
`feature/153-onglet-dossiers`
+167
View File
@@ -0,0 +1,167 @@
# Mini-spec front — Onglet permanent « Dossiers » (#153)
Branche Git (front + back) : `feature/153-onglet-dossiers`
Ticket Gitea : **#153** (ticket normal, plus epic)
> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent.
> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète.
---
## Contexte / objectif
Remplacer longlet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire.
Quand on ouvre **Dossiers** :
1. **En haut** — section **Dossiers à valider** (AM + familles pending)
2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier`
3. Différenciation visuelle famille vs AM : **couleur + icône**
4. **Barre de recherche** (n° dossier, nom, email…)
**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156).
---
## UX cible
### Onglets dashboard (`UserManagementPanel`)
| Avant (#107) | Après (#153) |
|--------------|--------------|
| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) |
| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** |
Ordre suggéré des onglets :
`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`)
### Section haute — À valider
- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget).
- Sources déjà branchées :
- `UserService.getPendingUsers(role: 'assistante_maternelle')`
- `UserService.getPendingFamilies()`
- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé).
- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ».
### Section basse — Tous les dossiers
1 ligne = **1 dossier** (`numero_dossier`), type :
| Type | Libellé UI | Couleur (suggestion) |
|------|------------|----------------------|
| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) |
| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) |
Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) :
- n° dossier
- type (pastille couleur + icône)
- libellé (noms parents ou AM)
- email(s) principal(aux)
- statut user / dossier si dispo (`actif`, `en_attente`, …)
- date utile si dispo
**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM.
### Recherche
- La search bar du panel (aujourdhui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché).
- Critères **minimum** : `numero_dossier`, nom, prénom, email.
- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…`
### État vide liste complète
Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*.
### Clic sur un dossier de la liste complète (#153)
| Cas | Comportement #153 |
|-----|-------------------|
| Pending | Ouvrir validation (review) — déjà en place |
| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition |
**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**.
Si louverture « review » dun dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »***à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types.
---
## Données / APIs (front)
### Déjà disponibles (préférer composer côté front pour #153)
| Besoin | API / service |
|--------|----------------|
| Pending AM | `getPendingUsers(role: assistante_maternelle)` |
| Pending familles | `getPendingFamilies()` |
| Parents (avec `numero_dossier`) | `getParents()` |
| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` |
| Détail unifié | `getDossierByNumero(numero)``GET /dossiers/:numeroDossier` |
**Pas dendpoint `GET /dossiers` liste** aujourdhui. Pour #153 :
- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`).
- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR).
**Règle recommandée** :
- Haut = pending only
- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only
**Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only.
### Si le back ajoute plus tard `GET /dossiers`
Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK.
---
## Fichiers front probables
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet |
| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) |
| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh |
| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne |
| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) |
| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail |
Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`.
---
## Hors scope (#153)
- Bouton créer dossier
- Mode `edit` wizard + ajout 2ᵉ parent → **#135**
- Suppressions → **#154**
- Famille N responsables → **#139**
- Changer les onglets Parents / AM / Enfants (restent)
---
## Critères dacceptation front
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
- [ ] Plus donglet conditionnel **« À valider »**
- [ ] Section haute pending si non vide ; validation au clic OK
- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier`
- [ ] Couleur + icône différencient famille / AM
- [ ] Recherche filtre (n° + nom + email minimum)
- [ ] **Aucun** bouton créer dans cet onglet
- [ ] Pas de régression validation pending (valider / refuser)
---
## Back (info — Cursor back séparé si besoin)
- Liste unifiée : **pas bloquante** si composition front
- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard
- `GET /dossiers/:numero` déjà là (#119)
---
## Branche
`feature/153-onglet-dossiers` (depuis `develop`)
+35
View File
@@ -0,0 +1,35 @@
# Matrice suppression — #154 / back **#159** / front **#160**
**Statut** : cadrage PO validé (sept. 2026)
**Milestone** : 0.1.0
**Email** : pas demail de suppression (cas rare)
## Droits
| Cible | Qui peut supprimer |
|-------|-------------------|
| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable |
## Matrice métier
| Point dentrée | Action | Effet |
|----------------|--------|--------|
| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants |
| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** |
| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent |
| Parents | Dernier parent | Parent + **enfants** rattachés |
| Enfants | Pas dernier | Enfant seul (retiré du dossier) |
| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) |
| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** |
| Pending / validé | — | **Mêmes règles** (pas de différenciation) |
## Warning
- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié).
- Miroir de `sans_responsable` (#157) côté enfants.
## Hors scope
Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard).
+169
View File
@@ -0,0 +1,169 @@
# Mini-spec front — Suppressions dashboard
**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160
**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159
**Epic** : #154 (complète #133)
**Branche back** : `feature/159-suppressions-backend`
**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md)
Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API.
---
## UX commune
Sur chaque ligne / carte des listes :
- Icône **poubelle** en bout de ligne
- Clic → **dialog de confirmation** (texte dimpact) → DELETE → **refresh** liste
- Pending = **mêmes** règles que validés
- **Pas** demail
| Liste | Poubelle visible si |
|-------|---------------------|
| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin |
| Gestionnaires | **admin** only |
| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin |
---
## Contrat API
Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR.
### 1. `DELETE /dossiers/:numeroDossier`
- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants.
- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos.
**Réponse 200** (exemple) :
```json
{
"type": "famille",
"numero_dossier": "2026-000043",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "Dossier famille supprimé."
}
```
ou
```json
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000015",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": [],
"message": "Dossier assistante maternelle supprimé."
}
```
**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »).
---
### 2. `DELETE /users/:id`
Comportement selon le **rôle** de la cible :
| Cible | Effet |
|-------|--------|
| Parent **co-parent** | Delete ce user seul |
| Parent **dernier** du dossier | Delete user + enfants du foyer |
| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos |
| Gestionnaire | Admin only ; self → 403 |
| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 |
**Réponse 200** :
```json
{
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "…"
}
```
**Dialogs** :
- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. »
- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. »
- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. »
Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier.
---
### 3. `DELETE /enfants/:id?deleteDossier=true|false`
- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false).
- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`.
- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants).
**Réponse 200** :
```json
{
"deleted_enfant_ids": ["…"],
"deleted_user_ids": ["…"],
"dossier_supprime": false,
"message": "…"
}
```
**Dialog** :
- Standard : « Lenfant sera supprimé du dossier de {famille} ({n°}). »
- Dernier : proposer **deux actions** :
1. Supprimer lenfant seulement (`deleteDossier=false`)
2. Supprimer aussi le dossier / parents (`deleteDossier=true`)
Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API).
---
### 4. `GET /dossiers` — flag `sans_enfant`
Chaque item famille peut exposer :
```json
"sans_enfant": true
```
- `true` si dossier **famille** sans enfant lié.
- AM : `false` ou omis.
**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM).
---
## UserService (Flutter) — signatures cibles
```dart
Future<void> deleteDossier(String numeroDossier);
Future<Map<String, dynamic>> deleteUser(String userId);
Future<Map<String, dynamic>> deleteEnfant(String enfantId, {bool deleteDossier = false});
```
(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.)
---
## Fichiers front probables
- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers
- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart`
- `user_service.dart`
---
## Critères front (#160)
- [ ] Poubelle selon droits
- [ ] Confirmations avec impact
- [ ] Refresh après succès
- [ ] Dernier enfant : choix dossier oui/non
- [ ] Warning `sans_enfant`
- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)
@@ -0,0 +1,54 @@
# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155)
**Ticket** : **#155**
**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`)
**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`.
---
## Principe
Les widgets partagés **admin + gestionnaire** ne doivent plus sappeler `Admin*`.
On garde `Admin*` seulement là où cest vraiment le rôle administrateur.
## Gardé `Admin*` (hors rename)
| Élément | Raison |
|---------|--------|
| `AdminManagementWidget` | Onglet **Administrateurs** |
| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` |
| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket |
## Renames faits
| Avant | Après |
|-------|--------|
| `widgets/admin/common/admin_child_detail_modal.dart``AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart``ChildDetailModal` |
| `admin_am_edit_modal``AdminAmEditModal` | `am_edit_modal``AmEditModal` |
| `admin_parent_edit_modal``AdminParentEditModal` | `parent_edit_modal``ParentEditModal` |
| `admin_user_card``AdminUserCard` | `user_card``UserCard` |
| `admin_enfant_user_card``AdminEnfantUserCard` | `enfant_user_card``EnfantUserCard` |
| `admin_am_photo_frame``AdminAmPhotoFrame` | `am_photo_frame``AmPhotoFrame` |
| `admin_am_children_capacity_grid` | `am_children_capacity_grid``AmChildrenCapacityGrid` |
| `admin_children_affiliation_panel` | `children_affiliation_panel``ChildrenAffiliationPanel` |
| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` |
| `admin_status_capsule` | `status_capsule``StatusCapsule` |
| `admin_list_state``AdminListState` | `user_list_state``UserListState` |
| `admin_detail_modal``AdminDetailModal` / `AdminDetailField` | `detail_modal``DetailModal` / `DetailField` |
| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) |
Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`.
**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md).
## Hors scope
- Refonte UX modales (ticket dédié)
- Rename API / back
- Déplacer tout `widgets/admin/``widgets/dashboard/` (panels) — possible follow-up
## Critères
- [x] Plus de préfixe `Admin` sur les composants **partagés** listés
- [ ] Build Flutter / recette dashboard admin + gestionnaire OK
- [x] Pas de changement comportemental (rename mécanique)
@@ -0,0 +1,203 @@
# Mini-spec — Déplacer les panels `widgets/admin/``widgets/dashboard/`
**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`)
**Phase 1** : widgets `Admin*``widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`)
**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1)
**Nature** : rename / move mécanique — **zéro** changement UX / métier
---
## Contexte
Après la phase 1 (#155), la situation est **hybride** :
| Emplacement | Contenu |
|-------------|---------|
| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) |
| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` |
Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen``UserManagementPanel`).
Ce ticket **termine loption C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin.
---
## Objectif
```
frontend/lib/widgets/admin/<panels & common partagés>
↓ git mv + update imports
frontend/lib/widgets/dashboard/…
```
Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire.
---
## Cible darborescence (proposée)
```
widgets/dashboard/
├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, …
├── user_management_panel.dart ← shell onglets
├── user_management_sub_bar.dart ← déjà déplacé #155
├── dossiers_management_widget.dart
├── dossier_list_card.dart
├── parent_management_widget.dart ← corriger le typo managmant au passage ?
├── enfant_management_widget.dart
├── assistante_maternelle_management_widget.dart
├── gestionnaire_management_widget.dart
├── pending_validation_widget.dart
├── parent_dossier_create_modal.dart
├── parent_dossier_wizard.dart
├── am_dossier_create_modal.dart
├── am_dossier_wizard.dart
├── validation_*.dart ← family/am wizards, refus, theme, confirm
├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard)
├── relais_management_panel.dart
├── common/ ← sous-dossier optionnel
│ ├── suppression_confirm_dialog.dart
│ ├── user_list.dart
│ └── validation_detail_section.dart
└── …
widgets/admin/ ← mince, rôle admin seulement
└── admin_management_widget.dart ← onglet Administrateurs
```
### Variante B (plus stricte)
`AdminManagementWidget` + éventuels helpers purement admin →
`screens/administrateurs/widgets/`
et **suppression** du dossier `widgets/admin/`.
**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair.
---
## Inventaire à déplacer (état actuel)
### Racine `widgets/admin/``widgets/dashboard/`
| Fichier actuel | Notes |
|----------------|--------|
| `user_management_panel.dart` | Shell partagé admin + gestionnaire |
| `dossiers_management_widget.dart` | |
| `dossier_list_card.dart` | |
| `parent_managmant_widget.dart` | Typo historique `managmant`**option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé |
| `enfant_management_widget.dart` | |
| `assistante_maternelle_management_widget.dart` | |
| `gestionnaire_management_widget.dart` | |
| `pending_validation_widget.dart` | |
| `parent_dossier_create_modal.dart` | |
| `parent_dossier_wizard.dart` | |
| `am_dossier_create_modal.dart` | |
| `am_dossier_wizard.dart` | |
| `validation_am_wizard.dart` | |
| `validation_family_wizard.dart` | |
| `validation_dossier_modal.dart` | |
| `validation_modal_theme.dart` | |
| `validation_refus_form.dart` | |
| `validation_valider_confirm_dialog.dart` | |
| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard |
| `relais_management_panel.dart` | |
### `widgets/admin/common/``widgets/dashboard/common/` (ou plat)
| Fichier | Notes |
|---------|--------|
| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) |
| `user_list.dart` | |
| `validation_detail_section.dart` | |
### **Ne pas** déplacer
| Fichier | Destination |
|---------|-------------|
| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) |
### Déjà fait (#155) — ne pas retraiter
Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …).
---
## Consommateurs dimports (à mettre à jour)
### Screens
- `screens/administrateurs/admin_dashboardScreen.dart``UserManagementPanel`, `ParametresPanel`
- `screens/gestionnaire/gestionnaire_dashboard_screen.dart``UserManagementPanel`
- `screens/administrateurs/creation/admin_create.dart``suppression_confirm_dialog`
- `screens/administrateurs/creation/gestionnaires_create.dart` — idem
### Widgets déjà en `dashboard/`
- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels
### Divers
- `widgets/common/identity_block.dart` (si import admin)
- Tous les fichiers **déplacés** entre eux (imports relatifs / package)
### Hors scope rename classes
Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports).
`AdminManagementWidget` **conserve** son nom.
---
## Plan dexécution
1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée
2. `git mv` fichiers selon inventaire
3. Remplacer globalement
`package:p_tits_pas/widgets/admin/``package:p_tits_pas/widgets/dashboard/`
**sauf** `…/widgets/admin/admin_management_widget.dart`
4. Corriger imports relatifs cassés
5. Grep de contrôle (ci-dessous)
6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire
7. Merge → squash master si flux habituel
---
## Vérifs
```bash
# Plus de panels partagés sous admin (seul AdminManagement attendu)
find frontend/lib/widgets/admin -name '*.dart'
# Plus dimports panels vers lancien chemin (sauf AdminManagement)
rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib
# Screens OK
rg -n "widgets/admin/" frontend/lib/screens
```
Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`).
---
## Hors scope
- Refonte UX des modales / panels (ticket dédié annoncé)
- Rename `EnfantAdminModel`
- Rename `screens/administrateurs/`
- Rename `AdminUserFormDialog` / `AdminCreateDialog`
- Changement API / back
- #152 (`est_multiple`) — autre branche
---
## Critères dacceptation
- [ ] Inventaire déplacé selon tableau
- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A)
- [ ] Imports screens + widgets à jour
- [ ] Build Flutter OK
- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression
- [ ] Aucun changement comportemental volontaire
---
## Risques / notes
- **Conflits de merge** si dautres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK)
- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé
- Docs darchive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
@@ -0,0 +1,14 @@
# Mini-spec — Uniformisation modale staff (Gestionnaire / Administrateur)
**Ticket** : **#164** — https://git.ptits-pas.fr/jmartin/petitspas/issues/164
**Branche** : `feature/164-staff-modal-uniformisation` (depuis `develop`)
**Périmètre** : **front only** — pas dAPI / BDD
**Milestone** : **0.1.0**
Voir le corps du ticket #164 pour la spec complète.
## Livré
- `frontend/lib/widgets/dashboard/staff_user_form_modal.dart``StaffUserFormModal`
- Ancien `AdminUserFormDialog` / `gestionnaires_create.dart` retiré
- Imports : `user_management_panel`, `gestionnaire_management_widget`, `admin_management_widget`
+239
View File
@@ -0,0 +1,239 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
enum DossierListType { famille, assistanteMaternelle }
class DossierListItem {
final DossierListType type;
final String numeroDossier;
final String libelle;
final List<String> emails;
final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl;
/// Dossier famille sans enfant lié (#159 / #160).
final bool sansEnfant;
/// Nombre denfants du foyer (famille uniquement ; null pour AM).
final int? enfantsCount;
const DossierListItem({
required this.type,
required this.numeroDossier,
required this.libelle,
this.emails = const [],
this.statut,
this.photoUrl,
this.sansEnfant = false,
this.enfantsCount,
});
DossierListItem copyWith({
DossierListType? type,
String? numeroDossier,
String? libelle,
List<String>? emails,
String? statut,
String? photoUrl,
bool? sansEnfant,
int? enfantsCount,
}) {
return DossierListItem(
type: type ?? this.type,
numeroDossier: numeroDossier ?? this.numeroDossier,
libelle: libelle ?? this.libelle,
emails: emails ?? this.emails,
statut: statut ?? this.statut,
photoUrl: photoUrl ?? this.photoUrl,
sansEnfant: sansEnfant ?? this.sansEnfant,
enfantsCount: enfantsCount ?? this.enfantsCount,
);
}
bool get isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle;
String get typeLabel => isFamille ? 'Famille' : 'AM';
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
String get namesLine => libelle;
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
/// Titre carte : numéro de dossier seul.
String get titleLine => numeroDossier;
bool matchesQuery(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
if (numeroDossier.toLowerCase().contains(q)) return true;
if (libelle.toLowerCase().contains(q)) return true;
for (final e in emails) {
if (e.toLowerCase().contains(q)) return true;
}
return false;
}
/// Une ligne par `numero_dossier` (foyer dédupliqué).
static List<DossierListItem> fromParents(List<ParentModel> parents) {
final byDossier = <String, List<ParentModel>>{};
for (final p in parents) {
final num = (p.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => []).add(p);
}
final items = <DossierListItem>[];
for (final entry in byDossier.entries) {
final seenIds = <String>{};
final names = <String>[];
final emails = <String>[];
final statuts = <String>[];
void consider(
String? id,
String? nom,
String? prenom,
String? email,
String? statut,
) {
final uid = (id ?? '').trim();
if (uid.isEmpty || !seenIds.add(uid)) return;
final label = formatDossierPersonLabel(
nom: nom,
prenom: prenom,
email: email,
);
if (label.isNotEmpty) names.add(label);
final e = (email ?? '').trim();
if (e.isNotEmpty) emails.add(e);
final s = (statut ?? '').trim();
if (s.isNotEmpty) statuts.add(s);
}
for (final p in entry.value) {
consider(
p.user.id,
p.user.nom,
p.user.prenom,
p.user.email,
p.user.statut,
);
final co = p.coParent;
if (co != null) {
consider(co.id, co.nom, co.prenom, co.email, co.statut);
}
}
final childIds = <String>{};
var maxCountFallback = 0;
for (final p in entry.value) {
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCountFallback) maxCountFallback = n;
}
final enfantsCount =
childIds.isNotEmpty ? childIds.length : maxCountFallback;
items.add(
DossierListItem(
type: DossierListType.famille,
numeroDossier: entry.key,
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails,
statut: _preferStatut(statuts),
enfantsCount: enfantsCount,
sansEnfant: enfantsCount == 0,
),
);
}
return items;
}
static List<DossierListItem> fromAssistantes(
List<AssistanteMaternelleModel> ams,
) {
final byDossier = <String, AssistanteMaternelleModel>{};
for (final am in ams) {
final num = (am.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => am);
}
return byDossier.entries.map((e) {
final u = e.value.user;
final name = formatDossierPersonLabel(
nom: u.nom,
prenom: u.prenom,
email: u.email,
);
final photo = (u.photoUrl ?? '').trim();
return DossierListItem(
type: DossierListType.assistanteMaternelle,
numeroDossier: e.key,
libelle: name.isNotEmpty ? name : 'AM',
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
statut: u.statut?.trim(),
photoUrl: photo.isEmpty ? null : photo,
);
}).toList();
}
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
static String? _preferStatut(List<String> raw) {
if (raw.isEmpty) return null;
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
for (final wanted in order) {
for (final s in raw) {
if (s.toLowerCase() == wanted) return s;
}
}
return raw.first;
}
}
/// Affichage carte dossier : `NOM Prénom` (repli email).
String formatDossierPersonLabel({
String? nom,
String? prenom,
String? email,
}) {
final n = (nom ?? '').trim().toUpperCase();
final p = formatPersonNameCase(prenom ?? '');
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
if (n.isNotEmpty) return n;
if (p.isNotEmpty) return p;
return (email ?? '').trim();
}
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
String formatDossierFamilyNamesLine(String libelle) {
var raw = libelle.trim();
if (raw.isEmpty) return '';
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
raw = raw
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
final parts = raw
.split(RegExp(r'\s+-\s+'))
.map((part) => _formatLoosePersonSegment(part.trim()))
.where((s) => s.isNotEmpty)
.toList();
return parts.join(' - ');
}
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
String _formatLoosePersonSegment(String segment) {
final words =
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return '';
if (words.length == 1) return words.first.toUpperCase();
// Convention affichage : premier mot = NOM, reste = prénom(s).
final nom = words.first.toUpperCase();
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
return '$nom $prenom';
}
+6 -5
View File
@@ -185,7 +185,6 @@ class EnfantDossier {
final String? dueDate;
final String? photoUrl;
final bool consentPhoto;
final bool estMultiple;
EnfantDossier({
required this.id,
@@ -197,7 +196,6 @@ class EnfantDossier {
this.dueDate,
this.photoUrl,
this.consentPhoto = false,
this.estMultiple = false,
});
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
@@ -215,8 +213,13 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
final rawId = json['id'] ??
json['enfant_id'] ??
json['enfantId'] ??
json['child_id'] ??
json['childId'];
return EnfantDossier(
id: json['id']?.toString() ?? '',
id: rawId?.toString().trim() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
@@ -226,8 +229,6 @@ class EnfantDossier {
photoUrl: resolvedPhoto,
consentPhoto:
json['consent_photo'] == true || json['consentPhoto'] == true,
estMultiple:
json['est_multiple'] == true || json['estMultiple'] == true,
);
}
}
@@ -12,7 +12,6 @@ class EnfantAdminModel {
final String status;
final String? photoUrl;
final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
@@ -27,7 +26,6 @@ class EnfantAdminModel {
required this.status,
this.photoUrl,
this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [],
this.sansResponsable,
});
@@ -56,7 +54,6 @@ class EnfantAdminModel {
String? status,
String? photoUrl,
bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) {
@@ -70,7 +67,6 @@ class EnfantAdminModel {
status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
);
@@ -111,8 +107,6 @@ class EnfantAdminModel {
),
photoUrl: photoUrl,
consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links,
sansResponsable: sansResponsable,
);
@@ -127,7 +121,6 @@ class EnfantAdminModel {
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
'consent_photo': consentPhoto,
'is_multiple': isMultiple,
};
}
@@ -30,7 +30,6 @@ class ChildData {
String lastName;
String dob; // Date de naissance ou prévisionnelle
bool photoConsent;
bool multipleBirth;
bool isUnbornChild;
File? imageFile;
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
@@ -40,7 +39,6 @@ class ChildData {
this.lastName = '',
this.dob = '',
this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false,
this.imageFile,
required this.cardColor, // Rendre requis dans le constructeur
@@ -38,7 +38,6 @@ class ChildData {
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
String genre;
bool photoConsent;
bool multipleBirth;
bool isUnbornChild;
File? imageFile;
/// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web).
@@ -55,7 +54,6 @@ class ChildData {
this.dob = '',
this.genre = '',
this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false,
this.imageFile,
this.imageBytes,
@@ -70,7 +68,6 @@ class ChildData {
String? dob,
String? genre,
bool? photoConsent,
bool? multipleBirth,
bool? isUnbornChild,
Object? imageFile = _unsetImage,
Object? imageBytes = _unsetImageBytes,
@@ -84,7 +81,6 @@ class ChildData {
dob: dob ?? this.dob,
genre: genre ?? this.genre,
photoConsent: photoConsent ?? this.photoConsent,
multipleBirth: multipleBirth ?? this.multipleBirth,
isUnbornChild: isUnbornChild ?? this.isUnbornChild,
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
imageBytes:
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
import 'package:p_tits_pas/widgets/dashboard/parametres_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
Future<void> _delete() async {
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
final name = widget.initialUser!.fullName.isEmpty
? widget.initialUser!.email
: widget.initialUser!.fullName;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (confirmed != true) return;
if (!confirmed) return;
setState(() {
_isSubmitting = true;
@@ -1,670 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const AdminUserFormDialog({
super.key,
this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
});
@override
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
}
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false;
bool _obscurePassword = true;
bool _isLoadingRelais = true;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
String? _currentUserId;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isSelfTarget =>
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey {
if (widget.initialUser != null) {
return (widget.initialUser!.role).toLowerCase();
}
return widget.adminMode ? 'administrateur' : 'gestionnaire';
}
String get _targetRoleLabel {
switch (_targetRoleKey) {
case 'super_admin':
return 'Super administrateur';
case 'administrateur':
return 'Administrateur';
case 'gestionnaire':
return 'Gestionnaire';
case 'assistante_maternelle':
return 'Assistante maternelle';
case 'parent':
return 'Parent';
default:
return 'Utilisateur';
}
}
IconData get _targetRoleIcon {
switch (_targetRoleKey) {
case 'super_admin':
return Icons.verified_user_outlined;
case 'administrateur':
return Icons.admin_panel_settings_outlined;
case 'gestionnaire':
return Icons.assignment_ind_outlined;
case 'assistante_maternelle':
return Icons.child_care_outlined;
case 'parent':
return Icons.supervisor_account_outlined;
default:
return Icons.person_outline;
}
}
@override
void initState() {
super.initState();
final user = widget.initialUser;
if (user != null) {
_nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? '';
_emailController.text = user.email;
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear();
final initialRelaisId = user.relaisId?.trim();
_selectedRelaisId =
(initialRelaisId == null || initialRelaisId.isEmpty)
? null
: initialRelaisId;
}
if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
_loadCurrentUserId();
}
Future<void> _loadCurrentUserId() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserId = cached.id;
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
});
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
_passwordToggleFocusNode.dispose();
super.dispose();
}
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
if (!mounted) return;
final uniqueById = <String, RelaisModel>{};
for (final relais in list) {
uniqueById[relais.id] = relais;
}
final filtered = uniqueById.values.where((r) => r.actif).toList();
if (_selectedRelaisId != null &&
!filtered.any((r) => r.id == _selectedRelaisId)) {
final selected = uniqueById[_selectedRelaisId!];
if (selected != null) {
filtered.add(selected);
} else {
// Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
}
}
setState(() {
_relais = filtered;
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() {
_relais = _fallbackRelaisFromUser();
_isLoadingRelais = false;
});
}
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
return null;
}
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) {
return base;
}
return validateEmail(value, allowEmpty: true);
}
String? _validatePassword(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Mot de passe');
if (base != null) return base;
if (value!.trim().length < 6) return 'Minimum 6 caractères';
return null;
}
String? _validatePhone(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Téléphone');
if (base != null) {
return base;
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String _toTitleCase(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return trimmed;
final words = trimmed.split(RegExp(r'\s+'));
final normalizedWords = words.map(_capitalizeComposedWord).toList();
return normalizedWords.join(' ');
}
String _capitalizeComposedWord(String word) {
if (word.isEmpty) return word;
final lower = word.toLowerCase();
final separators = <String>{"-", "'", ""};
final buffer = StringBuffer();
var capitalizeNext = true;
for (var i = 0; i < lower.length; i++) {
final char = lower[i];
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
buffer.write(char.toUpperCase());
capitalizeNext = false;
} else {
buffer.write(char);
capitalizeNext = separators.contains(char);
}
}
return buffer.toString();
}
Future<void> _submit() async {
if (widget.readOnly) return;
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSubmitting = true;
});
try {
final normalizedNom = _toTitleCase(_nomController.text);
final normalizedPrenom = _toTitleCase(_prenomController.text);
final normalizedPhone = normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) {
if (widget.adminMode) {
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty
? normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone,
password: passwordProvided ? _passwordController.text : null,
);
} else {
final currentUser = widget.initialUser!;
final initialNom = _toTitleCase(currentUser.nom ?? '');
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
final initialEmail = currentUser.email.trim();
final initialPhone = normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged =
normalizedNom == initialNom &&
normalizedPrenom == initialPrenom &&
_emailController.text.trim() == initialEmail &&
normalizedPhone == initialPhone &&
!passwordProvided;
if (onlyRelaisChanged) {
await UserService.updateGestionnaireRelais(
gestionnaireId: currentUser.id,
relaisId: _selectedRelaisId,
);
} else {
await UserService.updateGestionnaire(
gestionnaireId: currentUser.id,
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null,
);
}
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: normalizePhone(_telephoneController.text),
);
} else {
await UserService.createGestionnaire(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId,
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? (widget.adminMode
? 'Administrateur modifié avec succès.'
: 'Gestionnaire modifié avec succès.')
: (widget.adminMode
? 'Administrateur créé avec succès.'
: 'Gestionnaire créé avec succès.'),
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toString().replaceFirst('Exception: ', ''),
),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (!mounted) return;
setState(() {
_isSubmitting = false;
});
}
}
Future<void> _delete() async {
if (widget.readOnly) return;
if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
);
if (confirmed != true) return;
setState(() {
_isSubmitting = true;
});
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gestionnaire supprimé.')),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
setState(() {
_isSubmitting = false;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFEDE5FA),
child: Icon(
_targetRoleIcon,
size: 20,
color: const Color(0xFF6B3FA0),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isEditMode
? (widget.readOnly
? 'Consulter un "$_targetRoleLabel"'
: 'Modifier un "$_targetRoleLabel"')
: 'Créer un "$_targetRoleLabel"',
),
),
if (_isEditMode && !widget.readOnly)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
),
],
),
content: SizedBox(
width: 620,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _buildPrenomField()),
const SizedBox(width: 12),
Expanded(child: _buildNomField()),
],
),
const SizedBox(height: 12),
_buildEmailField(),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPasswordField()),
const SizedBox(width: 12),
Expanded(child: _buildTelephoneField()),
],
),
if (widget.withRelais) ...[
const SizedBox(height: 12),
_buildRelaisField(),
],
],
),
),
),
),
actions: [
if (widget.readOnly) ...[
FilledButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (_canDeleteTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
),
] else ...[
OutlinedButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
),
],
],
);
}
Widget _buildNomField() {
return TextFormField(
controller: _nomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Nom'),
);
}
Widget _buildPrenomField() {
return TextFormField(
controller: _prenomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Prénom'),
);
}
Widget _buildEmailField() {
return EmailTextFormField(
controller: _emailController,
readOnly: widget.readOnly,
label: 'Email',
validator: widget.readOnly ? null : _validateEmail,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
readOnly: widget.readOnly,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
decoration: InputDecoration(
labelText: _isEditMode
? 'Nouveau mot de passe'
: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
),
validator: widget.readOnly ? null : _validatePassword,
);
}
Widget _buildTelephoneField() {
return FrenchPhoneTextFormField(
controller: _telephoneController,
readOnly: widget.readOnly,
label: 'Téléphone (ex: 06 12 34 56 78)',
validator: widget.readOnly ? null : _validatePhone,
);
}
Widget _buildRelaisField() {
final selectedValue = _selectedRelaisId != null &&
_relais.any((relais) => relais.id == _selectedRelaisId)
? _selectedRelaisId
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String?>(
isExpanded: true,
value: selectedValue,
decoration: const InputDecoration(
labelText: 'Relais principal',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Aucun relais'),
),
..._relais.map(
(relais) => DropdownMenuItem<String?>(
value: relais.id,
child: Text(relais.nom),
),
),
],
onChanged: (_isLoadingRelais || widget.readOnly)
? null
: (value) {
setState(() {
_selectedRelaisId = value;
});
},
),
if (_isLoadingRelais) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
],
);
}
}
@@ -121,7 +121,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
dob: '',
isUnbornChild: false,
photoConsent: false,
multipleBirth: false,
cardColor: cardColor,
);
registrationData.addChild(newChild);
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
body: Column(
children: [
Expanded(
child: UserManagementPanel(showAdministrateursTab: false),
child: UserManagementPanel(
showAdministrateursTab: false,
allowStaffAccountCreation: false,
),
),
const AppFooter(),
],
@@ -60,6 +60,8 @@ class ApiConfig {
static const String userChildren = '/users/children';
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
/// Création dossier famille actif par le staff (#129) — body type register parent.
static const String parentsDossier = '/parents/dossier';
static const String assistantesMaternelles = '/assistantes-maternelles';
/// Création dossier AM actif par le staff (#156) — body type register AM.
static const String assistantesMaternellesDossier =
+234 -19
View File
@@ -525,17 +525,58 @@ class UserService {
return enfant.copyWith(parentLinks: enriched);
}
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
static Future<EnfantAdminModel> updateEnfant({
required String enfantId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
body: jsonEncode(body),
);
final id = enfantId.trim();
if (id.isEmpty) {
throw Exception('Identifiant enfant manquant.');
}
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
headers: await _headers(),
body: jsonEncode(body),
);
}
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
throw Exception(
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
@@ -605,14 +646,93 @@ class UserService {
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async {
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteEnfant(
String enfantId, {
bool deleteDossier = false,
}) async {
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
).replace(
queryParameters: {
'deleteDossier': deleteDossier ? 'true' : 'false',
},
);
final response = await http.delete(uri, headers: await _headers());
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
);
}
return _parseSuppressionBody(response.body);
}
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteDossier(
String numeroDossier,
) async {
final num = numeroDossier.trim();
if (num.isEmpty) {
throw Exception('Numéro de dossier manquant.');
}
final encoded = Uri.encodeComponent(num);
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression dossier'),
);
}
return _parseSuppressionBody(response.body);
}
/// Liste unifiée GET /dossiers — flags `sans_enfant` (#159).
static Future<List<Map<String, dynamic>>> listDossiers({String? q}) async {
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}')
.replace(
queryParameters: (q != null && q.trim().isNotEmpty)
? {'q': q.trim()}
: null,
);
final response = await http.get(uri, headers: await _headers());
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur chargement dossiers'),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) return const [];
return decoded
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
static Future<Map<String, bool>> getSansEnfantByNumero() async {
final rows = await listDossiers();
final out = <String, bool>{};
for (final row in rows) {
final num = (row['numero_dossier'] ?? '').toString().trim();
if (num.isEmpty) continue;
final type = (row['type'] ?? '').toString().toLowerCase();
if (type != 'famille') continue;
out[num] = row['sans_enfant'] == true;
}
return out;
}
static Map<String, dynamic> _parseSuppressionBody(String body) {
if (body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
@@ -645,6 +765,105 @@ class UserService {
return fallback;
}
/// Ajout dun co-parent sur foyer mono-parent (staff, #135).
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
static Future<Map<String, dynamic>> addCoParent(
String pivotUserId, {
required Map<String, dynamic> body,
}) async {
final id = pivotUserId.trim();
if (id.isEmpty) {
throw Exception('Identifiant du parent pivot manquant.');
}
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur ajout co-parent',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier famille actif côté staff (#129).
/// `POST /parents/dossier` — body aligné sur register parent complet.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
static Future<Map<String, dynamic>> createParentDossier(
Map<String, dynamic> body,
) async {
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur création dossier famille',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier AM actif côté staff (#156).
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
@@ -1084,22 +1303,18 @@ class UserService {
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async {
/// DELETE /users/:id — cascades métier #159 / #160.
static Future<Map<String, dynamic>> deleteUser(String userId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
);
}
return _parseSuppressionBody(response.body);
}
}
@@ -166,7 +166,6 @@ class ParentRegistrationPayload {
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth,
'consent_photo': c.photoConsent,
};
-1
View File
@@ -44,7 +44,6 @@ class RepriseMapper {
dob: dob,
genre: e.gender ?? '',
photoConsent: e.consentPhoto,
multipleBirth: e.estMultiple,
isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length],
repriseChildId: e.id,
@@ -0,0 +1,59 @@
/// Création comptes staff (gestionnaire / admin) — ticket #161.
bool canCreateStaffAccounts(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
/// Droits daffichage poubelle — tickets #154 / #160.
bool canDeleteMetier(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'gestionnaire' ||
r == 'administrateur' ||
r == 'super_admin';
}
bool canDeleteGestionnaire(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
bool canDeleteAdministrateur({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
final me = (currentRole ?? '').trim().toLowerCase();
if (me != 'administrateur' && me != 'super_admin') return false;
if (targetUserId.trim().isEmpty) return false;
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return false;
}
final target = targetRole.trim().toLowerCase();
if (target == 'super_admin') return false;
if (adminCount <= 1 && me != 'super_admin') return false;
return true;
}
String? adminDeleteBlockedReason({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return 'Vous ne pouvez pas supprimer votre propre compte.';
}
if (targetRole.trim().toLowerCase() == 'super_admin') {
return 'Le super administrateur ne peut pas être supprimé.';
}
if (adminCount <= 1 &&
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
}
return null;
}
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget {
final String searchQuery;
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
String? _error;
List<AppUser> _admins = [];
String? _currentUserRole;
String? _currentUserId;
@override
void initState() {
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (cached != null) {
setState(() {
_currentUserRole = (cached.role).toLowerCase();
_currentUserId = cached.id;
});
return;
}
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted || refreshed == null) return;
setState(() {
_currentUserRole = (refreshed.role).toLowerCase();
_currentUserId = refreshed.id;
});
}
@@ -79,13 +84,23 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
return _currentUserRole == 'super_admin';
}
bool _canDeleteAdmin(AppUser target) {
return canDeleteAdministrateur(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: target.id,
targetRole: target.role,
adminCount: _admins.length,
);
}
Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(
return StaffUserFormModal(
initialUser: user,
adminMode: true,
withRelais: false,
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
}
}
Future<void> _confirmDelete(AppUser user) async {
final blocked = adminDeleteBlockedReason(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: user.id,
targetRole: user.role,
adminCount: _admins.length,
);
if (blocked != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(blocked)),
);
return;
}
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
final isLast = _admins.length <= 1;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: [
if (isLast)
'Attention : cest le dernier administrateur.',
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(user.id);
if (!mounted) return;
final msg =
(result['message'] ?? 'Administrateur supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAdmins();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
@@ -117,7 +182,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard(
final canDelete = _canDeleteAdmin(user);
return UserCard(
title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
_openAdminEditDialog(user);
},
),
if (canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
],
);
},

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