Endpoint implémenté: GET /api/v1/dashboard/weekly
- Tous les types et structures ont été vérifiés contre l'implémentation backend
- Les champs optionnels et logiques réelles sont documentés
- Les comportements de calcul sont expliqués (type) et l'identification de groupe est documentée
L'app mobile appelle /api/v1/groups/{groupId}/schedule avec family.id au lieu de group.id, causant une erreur 500 "Group not found".
Créer/modifier l'endpoint /api/dashboard/weekly pour qu'il agrège automatiquement les données de tous les groupes de la famille authentifiée.
GET /api/dashboard/weekly
- Requiert un token JWT valide
- Extrait automatiquement
user.idetfamily.iddu token
{
startDate?: string; // ISO date, défaut: aujourd'hui
// Pas besoin de groupId - détecté automatiquement depuis la famille
}{
success: true,
data: {
days: DayTransportSummary[] // 7 jours glissants
}
}
interface DayTransportSummary {
date: string; // ISO date (YYYY-MM-DD)
transports: TransportSlotSummary[];
totalChildrenInVehicles: number;
totalVehiclesWithAssignments: number;
hasScheduledTransports: boolean;
}
interface TransportSlotSummary {
time: string; // Format HH:mm
groupId: string; // ID du groupe pour ce transport
groupName: string; // Nom du groupe pour identification
scheduleSlotId: string; // ID unique du slot de transport
vehicleAssignmentSummaries: VehicleAssignmentSummary[];
totalChildrenAssigned: number;
totalCapacity: number;
overallCapacityStatus: 'available' | 'limited' | 'full' | 'overcapacity';
}
interface VehicleAssignmentSummary {
vehicleId: string;
vehicleName: string;
vehicleCapacity: number;
assignedChildrenCount: number;
availableSeats: number;
capacityStatus: 'available' | 'limited' | 'full' | 'overcapacity';
vehicleFamilyId: string; // Pour savoir si c'est un véhicule de la famille
isFamilyVehicle: boolean; // true si vehicleFamilyId === authenticatedFamily.id
driver?: {
id: string;
name: string;
};
// OPTIONAL: Info conducteur si assigné
// OPTIONAL: Details des enfants assignés à ce véhicule
children?: {
childId: string;
childName: string;
childFamilyId: string;
isFamilyChild: boolean;
}[];
}Chaque transport inclut maintenant des informations de groupe pour une identification claire:
- groupId: ID unique du groupe associé au transport
- groupName: Nom lisible du groupe pour l'interface utilisateur
- scheduleSlotId: ID unique du slot de transport pour référence
Important: Ces champs remplacent le champ "destination" précédemment utilisé
private determineType(time: string): 'pickup' | 'dropoff' {
const hour = parseInt(time.split(':')[0]);
return hour < 12 ? 'pickup' : 'dropoff';
}- Matin (< 12h) :
pickup - Après-midi (≥ 12h) :
dropoff - Note : Ce champ est calculé par le backend mais N'EST PAS inclus dans la réponse API
interface WeeklyDashboardResponse {
success: boolean;
data?: {
days: DayTransportSummary[];
startDate?: string; // YYYY-MM-DD
endDate?: string; // YYYY-MM-DD
generatedAt?: string; // ISO timestamp
metadata?: {
familyId?: string;
familyName?: string;
totalGroups?: number;
totalChildren?: number;
};
};
}// Extraire depuis le JWT
const authenticatedUserId = req.user.id;
const authenticatedFamilyId = req.user.familyId;
// Ou via une jointure User -> Family
const family = await Family.findOne({
where: { userId: authenticatedUserId }
});Inclure: Tous les groupes auxquels la famille appartient
SELECT g.*
FROM groups g
JOIN group_families gf ON gf.groupId = g.id
WHERE gf.familyId = :authenticatedFamilyId
AND g.isActive = trueconst startDate = queryParams.startDate
? new Date(queryParams.startDate)
: new Date(); // Aujourd'hui
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 6); // +6 jours = 7 jours total
// Période: [startDate, startDate+1, ..., startDate+6]Inclure: Seulement les transports où au moins un enfant de la famille est assigné
✅ NOTE IMPLEMENTATION: Les enfants sont au niveau VehicleAssignmentSummary, pas ScheduleSlotChild.
Le filtrage se fait uniquement sur familyId, sans filtrage par statut.
// Prisma query avec filtrage DB-level pour performance
const scheduleSlots = await prisma.scheduleSlot.findMany({
where: {
groupId: { in: groupIds },
datetime: { gte: startDate, lte: endDate },
// Filtre DB-level: seulement les slots avec véhicules ayant des enfants de la famille
vehicleAssignments: {
some: {
childAssignments: {
some: {
child: {
familyId: authenticatedFamilyId
}
}
}
}
}
}
});- Aucun enfant de la famille n'est assigné aux véhicules
- Aucun véhicule assigné (slots sans véhicules sont ignorés)
Pour chaque transport (schedule slot), inclure:
SELECT va.*, v.*
FROM vehicle_assignments va
JOIN vehicles v ON v.id = va.vehicleId
WHERE va.scheduleSlotId = :slotId
AND v.familyId = :authenticatedFamilyId -- Véhicule appartient à la famillestatus (champ n'existe pas dans le schéma)
// Logique implémentée en TypeScript
for (const va of vehicleAssignments) {
const isFamilyVehicle = va.vehicle.family.id === authenticatedFamilyId;
const hasFamilyChildren = va.childAssignments.some(
ca => ca.child.family.id === authenticatedFamilyId
);
// Inclure si: véhicule de la famille OU a des enfants de la famille
if (isFamilyVehicle || hasFamilyChildren) {
// ... créer VehicleAssignmentSummary
}
}Logique combinée:
const vehiclesForSlot = [
...familyOwnedVehicles, // Tous les véhicules de la famille
...otherVehiclesWithFamilyChildren // Autres véhicules SI enfants dedans
];Pour chaque véhicule:
interface VehicleAssignmentSummary {
vehicleCapacity: number; // vehicle.capacity OU seatOverride si défini
assignedChildrenCount: number; // COUNT(child_assignments)
availableSeats: number; // capacity - assignedChildrenCount
capacityStatus: CapacityStatus;
}
// Détermination du status (calculé dans le backend)
function getCapacityStatus(available: number, total: number): CapacityStatus {
const ratio = available / total;
if (ratio <= 0) return 'overcapacity'; // Surbooké
if (ratio <= 0.1) return 'full'; // >= 90% plein
if (ratio <= 0.3) return 'limited'; // >= 70% plein
return 'available'; // < 70% plein
}✅ IMPLEMENTATION RÉELLE: Les enfants sont assignés au niveau véhicule, pas transport
const assignedChildrenCount = assignedChildren.length; // childAssignments au niveau véhicule// Récupérer les slots de TOUS les groupes de la famille
const allGroupIds = familyGroups.map(g => g.id);
const slots = await ScheduleSlot.findAll({
where: {
groupId: { [Op.in]: allGroupIds },
datetime: { [Op.between]: [startDate, endDate] }
},
include: [
{
model: VehicleAssignment,
include: [
{ model: Vehicle },
{
model: ChildAssignment,
where: { status: 'assigned' },
include: [
{
model: Child,
where: { familyId: authenticatedFamilyId } // FILTRE CRUCIAL
}
]
}
]
}
]
});
// Grouper par jour
const daysSummaries = groupSlotsByDay(slots);export async function getWeeklyDashboard(req: Request, res: Response) {
try {
// 1. Authentification
const authenticatedFamilyId = req.user.familyId;
if (!authenticatedFamilyId) {
return res.status(401).json({
success: false,
error: 'No family associated with user'
});
}
// 2. Paramètres
const startDate = req.query.startDate
? new Date(req.query.startDate as string)
: new Date();
startDate.setHours(0, 0, 0, 0);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 6);
endDate.setHours(23, 59, 59, 999);
// 3. Récupérer tous les groupes de la famille
const familyGroups = await Group.findAll({
include: [{
model: GroupFamily,
where: { familyId: authenticatedFamilyId }
}]
});
if (familyGroups.length === 0) {
return res.json({
success: true,
data: { days: [] } // Pas de groupes = pas de transports
});
}
const groupIds = familyGroups.map(g => g.id);
// 4. Récupérer les slots avec filtrage famille
const slots = await ScheduleSlot.findAll({
where: {
groupId: { [Op.in]: groupIds },
datetime: { [Op.between]: [startDate, endDate] }
},
include: [
{
model: VehicleAssignment,
required: true, // INNER JOIN - seulement si véhicules assignés
include: [
{
model: Vehicle,
required: true
},
{
model: ChildAssignment,
required: true, // CRUCIAL: seulement si enfants assignés
where: { status: 'assigned' },
include: [
{
model: Child,
required: true,
where: {
familyId: authenticatedFamilyId // FILTRE FAMILLE
}
}
]
}
]
}
],
order: [['datetime', 'ASC']]
});
// 5. Pour chaque slot, filtrer les véhicules selon les règles
const enrichedSlots = await Promise.all(
slots.map(async (slot) => {
const vehicleAssignments = await getFilteredVehicles(
slot,
authenticatedFamilyId
);
return { ...slot.toJSON(), vehicleAssignments };
})
);
// 6. Agréger par jour
const daysSummaries = groupSlotsByDay(enrichedSlots, startDate);
// 7. Retour
return res.json({
success: true,
data: { days: daysSummaries }
});
} catch (error) {
console.error('Error in getWeeklyDashboard:', error);
return res.status(500).json({
success: false,
error: 'Internal server error',
statusCode: 500
});
}
}
// Fonction helper pour filtrer les véhicules
async function getFilteredVehicles(
slot: ScheduleSlot,
familyId: string
): Promise<VehicleAssignmentSummary[]> {
const allVehicles = await VehicleAssignment.findAll({
where: { scheduleSlotId: slot.id },
include: [
{ model: Vehicle },
{
model: ChildAssignment,
where: { status: 'assigned' },
required: false,
include: [{ model: Child }]
}
]
});
// Filtrer selon les règles
const filteredVehicles = allVehicles.filter(va => {
const vehicle = va.vehicle;
const isFamilyVehicle = vehicle.familyId === familyId;
// Cas 1: Véhicule de la famille -> TOUJOURS inclure
if (isFamilyVehicle) {
return true;
}
// Cas 2: Véhicule d'une autre famille -> inclure SI enfants de la famille
const hasFamilyChildren = va.childAssignments.some(
ca => ca.child.familyId === familyId
);
return hasFamilyChildren;
});
// Transformer en VehicleAssignmentSummary
return filteredVehicles.map(va => {
const capacity = va.seatOverride ?? va.vehicle.capacity;
const assignedCount = va.childAssignments.filter(
ca => ca.status === 'assigned'
).length;
const available = capacity - assignedCount;
return {
vehicleId: va.vehicleId,
vehicleName: va.vehicle.name,
vehicleCapacity: capacity,
assignedChildrenCount: assignedCount,
availableSeats: available,
capacityStatus: getCapacityStatus(available, capacity),
vehicleFamilyId: va.vehicle.familyId,
isFamilyVehicle: va.vehicle.familyId === familyId
};
});
}
function groupSlotsByDay(
slots: ScheduleSlot[],
startDate: Date
): DayTransportSummary[] {
// Créer un tableau de 7 jours
const days: DayTransportSummary[] = [];
for (let i = 0; i < 7; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
// Filtrer les slots pour ce jour
const daySlots = slots.filter(slot => {
const slotDate = new Date(slot.datetime);
return slotDate.toDateString() === date.toDateString();
});
// Agréger les données
const transports = daySlots.map(slot => transformToTransportSummary(slot));
const totalChildren = transports.reduce(
(sum, t) => sum + t.totalChildrenAssigned, 0
);
const totalVehicles = transports.reduce(
(sum, t) => sum + t.vehicleAssignmentSummaries.length, 0
);
days.push({
date: date.toISOString().split('T')[0],
transports,
totalChildrenInVehicles: totalChildren,
totalVehiclesWithAssignments: totalVehicles,
hasScheduledTransports: transports.length > 0
});
}
return days;
}- ✅ Vérifie que seuls les transports du groupe sont retournés
- ✅ Vérifie que seuls les enfants de cette famille sont comptés
- ✅ Vérifie l'agrégation des transports de tous les groupes
- ✅ Vérifie qu'il n'y a pas de doublons
- ✅ Vérifie que tous les véhicules de la famille sont affichés
- ✅ Même s'ils sont vides (0 enfant assigné)
- ✅ Vérifie qu'ils apparaissent SI enfants de la famille dedans
- ✅ Vérifie qu'ils n'apparaissent PAS si aucun enfant de la famille
- ✅ Vérifie que exactement 7 jours sont retournés
- ✅ Vérifie que les jours sans transport ont
transports: []
- ✅ Vérifie
seatOverrideest prioritaire survehicle.capacity - ✅ Vérifie que
assignedChildrenCountcompte seulement status='assigned'
-
Phase 1 - Fix critique (maintenant)
- Implémenter l'endpoint
/api/dashboard/weekly - Filtrage correct par famille
- Tests unitaires
- Implémenter l'endpoint
-
Phase 2 - Optimisation (après)
- Cache Redis pour les données fréquentes
- Pagination si > 50 transports
- Logs de performance
-
Phase 3 - Features avancées (futur)
- Notifications push si transport bientôt
- Filtres par type de transport
- Historique des transports passés
- Ne PAS utiliser
family.idcommegroupId❌ - Toujours filtrer par
child.familyId = authenticatedFamilyId✅ - Respecter les règles de filtrage des véhicules ✅
- Retourner 7 jours même si certains sont vides ✅
- Gérer les erreurs 401/403/500 proprement ✅