Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da71803852 | ||
|
|
2380aa9826 | ||
|
|
c952113099 | ||
|
|
8204669ec9 | ||
|
|
76ab0f340a | ||
|
|
a0a5e15b04 | ||
|
|
823ea6cd22 | ||
|
|
dfac075a74 | ||
|
|
2d8b857a84 | ||
|
|
147051821a | ||
|
|
41f7006073 | ||
|
|
8d8627cf38 | ||
|
|
6a586110e7 | ||
|
|
f9fd8d73a8 | ||
|
|
93ee3a5549 | ||
|
|
db08aca714 | ||
|
|
494f0e4c19 | ||
|
|
9a4664a8ea | ||
|
|
bf00933f65 | ||
|
|
7b4650b81b | ||
|
|
b7ae07f5aa | ||
|
|
a88f791317 | ||
|
|
138398a4a0 | ||
|
|
7cf30643aa | ||
|
|
5eb3468fe0 | ||
|
|
df4f48e864 | ||
|
|
6f9136aae5 | ||
|
|
461c70df05 | ||
|
|
fa88d00657 | ||
|
|
6e18956a63 | ||
|
|
745349bc83 | ||
|
|
89f65356d1 | ||
|
|
71b1897678 | ||
|
|
3218daa12e | ||
|
|
1d6261b312 | ||
|
|
9557cf9947 | ||
|
|
939e7777ac | ||
|
|
b474842e19 | ||
|
|
a312d9c0fa | ||
|
|
b5b32062b3 | ||
|
|
946d8edcd2 | ||
|
|
c8cb82dd24 | ||
|
|
9cd180bf6a | ||
|
|
ca07d2e111 | ||
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
7f23356273 | ||
|
|
c8c9cfbc4d | ||
|
|
530e896b66 | ||
|
|
0029c5ab86 | ||
|
|
2ce9e9215f | ||
|
|
3fdd913367 | ||
|
|
6708f73b06 | ||
|
|
f596f062a6 | ||
|
|
86701731e3 | ||
|
|
b4abb7d6de | ||
|
|
cb5c1a5518 | ||
|
|
30ca99fb65 | ||
|
|
8ee2ca8ea6 | ||
|
|
e3552667bc | ||
|
|
4ae334b247 | ||
|
|
471a62ddb7 | ||
|
|
59afeb0a8d | ||
|
|
d2172eafdb | ||
|
|
d247867fa0 | ||
|
|
93912d1374 | ||
|
|
925b6d5cd4 | ||
|
|
b0dddd6695 | ||
|
|
ef7512dc1e | ||
|
|
2ececa711b | ||
|
|
7a3d997ff9 | ||
|
|
6b89d7b405 | ||
|
|
f7ac628445 | ||
|
|
26e9738ec7 | ||
|
|
0ec3410457 | ||
|
|
c3acf1970d | ||
|
|
6fe2b89a61 | ||
|
|
d6a9b3fd66 | ||
|
|
134b9781c8 | ||
|
|
b936d27445 | ||
|
|
18c1d1eba7 | ||
|
|
f74b14c203 | ||
|
|
f194b5f9e8 | ||
|
|
5ab8ae3423 | ||
|
|
3277f77846 | ||
|
|
2d80ad0d7e | ||
|
|
09386f8aa6 | ||
|
|
faa50f637c | ||
|
|
267fe63aec | ||
|
|
90b185740c | ||
|
|
b903dbf60b | ||
|
|
291ea26b34 | ||
|
|
59919834b9 | ||
|
|
cf6acbae7c | ||
|
|
7951c38d4d |
@@ -89,6 +89,9 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^src/(.*)$": "<rootDir>/$1"
|
||||||
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ model Child {
|
|||||||
dateOfBirth DateTime
|
dateOfBirth DateTime
|
||||||
photoUrl String?
|
photoUrl String?
|
||||||
photoConsent Boolean @default(false)
|
photoConsent Boolean @default(false)
|
||||||
isMultiple Boolean @default(false)
|
|
||||||
isUnborn Boolean @default(false)
|
isUnborn Boolean @default(false)
|
||||||
parentId String
|
parentId String
|
||||||
parent Parent @relation(fields: [parentId], references: [id])
|
parent Parent @relation(fields: [parentId], references: [id])
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
|||||||
import { EnfantsModule } from './routes/enfants/enfants.module';
|
import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
|
import { AbsencesGardeModule } from './modules/absences-garde';
|
||||||
|
import { CardsModule } from './modules/cards';
|
||||||
import { RelaisModule } from './routes/relais/relais.module';
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||||
|
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -55,8 +58,11 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
|
AbsencesGardeModule,
|
||||||
|
CardsModule,
|
||||||
RelaisModule,
|
RelaisModule,
|
||||||
DossiersModule,
|
DossiersModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AmChildren } from './am_children.entity';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
|
||||||
|
export enum TypeAbsenceGardeType {
|
||||||
|
ABSENCE_ENFANT = 'absence_enfant',
|
||||||
|
CONGE_AM = 'conge_am',
|
||||||
|
ARRET_MALADIE_AM = 'arret_maladie_am',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum StatutAbsenceGardeType {
|
||||||
|
EN_ATTENTE = 'en_attente',
|
||||||
|
ACCEPTE = 'accepte',
|
||||||
|
REFUSE = 'refuse',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérité métier des absences / congés / arrêts par couple AM↔enfant.
|
||||||
|
* Les cartes (module ultérieur) collectent ; cette table stocke.
|
||||||
|
* Ticket #193.
|
||||||
|
*/
|
||||||
|
@Entity('absences_garde', { schema: 'public' })
|
||||||
|
export class AbsencesGarde {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'id_placement', type: 'uuid' })
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AmChildren, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_placement', referencedColumnName: 'id' })
|
||||||
|
placement: AmChildren;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: TypeAbsenceGardeType,
|
||||||
|
enumName: 'type_absence_garde_type',
|
||||||
|
name: 'type',
|
||||||
|
})
|
||||||
|
type: TypeAbsenceGardeType;
|
||||||
|
|
||||||
|
@Column({ name: 'date_debut', type: 'date' })
|
||||||
|
date_debut: string;
|
||||||
|
|
||||||
|
@Column({ name: 'date_fin', type: 'date' })
|
||||||
|
date_fin: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: StatutAbsenceGardeType,
|
||||||
|
enumName: 'statut_absence_garde_type',
|
||||||
|
name: 'statut',
|
||||||
|
default: StatutAbsenceGardeType.EN_ATTENTE,
|
||||||
|
})
|
||||||
|
statut: StatutAbsenceGardeType;
|
||||||
|
|
||||||
|
/** Purge auto pour en_attente/refuse ; accepte → infinity. */
|
||||||
|
@Column({ name: 'expire_at', type: 'timestamptz' })
|
||||||
|
expire_at: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
|
||||||
|
cree_par?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
|
||||||
|
createdBy?: Users;
|
||||||
|
|
||||||
|
/** Réf. carte de collecte (FK quand module Cartes existera). */
|
||||||
|
@Column({ name: 'id_card_instance', type: 'uuid', nullable: true })
|
||||||
|
id_card_instance?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'motif', type: 'text', nullable: true })
|
||||||
|
motif?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
|
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
import { Users } from './users.entity';
|
import { Users } from './users.entity';
|
||||||
import { AmChildren } from './am_children.entity';
|
import { AmChildren } from './am_children.entity';
|
||||||
|
|
||||||
@@ -37,22 +37,32 @@ export class AssistanteMaternelle {
|
|||||||
@Column({ name: 'ville_residence', length: 100, nullable: true })
|
@Column({ name: 'ville_residence', length: 100, nullable: true })
|
||||||
residence_city?: string;
|
residence_city?: string;
|
||||||
|
|
||||||
@Column( { name: 'date_agrement', type: 'date', nullable: true })
|
@Column({ name: 'date_agrement', type: 'date', nullable: true })
|
||||||
agreement_date?: Date;
|
agreement_date?: Date;
|
||||||
|
|
||||||
@Column( { name: 'annee_experience', type: 'smallint', nullable: true })
|
@Column({ name: 'annee_experience', type: 'smallint', nullable: true })
|
||||||
years_experience?: number;
|
years_experience?: number;
|
||||||
|
|
||||||
@Column( { name: 'specialite', length: 100, nullable: true })
|
@Column({ name: 'specialite', length: 100, nullable: true })
|
||||||
specialty?: string;
|
specialty?: string;
|
||||||
|
|
||||||
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
@Column({ name: 'place_disponible', type: 'integer', nullable: true })
|
||||||
places_available?: number;
|
places_available?: number;
|
||||||
|
|
||||||
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
|
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
|
||||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||||
numero_dossier?: string;
|
numero_dossier?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placement AM↔enfant sélectionné sur le TdB AM (couple actif) — ticket #171.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'id_placement_garde_courant', type: 'uuid', nullable: true })
|
||||||
|
id_placement_garde_courant?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AmChildren, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'id_placement_garde_courant', referencedColumnName: 'id' })
|
||||||
|
placement_garde_courant?: AmChildren;
|
||||||
|
|
||||||
@OneToMany(() => AmChildren, (ac) => ac.am)
|
@OneToMany(() => AmChildren, (ac) => ac.am)
|
||||||
amChildren: AmChildren[];
|
amChildren: AmChildren[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { CardInstance } from './card_instances.entity';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
|
||||||
|
@Entity('card_audience_members', { schema: 'public' })
|
||||||
|
export class CardAudienceMember {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'id_card', type: 'uuid' })
|
||||||
|
id_card: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => CardInstance, (c) => c.audience, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_card', referencedColumnName: 'id' })
|
||||||
|
card: CardInstance;
|
||||||
|
|
||||||
|
@Column({ name: 'id_utilisateur', type: 'uuid' })
|
||||||
|
id_utilisateur: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Users, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
|
||||||
|
user: Users;
|
||||||
|
|
||||||
|
@Column({ name: 'role_snapshot', type: 'varchar', length: 64 })
|
||||||
|
role_snapshot: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_creator', type: 'boolean', default: false })
|
||||||
|
is_creator: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AmChildren } from './am_children.entity';
|
||||||
|
import { AbsencesGarde } from './absences_garde.entity';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
import { CardType } from './card_types.entity';
|
||||||
|
import { CardAudienceMember } from './card_audience_members.entity';
|
||||||
|
import { CardResponse } from './card_responses.entity';
|
||||||
|
|
||||||
|
export enum CardInstanceStatutType {
|
||||||
|
OUVERTE = 'ouverte',
|
||||||
|
REFUSEE = 'refusee',
|
||||||
|
TRAITEE = 'traitee',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CardOperationType {
|
||||||
|
CREATE = 'create',
|
||||||
|
UPDATE = 'update',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('card_instances', { schema: 'public' })
|
||||||
|
export class CardInstance {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'type_code', type: 'varchar', length: 64 })
|
||||||
|
type_code: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => CardType, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'type_code', referencedColumnName: 'code' })
|
||||||
|
type: CardType;
|
||||||
|
|
||||||
|
@Column({ name: 'id_placement', type: 'uuid' })
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AmChildren, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_placement', referencedColumnName: 'id' })
|
||||||
|
placement: AmChildren;
|
||||||
|
|
||||||
|
@Column({ name: 'id_absence', type: 'uuid', nullable: true })
|
||||||
|
id_absence?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AbsencesGarde, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'id_absence', referencedColumnName: 'id' })
|
||||||
|
absence?: AbsencesGarde;
|
||||||
|
|
||||||
|
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
|
||||||
|
cree_par?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
|
||||||
|
createdBy?: Users;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: CardOperationType,
|
||||||
|
enumName: 'card_operation_type',
|
||||||
|
name: 'operation',
|
||||||
|
default: CardOperationType.CREATE,
|
||||||
|
})
|
||||||
|
operation: CardOperationType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: CardInstanceStatutType,
|
||||||
|
enumName: 'card_instance_statut_type',
|
||||||
|
name: 'statut',
|
||||||
|
default: CardInstanceStatutType.OUVERTE,
|
||||||
|
})
|
||||||
|
statut: CardInstanceStatutType;
|
||||||
|
|
||||||
|
@Column({ name: 'payload', type: 'jsonb', default: {} })
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ name: 'purge_at', type: 'timestamptz' })
|
||||||
|
purge_at: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => CardAudienceMember, (m) => m.card)
|
||||||
|
audience: CardAudienceMember[];
|
||||||
|
|
||||||
|
@OneToMany(() => CardResponse, (r) => r.card)
|
||||||
|
responses: CardResponse[];
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { CardInstance } from './card_instances.entity';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
|
||||||
|
export enum CardResponseActionType {
|
||||||
|
ACCEPT = 'accept',
|
||||||
|
REFUSE = 'refuse',
|
||||||
|
ACK = 'ack',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('card_responses', { schema: 'public' })
|
||||||
|
export class CardResponse {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'id_card', type: 'uuid' })
|
||||||
|
id_card: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => CardInstance, (c) => c.responses, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_card', referencedColumnName: 'id' })
|
||||||
|
card: CardInstance;
|
||||||
|
|
||||||
|
@Column({ name: 'id_utilisateur', type: 'uuid' })
|
||||||
|
id_utilisateur: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Users, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_utilisateur', referencedColumnName: 'id' })
|
||||||
|
user: Users;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: CardResponseActionType,
|
||||||
|
enumName: 'card_response_action_type',
|
||||||
|
name: 'action',
|
||||||
|
})
|
||||||
|
action: CardResponseActionType;
|
||||||
|
|
||||||
|
@Column({ name: 'comment', type: 'text', nullable: true })
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export enum CardResponseModeType {
|
||||||
|
NONE = 'none',
|
||||||
|
ACK = 'ack',
|
||||||
|
ACCEPT_REFUSE = 'accept_refuse',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('card_types', { schema: 'public' })
|
||||||
|
export class CardType {
|
||||||
|
@PrimaryColumn({ name: 'code', type: 'varchar', length: 64 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ name: 'system', type: 'boolean', default: true })
|
||||||
|
system: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'titre', type: 'varchar', length: 120 })
|
||||||
|
titre: string;
|
||||||
|
|
||||||
|
@Column({ name: 'emitter_roles', type: 'text', array: true })
|
||||||
|
emitter_roles: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'recipient_roles', type: 'text', array: true })
|
||||||
|
recipient_roles: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'audience_resolver', type: 'varchar', length: 64 })
|
||||||
|
audience_resolver: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: CardResponseModeType,
|
||||||
|
enumName: 'card_response_mode_type',
|
||||||
|
name: 'response_mode',
|
||||||
|
})
|
||||||
|
response_mode: CardResponseModeType;
|
||||||
|
|
||||||
|
@Column({ name: 'retention_days', type: 'int', default: 14 })
|
||||||
|
retention_days: number;
|
||||||
|
|
||||||
|
@Column({ name: 'couleur', type: 'varchar', length: 32, nullable: true })
|
||||||
|
couleur?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
}
|
||||||
@@ -63,9 +63,6 @@ export class Children {
|
|||||||
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
|
||||||
consent_photo_at?: Date;
|
consent_photo_at?: Date;
|
||||||
|
|
||||||
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
|
|
||||||
is_multiple: boolean;
|
|
||||||
|
|
||||||
// Lien via table de jointure enfants_parents
|
// Lien via table de jointure enfants_parents
|
||||||
@OneToMany(() => ParentsChildren, pc => pc.child)
|
@OneToMany(() => ParentsChildren, pc => pc.child)
|
||||||
parentLinks: ParentsChildren[];
|
parentLinks: ParentsChildren[];
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
|
|
||||||
import { Children } from "./children.entity";
|
|
||||||
import { Users } from "./users.entity";
|
|
||||||
import { Parents } from "./parents.entity";
|
|
||||||
|
|
||||||
export enum TypeEvenementType {
|
|
||||||
ABSENCE_ENFANT = 'absence_enfant',
|
|
||||||
CONGE_AM = 'conge_am',
|
|
||||||
CONGE_PARENT = 'conge_parent',
|
|
||||||
ARRET_MALADIE_AM = 'arret_maladie_am',
|
|
||||||
EVENEMENT_RPE = 'evenement_rpe',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum StatutEvenementType {
|
|
||||||
PROPOSE = 'propose',
|
|
||||||
VALIDE = 'valide',
|
|
||||||
REFUSE = 'refuse',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity('evenements')
|
|
||||||
export class Evenement {
|
|
||||||
// Define your columns and relationships here
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: TypeEvenementType,
|
|
||||||
enumName: 'type_evenement_type',
|
|
||||||
name: 'type'
|
|
||||||
})
|
|
||||||
type: TypeEvenementType;
|
|
||||||
|
|
||||||
@ManyToOne(() => Children, { onDelete: 'CASCADE', nullable: true })
|
|
||||||
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
|
|
||||||
child?: Children;
|
|
||||||
|
|
||||||
@ManyToOne(() => Users, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'id_am', referencedColumnName: 'id' })
|
|
||||||
assistanteMaternelle?: Users;
|
|
||||||
|
|
||||||
@ManyToOne(() => Parents, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
|
|
||||||
parent?: Parents;
|
|
||||||
|
|
||||||
@ManyToOne(() => Users, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
|
|
||||||
created_by?: Users;
|
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true, name: 'date_debut' })
|
|
||||||
start_date?: Date;
|
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true, name: 'date_fin' })
|
|
||||||
end_date?: Date;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true, name: 'commentaires' })
|
|
||||||
comments?: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: StatutEvenementType,
|
|
||||||
enumName: 'statut_evenement_type',
|
|
||||||
name: 'statut',
|
|
||||||
default: StatutEvenementType.PROPOSE
|
|
||||||
})
|
|
||||||
status: StatutEvenementType;
|
|
||||||
|
|
||||||
@Column({type: 'timestamptz', nullable: true, name: 'delai_grace'})
|
|
||||||
grace_deadline?: Date;
|
|
||||||
|
|
||||||
@Column({type: 'boolean', default: false, name: 'urgent'})
|
|
||||||
urgent: boolean;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
|
||||||
created_at: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
|
||||||
updated_at: Date;
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { Users } from './users.entity';
|
import { Users } from './users.entity';
|
||||||
import { ParentsChildren } from './parents_children.entity';
|
import { ParentsChildren } from './parents_children.entity';
|
||||||
import { Dossier } from './dossiers.entity';
|
import { Dossier } from './dossiers.entity';
|
||||||
|
import { AmChildren } from './am_children.entity';
|
||||||
|
|
||||||
@Entity('parents', { schema: 'public' })
|
@Entity('parents', { schema: 'public' })
|
||||||
export class Parents {
|
export class Parents {
|
||||||
@@ -25,6 +26,17 @@ export class Parents {
|
|||||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||||
numero_dossier?: string;
|
numero_dossier?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placement AM↔enfant sélectionné sur le TdB parent (couple actif) — ticket #168.
|
||||||
|
* Null / absent = le front prend le premier couple actif retourné par l’API.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'id_placement_garde_courant', type: 'uuid', nullable: true })
|
||||||
|
id_placement_garde_courant?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AmChildren, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'id_placement_garde_courant', referencedColumnName: 'id' })
|
||||||
|
placement_garde_courant?: AmChildren;
|
||||||
|
|
||||||
// Lien vers enfants via la table enfants_parents
|
// Lien vers enfants via la table enfants_parents
|
||||||
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
||||||
parentChildren: ParentsChildren[];
|
parentChildren: ParentsChildren[];
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AbsencesGardeController } from './absences-garde.controller';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { TypeAbsenceGardeType } from 'src/entities/absences_garde.entity';
|
||||||
|
|
||||||
|
describe('AbsencesGardeController (#172)', () => {
|
||||||
|
let controller: AbsencesGardeController;
|
||||||
|
const serviceMock = {
|
||||||
|
lister: jest.fn(),
|
||||||
|
creer: jest.fn(),
|
||||||
|
maj: jest.fn(),
|
||||||
|
supprimer: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AbsencesGardeController],
|
||||||
|
providers: [{ provide: AbsencesGardeService, useValue: serviceMock }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get(AbsencesGardeController);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lister délègue au service', async () => {
|
||||||
|
serviceMock.lister.mockResolvedValue({ items: [] });
|
||||||
|
const res = await controller.lister(
|
||||||
|
'u1',
|
||||||
|
RoleType.PARENT,
|
||||||
|
'pl-1',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'2026-01-01',
|
||||||
|
'2026-12-31',
|
||||||
|
);
|
||||||
|
expect(serviceMock.lister).toHaveBeenCalledWith('u1', RoleType.PARENT, {
|
||||||
|
placementId: 'pl-1',
|
||||||
|
type: undefined,
|
||||||
|
statut: undefined,
|
||||||
|
from: '2026-01-01',
|
||||||
|
to: '2026-12-31',
|
||||||
|
});
|
||||||
|
expect(res.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creer délègue au service', async () => {
|
||||||
|
serviceMock.creer.mockResolvedValue({ id: 'a1' });
|
||||||
|
const dto = {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-02',
|
||||||
|
};
|
||||||
|
await controller.creer('u1', RoleType.PARENT, dto);
|
||||||
|
expect(serviceMock.creer).toHaveBeenCalledWith(
|
||||||
|
'u1',
|
||||||
|
RoleType.PARENT,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
|
import {
|
||||||
|
StatutAbsenceGardeType,
|
||||||
|
TypeAbsenceGardeType,
|
||||||
|
} from 'src/entities/absences_garde.entity';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
import {
|
||||||
|
AbsenceGardeDto,
|
||||||
|
CreerAbsenceGardeDto,
|
||||||
|
ListeAbsencesGardeDto,
|
||||||
|
MajAbsenceGardeDto,
|
||||||
|
} from './dto/absences-garde.dto';
|
||||||
|
|
||||||
|
@ApiTags('Absences garde')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('absences-garde')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
export class AbsencesGardeController {
|
||||||
|
constructor(private readonly absencesGardeService: AbsencesGardeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Lister les absences / congés / arrêts — ticket #172',
|
||||||
|
description:
|
||||||
|
'Sans placementId : tous les placements du user (parent = famille multi-AM ; AM = tous accueils). ' +
|
||||||
|
'Avec placementId : un couple enfant–AM.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'placementId', required: false })
|
||||||
|
@ApiQuery({ name: 'type', required: false, enum: TypeAbsenceGardeType })
|
||||||
|
@ApiQuery({ name: 'statut', required: false, enum: StatutAbsenceGardeType })
|
||||||
|
@ApiQuery({ name: 'from', required: false, description: 'YYYY-MM-DD' })
|
||||||
|
@ApiQuery({ name: 'to', required: false, description: 'YYYY-MM-DD' })
|
||||||
|
@ApiResponse({ status: 200, type: ListeAbsencesGardeDto })
|
||||||
|
lister(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Query('placementId') placementId?: string,
|
||||||
|
@Query('type') type?: TypeAbsenceGardeType,
|
||||||
|
@Query('statut') statut?: StatutAbsenceGardeType,
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
): Promise<ListeAbsencesGardeDto> {
|
||||||
|
return this.absencesGardeService.lister(userId, role, {
|
||||||
|
placementId,
|
||||||
|
type,
|
||||||
|
statut,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({ summary: 'Créer une période d’absence / congé / arrêt — #172' })
|
||||||
|
@ApiBody({ type: CreerAbsenceGardeDto })
|
||||||
|
@ApiResponse({ status: 201, type: AbsenceGardeDto })
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
creer(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Body() dto: CreerAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
return this.absencesGardeService.creer(userId, role, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Mettre à jour dates / statut / motif — #172',
|
||||||
|
description:
|
||||||
|
'Parent : accept/refuse congé (motivation si refus), ack arrêt. ' +
|
||||||
|
'AM : modifier dates (en attente / refuse / accepté), republication après refus.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: MajAbsenceGardeDto })
|
||||||
|
@ApiResponse({ status: 200, type: AbsenceGardeDto })
|
||||||
|
maj(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: MajAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
return this.absencesGardeService.maj(userId, role, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Supprimer une absence (hard delete) — #172' })
|
||||||
|
@ApiResponse({ status: 204 })
|
||||||
|
async supprimer(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.absencesGardeService.supprimer(userId, role, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AbsencesGarde } from 'src/entities/absences_garde.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AbsencesGardeController } from './absences-garde.controller';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([AbsencesGarde, AmChildren, ParentsChildren]),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get('jwt.accessSecret'),
|
||||||
|
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AbsencesGardeController],
|
||||||
|
providers: [AbsencesGardeService],
|
||||||
|
exports: [AbsencesGardeService],
|
||||||
|
})
|
||||||
|
export class AbsencesGardeModule {}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { AbsencesGardeService } from './absences-garde.service';
|
||||||
|
import {
|
||||||
|
AbsencesGarde,
|
||||||
|
StatutAbsenceGardeType,
|
||||||
|
TypeAbsenceGardeType,
|
||||||
|
} from 'src/entities/absences_garde.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('AbsencesGardeService (#172)', () => {
|
||||||
|
let service: AbsencesGardeService;
|
||||||
|
const absencesRepo = {
|
||||||
|
create: jest.fn((x) => x),
|
||||||
|
save: jest.fn(async (x) => ({
|
||||||
|
...x,
|
||||||
|
id: x.id ?? 'abs-1',
|
||||||
|
cree_le: new Date(),
|
||||||
|
modifie_le: new Date(),
|
||||||
|
})),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
const amChildrenRepo = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsChildrenRepo = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AbsencesGardeService,
|
||||||
|
{ provide: getRepositoryToken(AbsencesGarde), useValue: absencesRepo },
|
||||||
|
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(ParentsChildren),
|
||||||
|
useValue: parentsChildrenRepo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get(AbsencesGardeService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent crée absence_enfant → statut accepte', async () => {
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
absencesRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'abs-1',
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-02',
|
||||||
|
statut: StatutAbsenceGardeType.ACCEPTE,
|
||||||
|
expire_at: new Date('9999-12-31'),
|
||||||
|
cree_le: new Date(),
|
||||||
|
modifie_le: new Date(),
|
||||||
|
placement: {
|
||||||
|
enfantId: 'e-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
child: { id: 'e-1', first_name: 'Léo' },
|
||||||
|
am: { user: { prenom: 'Marie', nom: 'AM' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.creer('p-1', RoleType.PARENT, {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-02',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(absencesRepo.save).toHaveBeenCalled();
|
||||||
|
const savedArg = absencesRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedArg.statut).toBe(StatutAbsenceGardeType.ACCEPTE);
|
||||||
|
expect(res.type).toBe(TypeAbsenceGardeType.ABSENCE_ENFANT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent ne peut pas créer un congé AM', async () => {
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.creer('p-1', RoleType.PARENT, {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.CONGE_AM,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-05',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refus congé sans motif → BadRequest', async () => {
|
||||||
|
absencesRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'abs-1',
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.CONGE_AM,
|
||||||
|
date_debut: '2026-10-01',
|
||||||
|
date_fin: '2026-10-05',
|
||||||
|
statut: StatutAbsenceGardeType.EN_ATTENTE,
|
||||||
|
expire_at: new Date(),
|
||||||
|
});
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.maj('p-1', RoleType.PARENT, 'abs-1', {
|
||||||
|
statut: StatutAbsenceGardeType.REFUSE,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dates invalides → BadRequest', async () => {
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.creer('p-1', RoleType.PARENT, {
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
type: TypeAbsenceGardeType.ABSENCE_ENFANT,
|
||||||
|
date_debut: '2026-10-10',
|
||||||
|
date_fin: '2026-10-01',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { In, IsNull, Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
AbsencesGarde,
|
||||||
|
StatutAbsenceGardeType,
|
||||||
|
TypeAbsenceGardeType,
|
||||||
|
} from 'src/entities/absences_garde.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import {
|
||||||
|
AbsenceGardeDto,
|
||||||
|
CreerAbsenceGardeDto,
|
||||||
|
ListeAbsencesGardeDto,
|
||||||
|
MajAbsenceGardeDto,
|
||||||
|
} from './dto/absences-garde.dto';
|
||||||
|
|
||||||
|
const TTL_EN_ATTENTE_MS = 15 * 24 * 60 * 60 * 1000;
|
||||||
|
const TTL_REFUSE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
/** Sentinel « pas de purge » pour les périodes acceptées */
|
||||||
|
const EXPIRE_ACCEPTE = new Date('9999-12-31T23:59:59.999Z');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AbsencesGardeService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AbsencesGarde)
|
||||||
|
private readonly absencesRepo: Repository<AbsencesGarde>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepo: Repository<AmChildren>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async lister(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
opts: {
|
||||||
|
placementId?: string;
|
||||||
|
type?: TypeAbsenceGardeType;
|
||||||
|
statut?: StatutAbsenceGardeType;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
},
|
||||||
|
): Promise<ListeAbsencesGardeDto> {
|
||||||
|
const placementIds = await this.resolvePlacementIds(
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
opts.placementId,
|
||||||
|
);
|
||||||
|
if (placementIds.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const qb = this.absencesRepo
|
||||||
|
.createQueryBuilder('ag')
|
||||||
|
.leftJoinAndSelect('ag.placement', 'placement')
|
||||||
|
.leftJoinAndSelect('placement.child', 'child')
|
||||||
|
.leftJoinAndSelect('placement.am', 'am')
|
||||||
|
.leftJoinAndSelect('am.user', 'amUser')
|
||||||
|
.where('ag.id_placement IN (:...placementIds)', { placementIds })
|
||||||
|
.orderBy('ag.date_debut', 'DESC');
|
||||||
|
|
||||||
|
if (opts.type) {
|
||||||
|
qb.andWhere('ag.type = :type', { type: opts.type });
|
||||||
|
}
|
||||||
|
if (opts.statut) {
|
||||||
|
qb.andWhere('ag.statut = :statut', { statut: opts.statut });
|
||||||
|
}
|
||||||
|
if (opts.from) {
|
||||||
|
qb.andWhere('ag.date_fin >= :from', { from: opts.from });
|
||||||
|
}
|
||||||
|
if (opts.to) {
|
||||||
|
qb.andWhere('ag.date_debut <= :to', { to: opts.to });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await qb.getMany();
|
||||||
|
return { items: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async creer(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
dto: CreerAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
this.assertDates(dto.date_debut, dto.date_fin);
|
||||||
|
await this.assertCanAccessPlacement(userId, role, dto.id_placement);
|
||||||
|
this.assertCanCreateType(role, dto.type);
|
||||||
|
|
||||||
|
const statut = this.statutInitial(dto.type);
|
||||||
|
const entity = this.absencesRepo.create({
|
||||||
|
id_placement: dto.id_placement,
|
||||||
|
type: dto.type,
|
||||||
|
date_debut: dto.date_debut,
|
||||||
|
date_fin: dto.date_fin,
|
||||||
|
statut,
|
||||||
|
expire_at: this.expireAtFor(statut),
|
||||||
|
cree_par: userId,
|
||||||
|
motif: dto.motif?.trim() || undefined,
|
||||||
|
});
|
||||||
|
const saved = await this.absencesRepo.save(entity);
|
||||||
|
return this.getByIdForUser(saved.id, userId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async maj(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
id: string,
|
||||||
|
dto: MajAbsenceGardeDto,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
const row = await this.absencesRepo.findOne({ where: { id } });
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Absence introuvable');
|
||||||
|
}
|
||||||
|
await this.assertCanAccessPlacement(userId, role, row.id_placement);
|
||||||
|
|
||||||
|
if (dto.date_debut !== undefined || dto.date_fin !== undefined) {
|
||||||
|
const debut = dto.date_debut ?? row.date_debut;
|
||||||
|
const fin = dto.date_fin ?? row.date_fin;
|
||||||
|
this.assertDates(debut, fin);
|
||||||
|
// Parent : peut modifier ses absences enfant
|
||||||
|
// AM : peut modifier congé/arrêt en_attente (avant accept) ou dates si créateur
|
||||||
|
this.assertCanEditDates(role, row);
|
||||||
|
row.date_debut = debut;
|
||||||
|
row.date_fin = fin;
|
||||||
|
if (row.statut === StatutAbsenceGardeType.EN_ATTENTE) {
|
||||||
|
row.expire_at = this.expireAtFor(StatutAbsenceGardeType.EN_ATTENTE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.statut !== undefined && dto.statut !== row.statut) {
|
||||||
|
this.assertCanChangeStatut(role, row, dto.statut, dto.motif);
|
||||||
|
if (
|
||||||
|
dto.statut === StatutAbsenceGardeType.REFUSE &&
|
||||||
|
(!dto.motif || !dto.motif.trim())
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Une motivation est obligatoire en cas de refus',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
row.statut = dto.statut;
|
||||||
|
row.expire_at = this.expireAtFor(dto.statut);
|
||||||
|
if (dto.motif?.trim()) {
|
||||||
|
row.motif = dto.motif.trim();
|
||||||
|
}
|
||||||
|
} else if (dto.motif !== undefined) {
|
||||||
|
row.motif = dto.motif.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.absencesRepo.save(row);
|
||||||
|
return this.getByIdForUser(id, userId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async supprimer(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const row = await this.absencesRepo.findOne({ where: { id } });
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Absence introuvable');
|
||||||
|
}
|
||||||
|
await this.assertCanAccessPlacement(userId, role, row.id_placement);
|
||||||
|
|
||||||
|
if (
|
||||||
|
role === RoleType.PARENT &&
|
||||||
|
row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut supprimer que les absences enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.absencesRepo.delete({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getByIdForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
): Promise<AbsenceGardeDto> {
|
||||||
|
const row = await this.absencesRepo.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['placement', 'placement.child', 'placement.am', 'placement.am.user'],
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Absence introuvable');
|
||||||
|
}
|
||||||
|
await this.assertCanAccessPlacement(userId, role, row.id_placement);
|
||||||
|
return this.toDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private statutInitial(type: TypeAbsenceGardeType): StatutAbsenceGardeType {
|
||||||
|
if (type === TypeAbsenceGardeType.ABSENCE_ENFANT) {
|
||||||
|
return StatutAbsenceGardeType.ACCEPTE;
|
||||||
|
}
|
||||||
|
return StatutAbsenceGardeType.EN_ATTENTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private expireAtFor(statut: StatutAbsenceGardeType): Date {
|
||||||
|
const now = Date.now();
|
||||||
|
if (statut === StatutAbsenceGardeType.ACCEPTE) {
|
||||||
|
return EXPIRE_ACCEPTE;
|
||||||
|
}
|
||||||
|
if (statut === StatutAbsenceGardeType.REFUSE) {
|
||||||
|
return new Date(now + TTL_REFUSE_MS);
|
||||||
|
}
|
||||||
|
return new Date(now + TTL_EN_ATTENTE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertDates(debut: string, fin: string): void {
|
||||||
|
if (fin < debut) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'date_fin doit être supérieure ou égale à date_debut',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanCreateType(role: RoleType, type: TypeAbsenceGardeType): void {
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
if (type !== TypeAbsenceGardeType.ABSENCE_ENFANT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut créer que des absences enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
if (
|
||||||
|
type !== TypeAbsenceGardeType.CONGE_AM &&
|
||||||
|
type !== TypeAbsenceGardeType.ARRET_MALADIE_AM
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Une AM ne peut créer que congé ou arrêt maladie',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Rôle non autorisé à créer une absence');
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanEditDates(
|
||||||
|
role: RoleType,
|
||||||
|
row: AbsencesGarde,
|
||||||
|
): void {
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
if (row.type !== TypeAbsenceGardeType.ABSENCE_ENFANT) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut modifier que les absences enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
if (
|
||||||
|
row.type === TypeAbsenceGardeType.CONGE_AM ||
|
||||||
|
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
row.statut !== StatutAbsenceGardeType.EN_ATTENTE &&
|
||||||
|
row.statut !== StatutAbsenceGardeType.REFUSE &&
|
||||||
|
row.statut !== StatutAbsenceGardeType.ACCEPTE
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Statut incompatible avec une modification');
|
||||||
|
}
|
||||||
|
// Accepté : autorisé (S2b — re-validation via cartes plus tard ; API permet update dates)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Type non modifiable par l’AM');
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Modification non autorisée');
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanChangeStatut(
|
||||||
|
role: RoleType,
|
||||||
|
row: AbsencesGarde,
|
||||||
|
next: StatutAbsenceGardeType,
|
||||||
|
_motif?: string,
|
||||||
|
): void {
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
// Accept / refuse congé ; ack arrêt (accepte)
|
||||||
|
if (
|
||||||
|
row.type === TypeAbsenceGardeType.CONGE_AM ||
|
||||||
|
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
next !== StatutAbsenceGardeType.ACCEPTE &&
|
||||||
|
next !== StatutAbsenceGardeType.REFUSE
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Transition de statut invalide');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.type === TypeAbsenceGardeType.ARRET_MALADIE_AM &&
|
||||||
|
next === StatutAbsenceGardeType.REFUSE
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Un arrêt maladie ne se refuse pas (accusé seulement)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.statut !== StatutAbsenceGardeType.EN_ATTENTE) {
|
||||||
|
throw new BadRequestException('Cette demande n’est plus en attente');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Pas de changement de statut sur ce type pour un parent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
// Remise en attente après refus OU modification d’un congé déjà accepté (S2b)
|
||||||
|
if (
|
||||||
|
next === StatutAbsenceGardeType.EN_ATTENTE &&
|
||||||
|
(row.statut === StatutAbsenceGardeType.REFUSE ||
|
||||||
|
row.statut === StatutAbsenceGardeType.ACCEPTE)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'L’AM ne valide pas elle-même (sauf republication / modification)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Changement de statut non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePlacementIds(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
placementId?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (placementId) {
|
||||||
|
await this.assertCanAccessPlacement(userId, role, placementId);
|
||||||
|
return [placementId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
const liens = await this.parentsChildrenRepo.find({
|
||||||
|
where: { parentId: userId },
|
||||||
|
select: ['enfantId'],
|
||||||
|
});
|
||||||
|
const enfantIds = liens.map((l) => l.enfantId);
|
||||||
|
if (enfantIds.length === 0) return [];
|
||||||
|
const placements = await this.amChildrenRepo.find({
|
||||||
|
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||||||
|
select: ['id'],
|
||||||
|
});
|
||||||
|
return placements.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
const placements = await this.amChildrenRepo.find({
|
||||||
|
where: { amId: userId, date_fin: IsNull() },
|
||||||
|
select: ['id'],
|
||||||
|
});
|
||||||
|
return placements.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Rôle non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanAccessPlacement(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
placementId: string,
|
||||||
|
): Promise<AmChildren> {
|
||||||
|
const placement = await this.amChildrenRepo.findOne({
|
||||||
|
where: { id: placementId, date_fin: IsNull() },
|
||||||
|
});
|
||||||
|
if (!placement) {
|
||||||
|
throw new NotFoundException('Placement / couple introuvable ou inactif');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
if (placement.amId !== userId) {
|
||||||
|
throw new ForbiddenException('Ce placement ne vous appartient pas');
|
||||||
|
}
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
const lien = await this.parentsChildrenRepo.findOne({
|
||||||
|
where: { parentId: userId, enfantId: placement.enfantId },
|
||||||
|
});
|
||||||
|
if (!lien) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Ce placement ne concerne pas un de vos enfants',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Rôle non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: AbsencesGarde): AbsenceGardeDto {
|
||||||
|
const child = row.placement?.child;
|
||||||
|
const amUser = row.placement?.am?.user;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
id_placement: row.id_placement,
|
||||||
|
type: row.type,
|
||||||
|
date_debut: row.date_debut,
|
||||||
|
date_fin: row.date_fin,
|
||||||
|
statut: row.statut,
|
||||||
|
expire_at: row.expire_at?.toISOString?.() ?? String(row.expire_at),
|
||||||
|
cree_par: row.cree_par ?? null,
|
||||||
|
motif: row.motif ?? null,
|
||||||
|
id_enfant: child?.id ?? row.placement?.enfantId ?? null,
|
||||||
|
prenom_enfant: child?.first_name ?? null,
|
||||||
|
id_am: row.placement?.amId ?? null,
|
||||||
|
prenom_am: amUser?.prenom ?? null,
|
||||||
|
nom_am: amUser?.nom ?? null,
|
||||||
|
cree_le: row.cree_le?.toISOString?.() ?? String(row.cree_le),
|
||||||
|
modifie_le: row.modifie_le?.toISOString?.() ?? String(row.modifie_le),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
StatutAbsenceGardeType,
|
||||||
|
TypeAbsenceGardeType,
|
||||||
|
} from 'src/entities/absences_garde.entity';
|
||||||
|
|
||||||
|
export class CreerAbsenceGardeDto {
|
||||||
|
@ApiProperty({ description: 'Placement AM↔enfant (couple)' })
|
||||||
|
@IsUUID()
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TypeAbsenceGardeType })
|
||||||
|
@IsEnum(TypeAbsenceGardeType)
|
||||||
|
type: TypeAbsenceGardeType;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-10-01' })
|
||||||
|
@IsDateString()
|
||||||
|
date_debut: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-10-05' })
|
||||||
|
@IsDateString()
|
||||||
|
date_fin: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
motif?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MajAbsenceGardeDto {
|
||||||
|
@ApiPropertyOptional({ example: '2026-10-02' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_debut?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-10-06' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_fin?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: StatutAbsenceGardeType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(StatutAbsenceGardeType)
|
||||||
|
statut?: StatutAbsenceGardeType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Motivation de refus (parent) ou motif créateur',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2000)
|
||||||
|
motif?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbsenceGardeDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TypeAbsenceGardeType })
|
||||||
|
type: TypeAbsenceGardeType;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
date_debut: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
date_fin: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: StatutAbsenceGardeType })
|
||||||
|
statut: StatutAbsenceGardeType;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
expire_at: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
cree_par?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
motif?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
id_enfant?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom_enfant?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
id_am?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom_am?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
nom_am?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
cree_le: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
modifie_le: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListeAbsencesGardeDto {
|
||||||
|
@ApiProperty({ type: [AbsenceGardeDto] })
|
||||||
|
items: AbsenceGardeDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AbsencesGardeModule } from './absences-garde.module';
|
||||||
|
export { AbsencesGardeService } from './absences-garde.service';
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
MessageEvent,
|
||||||
|
Query,
|
||||||
|
Sse,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { Public } from 'src/common/decorators/public.decorator';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { CardsRealtimeService } from './cards-realtime.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE Cartes — #195
|
||||||
|
* Auth : Bearer (recommandé) ou `?access_token=` (EventSource navigateur).
|
||||||
|
*/
|
||||||
|
@ApiTags('Cartes')
|
||||||
|
@Controller('cards')
|
||||||
|
export class CardsRealtimeController {
|
||||||
|
constructor(
|
||||||
|
private readonly realtime: CardsRealtimeService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Sse('stream')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Flux SSE bulles (card.created|updated|deleted, response.added) — #195',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'access_token',
|
||||||
|
required: false,
|
||||||
|
description: 'JWT si pas de header Authorization (EventSource)',
|
||||||
|
})
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
async stream(
|
||||||
|
@Headers('authorization') authorization?: string,
|
||||||
|
@Query('access_token') accessToken?: string,
|
||||||
|
): Promise<Observable<MessageEvent>> {
|
||||||
|
const userId = await this.resolveUserId(authorization, accessToken);
|
||||||
|
return this.realtime.streamFor(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variante gardée (clients qui envoient Bearer correctement). */
|
||||||
|
@Get('stream/info')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiOperation({ summary: 'Debug : abonnés SSE pour mon user — #195' })
|
||||||
|
info(@User('id') userId: string) {
|
||||||
|
return {
|
||||||
|
user_id: userId,
|
||||||
|
subscribers: this.realtime.subscriberCount(userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveUserId(
|
||||||
|
authorization?: string,
|
||||||
|
accessToken?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
let token = accessToken?.trim();
|
||||||
|
if (!token && authorization?.startsWith('Bearer ')) {
|
||||||
|
token = authorization.slice(7).trim();
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Token manquant (Bearer ou access_token)');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<{ sub: string }>(token, {
|
||||||
|
secret: this.configService.get<string>('jwt.accessSecret'),
|
||||||
|
});
|
||||||
|
if (!payload?.sub) throw new UnauthorizedException('Token invalide');
|
||||||
|
return payload.sub;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Token invalide ou expiré');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MessageEvent } from '@nestjs/common';
|
||||||
|
import { CardsRealtimeService } from './cards-realtime.service';
|
||||||
|
|
||||||
|
describe('CardsRealtimeService (#195)', () => {
|
||||||
|
let service: CardsRealtimeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new CardsRealtimeService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('émet card.created aux abonnés du user', async () => {
|
||||||
|
const events: MessageEvent[] = [];
|
||||||
|
const sub = service.streamFor('user-a').subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(events[0]?.type).toBe('heartbeat');
|
||||||
|
expect(service.subscriberCount('user-a')).toBe(1);
|
||||||
|
|
||||||
|
service.emitToUsers(['user-a', 'user-b'], 'card.created', 'card-1', {
|
||||||
|
id: 'card-1',
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
|
const created = events.find((e) => e.type === 'card.created');
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
expect((created!.data as { card_id: string }).card_id).toBe('card-1');
|
||||||
|
expect(service.subscriberCount('user-b')).toBe(0);
|
||||||
|
|
||||||
|
sub.unsubscribe();
|
||||||
|
expect(service.subscriberCount('user-a')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne diffuse pas aux users non abonnés', async () => {
|
||||||
|
const events: MessageEvent[] = [];
|
||||||
|
const sub = service.streamFor('user-a').subscribe((e) => events.push(e));
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const before = events.length;
|
||||||
|
service.emitToUsers(['user-b'], 'card.updated', 'x');
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(events.length).toBe(before);
|
||||||
|
sub.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Injectable, MessageEvent, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Observable, Subject, interval, merge, takeUntil } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export type CardRealtimeEventType =
|
||||||
|
| 'card.created'
|
||||||
|
| 'card.updated'
|
||||||
|
| 'card.deleted'
|
||||||
|
| 'response.added'
|
||||||
|
| 'heartbeat';
|
||||||
|
|
||||||
|
export interface CardRealtimePayload {
|
||||||
|
event: CardRealtimeEventType;
|
||||||
|
card_id?: string;
|
||||||
|
data?: unknown;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bus SSE in-memory par utilisateur (V1 mono-instance).
|
||||||
|
* Rooms = userId (audience carte).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CardsRealtimeService implements OnModuleDestroy {
|
||||||
|
private readonly byUser = new Map<string, Set<Subject<CardRealtimePayload>>>();
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
for (const set of this.byUser.values()) {
|
||||||
|
for (const s of set) s.complete();
|
||||||
|
}
|
||||||
|
this.byUser.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flux SSE pour un utilisateur authentifié. */
|
||||||
|
streamFor(userId: string): Observable<MessageEvent> {
|
||||||
|
const subject = new Subject<CardRealtimePayload>();
|
||||||
|
let set = this.byUser.get(userId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.byUser.set(userId, set);
|
||||||
|
}
|
||||||
|
set.add(subject);
|
||||||
|
|
||||||
|
const heartbeat$ = interval(25_000).pipe(
|
||||||
|
map(
|
||||||
|
(): CardRealtimePayload => ({
|
||||||
|
event: 'heartbeat',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Observable<MessageEvent>((observer) => {
|
||||||
|
const sub = merge(subject, heartbeat$)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (payload) =>
|
||||||
|
observer.next({
|
||||||
|
type: payload.event,
|
||||||
|
data: payload,
|
||||||
|
} as MessageEvent),
|
||||||
|
error: (err) => observer.error(err),
|
||||||
|
complete: () => observer.complete(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ping initial
|
||||||
|
subject.next({
|
||||||
|
event: 'heartbeat',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sub.unsubscribe();
|
||||||
|
set!.delete(subject);
|
||||||
|
subject.complete();
|
||||||
|
if (set!.size === 0) this.byUser.delete(userId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emitToUsers(
|
||||||
|
userIds: string[],
|
||||||
|
event: Exclude<CardRealtimeEventType, 'heartbeat'>,
|
||||||
|
cardId: string | undefined,
|
||||||
|
data?: unknown,
|
||||||
|
): void {
|
||||||
|
const unique = [...new Set(userIds.filter(Boolean))];
|
||||||
|
const payload: CardRealtimePayload = {
|
||||||
|
event,
|
||||||
|
card_id: cardId,
|
||||||
|
data,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
for (const uid of unique) {
|
||||||
|
const set = this.byUser.get(uid);
|
||||||
|
if (!set) continue;
|
||||||
|
for (const s of set) s.next(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test / debug : nombre d’abonnés actifs. */
|
||||||
|
subscriberCount(userId?: string): number {
|
||||||
|
if (userId) return this.byUser.get(userId)?.size ?? 0;
|
||||||
|
let n = 0;
|
||||||
|
for (const s of this.byUser.values()) n += s.size;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { CardsService } from './cards.service';
|
||||||
|
import {
|
||||||
|
CarteDto,
|
||||||
|
CreerCarteDto,
|
||||||
|
ListeCartesDto,
|
||||||
|
MajCarteDto,
|
||||||
|
RepondreCarteDto,
|
||||||
|
} from './dto/cards.dto';
|
||||||
|
|
||||||
|
@ApiTags('Cartes')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@Controller('cards')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
export class CardsController {
|
||||||
|
constructor(private readonly cardsService: CardsService) {}
|
||||||
|
|
||||||
|
@Get('types')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({ summary: 'Types SYSTEM émissibles pour mon rôle — #194' })
|
||||||
|
listerTypes(@User('role') role: RoleType) {
|
||||||
|
return this.cardsService.listerTypes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({ summary: 'Feed bulles de l’utilisateur — #194' })
|
||||||
|
@ApiQuery({ name: 'placementId', required: false })
|
||||||
|
@ApiResponse({ status: 200, type: ListeCartesDto })
|
||||||
|
lister(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Query('placementId') placementId?: string,
|
||||||
|
): Promise<ListeCartesDto> {
|
||||||
|
return this.cardsService.lister(userId, role, placementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Créer une bulle (+ hook absences_garde) — #194',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: CreerCarteDto })
|
||||||
|
@ApiResponse({ status: 201, type: CarteDto })
|
||||||
|
creer(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Body() dto: CreerCarteDto,
|
||||||
|
): Promise<CarteDto> {
|
||||||
|
return this.cardsService.creer(userId, role, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/respond')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({ summary: 'Répondre (accept / refuse / ack) — #194' })
|
||||||
|
@ApiBody({ type: RepondreCarteDto })
|
||||||
|
repondre(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RepondreCarteDto,
|
||||||
|
): Promise<CarteDto> {
|
||||||
|
return this.cardsService.repondre(userId, role, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Modifier dates (créateur, ouverte/refusée) — #194',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: MajCarteDto })
|
||||||
|
maj(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: MajCarteDto,
|
||||||
|
): Promise<CarteDto> {
|
||||||
|
return this.cardsService.majEnAttente(userId, role, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(RoleType.PARENT, RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Supprimer bulle (+ absence si non traitée) — #194' })
|
||||||
|
async supprimer(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@User('role') role: RoleType,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.cardsService.supprimer(userId, role, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AbsencesGardeModule } from '../absences-garde';
|
||||||
|
import { CardType } from 'src/entities/card_types.entity';
|
||||||
|
import { CardInstance } from 'src/entities/card_instances.entity';
|
||||||
|
import { CardAudienceMember } from 'src/entities/card_audience_members.entity';
|
||||||
|
import { CardResponse } from 'src/entities/card_responses.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { CardsController } from './cards.controller';
|
||||||
|
import { CardsRealtimeController } from './cards-realtime.controller';
|
||||||
|
import { CardsService } from './cards.service';
|
||||||
|
import { CardsRealtimeService } from './cards-realtime.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AbsencesGardeModule,
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
CardType,
|
||||||
|
CardInstance,
|
||||||
|
CardAudienceMember,
|
||||||
|
CardResponse,
|
||||||
|
AmChildren,
|
||||||
|
ParentsChildren,
|
||||||
|
]),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get('jwt.accessSecret'),
|
||||||
|
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [CardsController, CardsRealtimeController],
|
||||||
|
providers: [CardsService, CardsRealtimeService],
|
||||||
|
exports: [CardsService, CardsRealtimeService],
|
||||||
|
})
|
||||||
|
export class CardsModule {}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { CardsService } from './cards.service';
|
||||||
|
import { CardsRealtimeService } from './cards-realtime.service';
|
||||||
|
import { AbsencesGardeService } from '../absences-garde/absences-garde.service';
|
||||||
|
import { CardType, CardResponseModeType } from 'src/entities/card_types.entity';
|
||||||
|
import {
|
||||||
|
CardInstance,
|
||||||
|
CardInstanceStatutType,
|
||||||
|
CardOperationType,
|
||||||
|
} from 'src/entities/card_instances.entity';
|
||||||
|
import { CardAudienceMember } from 'src/entities/card_audience_members.entity';
|
||||||
|
import { CardResponse } from 'src/entities/card_responses.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { StatutAbsenceGardeType, TypeAbsenceGardeType } from 'src/entities/absences_garde.entity';
|
||||||
|
|
||||||
|
describe('CardsService (#194)', () => {
|
||||||
|
let service: CardsService;
|
||||||
|
|
||||||
|
const typesRepo = { find: jest.fn(), findOne: jest.fn() };
|
||||||
|
const cardsRepo = {
|
||||||
|
create: jest.fn((x) => x),
|
||||||
|
save: jest.fn(async (x) => ({ ...x, id: x.id ?? 'card-1', cree_le: new Date(), modifie_le: new Date() })),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
manager: { query: jest.fn() },
|
||||||
|
};
|
||||||
|
const audienceRepo = {
|
||||||
|
create: jest.fn((x) => x),
|
||||||
|
save: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
};
|
||||||
|
const responsesRepo = {
|
||||||
|
create: jest.fn((x) => x),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const amChildrenRepo = { findOne: jest.fn() };
|
||||||
|
const parentsChildrenRepo = { find: jest.fn(), findOne: jest.fn() };
|
||||||
|
const absencesService = {
|
||||||
|
creer: jest.fn(),
|
||||||
|
maj: jest.fn(),
|
||||||
|
supprimer: jest.fn(),
|
||||||
|
};
|
||||||
|
const realtime = { emitToUsers: jest.fn() };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
CardsService,
|
||||||
|
{ provide: getRepositoryToken(CardType), useValue: typesRepo },
|
||||||
|
{ provide: getRepositoryToken(CardInstance), useValue: cardsRepo },
|
||||||
|
{ provide: getRepositoryToken(CardAudienceMember), useValue: audienceRepo },
|
||||||
|
{ provide: getRepositoryToken(CardResponse), useValue: responsesRepo },
|
||||||
|
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
|
||||||
|
{ provide: getRepositoryToken(ParentsChildren), useValue: parentsChildrenRepo },
|
||||||
|
{ provide: AbsencesGardeService, useValue: absencesService },
|
||||||
|
{ provide: CardsRealtimeService, useValue: realtime },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get(CardsService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AM crée congé → absence + carte ouverte + audience parents', async () => {
|
||||||
|
typesRepo.findOne.mockResolvedValue({
|
||||||
|
code: 'conge_am',
|
||||||
|
system: true,
|
||||||
|
titre: 'Congé AM',
|
||||||
|
emitter_roles: [RoleType.ASSISTANTE_MATERNELLE],
|
||||||
|
recipient_roles: [RoleType.PARENT],
|
||||||
|
audience_resolver: 'couple_parents',
|
||||||
|
response_mode: CardResponseModeType.ACCEPT_REFUSE,
|
||||||
|
retention_days: 14,
|
||||||
|
couleur: 'lavender',
|
||||||
|
});
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e-1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
absencesService.creer.mockResolvedValue({
|
||||||
|
id: 'abs-1',
|
||||||
|
type: TypeAbsenceGardeType.CONGE_AM,
|
||||||
|
statut: StatutAbsenceGardeType.EN_ATTENTE,
|
||||||
|
});
|
||||||
|
parentsChildrenRepo.find.mockResolvedValue([
|
||||||
|
{ parentId: 'p-1', enfantId: 'e-1' },
|
||||||
|
{ parentId: 'p-2', enfantId: 'e-1' },
|
||||||
|
]);
|
||||||
|
cardsRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'card-1',
|
||||||
|
type_code: 'conge_am',
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
id_absence: 'abs-1',
|
||||||
|
cree_par: 'am-1',
|
||||||
|
operation: CardOperationType.CREATE,
|
||||||
|
statut: CardInstanceStatutType.OUVERTE,
|
||||||
|
payload: { date_debut: '2026-11-01', date_fin: '2026-11-07' },
|
||||||
|
purge_at: new Date(),
|
||||||
|
cree_le: new Date(),
|
||||||
|
modifie_le: new Date(),
|
||||||
|
type: {
|
||||||
|
titre: 'Congé AM',
|
||||||
|
couleur: 'lavender',
|
||||||
|
response_mode: CardResponseModeType.ACCEPT_REFUSE,
|
||||||
|
retention_days: 14,
|
||||||
|
},
|
||||||
|
responses: [],
|
||||||
|
audience: [
|
||||||
|
{ id_utilisateur: 'am-1' },
|
||||||
|
{ id_utilisateur: 'p-1' },
|
||||||
|
{ id_utilisateur: 'p-2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.creer('am-1', RoleType.ASSISTANTE_MATERNELLE, {
|
||||||
|
type_code: 'conge_am',
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
date_debut: '2026-11-01',
|
||||||
|
date_fin: '2026-11-07',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(absencesService.creer).toHaveBeenCalled();
|
||||||
|
expect(audienceRepo.save).toHaveBeenCalled();
|
||||||
|
expect(realtime.emitToUsers).toHaveBeenCalledWith(
|
||||||
|
['am-1', 'p-1', 'p-2'],
|
||||||
|
'card.created',
|
||||||
|
'card-1',
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(res.type_code).toBe('conge_am');
|
||||||
|
expect(res.statut).toBe(CardInstanceStatutType.OUVERTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent ne peut pas émettre conge_am', async () => {
|
||||||
|
typesRepo.findOne.mockResolvedValue({
|
||||||
|
code: 'conge_am',
|
||||||
|
system: true,
|
||||||
|
emitter_roles: [RoleType.ASSISTANTE_MATERNELLE],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.creer('p-1', RoleType.PARENT, {
|
||||||
|
type_code: 'conge_am',
|
||||||
|
id_placement: 'pl-1',
|
||||||
|
date_debut: '2026-11-01',
|
||||||
|
date_fin: '2026-11-07',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { IsNull, Repository } from 'typeorm';
|
||||||
|
import { AbsencesGardeService } from '../absences-garde/absences-garde.service';
|
||||||
|
import {
|
||||||
|
StatutAbsenceGardeType,
|
||||||
|
TypeAbsenceGardeType,
|
||||||
|
} from 'src/entities/absences_garde.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { CardType, CardResponseModeType } from 'src/entities/card_types.entity';
|
||||||
|
import {
|
||||||
|
CardInstance,
|
||||||
|
CardInstanceStatutType,
|
||||||
|
CardOperationType,
|
||||||
|
} from 'src/entities/card_instances.entity';
|
||||||
|
import { CardAudienceMember } from 'src/entities/card_audience_members.entity';
|
||||||
|
import {
|
||||||
|
CardResponse,
|
||||||
|
CardResponseActionType,
|
||||||
|
} from 'src/entities/card_responses.entity';
|
||||||
|
import {
|
||||||
|
CarteDto,
|
||||||
|
CreerCarteDto,
|
||||||
|
ListeCartesDto,
|
||||||
|
MajCarteDto,
|
||||||
|
RepondreCarteDto,
|
||||||
|
} from './dto/cards.dto';
|
||||||
|
import { CardsRealtimeService } from './cards-realtime.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CardsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CardType)
|
||||||
|
private readonly typesRepo: Repository<CardType>,
|
||||||
|
@InjectRepository(CardInstance)
|
||||||
|
private readonly cardsRepo: Repository<CardInstance>,
|
||||||
|
@InjectRepository(CardAudienceMember)
|
||||||
|
private readonly audienceRepo: Repository<CardAudienceMember>,
|
||||||
|
@InjectRepository(CardResponse)
|
||||||
|
private readonly responsesRepo: Repository<CardResponse>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepo: Repository<AmChildren>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepo: Repository<ParentsChildren>,
|
||||||
|
private readonly absencesService: AbsencesGardeService,
|
||||||
|
private readonly realtime: CardsRealtimeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listerTypes(role: RoleType): Promise<CardType[]> {
|
||||||
|
const all = await this.typesRepo.find({ where: { system: true } });
|
||||||
|
return all.filter((t) => t.emitter_roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
async lister(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
placementId?: string,
|
||||||
|
): Promise<ListeCartesDto> {
|
||||||
|
const qb = this.cardsRepo
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.innerJoin('c.audience', 'aud', 'aud.id_utilisateur = :userId', { userId })
|
||||||
|
.leftJoinAndSelect('c.type', 'type')
|
||||||
|
.leftJoinAndSelect('c.responses', 'responses')
|
||||||
|
.where('c.purge_at > now()')
|
||||||
|
.orderBy(
|
||||||
|
`CASE c.statut WHEN 'refusee' THEN 0 WHEN 'ouverte' THEN 1 ELSE 2 END`,
|
||||||
|
'ASC',
|
||||||
|
)
|
||||||
|
.addOrderBy('c.modifie_le', 'DESC');
|
||||||
|
|
||||||
|
if (placementId) {
|
||||||
|
await this.assertPlacementAccess(userId, role, placementId);
|
||||||
|
qb.andWhere('c.id_placement = :placementId', { placementId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await qb.getMany();
|
||||||
|
return {
|
||||||
|
items: rows.map((c) => this.toDto(c, userId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async creer(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
dto: CreerCarteDto,
|
||||||
|
): Promise<CarteDto> {
|
||||||
|
if (dto.date_fin < dto.date_debut) {
|
||||||
|
throw new BadRequestException('date_fin < date_debut');
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = await this.typesRepo.findOne({ where: { code: dto.type_code } });
|
||||||
|
if (!type || !type.system) {
|
||||||
|
throw new NotFoundException('Type de carte inconnu');
|
||||||
|
}
|
||||||
|
if (!type.emitter_roles.includes(role)) {
|
||||||
|
throw new ForbiddenException('Vous ne pouvez pas émettre ce type de carte');
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = await this.assertPlacementAccess(
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
dto.id_placement,
|
||||||
|
);
|
||||||
|
const operation = dto.operation ?? CardOperationType.CREATE;
|
||||||
|
|
||||||
|
let absenceId = dto.id_absence;
|
||||||
|
const absenceType = this.mapAbsenceType(dto.type_code);
|
||||||
|
|
||||||
|
if (operation === CardOperationType.CREATE) {
|
||||||
|
const absence = await this.absencesService.creer(userId, role, {
|
||||||
|
id_placement: dto.id_placement,
|
||||||
|
type: absenceType,
|
||||||
|
date_debut: dto.date_debut,
|
||||||
|
date_fin: dto.date_fin,
|
||||||
|
motif: dto.motif,
|
||||||
|
});
|
||||||
|
absenceId = absence.id;
|
||||||
|
} else {
|
||||||
|
if (!absenceId) {
|
||||||
|
throw new BadRequestException('id_absence requis pour operation=update');
|
||||||
|
}
|
||||||
|
await this.absencesService.maj(userId, role, absenceId, {
|
||||||
|
date_debut: dto.date_debut,
|
||||||
|
date_fin: dto.date_fin,
|
||||||
|
motif: dto.motif,
|
||||||
|
// Congé accepté modifié → repasse en attente via cartes respond flow;
|
||||||
|
// pour absence enfant update immédiat déjà fait.
|
||||||
|
...(dto.type_code === 'conge_am'
|
||||||
|
? { statut: StatutAbsenceGardeType.EN_ATTENTE }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statutInitial =
|
||||||
|
type.response_mode === CardResponseModeType.NONE
|
||||||
|
? CardInstanceStatutType.TRAITEE
|
||||||
|
: CardInstanceStatutType.OUVERTE;
|
||||||
|
|
||||||
|
const card = this.cardsRepo.create({
|
||||||
|
type_code: type.code,
|
||||||
|
id_placement: dto.id_placement,
|
||||||
|
id_absence: absenceId,
|
||||||
|
cree_par: userId,
|
||||||
|
operation,
|
||||||
|
statut: statutInitial,
|
||||||
|
payload: {
|
||||||
|
date_debut: dto.date_debut,
|
||||||
|
date_fin: dto.date_fin,
|
||||||
|
motif: dto.motif ?? null,
|
||||||
|
},
|
||||||
|
purge_at: this.purgeAt(type.retention_days, statutInitial),
|
||||||
|
});
|
||||||
|
const saved = await this.cardsRepo.save(card);
|
||||||
|
|
||||||
|
if (absenceId) {
|
||||||
|
await this.linkAbsenceCard(absenceId, saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.buildAudience(saved, type, placement, userId, role);
|
||||||
|
|
||||||
|
const full = await this.loadCard(saved.id);
|
||||||
|
const dtoOut = this.toDto(full, userId);
|
||||||
|
this.emitAudience(full, 'card.created', dtoOut);
|
||||||
|
return dtoOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
async repondre(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
cardId: string,
|
||||||
|
dto: RepondreCarteDto,
|
||||||
|
): Promise<CarteDto> {
|
||||||
|
const card = await this.loadCard(cardId);
|
||||||
|
await this.assertInAudience(card, userId);
|
||||||
|
if (card.statut !== CardInstanceStatutType.OUVERTE) {
|
||||||
|
throw new BadRequestException('Cette carte n’est plus ouverte');
|
||||||
|
}
|
||||||
|
if (card.cree_par === userId) {
|
||||||
|
throw new ForbiddenException('Le créateur ne répond pas à sa propre carte');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = card.type.response_mode;
|
||||||
|
if (mode === CardResponseModeType.NONE) {
|
||||||
|
throw new BadRequestException('Ce type de carte ne demande pas de réponse');
|
||||||
|
}
|
||||||
|
if (mode === CardResponseModeType.ACK && dto.action !== CardResponseActionType.ACK) {
|
||||||
|
throw new BadRequestException('Action attendue : ack');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mode === CardResponseModeType.ACCEPT_REFUSE &&
|
||||||
|
dto.action !== CardResponseActionType.ACCEPT &&
|
||||||
|
dto.action !== CardResponseActionType.REFUSE
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Action attendue : accept ou refuse');
|
||||||
|
}
|
||||||
|
if (dto.action === CardResponseActionType.REFUSE && !dto.comment?.trim()) {
|
||||||
|
throw new BadRequestException('Motivation obligatoire en cas de refus');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.responsesRepo.save(
|
||||||
|
this.responsesRepo.create({
|
||||||
|
id_card: card.id,
|
||||||
|
id_utilisateur: userId,
|
||||||
|
action: dto.action,
|
||||||
|
comment: dto.comment?.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (card.id_absence) {
|
||||||
|
if (dto.action === CardResponseActionType.ACCEPT || dto.action === CardResponseActionType.ACK) {
|
||||||
|
await this.absencesService.maj(userId, role, card.id_absence, {
|
||||||
|
statut: StatutAbsenceGardeType.ACCEPTE,
|
||||||
|
});
|
||||||
|
card.statut = CardInstanceStatutType.TRAITEE;
|
||||||
|
} else if (dto.action === CardResponseActionType.REFUSE) {
|
||||||
|
await this.absencesService.maj(userId, role, card.id_absence, {
|
||||||
|
statut: StatutAbsenceGardeType.REFUSE,
|
||||||
|
motif: dto.comment!.trim(),
|
||||||
|
});
|
||||||
|
card.statut = CardInstanceStatutType.REFUSEE;
|
||||||
|
}
|
||||||
|
} else if (dto.action === CardResponseActionType.ACK) {
|
||||||
|
card.statut = CardInstanceStatutType.TRAITEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
|
||||||
|
await this.cardsRepo.save(card);
|
||||||
|
const full = await this.loadCard(cardId);
|
||||||
|
const dtoOut = this.toDto(full, userId);
|
||||||
|
this.emitAudience(full, 'response.added', {
|
||||||
|
action: dto.action,
|
||||||
|
card: dtoOut,
|
||||||
|
});
|
||||||
|
this.emitAudience(full, 'card.updated', dtoOut);
|
||||||
|
return dtoOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
async majEnAttente(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
cardId: string,
|
||||||
|
dto: MajCarteDto,
|
||||||
|
): Promise<CarteDto> {
|
||||||
|
const card = await this.loadCard(cardId);
|
||||||
|
if (card.cree_par !== userId) {
|
||||||
|
throw new ForbiddenException('Seul le créateur peut modifier cette carte');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
card.statut !== CardInstanceStatutType.OUVERTE &&
|
||||||
|
card.statut !== CardInstanceStatutType.REFUSEE
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Carte non modifiable dans cet état');
|
||||||
|
}
|
||||||
|
|
||||||
|
const debut =
|
||||||
|
dto.date_debut ?? String(card.payload?.['date_debut'] ?? '');
|
||||||
|
const fin = dto.date_fin ?? String(card.payload?.['date_fin'] ?? '');
|
||||||
|
if (!debut || !fin || fin < debut) {
|
||||||
|
throw new BadRequestException('Dates invalides');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.id_absence) {
|
||||||
|
await this.absencesService.maj(userId, role, card.id_absence, {
|
||||||
|
date_debut: debut,
|
||||||
|
date_fin: fin,
|
||||||
|
motif: dto.motif,
|
||||||
|
statut: StatutAbsenceGardeType.EN_ATTENTE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
card.payload = {
|
||||||
|
...card.payload,
|
||||||
|
date_debut: debut,
|
||||||
|
date_fin: fin,
|
||||||
|
motif: dto.motif ?? card.payload?.['motif'] ?? null,
|
||||||
|
};
|
||||||
|
card.statut = CardInstanceStatutType.OUVERTE;
|
||||||
|
card.purge_at = this.purgeAt(card.type.retention_days, card.statut);
|
||||||
|
await this.cardsRepo.save(card);
|
||||||
|
const full = await this.loadCard(cardId);
|
||||||
|
const dtoOut = this.toDto(full, userId);
|
||||||
|
this.emitAudience(full, 'card.updated', dtoOut);
|
||||||
|
return dtoOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
async supprimer(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
cardId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const card = await this.loadCard(cardId);
|
||||||
|
if (card.cree_par !== userId) {
|
||||||
|
throw new ForbiddenException('Seul le créateur peut supprimer cette carte');
|
||||||
|
}
|
||||||
|
const audienceIds = await this.audienceUserIds(card);
|
||||||
|
if (card.id_absence) {
|
||||||
|
const absStatut =
|
||||||
|
card.statut === CardInstanceStatutType.TRAITEE
|
||||||
|
? null
|
||||||
|
: card.id_absence;
|
||||||
|
// Supprime l’absence liée si pas encore acceptée définitivement
|
||||||
|
if (
|
||||||
|
card.statut === CardInstanceStatutType.OUVERTE ||
|
||||||
|
card.statut === CardInstanceStatutType.REFUSEE
|
||||||
|
) {
|
||||||
|
await this.absencesService.supprimer(userId, role, card.id_absence);
|
||||||
|
}
|
||||||
|
void absStatut;
|
||||||
|
}
|
||||||
|
await this.cardsRepo.delete({ id: cardId });
|
||||||
|
this.realtime.emitToUsers(audienceIds, 'card.deleted', cardId, {
|
||||||
|
id: cardId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitAudience(
|
||||||
|
card: CardInstance,
|
||||||
|
event: 'card.created' | 'card.updated' | 'response.added',
|
||||||
|
data: unknown,
|
||||||
|
): void {
|
||||||
|
const ids = (card.audience ?? []).map((a) => a.id_utilisateur);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
this.realtime.emitToUsers(ids, event, card.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async audienceUserIds(card: CardInstance): Promise<string[]> {
|
||||||
|
if (card.audience?.length) {
|
||||||
|
return card.audience.map((a) => a.id_utilisateur);
|
||||||
|
}
|
||||||
|
const rows = await this.audienceRepo.find({ where: { id_card: card.id } });
|
||||||
|
return rows.map((r) => r.id_utilisateur);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapAbsenceType(typeCode: string): TypeAbsenceGardeType {
|
||||||
|
if (typeCode === 'absence_enfant' || typeCode === 'absence_enfant_modif') {
|
||||||
|
return TypeAbsenceGardeType.ABSENCE_ENFANT;
|
||||||
|
}
|
||||||
|
if (typeCode === 'conge_am') return TypeAbsenceGardeType.CONGE_AM;
|
||||||
|
if (typeCode === 'arret_maladie_am') {
|
||||||
|
return TypeAbsenceGardeType.ARRET_MALADIE_AM;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(`Type non mappé à une absence: ${typeCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async linkAbsenceCard(absenceId: string, cardId: string): Promise<void> {
|
||||||
|
// Update direct pour éviter droits maj vides
|
||||||
|
await this.cardsRepo.manager.query(
|
||||||
|
`UPDATE absences_garde SET id_card_instance = $1, modifie_le = now() WHERE id = $2`,
|
||||||
|
[cardId, absenceId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildAudience(
|
||||||
|
card: CardInstance,
|
||||||
|
type: CardType,
|
||||||
|
placement: AmChildren,
|
||||||
|
creatorId: string,
|
||||||
|
creatorRole: RoleType,
|
||||||
|
): Promise<void> {
|
||||||
|
const members: Partial<CardAudienceMember>[] = [
|
||||||
|
{
|
||||||
|
id_card: card.id,
|
||||||
|
id_utilisateur: creatorId,
|
||||||
|
role_snapshot: creatorRole,
|
||||||
|
is_creator: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (type.audience_resolver === 'couple_am') {
|
||||||
|
members.push({
|
||||||
|
id_card: card.id,
|
||||||
|
id_utilisateur: placement.amId,
|
||||||
|
role_snapshot: RoleType.ASSISTANTE_MATERNELLE,
|
||||||
|
is_creator: false,
|
||||||
|
});
|
||||||
|
} else if (type.audience_resolver === 'couple_parents') {
|
||||||
|
const liens = await this.parentsChildrenRepo.find({
|
||||||
|
where: { enfantId: placement.enfantId },
|
||||||
|
});
|
||||||
|
for (const l of liens) {
|
||||||
|
if (l.parentId === creatorId) continue;
|
||||||
|
members.push({
|
||||||
|
id_card: card.id,
|
||||||
|
id_utilisateur: l.parentId,
|
||||||
|
role_snapshot: RoleType.PARENT,
|
||||||
|
is_creator: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dédup
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = members.filter((m) => {
|
||||||
|
const k = m.id_utilisateur!;
|
||||||
|
if (seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await this.audienceRepo.save(this.audienceRepo.create(unique));
|
||||||
|
}
|
||||||
|
|
||||||
|
private purgeAt(
|
||||||
|
retentionDays: number,
|
||||||
|
statut: CardInstanceStatutType,
|
||||||
|
): Date {
|
||||||
|
const days =
|
||||||
|
statut === CardInstanceStatutType.OUVERTE
|
||||||
|
? Math.max(retentionDays, 15)
|
||||||
|
: retentionDays;
|
||||||
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCard(id: string): Promise<CardInstance> {
|
||||||
|
const card = await this.cardsRepo.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['type', 'responses', 'audience'],
|
||||||
|
});
|
||||||
|
if (!card) throw new NotFoundException('Carte introuvable');
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertInAudience(card: CardInstance, userId: string): Promise<void> {
|
||||||
|
const ok = (card.audience ?? []).some((a) => a.id_utilisateur === userId);
|
||||||
|
if (!ok) {
|
||||||
|
const row = await this.audienceRepo.findOne({
|
||||||
|
where: { id_card: card.id, id_utilisateur: userId },
|
||||||
|
});
|
||||||
|
if (!row) throw new ForbiddenException('Carte hors de votre audience');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPlacementAccess(
|
||||||
|
userId: string,
|
||||||
|
role: RoleType,
|
||||||
|
placementId: string,
|
||||||
|
): Promise<AmChildren> {
|
||||||
|
const placement = await this.amChildrenRepo.findOne({
|
||||||
|
where: { id: placementId, date_fin: IsNull() },
|
||||||
|
});
|
||||||
|
if (!placement) {
|
||||||
|
throw new NotFoundException('Placement introuvable ou inactif');
|
||||||
|
}
|
||||||
|
if (role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
if (placement.amId !== userId) {
|
||||||
|
throw new ForbiddenException('Placement non autorisé');
|
||||||
|
}
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
|
if (role === RoleType.PARENT) {
|
||||||
|
const lien = await this.parentsChildrenRepo.findOne({
|
||||||
|
where: { parentId: userId, enfantId: placement.enfantId },
|
||||||
|
});
|
||||||
|
if (!lien) throw new ForbiddenException('Placement non autorisé');
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Rôle non autorisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(card: CardInstance, userId: string): CarteDto {
|
||||||
|
const lastRefuse = [...(card.responses ?? [])]
|
||||||
|
.reverse()
|
||||||
|
.find((r) => r.action === CardResponseActionType.REFUSE);
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
type_code: card.type_code,
|
||||||
|
titre: card.type?.titre ?? card.type_code,
|
||||||
|
couleur: card.type?.couleur ?? null,
|
||||||
|
id_placement: card.id_placement,
|
||||||
|
id_absence: card.id_absence ?? null,
|
||||||
|
operation: card.operation,
|
||||||
|
statut: card.statut,
|
||||||
|
payload: card.payload ?? {},
|
||||||
|
purge_at: card.purge_at?.toISOString?.() ?? String(card.purge_at),
|
||||||
|
cree_par: card.cree_par ?? null,
|
||||||
|
is_creator: card.cree_par === userId,
|
||||||
|
response_mode: card.type?.response_mode,
|
||||||
|
last_refuse_comment: lastRefuse?.comment ?? null,
|
||||||
|
cree_le: card.cree_le?.toISOString?.() ?? String(card.cree_le),
|
||||||
|
modifie_le: card.modifie_le?.toISOString?.() ?? String(card.modifie_le),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { CardInstanceStatutType, CardOperationType } from 'src/entities/card_instances.entity';
|
||||||
|
import { CardResponseActionType } from 'src/entities/card_responses.entity';
|
||||||
|
|
||||||
|
export class CreerCarteDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'absence_enfant | absence_enfant_modif | conge_am | arret_maladie_am',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
type_code: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsUUID()
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-10-01' })
|
||||||
|
@IsDateString()
|
||||||
|
date_debut: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-10-05' })
|
||||||
|
@IsDateString()
|
||||||
|
date_fin: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
motif?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requis pour operation=update (même id absences_garde)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
id_absence?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CardOperationType, default: CardOperationType.CREATE })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CardOperationType)
|
||||||
|
operation?: CardOperationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RepondreCarteDto {
|
||||||
|
@ApiProperty({ enum: CardResponseActionType })
|
||||||
|
@IsEnum(CardResponseActionType)
|
||||||
|
action: CardResponseActionType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Obligatoire si action=refuse' })
|
||||||
|
@ValidateIf((o) => o.action === CardResponseActionType.REFUSE)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(2000)
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MajCarteDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_debut?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date_fin?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
motif?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CarteDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
type_code: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
titre: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
couleur?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
id_placement: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
id_absence?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CardOperationType })
|
||||||
|
operation: CardOperationType;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CardInstanceStatutType })
|
||||||
|
statut: CardInstanceStatutType;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
purge_at: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
cree_par?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
is_creator: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
response_mode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
last_refuse_comment?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
cree_le: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
modifie_le: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListeCartesDto {
|
||||||
|
@ApiProperty({ type: [CarteDto] })
|
||||||
|
items: CarteDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { CardsModule } from './cards.module';
|
||||||
|
export { CardsService } from './cards.service';
|
||||||
|
export { CardsRealtimeService } from './cards-realtime.service';
|
||||||
+79
-2
@@ -1,20 +1,97 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
|
||||||
describe('AssistantesMaternellesController', () => {
|
describe('AssistantesMaternellesController', () => {
|
||||||
let controller: AssistantesMaternellesController;
|
let controller: AssistantesMaternellesController;
|
||||||
|
const authServiceMock = {
|
||||||
|
createAmDossierStaff: jest.fn(),
|
||||||
|
};
|
||||||
|
const amServiceMock = {
|
||||||
|
listerCouplesGarde: jest.fn(),
|
||||||
|
definirCoupleGardeCourant: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AssistantesMaternellesController],
|
controllers: [AssistantesMaternellesController],
|
||||||
providers: [AssistantesMaternellesService],
|
providers: [
|
||||||
}).compile();
|
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
|
||||||
|
{ provide: AuthService, useValue: authServiceMock },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
expect(controller).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
|
||||||
|
authServiceMock.createAmDossierStaff.mockResolvedValue({
|
||||||
|
message: 'ok',
|
||||||
|
user_id: 'u1',
|
||||||
|
statut: 'actif',
|
||||||
|
numero_dossier: '2026-000001',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
email: 'am.staff@test.fr',
|
||||||
|
prenom: 'Marie',
|
||||||
|
nom: 'TEST',
|
||||||
|
telephone: '0689567890',
|
||||||
|
consentement_photo: false,
|
||||||
|
lieu_naissance_ville: 'Paris',
|
||||||
|
lieu_naissance_pays: 'France',
|
||||||
|
nir: '285017512345678',
|
||||||
|
numero_agrement: 'AGR-TEST-001',
|
||||||
|
capacite_accueil: 3,
|
||||||
|
places_disponibles: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await controller.createDossier(body as any);
|
||||||
|
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: body.email,
|
||||||
|
acceptation_cgu: true,
|
||||||
|
acceptation_privacy: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.numero_dossier).toBe('2026-000001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listerCouplesGarde délègue au service (#171)', async () => {
|
||||||
|
amServiceMock.listerCouplesGarde.mockResolvedValue({
|
||||||
|
couples: [],
|
||||||
|
couple_courant_id: null,
|
||||||
|
});
|
||||||
|
const res = await controller.listerCouplesGarde('am-uuid');
|
||||||
|
expect(amServiceMock.listerCouplesGarde).toHaveBeenCalledWith('am-uuid');
|
||||||
|
expect(res.couple_courant_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('definirCoupleGardeCourant délègue au service (#171)', async () => {
|
||||||
|
amServiceMock.definirCoupleGardeCourant.mockResolvedValue({
|
||||||
|
couples: [{ id: 'pl-1', courant: true }],
|
||||||
|
couple_courant_id: 'pl-1',
|
||||||
|
});
|
||||||
|
const res = await controller.definirCoupleGardeCourant('am-uuid', {
|
||||||
|
couple_id: 'pl-1',
|
||||||
|
});
|
||||||
|
expect(amServiceMock.definirCoupleGardeCourant).toHaveBeenCalledWith(
|
||||||
|
'am-uuid',
|
||||||
|
'pl-1',
|
||||||
|
);
|
||||||
|
expect(res.couple_courant_id).toBe('pl-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Body,
|
Body,
|
||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -16,17 +19,51 @@ import { RoleType, Users } from 'src/entities/users.entity';
|
|||||||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
||||||
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
||||||
|
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
|
||||||
|
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
|
||||||
|
import { CouplesGardeAmResponseDto } from './dto/couples-garde-am.dto';
|
||||||
|
import { DefinirCoupleGardeCourantAmDto } from './dto/definir-couple-garde-courant-am.dto';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
|
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto';
|
||||||
|
|
||||||
@ApiTags("Assistantes Maternelles")
|
@ApiTags("Assistantes Maternelles")
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('assistantes-maternelles')
|
@Controller('assistantes-maternelles')
|
||||||
export class AssistantesMaternellesController {
|
export class AssistantesMaternellesController {
|
||||||
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
|
constructor(
|
||||||
|
private readonly assistantesMaternellesService: AssistantesMaternellesService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
|
@Post('dossier')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Créer un dossier AM complet (staff) — ticket #156',
|
||||||
|
description:
|
||||||
|
'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' +
|
||||||
|
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: StaffCreateAmDossierDto })
|
||||||
|
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
|
||||||
|
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
|
||||||
|
async createDossier(
|
||||||
|
@Body() dto: StaffCreateAmDossierDto,
|
||||||
|
): Promise<StaffCreateAmDossierResponseDto> {
|
||||||
|
const registerDto = {
|
||||||
|
...dto,
|
||||||
|
acceptation_cgu: true,
|
||||||
|
acceptation_privacy: true,
|
||||||
|
} as RegisterAMCompletDto;
|
||||||
|
return this.authService.createAmDossierStaff(registerDto);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Créer nounou' })
|
@ApiOperation({ summary: 'Créer nounou' })
|
||||||
@@ -49,6 +86,36 @@ export class AssistantesMaternellesController {
|
|||||||
return mapAmsForApi(ams);
|
return mapAmsForApi(ams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('me/couples-garde')
|
||||||
|
@Roles(RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Couples de garde de l’AM connectée — ticket #171',
|
||||||
|
description:
|
||||||
|
'Placements actifs (enfant + parents) pour peupler le bandeau couple TdB AM, ' +
|
||||||
|
'avec indication du couple courant.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: CouplesGardeAmResponseDto })
|
||||||
|
listerCouplesGarde(
|
||||||
|
@User('id') userId: string,
|
||||||
|
): Promise<CouplesGardeAmResponseDto> {
|
||||||
|
return this.assistantesMaternellesService.listerCouplesGarde(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('me/couples-garde/courant')
|
||||||
|
@Roles(RoleType.ASSISTANTE_MATERNELLE)
|
||||||
|
@ApiOperation({ summary: 'Définir le couple de garde courant (AM) — ticket #171' })
|
||||||
|
@ApiBody({ type: DefinirCoupleGardeCourantAmDto })
|
||||||
|
@ApiResponse({ status: 200, type: CouplesGardeAmResponseDto })
|
||||||
|
definirCoupleGardeCourant(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@Body() dto: DefinirCoupleGardeCourantAmDto,
|
||||||
|
): Promise<CouplesGardeAmResponseDto> {
|
||||||
|
return this.assistantesMaternellesService.definirCoupleGardeCourant(
|
||||||
|
userId,
|
||||||
|
dto.couple_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ import { AssistantesMaternellesController } from './assistantes_maternelles.cont
|
|||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { AmChildren } from 'src/entities/am_children.entity';
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
import { Children } from 'src/entities/children.entity';
|
import { Children } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]),
|
imports: [
|
||||||
AuthModule
|
TypeOrmModule.forFeature([
|
||||||
|
AssistanteMaternelle,
|
||||||
|
AmChildren,
|
||||||
|
Children,
|
||||||
|
Users,
|
||||||
|
ParentsChildren,
|
||||||
|
]),
|
||||||
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [AssistantesMaternellesController],
|
controllers: [AssistantesMaternellesController],
|
||||||
providers: [AssistantesMaternellesService],
|
providers: [AssistantesMaternellesService],
|
||||||
@@ -19,4 +27,4 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
TypeOrmModule,
|
TypeOrmModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AssistantesMaternellesModule { }
|
export class AssistantesMaternellesModule {}
|
||||||
|
|||||||
+138
-6
@@ -1,18 +1,150 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { Children } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
|
||||||
describe('AssistantesMaternellesService', () => {
|
describe('AssistantesMaternellesService — couples de garde (#171)', () => {
|
||||||
let service: AssistantesMaternellesService;
|
let service: AssistantesMaternellesService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
const amRepo = { findOne: jest.fn(), update: jest.fn() };
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const usersRepo = {};
|
||||||
providers: [AssistantesMaternellesService],
|
const amChildrenRepo = { find: jest.fn(), findOne: jest.fn() };
|
||||||
}).compile();
|
const childrenRepo = {};
|
||||||
|
const parentsChildrenRepo = { find: jest.fn() };
|
||||||
|
|
||||||
service = module.get<AssistantesMaternellesService>(AssistantesMaternellesService);
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AssistantesMaternellesService,
|
||||||
|
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
||||||
|
{ provide: getRepositoryToken(Users), useValue: usersRepo },
|
||||||
|
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepo },
|
||||||
|
{ provide: getRepositoryToken(Children), useValue: childrenRepo },
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(ParentsChildren),
|
||||||
|
useValue: parentsChildrenRepo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get(AssistantesMaternellesService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listerCouplesGarde', () => {
|
||||||
|
it('retourne vide si aucun placement', async () => {
|
||||||
|
amRepo.findOne.mockResolvedValue({
|
||||||
|
user_id: 'am-1',
|
||||||
|
id_placement_garde_courant: null,
|
||||||
|
});
|
||||||
|
amChildrenRepo.find.mockResolvedValue([]);
|
||||||
|
const res = await service.listerCouplesGarde('am-1');
|
||||||
|
expect(res).toEqual({ couples: [], couple_courant_id: null });
|
||||||
|
expect(parentsChildrenRepo.find).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mappe placements + parents et marque le courant', async () => {
|
||||||
|
amRepo.findOne.mockResolvedValue({
|
||||||
|
user_id: 'am-1',
|
||||||
|
id_placement_garde_courant: 'pl-2',
|
||||||
|
});
|
||||||
|
amChildrenRepo.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'pl-1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
child: { id: 'e1', first_name: 'Léa', last_name: 'M', photo_url: null },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pl-2',
|
||||||
|
enfantId: 'e2',
|
||||||
|
child: { id: 'e2', first_name: 'Emma', last_name: 'M', photo_url: null },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
parentsChildrenRepo.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
parentId: 'p1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
parent: { user: { prenom: 'Claire', nom: 'Martin', photo_url: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentId: 'p2',
|
||||||
|
enfantId: 'e1',
|
||||||
|
parent: { user: { prenom: 'Thomas', nom: 'Martin', photo_url: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentId: 'p1',
|
||||||
|
enfantId: 'e2',
|
||||||
|
parent: { user: { prenom: 'Claire', nom: 'Martin', photo_url: null } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.listerCouplesGarde('am-1');
|
||||||
|
expect(res.couples).toHaveLength(2);
|
||||||
|
expect(res.couple_courant_id).toBe('pl-2');
|
||||||
|
expect(res.couples[1].courant).toBe(true);
|
||||||
|
expect(res.couples[0].parents).toHaveLength(2);
|
||||||
|
expect(res.couples[0].parents[0].prenom).toBe('Claire');
|
||||||
|
expect(res.couples[0].enfant.prenom).toBe('Léa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 si AM inconnue', async () => {
|
||||||
|
amRepo.findOne.mockResolvedValue(null);
|
||||||
|
await expect(service.listerCouplesGarde('x')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('definirCoupleGardeCourant', () => {
|
||||||
|
it('persiste si le placement appartient à l’AM', async () => {
|
||||||
|
amRepo.findOne.mockResolvedValue({
|
||||||
|
user_id: 'am-1',
|
||||||
|
id_placement_garde_courant: null,
|
||||||
|
});
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
});
|
||||||
|
amRepo.update.mockResolvedValue({ affected: 1 });
|
||||||
|
amChildrenRepo.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'pl-1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
child: { id: 'e1', first_name: 'Léa', last_name: null, photo_url: null },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
parentsChildrenRepo.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
parentId: 'p1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
parent: { user: { prenom: 'Claire', nom: 'M', photo_url: null } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.definirCoupleGardeCourant('am-1', 'pl-1');
|
||||||
|
expect(amRepo.update).toHaveBeenCalledWith(
|
||||||
|
{ user_id: 'am-1' },
|
||||||
|
{ id_placement_garde_courant: 'pl-1' },
|
||||||
|
);
|
||||||
|
expect(res.couple_courant_id).toBe('pl-1');
|
||||||
|
expect(res.couples[0].courant).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 si placement d’une autre AM', async () => {
|
||||||
|
amRepo.findOne.mockResolvedValue({ user_id: 'am-1' });
|
||||||
|
amChildrenRepo.findOne.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.definirCoupleGardeCourant('am-1', 'pl-x'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { In, IsNull, Repository } from 'typeorm';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { AmChildren } from 'src/entities/am_children.entity';
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
||||||
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
||||||
|
import { CouplesGardeAmResponseDto } from './dto/couples-garde-am.dto';
|
||||||
import { validateNir } from 'src/common/utils/nir.util';
|
import { validateNir } from 'src/common/utils/nir.util';
|
||||||
|
|
||||||
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
|
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
|
||||||
@@ -28,6 +30,8 @@ export class AssistantesMaternellesService {
|
|||||||
private readonly amChildrenRepository: Repository<AmChildren>,
|
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||||
@InjectRepository(Children)
|
@InjectRepository(Children)
|
||||||
private readonly childrenRepository: Repository<Children>,
|
private readonly childrenRepository: Repository<Children>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
@@ -264,4 +268,107 @@ export class AssistantesMaternellesService {
|
|||||||
await this.assistantesMaternelleRepository.delete(id);
|
await this.assistantesMaternelleRepository.delete(id);
|
||||||
return { message: 'Assistante maternelle supprimée' };
|
return { message: 'Assistante maternelle supprimée' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les couples de garde (enfant ↔ parents) de l’AM connectée — ticket #171.
|
||||||
|
* Un couple = un placement actif dans enfants_assistantes_maternelles pour cette AM.
|
||||||
|
*/
|
||||||
|
async listerCouplesGarde(amUserId: string): Promise<CouplesGardeAmResponseDto> {
|
||||||
|
const am = await this.assistantesMaternelleRepository.findOne({
|
||||||
|
where: { user_id: amUserId },
|
||||||
|
});
|
||||||
|
if (!am) {
|
||||||
|
throw new NotFoundException('Assistante maternelle introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const placements = await this.amChildrenRepository.find({
|
||||||
|
where: { amId: amUserId, date_fin: IsNull() },
|
||||||
|
relations: ['child'],
|
||||||
|
order: { date_debut: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (placements.length === 0) {
|
||||||
|
return { couples: [], couple_courant_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const enfantIds = placements.map((p) => p.enfantId);
|
||||||
|
const liens = await this.parentsChildrenRepository.find({
|
||||||
|
where: { enfantId: In(enfantIds) },
|
||||||
|
relations: ['parent', 'parent.user'],
|
||||||
|
});
|
||||||
|
const parentsByEnfant = new Map<
|
||||||
|
string,
|
||||||
|
{ id: string; prenom: string | null; nom: string | null; photo_url: string | null }[]
|
||||||
|
>();
|
||||||
|
for (const l of liens) {
|
||||||
|
const list = parentsByEnfant.get(l.enfantId) ?? [];
|
||||||
|
const u = l.parent?.user;
|
||||||
|
list.push({
|
||||||
|
id: l.parentId,
|
||||||
|
prenom: u?.prenom ?? null,
|
||||||
|
nom: u?.nom ?? null,
|
||||||
|
photo_url: u?.photo_url ?? null,
|
||||||
|
});
|
||||||
|
parentsByEnfant.set(l.enfantId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCourant = am.id_placement_garde_courant ?? null;
|
||||||
|
const idsValides = new Set(placements.map((p) => p.id));
|
||||||
|
if (idCourant && !idsValides.has(idCourant)) {
|
||||||
|
idCourant = null;
|
||||||
|
await this.assistantesMaternelleRepository.update(
|
||||||
|
{ user_id: amUserId },
|
||||||
|
{ id_placement_garde_courant: () => 'NULL' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!idCourant && placements.length === 1) {
|
||||||
|
idCourant = placements[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const couples = placements.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
enfant: {
|
||||||
|
id: p.child.id,
|
||||||
|
prenom: p.child.first_name ?? null,
|
||||||
|
nom: p.child.last_name ?? null,
|
||||||
|
photo_url: p.child.photo_url ?? null,
|
||||||
|
},
|
||||||
|
parents: parentsByEnfant.get(p.enfantId) ?? [],
|
||||||
|
courant: idCourant != null && p.id === idCourant,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
couples,
|
||||||
|
couple_courant_id: idCourant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste le couple de garde actif pour l’AM — ticket #171.
|
||||||
|
*/
|
||||||
|
async definirCoupleGardeCourant(
|
||||||
|
amUserId: string,
|
||||||
|
coupleId: string,
|
||||||
|
): Promise<CouplesGardeAmResponseDto> {
|
||||||
|
const am = await this.assistantesMaternelleRepository.findOne({
|
||||||
|
where: { user_id: amUserId },
|
||||||
|
});
|
||||||
|
if (!am) {
|
||||||
|
throw new NotFoundException('Assistante maternelle introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = await this.amChildrenRepository.findOne({
|
||||||
|
where: { id: coupleId, amId: amUserId, date_fin: IsNull() },
|
||||||
|
});
|
||||||
|
if (!placement) {
|
||||||
|
throw new NotFoundException('Couple de garde introuvable ou inactif pour cette AM');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assistantesMaternelleRepository.update(
|
||||||
|
{ user_id: amUserId },
|
||||||
|
{ id_placement_garde_courant: coupleId },
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.listerCouplesGarde(amUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/** Identité minimale enfant — ticket #171 (miroir #168) */
|
||||||
|
export class CoupleGardeAmEnfantDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
nom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
photo_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identité minimale parent pour bandeau AM — ticket #171 */
|
||||||
|
export class CoupleGardeAmParentDto {
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'UUID utilisateur du parent' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
nom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
photo_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un couple côté AM = placement actif enfant ↔ foyer parents.
|
||||||
|
* `id` = enfants_assistantes_maternelles.id (même clé que #168).
|
||||||
|
*/
|
||||||
|
export class CoupleGardeAmDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Id du placement (enfants_assistantes_maternelles.id)',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: CoupleGardeAmEnfantDto })
|
||||||
|
enfant: CoupleGardeAmEnfantDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [CoupleGardeAmParentDto],
|
||||||
|
description: 'Parents rattachés à l’enfant (0–2 typiquement)',
|
||||||
|
})
|
||||||
|
parents: CoupleGardeAmParentDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'True si c’est le couple actuellement sélectionné' })
|
||||||
|
courant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CouplesGardeAmResponseDto {
|
||||||
|
@ApiProperty({ type: [CoupleGardeAmDto] })
|
||||||
|
couples: CoupleGardeAmDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
nullable: true,
|
||||||
|
description: 'Id du couple courant (null si aucun)',
|
||||||
|
})
|
||||||
|
couple_courant_id: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/** Corps PUT couple de garde courant AM — ticket #171 */
|
||||||
|
export class DefinirCoupleGardeCourantAmDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Id du placement (enfants_assistantes_maternelles.id) à sélectionner',
|
||||||
|
})
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
couple_id: string;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
|
||||||
|
export class StaffCreateAmDossierResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: StatutUtilisateurType,
|
||||||
|
example: StatutUtilisateurType.ACTIF,
|
||||||
|
})
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '2026-000042',
|
||||||
|
description: 'Numéro de dossier attribué',
|
||||||
|
})
|
||||||
|
numero_dossier: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création dossier AM par staff (#156).
|
||||||
|
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||||
|
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||||
|
*/
|
||||||
|
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
|
||||||
|
'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;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
|||||||
ParentsChildren,
|
ParentsChildren,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
ParentsModule,
|
forwardRef(() => ParentsModule),
|
||||||
DossiersModule,
|
DossiersModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
|
|||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
import { DossiersService } from '../dossiers/dossiers.service';
|
import { DossiersService } from '../dossiers/dossiers.service';
|
||||||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||||||
|
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -416,11 +417,20 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
* Cœur partagé création dossier parent (#129).
|
||||||
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
* - public : statut en_attente + mails pending
|
||||||
|
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
|
||||||
*/
|
*/
|
||||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
async createParentDossier(
|
||||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
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');
|
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,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
role: RoleType.PARENT,
|
role: RoleType.PARENT,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
adresse: dto.adresse,
|
||||||
code_postal: dto.code_postal,
|
code_postal: dto.code_postal,
|
||||||
@@ -496,7 +506,7 @@ export class AuthService {
|
|||||||
prenom: dto.co_parent_prenom,
|
prenom: dto.co_parent_prenom,
|
||||||
nom: dto.co_parent_nom,
|
nom: dto.co_parent_nom,
|
||||||
role: RoleType.PARENT,
|
role: RoleType.PARENT,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
telephone: dto.co_parent_telephone,
|
telephone: dto.co_parent_telephone,
|
||||||
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
|
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,
|
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.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||||
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
|
||||||
|
|
||||||
const enfantEnregistre = await manager.save(Children, enfant);
|
const enfantEnregistre = await manager.save(Children, enfant);
|
||||||
enfantsEnregistres.push(enfantEnregistre);
|
enfantsEnregistres.push(enfantEnregistre);
|
||||||
@@ -612,44 +621,287 @@ export class AuthService {
|
|||||||
|
|
||||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||||
|
|
||||||
try {
|
if (options.sendPendingEmail) {
|
||||||
await this.mailService.sendRegistrationPendingEmail(
|
try {
|
||||||
resultat.parent1.email,
|
|
||||||
resultat.parent1.prenom ?? '',
|
|
||||||
resultat.parent1.nom ?? '',
|
|
||||||
numeroDossier,
|
|
||||||
);
|
|
||||||
if (resultat.parent2) {
|
|
||||||
await this.mailService.sendRegistrationPendingEmail(
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
resultat.parent2.email,
|
resultat.parent1.email,
|
||||||
resultat.parent2.prenom ?? '',
|
resultat.parent1.prenom ?? '',
|
||||||
resultat.parent2.nom ?? '',
|
resultat.parent1.nom ?? '',
|
||||||
numeroDossier,
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
"[inscrireParentComplet] É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 {
|
return {
|
||||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
message,
|
||||||
parent_id: resultat.parent1.id,
|
parent_id: resultat.parent1.id,
|
||||||
co_parent_id: resultat.parent2?.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),
|
enfants_ids: resultat.enfants.map(e => e.id),
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
enfant_ids: resultat.enfants.map(e => e.id),
|
||||||
|
statut: options.statut,
|
||||||
numero_dossier: numeroDossier,
|
numero_dossier: numeroDossier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
* Inscription Parent publique — CDC (statut en_attente + mail pending).
|
||||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
|
||||||
*/
|
*/
|
||||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
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(
|
||||||
|
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cœur partagé création dossier AM (#156).
|
||||||
|
* - public : statut en_attente + mail pending
|
||||||
|
* - staff : statut actif + mail création MDP
|
||||||
|
*/
|
||||||
|
async createAmDossier(
|
||||||
|
dto: RegisterAMCompletDto,
|
||||||
|
options: {
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
sendPendingEmail: boolean;
|
||||||
|
sendPasswordSetupEmail: boolean;
|
||||||
|
requireCgu: boolean;
|
||||||
|
logContext?: string;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
message: string;
|
||||||
|
user_id: string;
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
numero_dossier: string;
|
||||||
|
}> {
|
||||||
|
const logCtx = options.logContext ?? 'createAmDossier';
|
||||||
|
|
||||||
|
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||||
);
|
);
|
||||||
@@ -669,8 +921,7 @@ export class AuthService {
|
|||||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||||
}
|
}
|
||||||
if (nirValidation.warning) {
|
if (nirValidation.warning) {
|
||||||
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
|
||||||
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
@@ -721,79 +972,142 @@ export class AuthService {
|
|||||||
let resultat: { user: Users };
|
let resultat: { user: Users };
|
||||||
try {
|
try {
|
||||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
const { numero: numeroDossier } =
|
||||||
|
await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
const user = manager.create(Users, {
|
const user = manager.create(Users, {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
adresse: dto.adresse,
|
||||||
code_postal: dto.code_postal,
|
code_postal: dto.code_postal,
|
||||||
ville: dto.ville,
|
ville: dto.ville,
|
||||||
token_creation_mdp: tokenCreationMdp,
|
token_creation_mdp: tokenCreationMdp,
|
||||||
token_creation_mdp_expire_le: dateExpiration,
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
photo_url: urlPhoto ?? undefined,
|
photo_url: urlPhoto ?? undefined,
|
||||||
consentement_photo: dto.consentement_photo,
|
consentement_photo: dto.consentement_photo,
|
||||||
date_consentement_photo: dateConsentementPhoto,
|
date_consentement_photo: dateConsentementPhoto,
|
||||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
date_naissance: dto.date_naissance
|
||||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
? new Date(dto.date_naissance)
|
||||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
: undefined,
|
||||||
numero_dossier: numeroDossier,
|
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||||
|
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
const userEnregistre = await manager.save(Users, user);
|
||||||
|
|
||||||
|
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||||
|
const am = amRepo.create({
|
||||||
|
user_id: userEnregistre.id,
|
||||||
|
approval_number: dto.numero_agrement,
|
||||||
|
nir: nirNormalized,
|
||||||
|
max_children: dto.capacite_accueil,
|
||||||
|
places_available: dto.places_disponibles,
|
||||||
|
biography: dto.biographie,
|
||||||
|
residence_city: dto.ville ?? undefined,
|
||||||
|
agreement_date: dto.date_agrement
|
||||||
|
? new Date(dto.date_agrement)
|
||||||
|
: undefined,
|
||||||
|
available: true,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
await amRepo.save(am);
|
||||||
|
|
||||||
|
return { user: userEnregistre };
|
||||||
});
|
});
|
||||||
const userEnregistre = await manager.save(Users, user);
|
|
||||||
|
|
||||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
|
||||||
const am = amRepo.create({
|
|
||||||
user_id: userEnregistre.id,
|
|
||||||
approval_number: dto.numero_agrement,
|
|
||||||
nir: nirNormalized,
|
|
||||||
max_children: dto.capacite_accueil,
|
|
||||||
places_available: dto.places_disponibles,
|
|
||||||
biography: dto.biographie,
|
|
||||||
residence_city: dto.ville ?? undefined,
|
|
||||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
|
||||||
available: true,
|
|
||||||
numero_dossier: numeroDossier,
|
|
||||||
});
|
|
||||||
await amRepo.save(am);
|
|
||||||
|
|
||||||
return { user: userEnregistre };
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.isPostgresUniqueViolation(err)) {
|
if (this.isPostgresUniqueViolation(err)) {
|
||||||
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
|
throw new ConflictException(
|
||||||
|
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||||
|
|
||||||
try {
|
if (options.sendPendingEmail) {
|
||||||
await this.mailService.sendRegistrationPendingEmail(
|
try {
|
||||||
resultat.user.email,
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
resultat.user.prenom ?? '',
|
resultat.user.email,
|
||||||
resultat.user.nom ?? '',
|
resultat.user.prenom ?? '',
|
||||||
numeroDossier,
|
resultat.user.nom ?? '',
|
||||||
);
|
numeroDossier,
|
||||||
} catch (err) {
|
);
|
||||||
this.logger.error(
|
} catch (err) {
|
||||||
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
this.logger.error(
|
||||||
err instanceof Error ? err.stack : String(err),
|
`[${logCtx}] É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.user.email,
|
||||||
|
prenom: resultat.user.prenom ?? '',
|
||||||
|
nom: resultat.user.nom ?? '',
|
||||||
|
token: tokenCreationMdp,
|
||||||
|
numeroDossier,
|
||||||
|
},
|
||||||
|
'am',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
options.statut === StatutUtilisateurType.ACTIF
|
||||||
|
? 'Dossier AM 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 {
|
return {
|
||||||
message:
|
message,
|
||||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
|
||||||
user_id: resultat.user.id,
|
user_id: resultat.user.id,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
numero_dossier: numeroDossier,
|
numero_dossier: numeroDossier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||||
|
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||||
|
*/
|
||||||
|
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||||
|
return this.createAmDossier(dto, {
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
sendPendingEmail: true,
|
||||||
|
sendPasswordSetupEmail: false,
|
||||||
|
requireCgu: true,
|
||||||
|
logContext: 'inscrireAMComplet',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
|
||||||
|
*/
|
||||||
|
async createAmDossierStaff(dto: RegisterAMCompletDto) {
|
||||||
|
return this.createAmDossier(dto, {
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
sendPendingEmail: false,
|
||||||
|
sendPasswordSetupEmail: true,
|
||||||
|
requireCgu: false,
|
||||||
|
logContext: 'createAmDossierStaff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||||
*/
|
*/
|
||||||
@@ -1072,9 +1386,6 @@ export class AuthService {
|
|||||||
enfant.status = StatutEnfantType.A_NAITRE;
|
enfant.status = StatutEnfantType.A_NAITRE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (enfantDto.grossesse_multiple !== undefined) {
|
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
|
||||||
}
|
|
||||||
if (enfantDto.consent_photo !== undefined) {
|
if (enfantDto.consent_photo !== undefined) {
|
||||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||||
enfant.consent_photo_at = enfant.consent_photo
|
enfant.consent_photo_at = enfant.consent_photo
|
||||||
|
|||||||
@@ -55,11 +55,6 @@ export class EnfantInscriptionDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
photo_filename?: string;
|
photo_filename?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
grossesse_multiple?: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
required: false,
|
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 {
|
||||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
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 { 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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
|
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||||
|
|
||||||
@ApiTags('Dossiers')
|
@ApiTags('Dossiers')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('dossiers')
|
@Controller('dossiers')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class DossiersController {
|
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 d’abord, 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')
|
@Get(':numeroDossier')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@@ -23,4 +64,21 @@ export class DossiersController {
|
|||||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
return this.dossiersService.getDossierByNumero(numeroDossier);
|
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 { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { ParentsModule } from '../parents/parents.module';
|
import { ParentsModule } from '../parents/parents.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
import { DossiersController } from './dossiers.controller';
|
import { DossiersController } from './dossiers.controller';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||||
ParentsModule,
|
ParentsModule,
|
||||||
|
SuppressionsModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.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()
|
@Injectable()
|
||||||
export class DossiersService {
|
export class DossiersService {
|
||||||
@@ -18,8 +21,173 @@ export class DossiersService {
|
|||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
private readonly parentsService: ParentsService,
|
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> {
|
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
const num = numeroDossier?.trim();
|
const num = numeroDossier?.trim();
|
||||||
if (!num) {
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -6,11 +7,23 @@ import {
|
|||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
|
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
||||||
|
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
if (v === 'true' || v === '1') return true;
|
||||||
|
if (v === 'false' || v === '0' || v === '') return false;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateEnfantsDto {
|
export class CreateEnfantsDto {
|
||||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||||
@IsEnum(StatutEnfantType)
|
@IsEnum(StatutEnfantType)
|
||||||
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
|||||||
photo_url?: string;
|
photo_url?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consent_photo: boolean;
|
consent_photo: boolean;
|
||||||
|
|
||||||
@@ -60,7 +74,16 @@ export class CreateEnfantsDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
consent_photo_at?: string;
|
consent_photo_at?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
/**
|
||||||
@IsBoolean()
|
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||||
is_multiple: boolean;
|
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
||||||
|
format: 'uuid',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parent_user_id?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
|
|||||||
@ApiProperty({ example: false })
|
@ApiProperty({ example: false })
|
||||||
consent_photo: boolean;
|
consent_photo: boolean;
|
||||||
|
|
||||||
@ApiProperty({ example: false })
|
|
||||||
is_multiple: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 'UUID-parent' })
|
@ApiProperty({ example: 'UUID-parent' })
|
||||||
parent_id: string;
|
parent_id: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
|
CallHandler,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ExecutionContext,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
import { EnfantsService } from './enfants.service';
|
import { EnfantsService } from './enfants.service';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||||
@@ -23,38 +38,80 @@ import { User } from 'src/common/decorators/user.decorator';
|
|||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
|
const photoMulterOptions = {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: './uploads/photos',
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const ext = extname(file.originalname);
|
||||||
|
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||||
|
return cb(new Error('Seules les images sont autorisées'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
||||||
|
* JSON sans photo (#132) passe sans interceptor fichier.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
||||||
|
private readonly multipart = new (FileInterceptor(
|
||||||
|
'photo',
|
||||||
|
photoMulterOptions,
|
||||||
|
))();
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const ct = String(req.headers['content-type'] ?? '');
|
||||||
|
if (!ct.includes('multipart/form-data')) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
return this.multipart.intercept(context, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@ApiTags('Enfants')
|
@ApiTags('Enfants')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('enfants')
|
@Controller('enfants')
|
||||||
export class EnfantsController {
|
export class EnfantsController {
|
||||||
constructor(private readonly enfantsService: EnfantsService) { }
|
constructor(
|
||||||
|
private readonly enfantsService: EnfantsService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) { }
|
||||||
|
|
||||||
@Roles(RoleType.PARENT)
|
@Roles(
|
||||||
@Post()
|
RoleType.PARENT,
|
||||||
@ApiConsumes('multipart/form-data')
|
RoleType.GESTIONNAIRE,
|
||||||
@UseInterceptors(
|
RoleType.ADMINISTRATEUR,
|
||||||
FileInterceptor('photo', {
|
RoleType.SUPER_ADMIN,
|
||||||
storage: diskStorage({
|
|
||||||
destination: './uploads/photos',
|
|
||||||
filename: (req, file, cb) => {
|
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
|
||||||
const ext = extname(file.originalname);
|
|
||||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
fileFilter: (req, file, cb) => {
|
|
||||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
|
||||||
return cb(new Error('Seules les images sont autorisées'), false);
|
|
||||||
}
|
|
||||||
cb(null, true);
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
fileSize: 5 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Créer un enfant',
|
||||||
|
description:
|
||||||
|
'PARENT : multipart éventuel, rattache au compte connecté. ' +
|
||||||
|
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json', 'multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
description:
|
||||||
|
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
|
||||||
|
type: CreateEnfantsDto,
|
||||||
|
})
|
||||||
|
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateEnfantsDto,
|
@Body() dto: CreateEnfantsDto,
|
||||||
@UploadedFile() photo: Express.Multer.File,
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
@@ -90,17 +147,47 @@ export class EnfantsController {
|
|||||||
RoleType.GESTIONNAIRE,
|
RoleType.GESTIONNAIRE,
|
||||||
)
|
)
|
||||||
@Patch(':id')
|
@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(
|
update(
|
||||||
@Param('id', new ParseUUIDPipe()) id: string,
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
@Body() dto: UpdateEnfantsDto,
|
@Body() dto: UpdateEnfantsDto,
|
||||||
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
@User() currentUser: Users,
|
@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')
|
@Delete(':id')
|
||||||
remove(@Param('id', new ParseUUIDPipe()) id: string) {
|
@ApiOperation({
|
||||||
return this.enfantsService.remove(id);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
imports: [
|
||||||
AuthModule
|
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||||
|
AuthModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [EnfantsController],
|
controllers: [EnfantsController],
|
||||||
providers: [EnfantsService]
|
providers: [EnfantsService],
|
||||||
|
exports: [EnfantsService],
|
||||||
})
|
})
|
||||||
export class EnfantsModule { }
|
export class EnfantsModule { }
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
|||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
|
|
||||||
|
const STAFF_ROLES: RoleType[] = [
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnfantsService {
|
export class EnfantsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -24,20 +30,35 @@ export class EnfantsService {
|
|||||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d'un enfant
|
private isStaff(user: Users): boolean {
|
||||||
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
return STAFF_ROLES.includes(user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création d'un enfant.
|
||||||
|
* - PARENT : rattache au parent connecté (multipart photo optionnel).
|
||||||
|
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
|
||||||
|
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateEnfantsDto,
|
||||||
|
currentUser: Users,
|
||||||
|
photoFile?: Express.Multer.File,
|
||||||
|
): Promise<Children> {
|
||||||
|
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
|
||||||
|
|
||||||
const parent = await this.parentsRepository.findOne({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id: currentUser.id },
|
where: { user_id: pivotUserId },
|
||||||
relations: ['co_parent'],
|
relations: ['co_parent'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||||
|
|
||||||
// Vérif métier simple
|
// Vérif métier simple (aligné comportement historique parent)
|
||||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
||||||
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
||||||
const exist = await this.childrenRepository.findOne({
|
const exist = await this.childrenRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
first_name: dto.first_name,
|
first_name: dto.first_name,
|
||||||
@@ -47,50 +68,107 @@ export class EnfantsService {
|
|||||||
});
|
});
|
||||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||||
|
|
||||||
// Gestion de la photo uploadée
|
// Gestion de la photo uploadée (multipart parent ou staff)
|
||||||
|
let photoUrl = dto.photo_url;
|
||||||
|
let consentAt: Date | undefined;
|
||||||
if (photoFile) {
|
if (photoFile) {
|
||||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||||
if (dto.consent_photo) {
|
if (dto.consent_photo) {
|
||||||
dto.consent_photo_at = new Date().toISOString();
|
consentAt = new Date();
|
||||||
}
|
}
|
||||||
|
} else if (dto.consent_photo) {
|
||||||
|
consentAt = dto.consent_photo_at
|
||||||
|
? new Date(dto.consent_photo_at)
|
||||||
|
: new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Création
|
const child = this.childrenRepository.create({
|
||||||
const child = this.childrenRepository.create(dto);
|
status: dto.status,
|
||||||
|
first_name: dto.first_name,
|
||||||
|
last_name: dto.last_name,
|
||||||
|
gender: dto.gender,
|
||||||
|
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
||||||
|
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
||||||
|
photo_url: photoUrl,
|
||||||
|
consent_photo: !!dto.consent_photo,
|
||||||
|
consent_photo_at: consentAt,
|
||||||
|
});
|
||||||
await this.childrenRepository.save(child);
|
await this.childrenRepository.save(child);
|
||||||
|
|
||||||
// Lien parent-enfant (Parent 1)
|
// Lien parent-enfant (pivot)
|
||||||
const parentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.user_id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.user_id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(parentLink);
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Rattachement automatique au co-parent s'il existe
|
// Rattachement automatique au co-parent s'il existe
|
||||||
if (parent.co_parent) {
|
if (parent.co_parent) {
|
||||||
const coParentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.co_parent.id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.co_parent.id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(coParentLink);
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findOne(child.id, currentUser);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des enfants (admin/gestionnaire)
|
private resolvePivotParentUserId(
|
||||||
async findAll(): Promise<Children[]> {
|
dto: CreateEnfantsDto,
|
||||||
return this.childrenRepository.find({
|
currentUser: Users,
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
): string {
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
if (this.isStaff(currentUser)) {
|
||||||
|
const id = dto.parent_user_id?.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'parent_user_id est obligatoire pour créer un enfant (staff)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.role === RoleType.PARENT) {
|
||||||
|
if (
|
||||||
|
dto.parent_user_id &&
|
||||||
|
dto.parent_user_id.trim() !== currentUser.id
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut pas créer un enfant pour un autre compte',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Accès interdit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flag API #157 — true si aucun lien enfants_parents. */
|
||||||
|
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
||||||
|
return Object.assign(child, {
|
||||||
|
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
||||||
|
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
||||||
|
const children = await this.childrenRepository.find({
|
||||||
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
|
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||||
|
});
|
||||||
|
return children.map((c) => this.withSansResponsable(c));
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer un enfant par id
|
// Récupérer un enfant par id
|
||||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
async findOne(
|
||||||
|
id: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<Children & { sans_responsable: boolean }> {
|
||||||
const child = await this.childrenRepository.findOne({
|
const child = await this.childrenRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['parentLinks'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
});
|
});
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
@@ -104,27 +182,40 @@ export class EnfantsService {
|
|||||||
case RoleType.ADMINISTRATEUR:
|
case RoleType.ADMINISTRATEUR:
|
||||||
case RoleType.SUPER_ADMIN:
|
case RoleType.SUPER_ADMIN:
|
||||||
case RoleType.GESTIONNAIRE:
|
case RoleType.GESTIONNAIRE:
|
||||||
// accès complet
|
// accès complet (y compris orphelins)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException('Accès interdit');
|
throw new ForbiddenException('Accès interdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return child;
|
return this.withSansResponsable(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mise à jour
|
// 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 } });
|
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
const patch: Partial<Children> = { ...dto } as Partial<Children>;
|
const { parent_user_id: _ignored, ...rest } = dto;
|
||||||
|
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
||||||
if (dto.consent_photo !== undefined) {
|
if (dto.consent_photo !== undefined) {
|
||||||
patch.consent_photo = dto.consent_photo;
|
patch.consent_photo = dto.consent_photo;
|
||||||
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
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);
|
await this.childrenRepository.update(id, patch);
|
||||||
return this.findOne(id, currentUser);
|
return this.findOne(id, currentUser);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/** Identité minimale enfant pour le bandeau couple — ticket #168 */
|
||||||
|
export class CoupleGardeEnfantDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
nom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
photo_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identité minimale AM pour le bandeau couple — ticket #168 */
|
||||||
|
export class CoupleGardeAmDto {
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'UUID utilisateur de l’AM' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
prenom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
nom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
photo_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un couple de garde = placement actif enfant ↔ AM */
|
||||||
|
export class CoupleGardeDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Id du placement (enfants_assistantes_maternelles.id)',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: CoupleGardeEnfantDto })
|
||||||
|
enfant: CoupleGardeEnfantDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: CoupleGardeAmDto })
|
||||||
|
am: CoupleGardeAmDto;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'True si c’est le couple actuellement sélectionné' })
|
||||||
|
courant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CouplesGardeResponseDto {
|
||||||
|
@ApiProperty({ type: [CoupleGardeDto] })
|
||||||
|
couples: CoupleGardeDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
nullable: true,
|
||||||
|
description: 'Id du couple courant (null si aucun / premier couple implicite côté client)',
|
||||||
|
})
|
||||||
|
couple_courant_id: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/** Corps PUT couple de garde courant — ticket #168 */
|
||||||
|
export class DefinirCoupleGardeCourantDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Id du placement (enfants_assistantes_maternelles.id) à sélectionner',
|
||||||
|
})
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
couple_id: string;
|
||||||
|
}
|
||||||
@@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto {
|
|||||||
description: 'Consentement affichage photo (colonne consentement_photo)',
|
description: 'Consentement affichage photo (colonne consentement_photo)',
|
||||||
})
|
})
|
||||||
consent_photo?: boolean;
|
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 */
|
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
nom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
prenom?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
telephone?: string | null;
|
telephone?: string | null;
|
||||||
|
|
||||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PendingFamilyDto {
|
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;
|
libelle: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@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 d’un co-parent sur un foyer existant (staff) — ticket #135.
|
||||||
|
* Corps sans préfixe `co_parent_*` (l’URL 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 l’adresse 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,132 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ParentsController } from './parents.controller';
|
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', () => {
|
describe('ParentsController', () => {
|
||||||
let controller: ParentsController;
|
let controller: ParentsController;
|
||||||
|
const authServiceMock = {
|
||||||
|
createParentDossierStaff: jest.fn(),
|
||||||
|
addCoParentStaff: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsServiceMock = {
|
||||||
|
listerCouplesGarde: jest.fn(),
|
||||||
|
definirCoupleGardeCourant: jest.fn(),
|
||||||
|
};
|
||||||
|
const userServiceMock = {};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [ParentsController],
|
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);
|
controller = module.get<ParentsController>(ParentsController);
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
expect(controller).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('listerCouplesGarde délègue au service (#168)', async () => {
|
||||||
|
parentsServiceMock.listerCouplesGarde.mockResolvedValue({
|
||||||
|
couples: [],
|
||||||
|
couple_courant_id: null,
|
||||||
|
});
|
||||||
|
const res = await controller.listerCouplesGarde('parent-uuid');
|
||||||
|
expect(parentsServiceMock.listerCouplesGarde).toHaveBeenCalledWith('parent-uuid');
|
||||||
|
expect(res.couples).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('definirCoupleGardeCourant délègue au service (#168)', async () => {
|
||||||
|
parentsServiceMock.definirCoupleGardeCourant.mockResolvedValue({
|
||||||
|
couples: [{ id: 'pl-1', courant: true }],
|
||||||
|
couple_courant_id: 'pl-1',
|
||||||
|
});
|
||||||
|
const res = await controller.definirCoupleGardeCourant('parent-uuid', {
|
||||||
|
couple_id: 'pl-1',
|
||||||
|
});
|
||||||
|
expect(parentsServiceMock.definirCoupleGardeCourant).toHaveBeenCalledWith(
|
||||||
|
'parent-uuid',
|
||||||
|
'pl-1',
|
||||||
|
);
|
||||||
|
expect(res.couple_courant_id).toBe('pl-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
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,37 +3,124 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
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 { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||||
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||||
|
import { CouplesGardeResponseDto } from './dto/couples-garde.dto';
|
||||||
|
import { DefinirCoupleGardeCourantDto } from './dto/definir-couple-garde-courant.dto';
|
||||||
|
|
||||||
@ApiTags('Parents')
|
@ApiTags('Parents')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('parents')
|
@Controller('parents')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class ParentsController {
|
export class ParentsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly parentsService: ParentsService,
|
private readonly parentsService: ParentsService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get('me/couples-garde')
|
||||||
|
@Roles(RoleType.PARENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Lister les couples de garde du parent connecté — ticket #168',
|
||||||
|
description:
|
||||||
|
'Retourne les placements actifs enfant↔AM rattachés au parent, ' +
|
||||||
|
'avec indication du couple courant (bandeau TdB quotidien).',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: CouplesGardeResponseDto })
|
||||||
|
@ApiResponse({ status: 403, description: 'Réservé au rôle parent' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||||
|
listerCouplesGarde(@User('id') userId: string): Promise<CouplesGardeResponseDto> {
|
||||||
|
return this.parentsService.listerCouplesGarde(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('me/couples-garde/courant')
|
||||||
|
@Roles(RoleType.PARENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Définir le couple de garde courant — ticket #168',
|
||||||
|
description:
|
||||||
|
'Persiste la préférence de couple actif (enfant|nounou) pour contextualiser le TdB.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: DefinirCoupleGardeCourantDto })
|
||||||
|
@ApiResponse({ status: 200, type: CouplesGardeResponseDto })
|
||||||
|
@ApiResponse({ status: 400, description: 'Couple hors périmètre du parent' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Parent ou couple introuvable' })
|
||||||
|
definirCoupleGardeCourant(
|
||||||
|
@User('id') userId: string,
|
||||||
|
@Body() dto: DefinirCoupleGardeCourantDto,
|
||||||
|
): Promise<CouplesGardeResponseDto> {
|
||||||
|
return this.parentsService.definirCoupleGardeCourant(userId, dto.couple_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 l’e-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')
|
@Get('pending-families')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||||
@@ -127,6 +214,28 @@ export class ParentsController {
|
|||||||
return mapParentForApi(parent);
|
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)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Post(':id/enfants/:enfantId')
|
@Post(':id/enfants/:enfantId')
|
||||||
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||||
|
|||||||
@@ -5,15 +5,25 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
import { ParentsController } from './parents.controller';
|
import { ParentsController } from './parents.controller';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
TypeOrmModule.forFeature([
|
||||||
|
Parents,
|
||||||
|
Users,
|
||||||
|
DossierFamille,
|
||||||
|
DossierFamilleEnfant,
|
||||||
|
ParentsChildren,
|
||||||
|
AmChildren,
|
||||||
|
]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
|
forwardRef(() => AuthModule),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
|
||||||
describe('ParentsService', () => {
|
describe('ParentsService — couples de garde (#168)', () => {
|
||||||
let service: ParentsService;
|
let service: ParentsService;
|
||||||
|
|
||||||
|
const parentsRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsChildrenRepository = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const amChildrenRepository = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [ParentsService],
|
providers: [
|
||||||
|
ParentsService,
|
||||||
|
{ provide: getRepositoryToken(Parents), useValue: parentsRepository },
|
||||||
|
{ provide: getRepositoryToken(Users), useValue: {} },
|
||||||
|
{ provide: getRepositoryToken(DossierFamille), useValue: {} },
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(ParentsChildren),
|
||||||
|
useValue: parentsChildrenRepository,
|
||||||
|
},
|
||||||
|
{ provide: getRepositoryToken(AmChildren), useValue: amChildrenRepository },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<ParentsService>(ParentsService);
|
service = module.get<ParentsService>(ParentsService);
|
||||||
@@ -15,4 +46,107 @@ describe('ParentsService', () => {
|
|||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listerCouplesGarde', () => {
|
||||||
|
it('retourne une liste vide si le parent n’a pas d’enfant', async () => {
|
||||||
|
parentsRepository.findOne.mockResolvedValue({
|
||||||
|
user_id: 'p1',
|
||||||
|
id_placement_garde_courant: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepository.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await service.listerCouplesGarde('p1');
|
||||||
|
expect(res).toEqual({ couples: [], couple_courant_id: null });
|
||||||
|
expect(amChildrenRepository.find).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mappe les placements actifs en couples et marque le courant', async () => {
|
||||||
|
parentsRepository.findOne.mockResolvedValue({
|
||||||
|
user_id: 'p1',
|
||||||
|
id_placement_garde_courant: 'pl-2',
|
||||||
|
});
|
||||||
|
parentsChildrenRepository.find.mockResolvedValue([
|
||||||
|
{ enfantId: 'e1' },
|
||||||
|
{ enfantId: 'e2' },
|
||||||
|
]);
|
||||||
|
amChildrenRepository.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
child: { id: 'e1', first_name: 'Léo', last_name: 'M', photo_url: null },
|
||||||
|
am: { user: { prenom: 'Marie', nom: 'N', photo_url: '/a.jpg' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pl-2',
|
||||||
|
amId: 'am-2',
|
||||||
|
child: { id: 'e2', first_name: 'Léa', last_name: 'M', photo_url: null },
|
||||||
|
am: { user: { prenom: 'Sophie', nom: 'P', photo_url: null } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.listerCouplesGarde('p1');
|
||||||
|
expect(res.couples).toHaveLength(2);
|
||||||
|
expect(res.couple_courant_id).toBe('pl-2');
|
||||||
|
expect(res.couples[1].courant).toBe(true);
|
||||||
|
expect(res.couples[0].am.prenom).toBe('Marie');
|
||||||
|
expect(res.couples[0].enfant.prenom).toBe('Léo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 si parent inconnu', async () => {
|
||||||
|
parentsRepository.findOne.mockResolvedValue(null);
|
||||||
|
await expect(service.listerCouplesGarde('x')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('definirCoupleGardeCourant', () => {
|
||||||
|
it('persiste le couple si l’enfant est rattaché au parent', async () => {
|
||||||
|
parentsRepository.findOne.mockResolvedValue({
|
||||||
|
user_id: 'p1',
|
||||||
|
id_placement_garde_courant: null,
|
||||||
|
});
|
||||||
|
amChildrenRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
date_fin: null,
|
||||||
|
});
|
||||||
|
parentsChildrenRepository.findOne.mockResolvedValue({
|
||||||
|
parentId: 'p1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
});
|
||||||
|
parentsRepository.update.mockResolvedValue({ affected: 1 });
|
||||||
|
// second call via listerCouplesGarde
|
||||||
|
parentsChildrenRepository.find.mockResolvedValue([{ enfantId: 'e1' }]);
|
||||||
|
amChildrenRepository.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'pl-1',
|
||||||
|
amId: 'am-1',
|
||||||
|
child: { id: 'e1', first_name: 'Léo', last_name: null, photo_url: null },
|
||||||
|
am: { user: { prenom: 'Marie', nom: null, photo_url: null } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await service.definirCoupleGardeCourant('p1', 'pl-1');
|
||||||
|
expect(parentsRepository.update).toHaveBeenCalledWith(
|
||||||
|
{ user_id: 'p1' },
|
||||||
|
{ id_placement_garde_courant: 'pl-1' },
|
||||||
|
);
|
||||||
|
expect(res.couple_courant_id).toBe('pl-1');
|
||||||
|
expect(res.couples[0].courant).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 si le couple n’appartient pas au parent', async () => {
|
||||||
|
parentsRepository.findOne.mockResolvedValue({ user_id: 'p1' });
|
||||||
|
amChildrenRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'pl-1',
|
||||||
|
enfantId: 'e99',
|
||||||
|
});
|
||||||
|
parentsChildrenRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.definirCoupleGardeCourant('p1', 'pl-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, Repository } from 'typeorm';
|
import { In, IsNull, Repository } from 'typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { Children } from 'src/entities/children.entity';
|
import { Children } from 'src/entities/children.entity';
|
||||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { CouplesGardeResponseDto } from './dto/couples-garde.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ParentsService {
|
export class ParentsService {
|
||||||
@@ -32,6 +34,8 @@ export class ParentsService {
|
|||||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||||
@InjectRepository(ParentsChildren)
|
@InjectRepository(ParentsChildren)
|
||||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Création d’un parent
|
// Création d’un parent
|
||||||
@@ -114,34 +118,86 @@ export class ParentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
* Membres du foyer (user ids) pour affiliation enfant.
|
||||||
|
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||||
*/
|
*/
|
||||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||||
await this.findOne(parentUserId);
|
const ids = new Set<string>([parent.user_id]);
|
||||||
|
|
||||||
const existing = await this.parentsChildrenRepository.findOne({
|
if (parent.co_parent?.id) {
|
||||||
where: { parentId: parentUserId, enfantId },
|
ids.add(parent.co_parent.id);
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||||
|
const reverseLinks = await this.parentsRepository.find({
|
||||||
|
where: { co_parent: { id: parent.user_id } },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of reverseLinks) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dossier = parent.numero_dossier?.trim();
|
||||||
|
if (dossier) {
|
||||||
|
const sameDossier = await this.parentsRepository.find({
|
||||||
|
where: { numero_dossier: dossier },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of sameDossier) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||||
|
*/
|
||||||
|
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||||
|
where: { id: enfantId },
|
||||||
|
});
|
||||||
if (!child) {
|
if (!child) {
|
||||||
throw new NotFoundException('Enfant introuvable');
|
throw new NotFoundException('Enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.parentsChildrenRepository.save(
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
let created = 0;
|
||||||
);
|
|
||||||
|
for (const memberId of foyerIds) {
|
||||||
|
const existing = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: memberId, enfantId },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
await this.parentsChildrenRepository.save(
|
||||||
|
this.parentsChildrenRepository.create({
|
||||||
|
parentId: memberId,
|
||||||
|
enfantId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created === 0) {
|
||||||
|
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
|
||||||
|
}
|
||||||
|
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||||
*/
|
*/
|
||||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
await this.findOne(parentUserId);
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
const link = await this.parentsChildrenRepository.findOne({
|
const link = await this.parentsChildrenRepository.findOne({
|
||||||
where: { parentId: parentUserId, enfantId },
|
where: { parentId: parentUserId, enfantId },
|
||||||
@@ -150,12 +206,12 @@ export class ParentsService {
|
|||||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
if (totalLinks <= 1) {
|
await this.parentsChildrenRepository.delete({
|
||||||
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
parentId: In(foyerIds),
|
||||||
}
|
enfantId,
|
||||||
|
});
|
||||||
|
|
||||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +255,15 @@ export class ParentsService {
|
|||||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||||
)
|
)
|
||||||
SELECT
|
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.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||||
MIN(u.cree_le) AS date_soumission,
|
MIN(u.cree_le) AS date_soumission,
|
||||||
@@ -215,6 +279,8 @@ export class ParentsService {
|
|||||||
json_build_object(
|
json_build_object(
|
||||||
'id', u.id::text,
|
'id', u.id::text,
|
||||||
'email', u.email,
|
'email', u.email,
|
||||||
|
'nom', u.nom,
|
||||||
|
'prenom', u.prenom,
|
||||||
'telephone', u.telephone,
|
'telephone', u.telephone,
|
||||||
'code_postal', u.code_postal,
|
'code_postal', u.code_postal,
|
||||||
'ville', u.ville
|
'ville', u.ville
|
||||||
@@ -265,11 +331,21 @@ export class ParentsService {
|
|||||||
return [];
|
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)) {
|
if (Array.isArray(parents)) {
|
||||||
return parents.map((p: any) => ({
|
return parents.map((p: any) => ({
|
||||||
id: String(p?.id ?? ''),
|
id: String(p?.id ?? ''),
|
||||||
email: String(p?.email ?? ''),
|
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,
|
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||||
ville: p?.ville != null ? String(p.ville) : null,
|
ville: p?.ville != null ? String(p.ville) : null,
|
||||||
@@ -298,7 +374,6 @@ export class ParentsService {
|
|||||||
status: child.status,
|
status: child.status,
|
||||||
photo_url: child.photo_url ?? undefined,
|
photo_url: child.photo_url ?? undefined,
|
||||||
consent_photo: child.consent_photo,
|
consent_photo: child.consent_photo,
|
||||||
est_multiple: child.is_multiple,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,4 +509,108 @@ export class ParentsService {
|
|||||||
}
|
}
|
||||||
return raw.map((r: { id: string }) => r.id);
|
return raw.map((r: { id: string }) => r.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les couples de garde (enfant ↔ AM) du parent connecté — ticket #168.
|
||||||
|
* Un couple = un placement actif dans enfants_assistantes_maternelles pour un enfant du parent.
|
||||||
|
*/
|
||||||
|
async listerCouplesGarde(parentUserId: string): Promise<CouplesGardeResponseDto> {
|
||||||
|
const parent = await this.parentsRepository.findOne({
|
||||||
|
where: { user_id: parentUserId },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const liensEnfants = await this.parentsChildrenRepository.find({
|
||||||
|
where: { parentId: parentUserId },
|
||||||
|
select: ['enfantId'],
|
||||||
|
});
|
||||||
|
const enfantIds = liensEnfants.map((l) => l.enfantId);
|
||||||
|
if (enfantIds.length === 0) {
|
||||||
|
return { couples: [], couple_courant_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const placements = await this.amChildrenRepository.find({
|
||||||
|
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||||||
|
relations: ['child', 'am', 'am.user'],
|
||||||
|
order: { date_debut: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let idCourant = parent.id_placement_garde_courant ?? null;
|
||||||
|
const idsValides = new Set(placements.map((p) => p.id));
|
||||||
|
if (idCourant && !idsValides.has(idCourant)) {
|
||||||
|
idCourant = null;
|
||||||
|
await this.parentsRepository.update(
|
||||||
|
{ user_id: parentUserId },
|
||||||
|
{ id_placement_garde_courant: () => 'NULL' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!idCourant && placements.length === 1) {
|
||||||
|
idCourant = placements[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const couples = placements.map((p) => {
|
||||||
|
const amUser = p.am?.user;
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
enfant: {
|
||||||
|
id: p.child.id,
|
||||||
|
prenom: p.child.first_name ?? null,
|
||||||
|
nom: p.child.last_name ?? null,
|
||||||
|
photo_url: p.child.photo_url ?? null,
|
||||||
|
},
|
||||||
|
am: {
|
||||||
|
id: p.amId,
|
||||||
|
prenom: amUser?.prenom ?? null,
|
||||||
|
nom: amUser?.nom ?? null,
|
||||||
|
photo_url: amUser?.photo_url ?? null,
|
||||||
|
},
|
||||||
|
courant: idCourant != null && p.id === idCourant,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
couples,
|
||||||
|
couple_courant_id: idCourant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste le couple de garde actif pour le parent — ticket #168.
|
||||||
|
*/
|
||||||
|
async definirCoupleGardeCourant(
|
||||||
|
parentUserId: string,
|
||||||
|
coupleId: string,
|
||||||
|
): Promise<CouplesGardeResponseDto> {
|
||||||
|
const parent = await this.parentsRepository.findOne({
|
||||||
|
where: { user_id: parentUserId },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = await this.amChildrenRepository.findOne({
|
||||||
|
where: { id: coupleId, date_fin: IsNull() },
|
||||||
|
});
|
||||||
|
if (!placement) {
|
||||||
|
throw new NotFoundException('Couple de garde introuvable ou inactif');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lien = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: parentUserId, enfantId: placement.enfantId },
|
||||||
|
});
|
||||||
|
if (!lien) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce couple ne concerne pas un enfant rattaché à ce parent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.parentsRepository.update(
|
||||||
|
{ user_id: parentUserId },
|
||||||
|
{ id_placement_garde_courant: coupleId },
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.listerCouplesGarde(parentUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ export class RelaisController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
@ApiOperation({
|
||||||
|
summary: 'Lister tous les relais',
|
||||||
|
description:
|
||||||
|
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.relaisService.findAll();
|
return this.relaisService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -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 d’enfants 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 d’utilisateur 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 { GestionnairesController } from './gestionnaires.controller';
|
||||||
import { GestionnairesService } from './gestionnaires.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('GestionnairesController', () => {
|
describe('GestionnairesController roles (#161)', () => {
|
||||||
let controller: GestionnairesController;
|
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [GestionnairesController],
|
);
|
||||||
providers: [GestionnairesService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<GestionnairesController>(GestionnairesController);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
expect(controller).toBeDefined();
|
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 {
|
export class GestionnairesController {
|
||||||
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
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: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
@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 })
|
@ApiBody({ type: CreateGestionnaireDto })
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
||||||
@@ -43,7 +43,7 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findAll();
|
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' })
|
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
||||||
@ApiResponse({ status: 400, description: 'ID invalide' })
|
@ApiResponse({ status: 400, description: 'ID invalide' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
@@ -56,8 +56,8 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findOne(id);
|
return this.gestionnairesService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
|
@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: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@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 { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('UserController', () => {
|
describe('UserController roles (#161)', () => {
|
||||||
let controller: UserController;
|
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [UserController],
|
);
|
||||||
providers: [UserService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<UserController>(UserController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,18 +10,22 @@ import { CreateUserDto } from './dto/create_user.dto';
|
|||||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
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')
|
@Post('admin')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
|
||||||
createAdmin(
|
createAdmin(
|
||||||
@Body() dto: CreateAdminDto,
|
@Body() dto: CreateAdminDto,
|
||||||
@User() currentUser: Users
|
@User() currentUser: Users
|
||||||
@@ -146,12 +150,20 @@ export class UserController {
|
|||||||
return this.userService.suspendUser(id, currentUser, comment);
|
return this.userService.suspendUser(id, currentUser, comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer un utilisateur (super_admin uniquement)
|
// Supprimer un utilisateur — cascades métier #159
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(
|
||||||
@ApiOperation({ summary: 'Supprimer un utilisateur' })
|
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" })
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
remove(@Param('id') id: string, @User() currentUser: Users) {
|
remove(@Param('id') id: string, @User() currentUser: Users) {
|
||||||
return this.userService.remove(id, currentUser);
|
return this.suppressionService.deleteUser(id, currentUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
import { MailModule } from 'src/modules/mail/mail.module';
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
|
|||||||
GestionnairesModule,
|
GestionnairesModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@@ -1,18 +1,87 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { UserService } from './user.service';
|
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;
|
let service: UserService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
const dto = {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
email: 'nouveau.admin@ptits-pas.fr',
|
||||||
providers: [UserService],
|
password: 'Password1!',
|
||||||
}).compile();
|
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', () => {
|
it('autorise un administrateur à créer un admin', async () => {
|
||||||
expect(service).toBeDefined();
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,8 +117,14 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
// #161 — admin et super_admin peuvent créer un administrateur
|
||||||
throw new ForbiddenException('Seuls les super administrateurs 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 });
|
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||||
@@ -520,6 +526,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, currentUser: Users): Promise<void> {
|
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) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-24
@@ -35,13 +35,15 @@ DO $$ BEGIN
|
|||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
|
||||||
CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse');
|
CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse');
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_absence_garde_type') THEN
|
||||||
CREATE TYPE type_evenement_type AS ENUM (
|
CREATE TYPE type_absence_garde_type AS ENUM (
|
||||||
'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe'
|
'absence_enfant', 'conge_am', 'arret_maladie_am'
|
||||||
);
|
);
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_absence_garde_type') THEN
|
||||||
CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse');
|
CREATE TYPE statut_absence_garde_type AS ENUM (
|
||||||
|
'en_attente', 'accepte', 'refuse'
|
||||||
|
);
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
|
||||||
CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse');
|
CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse');
|
||||||
@@ -141,7 +143,10 @@ CREATE TABLE assistantes_maternelles (
|
|||||||
annee_experience SMALLINT,
|
annee_experience SMALLINT,
|
||||||
specialite VARCHAR(100),
|
specialite VARCHAR(100),
|
||||||
place_disponible INT,
|
place_disponible INT,
|
||||||
numero_dossier VARCHAR(20)
|
numero_dossier VARCHAR(20),
|
||||||
|
-- Préférence couple de garde actif (TdB quotidien AM) — ticket #171
|
||||||
|
-- FK ajoutée après création de enfants_assistantes_maternelles (voir ALTER plus bas)
|
||||||
|
id_placement_garde_courant UUID
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_assistantes_maternelles_numero_dossier
|
CREATE INDEX idx_assistantes_maternelles_numero_dossier
|
||||||
@@ -154,7 +159,10 @@ CREATE INDEX idx_assistantes_maternelles_numero_dossier
|
|||||||
CREATE TABLE parents (
|
CREATE TABLE parents (
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
id_co_parent UUID REFERENCES utilisateurs(id),
|
id_co_parent UUID REFERENCES utilisateurs(id),
|
||||||
numero_dossier VARCHAR(20)
|
numero_dossier VARCHAR(20),
|
||||||
|
-- Préférence couple de garde actif (TdB quotidien) — ticket #168
|
||||||
|
-- FK ajoutée après création de enfants_assistantes_maternelles (voir ALTER plus bas)
|
||||||
|
id_placement_garde_courant UUID
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_parents_numero_dossier
|
CREATE INDEX idx_parents_numero_dossier
|
||||||
@@ -174,8 +182,7 @@ CREATE TABLE enfants (
|
|||||||
date_prevue_naissance DATE,
|
date_prevue_naissance DATE,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
consentement_photo BOOLEAN DEFAULT false,
|
consentement_photo BOOLEAN DEFAULT false,
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
date_consentement_photo TIMESTAMPTZ
|
||||||
est_multiple BOOLEAN DEFAULT false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@@ -207,6 +214,26 @@ CREATE UNIQUE INDEX uq_enfant_garde_active
|
|||||||
ON enfants_assistantes_maternelles (id_enfant)
|
ON enfants_assistantes_maternelles (id_enfant)
|
||||||
WHERE date_fin IS NULL;
|
WHERE date_fin IS NULL;
|
||||||
|
|
||||||
|
-- FK couple courant parent → placement (#168) — après table enfants_assistantes_maternelles
|
||||||
|
ALTER TABLE parents
|
||||||
|
ADD CONSTRAINT fk_parents_placement_garde_courant
|
||||||
|
FOREIGN KEY (id_placement_garde_courant)
|
||||||
|
REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_parents_placement_garde_courant
|
||||||
|
ON parents(id_placement_garde_courant)
|
||||||
|
WHERE id_placement_garde_courant IS NOT NULL;
|
||||||
|
|
||||||
|
-- FK couple courant AM → placement (#171)
|
||||||
|
ALTER TABLE assistantes_maternelles
|
||||||
|
ADD CONSTRAINT fk_am_placement_garde_courant
|
||||||
|
FOREIGN KEY (id_placement_garde_courant)
|
||||||
|
REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_am_placement_garde_courant
|
||||||
|
ON assistantes_maternelles(id_placement_garde_courant)
|
||||||
|
WHERE id_placement_garde_courant IS NOT NULL;
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : dossier_famille (inscription parent — ticket #119)
|
-- Table : dossier_famille (inscription parent — ticket #119)
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@@ -295,25 +322,34 @@ CREATE TABLE avenants_contrats (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : evenements
|
-- Table : absences_garde (vérité métier absences/congés/arrêt)
|
||||||
|
-- 1 ligne = 1 période ; rattachement = placement AM↔enfant (#193)
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE evenements (
|
CREATE TABLE absences_garde (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
type type_evenement_type,
|
id_placement UUID NOT NULL
|
||||||
id_enfant UUID REFERENCES enfants(id) ON DELETE CASCADE,
|
REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE,
|
||||||
id_am UUID REFERENCES utilisateurs(id),
|
type type_absence_garde_type NOT NULL,
|
||||||
id_parent UUID REFERENCES parents(id_utilisateur),
|
date_debut DATE NOT NULL,
|
||||||
cree_par UUID REFERENCES utilisateurs(id),
|
date_fin DATE NOT NULL,
|
||||||
date_debut TIMESTAMPTZ,
|
statut statut_absence_garde_type NOT NULL DEFAULT 'en_attente',
|
||||||
date_fin TIMESTAMPTZ,
|
expire_at TIMESTAMPTZ NOT NULL,
|
||||||
commentaires TEXT,
|
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||||
statut statut_evenement_type DEFAULT 'propose',
|
id_card_instance UUID,
|
||||||
delai_grace TIMESTAMPTZ,
|
motif TEXT,
|
||||||
urgent BOOLEAN DEFAULT false,
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
CONSTRAINT chk_absences_garde_dates CHECK (date_fin >= date_debut)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_absences_garde_placement_dates
|
||||||
|
ON absences_garde (id_placement, date_debut, date_fin);
|
||||||
|
CREATE INDEX idx_absences_garde_placement_type_statut
|
||||||
|
ON absences_garde (id_placement, type, statut);
|
||||||
|
CREATE INDEX idx_absences_garde_expire_at
|
||||||
|
ON absences_garde (expire_at)
|
||||||
|
WHERE statut IN ('en_attente', 'refuse');
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : signalements_bugs
|
-- Table : signalements_bugs
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
|
"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,,False
|
"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,,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,,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,,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,,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,,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,,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,,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,,False
|
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,
|
||||||
|
|||||||
|
+10
-10
@@ -104,31 +104,31 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7) Type d’événement — `type`
|
## 7) Type d’absence de garde — `type`
|
||||||
|
|
||||||
**Tables/colonnes** : `evenements.type`
|
**Tables/colonnes** : `absences_garde.type`
|
||||||
**Valeurs autorisées** :
|
**Valeurs autorisées** :
|
||||||
|
|
||||||
| Valeur | Description |
|
| Valeur | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `absence_enfant` | Enfant absent |
|
| `absence_enfant` | Absence déclarée par un parent |
|
||||||
| `conge_am` | Congé de l’assistante maternelle |
|
| `conge_am` | Congé de l’assistante maternelle |
|
||||||
| `conge_parent` | Congé du parent |
|
|
||||||
| `arret_maladie_am` | Arrêt maladie AM |
|
| `arret_maladie_am` | Arrêt maladie AM |
|
||||||
| `evenement_rpe` | Événement RPE |
|
|
||||||
|
> Remplace l’ancien enum `type_evenement_type` / table `evenements` (supprimés ticket #193).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8) Statut d’événement — `statut`
|
## 8) Statut d’absence de garde — `statut`
|
||||||
|
|
||||||
**Tables/colonnes** : `evenements.statut`
|
**Tables/colonnes** : `absences_garde.statut`
|
||||||
**Valeurs autorisées** :
|
**Valeurs autorisées** :
|
||||||
|
|
||||||
| Valeur | Description |
|
| Valeur | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `propose` | Événement proposé |
|
| `en_attente` | En attente de validation / ack |
|
||||||
| `valide` | Événement validé |
|
| `accepte` | Accepté / acté (conservé en historique métier) |
|
||||||
| `rejete` | Événement refusé |
|
| `refuse` | Refusé (temporaire ; purge TTL) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
|||||||
| **contrats(id_dossier)** → `dossiers(id)` | **CASCADE** | 1:1, contrat détruit si dossier supprimé |
|
| **contrats(id_dossier)** → `dossiers(id)` | **CASCADE** | 1:1, contrat détruit si dossier supprimé |
|
||||||
| **avenants_contrats(id_contrat)** → `contrats(id)` | **CASCADE** | Avenants détruits avec le contrat |
|
| **avenants_contrats(id_contrat)** → `contrats(id)` | **CASCADE** | Avenants détruits avec le contrat |
|
||||||
| **avenants_contrats(initie_par)** → `utilisateurs(id)` | **SET NULL** | Historiser l’avenant sans bloquer |
|
| **avenants_contrats(initie_par)** → `utilisateurs(id)` | **SET NULL** | Historiser l’avenant sans bloquer |
|
||||||
| **evenements(id_enfant)** → `enfants(id)` | **CASCADE** | Événements n’ont plus de sens |
|
| **absences_garde(id_placement)** → `enfants_assistantes_maternelles(id)` | **CASCADE** | Plus de couple = plus d’absences |
|
||||||
| **evenements(id_am)** → `utilisateurs(id)` | **SET NULL** | Garder la trace même si AM supprimée |
|
| **absences_garde(cree_par)** → `utilisateurs(id)` | **SET NULL** | Conserver la période si user supprimé |
|
||||||
| **evenements(id_parent)** → `parents(id_utilisateur)` | **SET NULL** | Garder la trace si parent supprimé |
|
|
||||||
| **evenements(cree_par)** → `utilisateurs(id)` | **SET NULL** | Conserver l’historique de création |
|
|
||||||
| **signalements_bugs(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Conserver le ticket même si compte supprimé |
|
| **signalements_bugs(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Conserver le ticket même si compte supprimé |
|
||||||
| **uploads(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Fichier reste référencé sans l’auteur |
|
| **uploads(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Fichier reste référencé sans l’auteur |
|
||||||
| **notifications(id_utilisateur)** → `utilisateurs(id)` | **CASCADE** | Notifications propres à l’utilisateur |
|
| **notifications(id_utilisateur)** → `utilisateurs(id)` | **CASCADE** | Notifications propres à l’utilisateur |
|
||||||
@@ -79,10 +77,8 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
|||||||
- `avenants_contrats.initie_par` → **SET NULL**
|
- `avenants_contrats.initie_par` → **SET NULL**
|
||||||
|
|
||||||
### Événements
|
### Événements
|
||||||
- `evenements.id_enfant` → **CASCADE**
|
- `absences_garde.id_placement` → **CASCADE**
|
||||||
- `evenements.id_am` → **SET NULL**
|
- `absences_garde.cree_par` → **SET NULL**
|
||||||
- `evenements.id_parent` → **SET NULL**
|
|
||||||
- `evenements.cree_par` → **SET NULL**
|
|
||||||
|
|
||||||
### Divers
|
### Divers
|
||||||
- `signalements_bugs.id_utilisateur` → **SET NULL**
|
- `signalements_bugs.id_utilisateur` → **SET NULL**
|
||||||
@@ -109,11 +105,11 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
|||||||
|
|
||||||
4. **Suppression d’un enfant**
|
4. **Suppression d’un enfant**
|
||||||
- Supprimer `enfants(id=childA)`
|
- Supprimer `enfants(id=childA)`
|
||||||
- Attendu : `enfants_parents` (CASCADE), `dossiers` du childA (CASCADE), `evenements` du childA (CASCADE).
|
- Attendu : `enfants_parents` (CASCADE), `dossiers` du childA (CASCADE), `absences_garde` des placements du childA (CASCADE via EAM).
|
||||||
|
|
||||||
5. **Suppression d’un utilisateur AM**
|
5. **Suppression d’un utilisateur AM**
|
||||||
- Supprimer `utilisateurs(id=amB)`
|
- Supprimer `utilisateurs(id=amB)`
|
||||||
- Attendu : `evenements.id_am` devient **NULL** (historique conservé).
|
- Attendu : absences liées via placement AM (CASCADE si le lien EAM est retiré).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- Ticket #193 — Back métier absences / congés / arrêt (périodes par placement)
|
||||||
|
-- Idempotent : safe à rejouer.
|
||||||
|
-- Remplace la table legacy `evenements` (mal conçue, non utilisée en API).
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Enums
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_absence_garde_type') THEN
|
||||||
|
CREATE TYPE type_absence_garde_type AS ENUM (
|
||||||
|
'absence_enfant',
|
||||||
|
'conge_am',
|
||||||
|
'arret_maladie_am'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_absence_garde_type') THEN
|
||||||
|
CREATE TYPE statut_absence_garde_type AS ENUM (
|
||||||
|
'en_attente',
|
||||||
|
'accepte',
|
||||||
|
'refuse'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Table absences_garde (1 ligne = 1 période)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS absences_garde (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
id_placement UUID NOT NULL
|
||||||
|
REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE,
|
||||||
|
type type_absence_garde_type NOT NULL,
|
||||||
|
date_debut DATE NOT NULL,
|
||||||
|
date_fin DATE NOT NULL,
|
||||||
|
statut statut_absence_garde_type NOT NULL DEFAULT 'en_attente',
|
||||||
|
-- TTL purge : obligatoire ; pour statut=accepte utiliser 'infinity' (pas de purge métier)
|
||||||
|
expire_at TIMESTAMPTZ NOT NULL,
|
||||||
|
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||||
|
id_card_instance UUID NULL,
|
||||||
|
motif TEXT,
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_absences_garde_dates CHECK (date_fin >= date_debut)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_absences_garde_placement_dates
|
||||||
|
ON absences_garde (id_placement, date_debut, date_fin);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_absences_garde_placement_type_statut
|
||||||
|
ON absences_garde (id_placement, type, statut);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_absences_garde_expire_at
|
||||||
|
ON absences_garde (expire_at)
|
||||||
|
WHERE statut IN ('en_attente', 'refuse');
|
||||||
|
|
||||||
|
COMMENT ON TABLE absences_garde IS
|
||||||
|
'Vérité métier des absences/congés/arrêts par couple AM↔enfant (placement). Les cartes collectent ; cette table stocke.';
|
||||||
|
COMMENT ON COLUMN absences_garde.expire_at IS
|
||||||
|
'Purge auto (job ultérieur) pour en_attente/refuse. accepte → infinity.';
|
||||||
|
COMMENT ON COLUMN absences_garde.id_card_instance IS
|
||||||
|
'Réf. carte de collecte (FK cards quand le module Cartes existera).';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Drop legacy evenements (+ enums si plus référencés)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DROP TABLE IF EXISTS evenements CASCADE;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE udt_name = 'type_evenement_type'
|
||||||
|
) THEN
|
||||||
|
DROP TYPE type_evenement_type;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE udt_name = 'statut_evenement_type'
|
||||||
|
) THEN
|
||||||
|
DROP TYPE statut_evenement_type;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Ticket #171 — Couple de garde courant (préférence AM)
|
||||||
|
-- Idempotent : safe à rejouer.
|
||||||
|
|
||||||
|
ALTER TABLE assistantes_maternelles
|
||||||
|
ADD COLUMN IF NOT EXISTS id_placement_garde_courant UUID
|
||||||
|
REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_am_placement_garde_courant
|
||||||
|
ON assistantes_maternelles(id_placement_garde_courant)
|
||||||
|
WHERE id_placement_garde_courant IS NOT NULL;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- Ticket #194 — Module Cartes SYSTEM (collecte absences/congés/arrêt)
|
||||||
|
-- Idempotent.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_response_mode_type') THEN
|
||||||
|
CREATE TYPE card_response_mode_type AS ENUM (
|
||||||
|
'none', 'ack', 'accept_refuse'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_instance_statut_type') THEN
|
||||||
|
CREATE TYPE card_instance_statut_type AS ENUM (
|
||||||
|
'ouverte', 'refusee', 'traitee'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_operation_type') THEN
|
||||||
|
CREATE TYPE card_operation_type AS ENUM ('create', 'update');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'card_response_action_type') THEN
|
||||||
|
CREATE TYPE card_response_action_type AS ENUM (
|
||||||
|
'accept', 'refuse', 'ack'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS card_types (
|
||||||
|
code VARCHAR(64) PRIMARY KEY,
|
||||||
|
system BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
titre VARCHAR(120) NOT NULL,
|
||||||
|
emitter_roles TEXT[] NOT NULL,
|
||||||
|
recipient_roles TEXT[] NOT NULL,
|
||||||
|
audience_resolver VARCHAR(64) NOT NULL,
|
||||||
|
response_mode card_response_mode_type NOT NULL,
|
||||||
|
retention_days INT NOT NULL DEFAULT 14,
|
||||||
|
couleur VARCHAR(32),
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS card_instances (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type_code VARCHAR(64) NOT NULL REFERENCES card_types(code),
|
||||||
|
id_placement UUID NOT NULL
|
||||||
|
REFERENCES enfants_assistantes_maternelles(id) ON DELETE CASCADE,
|
||||||
|
id_absence UUID REFERENCES absences_garde(id) ON DELETE SET NULL,
|
||||||
|
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||||
|
operation card_operation_type NOT NULL DEFAULT 'create',
|
||||||
|
statut card_instance_statut_type NOT NULL DEFAULT 'ouverte',
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
purge_at TIMESTAMPTZ NOT NULL,
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_instances_placement
|
||||||
|
ON card_instances (id_placement, statut, cree_le DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_instances_purge
|
||||||
|
ON card_instances (purge_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_instances_absence
|
||||||
|
ON card_instances (id_absence)
|
||||||
|
WHERE id_absence IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS card_audience_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
id_card UUID NOT NULL REFERENCES card_instances(id) ON DELETE CASCADE,
|
||||||
|
id_utilisateur UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
|
role_snapshot VARCHAR(64) NOT NULL,
|
||||||
|
is_creator BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (id_card, id_utilisateur)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_audience_user
|
||||||
|
ON card_audience_members (id_utilisateur, id_card);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS card_responses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
id_card UUID NOT NULL REFERENCES card_instances(id) ON DELETE CASCADE,
|
||||||
|
id_utilisateur UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
|
action card_response_action_type NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_card_responses_card
|
||||||
|
ON card_responses (id_card, cree_le DESC);
|
||||||
|
|
||||||
|
-- Seeds SYSTEM
|
||||||
|
INSERT INTO card_types (
|
||||||
|
code, system, titre, emitter_roles, recipient_roles,
|
||||||
|
audience_resolver, response_mode, retention_days, couleur
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'absence_enfant', true, 'Absence enfant',
|
||||||
|
ARRAY['parent'], ARRAY['assistante_maternelle'],
|
||||||
|
'couple_am', 'none', 7, 'peach'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'absence_enfant_modif', true, 'Absence enfant modifiée',
|
||||||
|
ARRAY['parent'], ARRAY['assistante_maternelle'],
|
||||||
|
'couple_am', 'ack', 7, 'peach'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'conge_am', true, 'Congé AM',
|
||||||
|
ARRAY['assistante_maternelle'], ARRAY['parent'],
|
||||||
|
'couple_parents', 'accept_refuse', 14, 'lavender'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'arret_maladie_am', true, 'Arrêt maladie AM',
|
||||||
|
ARRAY['assistante_maternelle'], ARRAY['parent'],
|
||||||
|
'couple_parents', 'ack', 14, 'pink'
|
||||||
|
)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
titre = EXCLUDED.titre,
|
||||||
|
emitter_roles = EXCLUDED.emitter_roles,
|
||||||
|
recipient_roles = EXCLUDED.recipient_roles,
|
||||||
|
audience_resolver = EXCLUDED.audience_resolver,
|
||||||
|
response_mode = EXCLUDED.response_mode,
|
||||||
|
retention_days = EXCLUDED.retention_days,
|
||||||
|
couleur = EXCLUDED.couleur,
|
||||||
|
modifie_le = now();
|
||||||
|
|
||||||
|
COMMENT ON TABLE card_types IS 'Catalogue types de cartes (SYSTEM seeds #194 ; OPTIONNEL plus tard).';
|
||||||
|
COMMENT ON TABLE card_instances IS 'Bulles / file d’attention — collecte, pas vérité métier absences.';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- #152 — Suppression grossesse multiple / est_multiple
|
||||||
|
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Ticket #168 — Couple de garde courant (préférence parent)
|
||||||
|
-- Idempotent : safe à rejouer.
|
||||||
|
|
||||||
|
ALTER TABLE parents
|
||||||
|
ADD COLUMN IF NOT EXISTS id_placement_garde_courant UUID
|
||||||
|
REFERENCES enfants_assistantes_maternelles(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_parents_placement_garde_courant
|
||||||
|
ON parents(id_placement_garde_courant)
|
||||||
|
WHERE id_placement_garde_courant IS NOT NULL;
|
||||||
@@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
|||||||
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
|
INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
|
||||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
|
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
|
||||||
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false)
|
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
@@ -161,23 +161,9 @@ VALUES (
|
|||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- Événement (absence enfant)
|
-- Absences_garde : seeds quand placements EAM présents (API / ticket peuplement)
|
||||||
|
-- Legacy evenements supprimée (#193).
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO evenements (id, type, id_enfant, id_am, id_parent, cree_par, date_debut, date_fin, commentaires, statut, urgence)
|
|
||||||
VALUES (
|
|
||||||
'e0000000-0000-0000-0000-000000000001',
|
|
||||||
'absence_enfant',
|
|
||||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
||||||
'66666666-6666-6666-6666-666666666666',
|
|
||||||
'33333333-3333-3333-3333-333333333333',
|
|
||||||
'33333333-3333-3333-3333-333333333333',
|
|
||||||
'2025-09-12',
|
|
||||||
'2025-09-12',
|
|
||||||
'Enfant malade (rhume).',
|
|
||||||
'propose',
|
|
||||||
false
|
|
||||||
)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- Upload (justificatif lié au dossier)
|
-- Upload (justificatif lié au dossier)
|
||||||
|
|||||||
@@ -49,14 +49,14 @@ VALUES
|
|||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS ==========
|
-- ========== ENFANTS ==========
|
||||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
|
||||||
VALUES
|
VALUES
|
||||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
|
('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', true),
|
('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', true),
|
('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', false),
|
('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', false),
|
('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', false)
|
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||||
|
|||||||
@@ -79,12 +79,11 @@ LEFT JOIN avenants_contrats a ON a.id_contrat = c.id
|
|||||||
GROUP BY c.id, c.id_dossier, c.statut
|
GROUP BY c.id, c.id_dossier, c.statut
|
||||||
ORDER BY c.cree_le DESC;
|
ORDER BY c.cree_le DESC;
|
||||||
|
|
||||||
\echo '=== 9) Evénements par enfant (30 derniers jours) =============='
|
\echo '=== 9) Absences_garde (30 derniers jours) ====================='
|
||||||
SELECT ev.id, ev.type, ev.id_enfant, e.prenom AS enfant, ev.date_debut, ev.date_fin, ev.statut
|
SELECT ag.id, ag.type, ag.id_placement, ag.date_debut, ag.date_fin, ag.statut, ag.expire_at
|
||||||
FROM evenements ev
|
FROM absences_garde ag
|
||||||
JOIN enfants e ON e.id = ev.id_enfant
|
WHERE ag.date_debut >= (NOW()::date - INTERVAL '30 days')
|
||||||
WHERE ev.date_debut >= (NOW()::date - INTERVAL '30 days')
|
ORDER BY ag.date_debut DESC;
|
||||||
ORDER BY ev.date_debut DESC;
|
|
||||||
|
|
||||||
\echo '=== 10) Uploads & notifications récentes ======================='
|
\echo '=== 10) Uploads & notifications récentes ======================='
|
||||||
SELECT u.courriel, up.fichier_url, up.type_fichier, up.cree_le
|
SELECT u.courriel, up.fichier_url, up.type_fichier, up.cree_le
|
||||||
@@ -135,11 +134,11 @@ FROM avenants_contrats a
|
|||||||
LEFT JOIN contrats c ON c.id = a.id_contrat
|
LEFT JOIN contrats c ON c.id = a.id_contrat
|
||||||
WHERE c.id IS NULL;
|
WHERE c.id IS NULL;
|
||||||
|
|
||||||
-- Evénements sans enfant
|
-- Absences sans placement
|
||||||
SELECT ev.*
|
SELECT ag.*
|
||||||
FROM evenements ev
|
FROM absences_garde ag
|
||||||
LEFT JOIN enfants e ON e.id = ev.id_enfant
|
LEFT JOIN enfants_assistantes_maternelles eam ON eam.id = ag.id_placement
|
||||||
WHERE e.id IS NULL;
|
WHERE eam.id IS NULL;
|
||||||
|
|
||||||
\echo '=== 13) Performance : EXPLAIN sur requêtes clés ==============='
|
\echo '=== 13) Performance : EXPLAIN sur requêtes clés ==============='
|
||||||
|
|
||||||
@@ -151,12 +150,12 @@ WHERE m.id_dossier = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
|
|||||||
ORDER BY m.cree_le DESC
|
ORDER BY m.cree_le DESC
|
||||||
LIMIT 20;
|
LIMIT 20;
|
||||||
|
|
||||||
-- Evénements par enfant et période (idx_evenements_id_enfant_date_debut)
|
-- Absences par placement et période (idx_absences_garde_placement_dates)
|
||||||
EXPLAIN ANALYZE
|
EXPLAIN ANALYZE
|
||||||
SELECT ev.*
|
SELECT ag.*
|
||||||
FROM evenements ev
|
FROM absences_garde ag
|
||||||
WHERE ev.id_enfant = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
WHERE ag.id_placement IS NOT NULL
|
||||||
AND ev.date_debut >= '2025-01-01';
|
AND ag.date_debut >= '2025-01-01';
|
||||||
|
|
||||||
-- Notifications non lues (idx_notifications_user_lu_cree_le)
|
-- Notifications non lues (idx_notifications_user_lu_cree_le)
|
||||||
EXPLAIN ANALYZE
|
EXPLAIN ANALYZE
|
||||||
@@ -199,7 +198,7 @@ SELECT
|
|||||||
(SELECT COUNT(*) FROM messages) AS nb_messages,
|
(SELECT COUNT(*) FROM messages) AS nb_messages,
|
||||||
(SELECT COUNT(*) FROM contrats) AS nb_contrats,
|
(SELECT COUNT(*) FROM contrats) AS nb_contrats,
|
||||||
(SELECT COUNT(*) FROM avenants_contrats) AS nb_avenants,
|
(SELECT COUNT(*) FROM avenants_contrats) AS nb_avenants,
|
||||||
(SELECT COUNT(*) FROM evenements) AS nb_evenements,
|
(SELECT COUNT(*) FROM absences_garde) AS nb_absences_garde,
|
||||||
(SELECT COUNT(*) FROM uploads) AS nb_uploads,
|
(SELECT COUNT(*) FROM uploads) AS nb_uploads,
|
||||||
(SELECT COUNT(*) FROM notifications) AS nb_notifications,
|
(SELECT COUNT(*) FROM notifications) AS nb_notifications,
|
||||||
(SELECT COUNT(*) FROM validations) AS nb_validations;
|
(SELECT COUNT(*) FROM validations) AS nb_validations;
|
||||||
|
|||||||
+61
-70
@@ -1,94 +1,85 @@
|
|||||||
# 📚 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 |
|
||||||
|
| [30 — Découpage tickets quotidien](./30_DECOUPAGE-TICKETS-QUOTIDIEN.md) | Brouillon / backlog tickets parent/AM |
|
||||||
|
| [31 — Mini-spec quotidien parent/AM](./31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md) | Besoin figé atelier sept. 2026 |
|
||||||
|
| [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
|
## Architecture & infra
|
||||||
- [**01 - Cahier des Charges**](./01_CAHIER-DES-CHARGES.md) - Cahier des charges complet du projet P'titsPas (V1.3 - 24/11/2025)
|
|
||||||
|
|
||||||
### Architecture & Infrastructure
|
| Doc | Contenu |
|
||||||
- [**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
|
| [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
|
## Workflows & métier
|
||||||
- [**04 - Roadmap Générale**](./04_ROADMAP-GENERALE.md) - Roadmap complète du projet (Phases 1 à 5+)
|
|
||||||
|
|
||||||
### Développement
|
| Doc | Contenu |
|
||||||
- [**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
|
| [20 — Workflow création de compte](./20_WORKFLOW-CREATION-COMPTE.md) | Inscription / validation (détail historique) |
|
||||||
- [**14 - Note backend config setup**](./14_NOTE-BACKEND-CONFIG-SETUP.md) - Setup configuration
|
| [juridique/](./juridique/README.md) | CGU / CGC / privacy + [22 technique](./juridique/22_DOCUMENTS-LEGAUX.md) |
|
||||||
- [**92 - Note backend gestionnaires**](./92_NOTE-BACKEND-GESTIONNAIRES.md) - Gestionnaires
|
|
||||||
- [**99 - Règles de codage**](./99_REGLES-CODAGE.md) - Conventions de code
|
|
||||||
|
|
||||||
### Workflows Fonctionnels
|
## Charte & maquettes
|
||||||
- [**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
|
|
||||||
|
|
||||||
### Juridique (sources & technique)
|
| Doc | Contenu |
|
||||||
- [**Dossier juridique**](./juridique/README.md) - Index : CGU/CGC en Markdown,
|
|-----|---------|
|
||||||
export PDF, lien vers la doc technique n°22
|
| [CHARTE_GRAPHIQUE.md](./CHARTE_GRAPHIQUE.md) | Charte UI |
|
||||||
|
| [maquettes/](./maquettes/README.md) | Maquettes TdB quotidien (réf. v4 + historique) |
|
||||||
|
|
||||||
### Projet & suivi (Gitea / tickets)
|
## Projet & outillage
|
||||||
- [**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)
|
|
||||||
|
|
||||||
### Archive & convention de nommage
|
| Doc | Contenu |
|
||||||
- [**Dossier archive**](./archive/README.md) - Fichiers **sans** `NN_` déplacés
|
|-----|---------|
|
||||||
(temporaires, obsolètes) ; règles de rangement et suppression
|
| [23 — Suivi tickets](./23_SUIVI-TICKETS.md) | Pointeur Gitea |
|
||||||
- Pointeur : [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md) → voir **26**
|
| [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 |
|
||||||
|
|
||||||
### Exceptions de nommage (racine `docs/`)
|
## Audit
|
||||||
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`
|
|
||||||
|
|
||||||
### Administration (À créer)
|
| Doc | Contenu |
|
||||||
- [**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
|
| [90 — Audit YNOV](./90_AUDIT.md) | Analyse code étudiant |
|
||||||
|
|
||||||
### Frontend (À créer)
|
## Archive
|
||||||
- [**40 - Frontend Flutter**](./40_FRONTEND.md) - Structure de l'application mobile/web
|
|
||||||
|
|
||||||
### Audit & Analyse
|
| Emplacement | Usage |
|
||||||
- [**90 - Audit du projet YNOV**](./90_AUDIT.md) - Analyse complète du code étudiant et fonctionnalités
|
|-------------|--------|
|
||||||
|
| [archive/](./archive/README.md) | Obsolete / temporaires |
|
||||||
|
| [archive/obsolete/](./archive/obsolete/) | CDC V1.3, EVOLUTIONS_CDC, SuperNounou, listes figées |
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Données de test
|
||||||
|
|
||||||
|
| Doc | Contenu |
|
||||||
|
|-----|---------|
|
||||||
|
| [test-data/](./test-data/README.md) | Jeux utilisateurs test |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Cloner le projet
|
git clone … ptitspas-app
|
||||||
git clone ssh://gitea-jmartin/jmartin/app.git ptitspas-app
|
|
||||||
|
|
||||||
# Lancer l'environnement de développement
|
|
||||||
cd ptitspas-app
|
cd ptitspas-app
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
# Front https://app.ptits-pas.fr — API /api — PgAdmin /pgadmin
|
||||||
# Accéder aux services
|
|
||||||
Frontend: https://app.ptits-pas.fr
|
|
||||||
API: https://app.ptits-pas.fr/api
|
|
||||||
PgAdmin: https://app.ptits-pas.fr/pgadmin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔗 Liens utiles
|
## Liens
|
||||||
|
|
||||||
- **Gitea** : https://git.ptits-pas.fr
|
- Gitea : https://git.ptits-pas.fr/jmartin/petitspas
|
||||||
- **Production** : https://app.ptits-pas.fr
|
- Prod : 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
|
|
||||||
|
|
||||||
|
Mainteneur : Julien Martin (julien.martin@ptits-pas.fr).
|
||||||
|
|||||||
+155
-70
@@ -1,14 +1,19 @@
|
|||||||
---
|
---
|
||||||
title: "P'titsPas - Cahier des Charges Fonctionnel"
|
title: "P'titsPas - Cahier des Charges Fonctionnel"
|
||||||
author: "Julien MARTIN"
|
author: "Julien MARTIN"
|
||||||
date: "Novembre 2025"
|
date: "Septembre 2026"
|
||||||
version: "v1.3"
|
version: "v1.4"
|
||||||
---
|
---
|
||||||
|
|
||||||
# P'titsPas – Cahier des Charges Fonctionnel
|
# 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 d’enfants.
|
> **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 d’enfants.
|
||||||
|
|
||||||
|
> **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
|
## 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.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.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.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 d’un administrateur
|
### 3.4 Création d’un administrateur
|
||||||
### 3.5 Fiche enfant
|
### 3.5 Fiche enfant
|
||||||
### 3.6 Authentification et sécurité
|
### 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. Tableaux de bord
|
||||||
### 4.1 Vue d’ensemble
|
### 4.1 Vue d’ensemble
|
||||||
@@ -60,15 +68,15 @@ version: "v1.3"
|
|||||||
#### 4.3.5 Heures supplémentaires
|
#### 4.3.5 Heures supplémentaires
|
||||||
#### 4.3.6 Messagerie
|
#### 4.3.6 Messagerie
|
||||||
### 4.4 Tableau de bord des gestionnaires
|
### 4.4 Tableau de bord des gestionnaires
|
||||||
#### 4.4.1 Comptes à valider
|
#### 4.4.1 Dossiers à valider
|
||||||
#### 4.4.2 Liste des utilisateurs
|
#### 4.4.2 Gestion des utilisateurs (partagée)
|
||||||
#### 4.4.3 Contrats
|
#### 4.4.3 Contrats
|
||||||
#### 4.4.4 Messagerie
|
#### 4.4.4 Messagerie
|
||||||
#### 4.4.5 Événements RPE
|
#### 4.4.5 Événements RPE
|
||||||
#### 4.4.6 Alertes
|
#### 4.4.6 Alertes
|
||||||
### 4.5 Tableau de bord des administrateurs
|
### 4.5 Tableau de bord des administrateurs
|
||||||
#### 4.5.1 Menu Profil
|
#### 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.3 Gestion des enfants
|
||||||
#### 4.5.4 Paramètres de la plateforme
|
#### 4.5.4 Paramètres de la plateforme
|
||||||
#### 4.5.5 Statistiques et supervision
|
#### 4.5.5 Statistiques et supervision
|
||||||
@@ -193,18 +201,23 @@ Les assistantes maternelles peuvent :
|
|||||||
|
|
||||||
Les gestionnaires (responsables de relais petite enfance) disposent d’un tableau de bord de supervision. Ils peuvent :
|
Les gestionnaires (responsables de relais petite enfance) disposent d’un tableau de bord de supervision. Ils peuvent :
|
||||||
|
|
||||||
- Valider ou rejeter les demandes de création de compte
|
- Valider ou refuser les **dossiers** (inscriptions parent / AM) et accompagner les reprises
|
||||||
- Suivre les mises en relation et les contrats
|
- Gérer les usagers (fiches parent / AM / enfant, rattachements, dossiers) — socle partagé avec l’admin
|
||||||
- Organiser des événements ou des rendez-vous
|
- Suivre les mises en relation et les contrats (cible CDC)
|
||||||
- Gérer les conflits ou les fins de contrat
|
- 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é)
|
- Lancer des sondages ou modérer un blog RPE (si activé)
|
||||||
- Voir les historiques et statistiques liés à leur périmètre
|
- Voir les historiques et statistiques liés à leur périmètre
|
||||||
|
|
||||||
|
Ils **ne créent pas** les comptes gestionnaire / administrateur (réservé à l’admin).
|
||||||
|
|
||||||
### 2.1.4 Administrateurs
|
### 2.1.4 Administrateurs
|
||||||
|
|
||||||
Les administrateurs sont les représentants techniques et institutionnels de la collectivité (DSI ou agents désignés). Ils peuvent :
|
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 l’interface (logo, couleurs, nom de la ville)
|
- Personnaliser l’interface (logo, couleurs, nom de la ville)
|
||||||
- Activer ou désactiver des modules complémentaires
|
- Activer ou désactiver des modules complémentaires
|
||||||
- Consulter les statistiques d’usage
|
- Consulter les statistiques d’usage
|
||||||
@@ -245,12 +258,15 @@ Le parcours de création d’un compte parent s’effectue 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.
|
**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
|
### 3.1.3 Informations sur l'enfant
|
||||||
|
- Un ou **plusieurs** enfants peuvent être ajoutés
|
||||||
- Prénom (facultatif si enfant à naître)
|
- Prénom (facultatif si enfant à naître)
|
||||||
- Nom (hérité des parents)
|
- 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)
|
- 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é
|
- Photo (selon règles d’inscription) et **consentement photo**
|
||||||
- Rattachement automatique aux deux parents
|
- Rattachement automatique au parent 1 et au parent 2 s’il est renseigné
|
||||||
|
|
||||||
|
**Note V1.4** : il n’existe **pas** de champ « naissance multiple / jumeaux » (retiré du produit).
|
||||||
|
|
||||||
### 3.1.4 Présentation du dossier
|
### 3.1.4 Présentation du dossier
|
||||||
- Zone de texte libre permettant aux parents de décrire leur situation
|
- Zone de texte libre permettant aux parents de décrire leur situation
|
||||||
@@ -265,10 +281,12 @@ Le parcours de création d’un compte parent s’effectue en plusieurs étapes
|
|||||||
### 3.1.6 Récapitulatif et validation
|
### 3.1.6 Récapitulatif et validation
|
||||||
- Résumé des données saisies
|
- Résumé des données saisies
|
||||||
- Vérification, puis envoi de la demande
|
- Vérification, puis envoi de la demande
|
||||||
|
- Attribution d’un **numéro de dossier** (format type AAAA-NNNNNN)
|
||||||
- Les comptes parent sont soumis à validation par un gestionnaire avant activation
|
- 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
|
- 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 7 jours
|
- 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
|
- Une fois le mot de passe créé, le parent peut se connecter à son espace
|
||||||
|
- En cas de **refus**, le dossier n’est pas supprimé : l’usager peut **reprendre** sa demande (lien e-mail ou numéro de dossier) — voir §3.7
|
||||||
|
|
||||||
## 3.2 Création de compte assistante maternelle
|
## 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
|
- Champ libre : message à destination du gestionnaire
|
||||||
- Permet de justifier une demande ou d’ajouter des précisions
|
- Permet de justifier une demande ou d’ajouter des précisions
|
||||||
|
|
||||||
### 3.1.4 – Acceptation des CGU
|
### 3.2.4 – Acceptation des CGU
|
||||||
- Les utilisateurs doivent cocher la case
|
- Les utilisateurs doivent cocher la case
|
||||||
« J’ai lu et j’accepte les Conditions Générales d’Utilisation et la Politique de confidentialité ».
|
« J’ai lu et j’accepte les Conditions Générales d’Utilisation et la Politique de confidentialité ».
|
||||||
- Un lien direct ouvre la version PDF des CGU.
|
- 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
|
### 3.2.5 Récapitulatif et validation
|
||||||
- Résumé des données saisies
|
- Résumé des données saisies
|
||||||
- Vérification, puis envoi de la demande
|
- Vérification, puis envoi de la demande
|
||||||
|
- Attribution d’un **numéro de dossier**
|
||||||
- Validation par un gestionnaire requise avant activation
|
- 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
|
- 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 7 jours
|
- 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
|
- 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 d’un gestionnaire
|
## 3.3 Création d’un gestionnaire
|
||||||
|
|
||||||
- Réalisée par un administrateur
|
- Réalisée par un **administrateur** (ou super administrateur) — un gestionnaire ne crée pas de comptes staff
|
||||||
- Champs obligatoires : nom, prénom, adresse e-mail, mot de passe
|
- Champs : nom, prénom, e-mail, téléphone, mot de passe, **relais principal** (optionnel)
|
||||||
- Affectation à un ou plusieurs relais petite enfance
|
- Le mot de passe peut être modifié à la première connexion si la politique l’exige
|
||||||
- Le mot de passe doit être modifié lors de la première connexion
|
- Formulaire unifié de création / édition (même présentation que pour les administrateurs)
|
||||||
|
|
||||||
## 3.4 Création d’un administrateur
|
## 3.4 Création d’un administrateur
|
||||||
|
|
||||||
- Seuls les administrateurs existants peuvent créer de nouveaux comptes administrateurs
|
- Seuls les **administrateurs** (et super administrateur) peuvent créer de nouveaux comptes administrateurs
|
||||||
- Les droits sont équivalents (possibilité de restreindre par périmètre dans une version multi-mairies)
|
- Champs : nom, prénom, e-mail, téléphone, mot de passe (pas de relais)
|
||||||
- Obligation de changer le mot de passe à la première connexion
|
- Les droits sont équivalents entre administrateurs (le **super administrateur** est un compte d’installation non supprimable)
|
||||||
|
- Obligation de changer le mot de passe à la première connexion si exigé
|
||||||
|
|
||||||
## 3.5 Fiche enfant
|
## 3.5 Fiche enfant
|
||||||
|
|
||||||
@@ -331,22 +352,68 @@ Chaque enfant est représenté par une fiche :
|
|||||||
|
|
||||||
- Prénom (facultatif si enfant à naître)
|
- Prénom (facultatif si enfant à naître)
|
||||||
- Nom
|
- Nom
|
||||||
- Genre (H / F) - obligatoire
|
- Genre (H / F / Autre) — obligatoire
|
||||||
- Date de naissance ou date prévisionnelle
|
- Date de naissance ou date prévisionnelle
|
||||||
- Photo (obligatoire si l'enfant est né ET si l'option *Photo obligatoire* est activée)
|
- Photo et consentement photo selon configuration (consentement tracé)
|
||||||
- Consentement photo enregistré : valeur booléenne + horodatage liés à l'accord donné par le parent
|
- Statut : à naître / actif / scolarisé (évolutions de libellés possibles ultérieurement)
|
||||||
- Statut : à naître / actif / scolarisé
|
- Responsables (parents) rattachés — liens vers les fiches parent
|
||||||
- Indication possible : jumeaux, triplés, etc.
|
- Assistante maternelle de rattachement (le cas échéant)
|
||||||
- Possibilité de rattacher un enfant à plusieurs parents (garde alternée)
|
|
||||||
|
**Note V1.4** : pas d’indication « jumeaux / triplés » comme champ dédié.
|
||||||
|
|
||||||
|
Les fiches enfants sont :
|
||||||
|
- Accessibles depuis la fiche parent, la fiche AM, l’onglet **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é
|
## 3.6 Authentification et sécurité
|
||||||
|
|
||||||
- Tous les comptes utilisent une combinaison adresse e-mail + mot de passe
|
- Authentification par e-mail + mot de passe
|
||||||
- Les gestionnaires et administrateurs doivent modifier leur mot de passe à la première connexion
|
- Comptes **en attente** ou **suspendus** : connexion refusée
|
||||||
- Des mécanismes de récupération sont disponibles en cas de perte
|
- 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 d’envoi 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.
|
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
|
||||||
|
- L’usager 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 l’inscription publique, le staff peut **créer** un dossier famille ou AM (wizard)
|
||||||
|
- Le staff peut **éditer** un dossier existant (y compris ajout d’un 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
|
# 4. Tableaux de bord
|
||||||
|
|
||||||
Chaque rôle utilisateur dispose d’un tableau de bord personnalisé, adapté à ses fonctions dans la plateforme. Ces interfaces sont pensées pour être lisibles, fonctionnelles et évolutives.
|
Chaque rôle utilisateur dispose d’un 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 d’un tableau de bord personnalisé, adapté
|
|||||||
|------------------------|-------------------------------------------------------------------|
|
|------------------------|-------------------------------------------------------------------|
|
||||||
| Parents | Recherche d'assistante maternelle, gestion des enfants, contrat, agenda |
|
| Parents | Recherche d'assistante maternelle, gestion des enfants, contrat, agenda |
|
||||||
| Assistantes maternelles| Dossiers reçus, enfants accueillis, heures sup, agenda, messagerie|
|
| Assistantes maternelles| Dossiers reçus, enfants accueillis, heures sup, agenda, messagerie|
|
||||||
| Gestionnaires | Validation de comptes, contrats, messagerie, événements RPE |
|
| Gestionnaires | Dossiers à valider, gestion utilisateurs/enfants (partagée), contrats, messagerie, événements RPE |
|
||||||
| Administrateurs | Paramètres globaux, gestion des utilisateurs, statistiques |
|
| Administrateurs | Paramètres globaux, même gestion utilisateurs (droits staff élargis), statistiques |
|
||||||
|
|
||||||
Les tableaux de bord intègrent :
|
Les tableaux de bord intègrent :
|
||||||
- Une **barre de navigation supérieure** (liens de navigation rapide)
|
- Une **barre de navigation supérieure** (liens de navigation rapide)
|
||||||
@@ -502,17 +569,24 @@ L’assistante maternelle accède à une interface dédiée à la gestion de ses
|
|||||||
|
|
||||||
Le gestionnaire RPE dispose d’une 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.
|
Le gestionnaire RPE dispose d’une 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 d’attente des demandes de création de compte
|
- File d’attente des **dossiers** (famille / AM) en attente de validation
|
||||||
- Détails de chaque demande : parent, assistante maternelle
|
- Affichage du **numéro de dossier** et des informations essentielles
|
||||||
- Actions possibles : Valider / Refuser / Demander des précisions
|
- Ouverture en **revue** (wizard étapes) : Valider / Refuser
|
||||||
|
- Le refus n’efface pas le dossier : l’usager peut reprendre (voir §3.7)
|
||||||
|
- L’onglet **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 d’inscription
|
> **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 à l’administrateur.
|
||||||
- Accès rapide aux informations et historiques
|
|
||||||
- Possibilité de contacter un utilisateur
|
- 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
|
### 4.4.3 Contrats
|
||||||
|
|
||||||
@@ -566,47 +640,56 @@ Le menu profil de l’administrateur comprend uniquement :
|
|||||||
|
|
||||||
Toutes les fonctionnalités de gestion sont accessibles via des onglets distincts dans le tableau de bord.
|
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)
|
||||||
|
|
||||||
L’administration des utilisateurs est organisée par panneaux distincts pour chaque type de profil.
|
L’administration des utilisateurs est organisée par panneaux / onglets. **Admin et gestionnaire** partagent les panneaux usagers ; l’admin dispose en plus des droits staff.
|
||||||
|
|
||||||
#### a. Gestion des gestionnaires
|
#### a. Gestion des gestionnaires
|
||||||
- Liste des gestionnaires existants
|
- Liste des gestionnaires existants
|
||||||
- Création (nom, prénom, e-mail, mot de passe)
|
- Création / édition (nom, prénom, e-mail, téléphone, mot de passe, relais principal) — **administrateur uniquement** pour la création
|
||||||
- Attribution à un ou plusieurs RPE
|
- Réinitialisation / nouveau mot de passe en édition
|
||||||
- Réinitialisation du mot de passe
|
- Suppression du compte — **administrateur uniquement** (pas d’auto-suppression)
|
||||||
- Suppression du compte
|
|
||||||
|
|
||||||
#### b. Gestion des parents
|
#### b. Gestion des parents
|
||||||
- Liste complète des parents enregistrés
|
- Liste des parents enregistrés
|
||||||
- Recherche par nom, statut, enfants associés
|
- Ouverture de la **fiche parent** (identité, statut, enfants, lien co-parent)
|
||||||
- Modification des informations
|
- Rattacher / détacher un enfant
|
||||||
- Suppression d’un compte
|
- Création de dossier famille (wizard staff)
|
||||||
- Consultation du statut des dossiers liés
|
- Suppression d’un compte / dossier selon droits (§3.8)
|
||||||
|
|
||||||
#### c. Gestion des assistantes maternelles
|
#### c. Gestion des assistantes maternelles
|
||||||
- Liste des assistantes avec numéro d’agrément
|
- Liste des AM (agrément, capacité, etc.)
|
||||||
- Modification ou suppression d’un compte
|
- **Fiche AM** (identité, professionnel, enfants accueillis)
|
||||||
- Filtrage par zone géographique ou capacité
|
- Respect de la **capacité max** pour les rattachements
|
||||||
|
- Création de dossier AM (wizard staff)
|
||||||
|
- Suppression selon droits
|
||||||
|
|
||||||
#### d. Gestion des administrateurs
|
#### d. Gestion des administrateurs
|
||||||
- Création de nouveaux comptes administrateurs
|
- Création de nouveaux comptes administrateurs — **administrateur / super admin**
|
||||||
- Suivi des droits
|
- 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
|
### 4.5.3 Gestion des enfants
|
||||||
|
|
||||||
Deux accès possibles à la gestion des enfants :
|
Deux accès possibles à la gestion des enfants :
|
||||||
|
|
||||||
#### a. Par fiche parent
|
#### a. Par fiche parent (ou fiche AM)
|
||||||
- Consultation et édition des enfants associés à chaque parent
|
- Consultation et édition des enfants associés
|
||||||
|
- Rattacher / détacher depuis la fiche
|
||||||
|
|
||||||
#### b. Vue globale “Enfants”
|
#### b. Vue globale “Enfants”
|
||||||
- Liste complète avec :
|
- Liste complète avec :
|
||||||
- Nom, prénom, date de naissance ou prévisionnelle
|
- Nom, prénom, date de naissance ou prévisionnelle
|
||||||
- Statut (à naître, actif, scolarisé)
|
- Statut (à naître, actif, scolarisé)
|
||||||
- Parents associés
|
- Parents associés (alerte si **sans responsable**)
|
||||||
- Contrat en cours (si applicable)
|
- AM associée le cas échéant
|
||||||
|
- Contrat en cours (si applicable — module contrats)
|
||||||
|
- Création d’un enfant rattaché à un **foyer existant**
|
||||||
- Possibilité de modifier ou supprimer une fiche enfant
|
- Possibilité de modifier ou supprimer une fiche enfant
|
||||||
|
|
||||||
### 4.5.4 Paramètres de la plateforme
|
### 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 d’information)
|
- Le gestionnaire (à titre d’information)
|
||||||
- Contient :
|
- Contient :
|
||||||
- Identité
|
- Identité
|
||||||
- Photo (obligatoire si né)
|
- Photo (selon configuration / consentement)
|
||||||
- Statut (à naître / actif / scolarisé)
|
- Statut (à naître / actif / scolarisé)
|
||||||
- Parents associés
|
- Parents associés
|
||||||
- Mention s’il s’agit de jumeaux, triplés, etc.
|
- AM associée le cas échéant
|
||||||
|
|
||||||
## 6.4 Contrats et avenants
|
## 6.4 Contrats et avenants
|
||||||
|
|
||||||
@@ -1185,11 +1268,11 @@ P'titsPas s’inscrit dans une démarche de service public, avec des fondements
|
|||||||
| **Gestionnaires** | Suivi global des situations, outils de médiation, organisation d’événements |
|
| **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 |
|
| **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 :
|
**Cible CDC (plateforme complète)** — fonctionnalités décrites dans ce document :
|
||||||
- Création de comptes
|
- Création de comptes et gestion des utilisateurs / dossiers
|
||||||
- Recherche et sélection de nounous
|
- Recherche et sélection d’assistantes maternelles
|
||||||
- Suivi des dossiers
|
- Suivi des dossiers
|
||||||
- Génération de contrat et avenants
|
- Génération de contrat et avenants
|
||||||
- Messagerie
|
- Messagerie
|
||||||
@@ -1198,6 +1281,8 @@ Fonctionnalités incluses dès la première version :
|
|||||||
- Gestion des heures supplémentaires
|
- Gestion des heures supplémentaires
|
||||||
- Suivi RGPD et administration
|
- 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
|
## 10.4 Perspectives
|
||||||
|
|
||||||
La structure du produit permet une montée en charge progressive :
|
La structure du produit permet une montée en charge progressive :
|
||||||
|
|||||||
+15
-15
@@ -44,22 +44,21 @@ Les **Phases 2, 3, 4+** sont des **ébauches indicatives** qui seront affinées
|
|||||||
- ✅ Logging & Monitoring
|
- ✅ Logging & Monitoring
|
||||||
- ✅ Tests & Documentation
|
- ✅ Tests & Documentation
|
||||||
|
|
||||||
### Versions incrémentales
|
### Versions incrémentales (semver / Gitea)
|
||||||
|
|
||||||
| Version | Objectif | Tickets | Estimation |
|
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)**.
|
||||||
| **0.1.0** | MVP Fonctionnel | ~21 | ~45h |
|
|
||||||
| **0.2.0** | Sécurité & RGPD | ~10 | ~35h |
|
|
||||||
| **0.3.0** | Interfaces Complètes | ~17 | ~52h |
|
|
||||||
| **0.4.0** | Tests & Documentation | ~6 | ~24h |
|
|
||||||
| **0.5.0** | Monitoring & Optimisations | ~7 | ~17h |
|
|
||||||
| **1.0.0** | 🎉 **Release Phase 1** | **61** | **~173h** |
|
|
||||||
|
|
||||||
### Livrable
|
| 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.
|
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
|
- [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
|
- [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
|
- [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
|
- [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é
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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** | **Quotidien parent / AM** (TdB 3 colonnes, cartes, blog, messagerie) — [#165](https://git.ptits-pas.fr/jmartin/petitspas/issues/165)… | **Ouverte** — [mini-spec](./31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md) · [découpage](./30_DECOUPAGE-TICKETS-QUOTIDIEN.md) |
|
||||||
|
| **0.3.0** | Contrat + planning | Fermée (vide) — à réouvrir au besoin |
|
||||||
|
| **0.4.0** | Carnet de liaison | Fermée (vide) — à réouvrir au besoin |
|
||||||
|
| **0.9.0** | Hors périmètre cleanup 0.1.0 | Fermée (vide) |
|
||||||
|
| **1.0.0** | Release majeure (critères PO) | Fermée (réserve) |
|
||||||
|
| **Backlog transverse** | Doc / CI / RGPD / monitoring | Fermée (vide) |
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| [31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md](./31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md) | Besoin quotidien parent/AM (0.2.0) |
|
||||||
|
| [30_DECOUPAGE-TICKETS-QUOTIDIEN.md](./30_DECOUPAGE-TICKETS-QUOTIDIEN.md) | Mapping tickets #165–#189 |
|
||||||
|
| 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 2–5 : 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 d’une 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` |
|
||||||
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
|
|||||||
| `photo_url` | TEXT | | URL de la photo |
|
| `photo_url` | TEXT | | URL de la photo |
|
||||||
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
||||||
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
||||||
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 **l’ensemble** 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 à l’inscription / 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 d’un 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` (N–N) |
|
||||||
|
| 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** s’il 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 d’entré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 d’usage |
|
||||||
|
|--------|------------------|
|
||||||
|
| 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 **n’exige 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)
|
||||||
@@ -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.
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 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)).
|
||||||
|
|
||||||
|
**Backlog en cours `0.2.0` (quotidien)** : epic [#165](https://git.ptits-pas.fr/jmartin/petitspas/issues/165) · [découpage #166–#189](./30_DECOUPAGE-TICKETS-QUOTIDIEN.md) · [mini-spec](./31_MINI-SPEC-QUOTIDIEN-PARENT-AM.md).
|
||||||
|
|
||||||
|
Archive historique (liste Phase 1 figée, avril 2026) :
|
||||||
|
[archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md).
|
||||||
+54
-33
@@ -1,7 +1,7 @@
|
|||||||
# 📋 Décisions Projet - P'titsPas
|
# 📋 Décisions Projet - P'titsPas
|
||||||
|
|
||||||
**Version** : 1.2
|
**Version** : 1.4
|
||||||
**Date** : 16 Juin 2026
|
**Date** : 24 Septembre 2026
|
||||||
**Auteur** : Équipe PtitsPas
|
**Auteur** : Équipe PtitsPas
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -423,31 +423,23 @@ ptitspas-app/
|
|||||||
- Maintenance (tout au même endroit)
|
- Maintenance (tout au même endroit)
|
||||||
- Versioning (Git)
|
- Versioning (Git)
|
||||||
|
|
||||||
**Structure** :
|
**Structure** (sept. 2026) :
|
||||||
```
|
```
|
||||||
docs/
|
docs/
|
||||||
├── 00_INDEX.md
|
├── 00_INDEX.md
|
||||||
├── 01_CAHIER-DES-CHARGES.md
|
├── 01_CAHIER-DES-CHARGES.md # V1.4 fonctionnel
|
||||||
├── 02_ARCHITECTURE.md
|
├── 05_VERSIONS-ET-MILESTONES.md
|
||||||
├── 03_DEPLOYMENT.md
|
├── 12_SRS-GESTION-UTILISATEURS.md
|
||||||
├── 10_DATABASE.md
|
├── 23_SUIVI-TICKETS.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
|
|
||||||
├── 24_DECISIONS-PROJET.md (ce document)
|
├── 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
|
├── 90_AUDIT.md
|
||||||
└── test-data/
|
└── test-data/
|
||||||
```
|
```
|
||||||
@@ -473,19 +465,34 @@ docs/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 26. Branches Git
|
### 26. Branches Git + merge squash (norme)
|
||||||
|
|
||||||
**Décision** : ✅ **Stratégie simple : master + feature branches**
|
**Décision** : ✅ **`develop` + `master` + `feature/*`, merges en squash**
|
||||||
|
|
||||||
**Justification** :
|
**Justification** :
|
||||||
- Simplicité (petite équipe)
|
- Simplicité (petite équipe)
|
||||||
- Flexibilité
|
- Historique lisible sur `develop` / `master` (1 commit = 1 ticket)
|
||||||
|
- Déploiement prod uniquement depuis `master`
|
||||||
|
|
||||||
**Branches** :
|
**Branches** :
|
||||||
- `master` : Production
|
- `master` : Production (déploiement auto)
|
||||||
- `archive/*` : Archives (ex: maquette initiale)
|
- `develop` : Intégration / recette
|
||||||
- `migration/*` : Migrations (ex: intégration YNOV)
|
- `feature/*` : Ticket en cours (depuis `develop`)
|
||||||
- `feature/*` : Nouvelles fonctionnalités
|
- `archive/*`, `migration/*` : Archives / migrations ponctuelles
|
||||||
|
|
||||||
|
**Flux (norme à partir de #169+)** :
|
||||||
|
1. Branche `feature/N-…` depuis `develop`
|
||||||
|
2. PR **feature → `develop`** : merge **squash** (`Do: squash` côté Gitea)
|
||||||
|
3. Quand un lot est prêt : PR **`develop` → `master`** : merge **squash** également
|
||||||
|
4. Message squash : `feat(#N): …` / `fix(#N): …` + `Closes #N` si applicable
|
||||||
|
|
||||||
|
**Exception** : les merges `--no-ff` déjà poussés (ex. #167, #168) restent tels quels — pas de réécriture d’historique.
|
||||||
|
|
||||||
|
**API Gitea** (rappel) :
|
||||||
|
```http
|
||||||
|
POST /repos/jmartin/petitspas/pulls/{index}/merge
|
||||||
|
{"Do":"squash","MergeTitleField":"feat(#N): …","MergeMessageField":"Closes #N"}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -563,6 +570,19 @@ docs/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 32. Module Absences + Cartes (séparation back / collecte)
|
||||||
|
|
||||||
|
**Décision** : ✅ **Back `absences_garde` = vérité métier** ; **Cartes = file d’attention / collecte** (plugin isolé). Types SYSTEM (absence, congé, arrêt) indéboulonnables ; types OPTIONNELS (sondages…) plus tard. Annulation = DELETE. `expire_at` dès V1.
|
||||||
|
|
||||||
|
**Justification** :
|
||||||
|
- Premier vrai module d’interaction multi-acteurs
|
||||||
|
- Évite de stocker l’historique congés dans des bulles éphémères
|
||||||
|
- Portabilité / généricité des cartes sans coupler le métier garde
|
||||||
|
|
||||||
|
**Flux V1** : BDD absences → API liste/CRUD → module Cartes SYSTEM → front bulles (séparé).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📋 Résumé des décisions critiques
|
## 📋 Résumé des décisions critiques
|
||||||
|
|
||||||
| # | Décision | Statut | Impact |
|
| # | Décision | Statut | Impact |
|
||||||
@@ -583,6 +603,7 @@ docs/
|
|||||||
| 14 | Migration données | ❌ Rejeté | N/A |
|
| 14 | Migration données | ❌ Rejeté | N/A |
|
||||||
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
||||||
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
|
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
|
||||||
|
| 32 | Absences back + Cartes collecte | ✅ 0.2.0 | Métier / archi |
|
||||||
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
|
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -594,11 +615,11 @@ docs/
|
|||||||
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
|
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
|
||||||
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
|
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
|
||||||
| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) |
|
| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) |
|
||||||
| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant |
|
| 24/09/2026 | 1.4 | Décision 32 — Absences (`absences_garde`) ≠ Cartes ; Epic C recentré ; drop `evenements` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Dernière mise à jour** : 16 Juin 2026
|
**Dernière mise à jour** : 24 Septembre 2026
|
||||||
**Version** : 1.2
|
**Version** : 1.4
|
||||||
**Statut** : ✅ Document validé
|
**Statut** : ✅ Document validé
|
||||||
|
|
||||||
|
|||||||
@@ -116,10 +116,17 @@ curl -s -H "Authorization: token $GITEA_TOKEN" \
|
|||||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
|
||||||
|
|
||||||
# Créer une PR (head = branche source, base = branche cible)
|
# Créer une PR (head = branche source, base = branche cible)
|
||||||
|
# Norme : feature/* → develop, puis develop → master
|
||||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"head":"develop","base":"master","title":"Titre de la PR"}' \
|
-d '{"head":"feature/XX-nom","base":"develop","title":"feat(#XX): Titre","body":"Closes #XX"}' \
|
||||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
||||||
|
|
||||||
|
# Merger en squash (norme — voir 24_DECISIONS-PROJET.md §26)
|
||||||
|
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"Do":"squash","MergeTitleField":"feat(#XX): Titre","MergeMessageField":"Closes #XX"}' \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls/{index}/merge"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.4 Branches
|
### 3.4 Branches
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ frontend/lib/
|
|||||||
|
|
||||||
## Workflow Git
|
## Workflow Git
|
||||||
|
|
||||||
|
Norme projet : **squash merge** (voir [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md) §26).
|
||||||
|
Cible habituelle : `feature/*` → `develop` ; puis `develop` → `master` quand le lot est prêt.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Créer une branche feature
|
# 1. Créer une branche feature
|
||||||
git checkout develop
|
git checkout develop
|
||||||
@@ -204,15 +207,22 @@ git checkout -b feature/XX-nom-ticket
|
|||||||
git add .
|
git add .
|
||||||
git commit -m "feat(#XX): Description courte"
|
git commit -m "feat(#XX): Description courte"
|
||||||
|
|
||||||
# 3. Pousser et créer PR
|
# 3. Pousser et créer PR vers develop
|
||||||
git push -u origin feature/XX-nom-ticket
|
git push -u origin feature/XX-nom-ticket
|
||||||
|
|
||||||
# 4. Créer PR vers master via Gitea ou API
|
# 4. Créer PR (base = develop)
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "Authorization: token giteabu_1796c6aace0e2ef7e4fdb49cdc3bc1bf8ee31fbc" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"title":"feat(#XX): Titre","body":"Description\n\nCloses #XX","head":"feature/XX-nom-ticket","base":"master"}' \
|
-d '{"title":"feat(#XX): Titre","body":"Description\n\nCloses #XX","head":"feature/XX-nom-ticket","base":"develop"}' \
|
||||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
||||||
|
|
||||||
|
# 5. Merger en squash (pas merge commit)
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"Do":"squash","MergeTitleField":"feat(#XX): Titre","MergeMessageField":"Closes #XX"}' \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls/{index}/merge"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
**Version** : 1.1
|
**Version** : 1.1
|
||||||
**Date** : 16 juin 2026
|
**Date** : 16 juin 2026
|
||||||
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
|
**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)
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 à l’inscription |
|
||||||
|
| 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 à l’inscription |
|
||||||
|
|
||||||
|
### 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 l’onglet) |
|
||||||
|
| 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 l’onglet 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) |
|
||||||
|
| 121–122, 124–125 | 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 d’entré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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user