• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inclusion-numerique / coop-mediation-numerique / 3de2f5c2-ec70-46ec-b81f-a7a2106e504e

02 Feb 2026 11:11PM UTC coverage: 7.372% (-3.4%) from 10.731%
3de2f5c2-ec70-46ec-b81f-a7a2106e504e

push

circleci

web-flow
Merge pull request #407 from inclusion-numerique/dev

release

469 of 9672 branches covered (4.85%)

Branch coverage included in aggregate %.

0 of 28 new or added lines in 6 files covered. (0.0%)

1237 existing lines in 145 files now uncovered.

1330 of 14731 relevant lines covered (9.03%)

40.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/apps/web/src/features/dataspace/syncFromDataspaceCore.ts
1
import type {
2
  DataspaceContrat,
3
  DataspaceLieuActivite,
4
  DataspaceMediateur,
5
  DataspaceStructureEmployeuse,
6
} from '@app/web/external-apis/dataspace/dataspaceApiClient'
7
import { findOrCreateStructure } from '@app/web/features/structures/findOrCreateStructure'
8
import { prismaClient } from '@app/web/prismaClient'
9
import { v4 } from 'uuid'
10

11
/**
12
 * Core sync logic shared between initializeInscription and updateUserFromDataspaceData
13
 *
14
 * Business Rules:
15
 * - Dataspace null response → NO-OP
16
 * - is_conseiller_numerique: true → Dataspace is source of truth for emplois/structures
17
 * - is_conseiller_numerique: false → Local is source of truth, only update flag
18
 * - is_coordinateur: true → Create Coordinateur (never delete)
19
 * - Lieux d'activité: NOT synced here (only imported once during inscription)
20
 */
21

22
// ============================================================================
23
// Types
24
// ============================================================================
25

26
export type SyncFromDataspaceCoreResult = {
27
  coordinateurId: string | null
28
  structuresSynced: number
29
  structuresRemoved: number
30
  coordinateurCreated: boolean
31
}
32

33
export type SyncChanges = {
34
  conseillerNumeriqueCreated: boolean
35
  conseillerNumeriqueRemoved: boolean
36
  coordinateurCreated: boolean
37
  coordinateurUpdated: boolean
38
  structuresSynced: number
39
  structuresRemoved: number
40
}
41

42
type PreparedStructure = {
43
  structureId: string
44
  contracts: DataspaceContrat[]
45
  contract: DataspaceContrat | null
46
}
47

48
// ============================================================================
49
// Helper Functions
50
// ============================================================================
51

52
/**
53
 * Build full address from Dataspace address format
54
 */
UNCOV
55
export const buildAdresseFromDataspace = (adresse: {
×
56
  numero_voie: number | null
57
  nom_voie: string | null
58
  repetition: string | null
59
}): string => {
UNCOV
60
  const parts: string[] = []
×
61

UNCOV
62
  if (adresse.numero_voie) {
×
UNCOV
63
    parts.push(adresse.numero_voie.toString())
×
64
  }
65

UNCOV
66
  if (adresse.repetition && adresse.repetition !== 'null') {
×
UNCOV
67
    parts.push(adresse.repetition)
×
68
  }
69

UNCOV
70
  if (adresse.nom_voie && adresse.nom_voie !== 'null') {
×
UNCOV
71
    parts.push(adresse.nom_voie)
×
72
  }
73

UNCOV
74
  return parts.join(' ').trim()
×
75
}
76

77
/**
78
 * Get the active or most recent contract from a list of contracts
79
 */
UNCOV
80
export const getActiveOrMostRecentContract = (
×
81
  contrats: DataspaceContrat[],
82
): DataspaceContrat | null => {
UNCOV
83
  if (contrats.length === 0) {
×
84
    return null
×
85
  }
86

UNCOV
87
  const now = new Date()
×
88

89
  // Find active contract (started, not ended, not ruptured)
UNCOV
90
  const activeContract = contrats.find((contrat) => {
×
UNCOV
91
    const dateDebut = new Date(contrat.date_debut)
×
UNCOV
92
    const dateFin = new Date(contrat.date_fin)
×
UNCOV
93
    const hasNotStarted = dateDebut > now
×
UNCOV
94
    const hasEnded = dateFin < now
×
UNCOV
95
    const isRuptured = contrat.date_rupture !== null
×
96

UNCOV
97
    return !hasNotStarted && !hasEnded && !isRuptured
×
98
  })
99

UNCOV
100
  if (activeContract) {
×
UNCOV
101
    return activeContract
×
102
  }
103

104
  // No active contract - return the most recent one by date_debut
UNCOV
105
  return contrats.toSorted(
×
106
    (a, b) =>
UNCOV
107
      new Date(b.date_debut).getTime() - new Date(a.date_debut).getTime(),
×
108
  )[0]
109
}
110

111
/**
112
 * Get the end date for an emploi based on contract
113
 * Returns date_rupture if contract was terminated early, otherwise date_fin if contract has ended
114
 */
UNCOV
115
export const getEmploiEndDate = (contrat: DataspaceContrat): Date | null => {
×
116
  // If contract was ruptured, use rupture date
UNCOV
117
  if (contrat.date_rupture) {
×
118
    return new Date(contrat.date_rupture)
×
119
  }
120

121
  // If date_fin is null, contract has no end date (CDI without termination)
UNCOV
122
  if (!contrat.date_fin) {
×
123
    return null
×
124
  }
125

126
  // If contract has ended, use end date
UNCOV
127
  const dateFin = new Date(contrat.date_fin)
×
UNCOV
128
  if (dateFin < new Date()) {
×
UNCOV
129
    return dateFin
×
130
  }
131

132
  // Contract is still active
UNCOV
133
  return null
×
134
}
135

136
/**
137
 * Prepare structure data from Dataspace for EmployeStructure sync
138
 * Returns structure IDs and contract info for each structure
139
 */
UNCOV
140
export const prepareStructuresFromDataspace = async (
×
141
  structuresEmployeuses: DataspaceStructureEmployeuse[],
142
): Promise<PreparedStructure[]> => {
UNCOV
143
  const prepared: PreparedStructure[] = []
×
144

UNCOV
145
  for (const structureEmployeuse of structuresEmployeuses) {
×
UNCOV
146
    const adresse = buildAdresseFromDataspace(structureEmployeuse.adresse)
×
147

148
    // Find or create structure (outside transaction - structures are stable)
UNCOV
149
    const structure = await findOrCreateStructure({
×
150
      coopId: structureEmployeuse.ids?.coop,
151
      siret: structureEmployeuse.siret,
152
      nom: structureEmployeuse.nom,
153
      adresse,
154
      codePostal: structureEmployeuse.adresse.code_postal,
155
      codeInsee: structureEmployeuse.adresse.code_insee,
156
      commune: structureEmployeuse.adresse.nom_commune,
157
      nomReferent: structureEmployeuse.contact
×
158
        ? `${structureEmployeuse.contact.prenom} ${structureEmployeuse.contact.nom}`.trim()
159
        : null,
160
      courrielReferent:
161
        structureEmployeuse.contact?.courriels?.mail_gestionnaire ?? null,
×
162
      telephoneReferent: structureEmployeuse.contact?.telephone ?? null,
×
163
    })
164

UNCOV
165
    const contracts = structureEmployeuse.contrats ?? []
×
UNCOV
166
    prepared.push({
×
167
      structureId: structure.id,
168
      contracts,
169
      contract: getActiveOrMostRecentContract(contracts),
170
    })
171
  }
172

UNCOV
173
  return prepared
×
174
}
175

176
// ============================================================================
177
// Core Sync Operations
178
// ============================================================================
179

180
/**
181
 * Sync ALL structures employeuses from Dataspace data
182
 * After sync, user has exactly the same EmployeStructure records as in Dataspace.
183
 * - Creates/updates EmployeStructure for each structure in Dataspace
184
 * - Removes EmployeStructure records for structures NOT in Dataspace
185
 * - Edge case: if structure has no contracts, create/keep EmployeStructure with no fin date
186
 *
187
 * All EmployeStructure operations are performed in a single transaction.
188
 * Only called when is_conseiller_numerique: true in Dataspace API
189
 */
UNCOV
190
export const syncStructuresEmployeusesFromDataspace = async ({
×
191
  userId,
192
  structuresEmployeuses,
193
}: {
194
  userId: string
195
  structuresEmployeuses: DataspaceStructureEmployeuse[]
196
}): Promise<{ structureIds: string[]; removed: number }> => {
197
  // Step 1: Prepare all structures (find/create) outside transaction
UNCOV
198
  const preparedStructures = await prepareStructuresFromDataspace(
×
199
    structuresEmployeuses,
200
  )
UNCOV
201
  const structureIds = preparedStructures.map(
×
UNCOV
202
    (preparedStructure) => preparedStructure.structureId,
×
203
  )
204

205
  // Step 2: Perform all EmployeStructure operations in a single transaction
UNCOV
206
  const result = await prismaClient.$transaction(async (transaction) => {
×
UNCOV
207
    let removedCount = 0
×
208

209
    // Get all existing emplois for this user
UNCOV
210
    const existingEmplois = await transaction.employeStructure.findMany({
×
211
      where: { userId },
212
      select: {
213
        id: true,
214
        structureId: true,
215
        debut: true,
216
        fin: true,
217
        suppression: true,
218
      },
219
    })
220

221
    // Create a map for quick lookup
UNCOV
222
    const emploisByStructureId = new Map(
×
UNCOV
223
      existingEmplois.map((emploi) => [emploi.structureId, emploi]),
×
224
    )
225

226
    // Process each structure from Dataspace
UNCOV
227
    for (const { structureId, contracts, contract } of preparedStructures) {
×
UNCOV
228
      const existingEmploi = emploisByStructureId.get(structureId)
×
229

230
      // Handle case where there are no contracts from API
231
      // Edge case: create/keep EmployeStructure with no fin date
UNCOV
232
      if (contracts.length === 0) {
×
233
        if (existingEmploi) {
×
234
          // Keep existing emploi but remove fin/suppression to ensure user stays active
235
          if (
×
236
            existingEmploi.fin !== null ||
×
237
            existingEmploi.suppression !== null
238
          ) {
239
            await transaction.employeStructure.update({
×
240
              where: { id: existingEmploi.id },
241
              data: {
242
                fin: null,
243
                suppression: null,
244
              },
245
            })
246
          }
247
          // Otherwise keep as-is
248
        } else {
249
          // Create new "fictive" emploi without fin date (active employment)
250
          await transaction.employeStructure.create({
×
251
            data: {
252
              userId,
253
              structureId,
254
              debut: new Date(),
255
              fin: null,
256
              suppression: null,
257
            },
258
          })
259
        }
260
      } else {
261
        // Calculate dates from contract
UNCOV
262
        const creationDate = contract
×
263
          ? new Date(contract.date_debut)
264
          : new Date()
UNCOV
265
        const suppressionDate = contract ? getEmploiEndDate(contract) : null
×
266

UNCOV
267
        if (existingEmploi) {
×
268
          // Update existing emploi with contract dates if different
UNCOV
269
          if (
×
270
            existingEmploi.debut.getTime() !== creationDate.getTime() ||
×
271
            existingEmploi.fin?.getTime() !== suppressionDate?.getTime()
272
          ) {
273
            await transaction.employeStructure.update({
×
274
              where: { id: existingEmploi.id },
275
              data: {
276
                debut: creationDate,
277
                fin: suppressionDate,
278
                suppression: suppressionDate,
279
              },
280
            })
281
          }
282
        } else {
283
          // Create new emploi with contract dates
UNCOV
284
          await transaction.employeStructure.create({
×
285
            data: {
286
              userId,
287
              structureId,
288
              debut: creationDate,
289
              fin: suppressionDate,
290
              suppression: suppressionDate,
291
            },
292
          })
293
        }
294
      }
295
    }
296

297
    // Remove EmployeStructure records for structures NOT in Dataspace
298
    // This ensures user has exactly the same structures as in Dataspace after sync
UNCOV
299
    const deleteResult = await transaction.employeStructure.deleteMany({
×
300
      where: {
301
        userId,
302
        structureId: {
303
          notIn: structureIds,
304
        },
305
      },
306
    })
UNCOV
307
    removedCount = deleteResult.count
×
308

UNCOV
309
    return { removedCount }
×
310
  })
311

UNCOV
312
  return { structureIds, removed: result.removedCount }
×
313
}
314

315
/**
316
 * Create Coordinateur if not exists (never delete)
317
 * Only creates if is_coordinateur is true from Dataspace
318
 */
UNCOV
319
export const upsertCoordinateur = async ({
×
320
  userId,
321
}: {
322
  userId: string
323
}): Promise<{ coordinateurId: string; created: boolean }> => {
UNCOV
324
  const existingCoordinateur = await prismaClient.coordinateur.findUnique({
×
325
    where: { userId },
326
    select: { id: true },
327
  })
328

UNCOV
329
  if (existingCoordinateur) {
×
330
    return { coordinateurId: existingCoordinateur.id, created: false }
×
331
  }
332

333
  // Create new coordinateur
UNCOV
334
  const newCoordinateur = await prismaClient.coordinateur.create({
×
335
    data: {
336
      userId,
337
    },
338
    select: { id: true },
339
  })
340

UNCOV
341
  return { coordinateurId: newCoordinateur.id, created: true }
×
342
}
343

344
/**
345
 * Create Mediateur if not exists (never delete)
346
 * Only creates if explicitly called (typically during first-time lieux import)
347
 */
UNCOV
348
export const upsertMediateur = async ({
×
349
  userId,
350
}: {
351
  userId: string
352
}): Promise<{ mediateurId: string; created: boolean }> => {
353
  const existingMediateur = await prismaClient.mediateur.findUnique({
×
354
    where: { userId },
355
    select: { id: true },
356
  })
357

358
  if (existingMediateur) {
×
359
    return { mediateurId: existingMediateur.id, created: false }
×
360
  }
361

362
  // Create new mediateur
363
  const newMediateur = await prismaClient.mediateur.create({
×
364
    data: {
365
      userId,
366
    },
367
    select: { id: true },
368
  })
369

370
  return { mediateurId: newMediateur.id, created: true }
×
371
}
372

373
/**
374
 * Import lieux d'activité from Dataspace data for a mediateur (one-time import)
375
 * Creates MediateurEnActivite links for each lieu
376
 * This is NOT part of regular sync - only called during inscription
377
 */
UNCOV
378
export const importLieuxActiviteFromDataspace = async ({
×
379
  mediateurId,
380
  lieuxActivite,
381
}: {
382
  mediateurId: string
383
  lieuxActivite: DataspaceLieuActivite[]
384
}): Promise<{ structureIds: string[] }> => {
385
  const structureIds: string[] = []
×
386

387
  for (const lieuActivite of lieuxActivite) {
×
388
    // Some lieux activite from dataspace are lacking required data, we ignore them :
389
    // e.g :     {
390
    //   "nom": "Médiathèque de Saint-Quentin-la-Poterie",
391
    //   "siret": null,
392
    //   "adresse": {
393
    //     "nom_voie": null,
394
    //     "code_insee": null,
395
    //     "repetition": null,
396
    //     "code_postal": null,
397
    //     "nom_commune": null,
398
    //     "numero_voie": null
399
    //   },
400
    //   "contact": null
401
    // },
402

403
    if (
×
404
      !lieuActivite.adresse.code_insee ||
×
405
      !lieuActivite.adresse.code_postal ||
406
      !lieuActivite.adresse.nom_commune ||
407
      !lieuActivite.adresse.nom_voie ||
408
      !lieuActivite.adresse.nom_voie.trim()
409
    ) {
410
      continue
×
411
    }
412

413
    const adresse = buildAdresseFromDataspace(lieuActivite.adresse)
×
414

415
    // Find or create structure
416
    const structure = await findOrCreateStructure({
×
417
      siret: lieuActivite.siret,
418
      nom: lieuActivite.nom,
419
      adresse,
420
      codePostal: lieuActivite.adresse.code_postal,
421
      codeInsee: lieuActivite.adresse.code_insee,
422
      commune: lieuActivite.adresse.nom_commune,
423
      nomReferent: lieuActivite.contact
×
424
        ? `${lieuActivite.contact.prenom} ${lieuActivite.contact.nom}`.trim()
425
        : null,
426
      courrielReferent:
427
        lieuActivite.contact?.courriels?.mail_gestionnaire ??
×
428
        lieuActivite.contact?.courriels?.mail_pro ??
429
        null,
430
      telephoneReferent: lieuActivite.contact?.telephone ?? null,
×
431
    })
432

433
    structureIds.push(structure.id)
×
434

435
    // Create mediateurEnActivite link if not exists
436
    const existingActivite = await prismaClient.mediateurEnActivite.findFirst({
×
437
      where: {
438
        mediateurId,
439
        structureId: structure.id,
440
        suppression: null,
441
        fin: null,
442
      },
443
      select: {
444
        id: true,
445
      },
446
    })
447

448
    if (!existingActivite) {
×
449
      await prismaClient.mediateurEnActivite.create({
×
450
        data: {
451
          id: v4(),
452
          mediateurId,
453
          structureId: structure.id,
454
          debut: new Date(),
455
        },
456
      })
457
    }
458
  }
459

460
  return { structureIds }
×
461
}
462

463
// ============================================================================
464
// Main Sync Core Function
465
// ============================================================================
466

467
/**
468
 * Core idempotent sync from Dataspace data
469
 *
470
 * This function handles:
471
 * 1. Coordinateur creation (only if is_coordinateur is true, never delete)
472
 * 2. Structures employeuses sync (only if is_conseiller_numerique is true)
473
 *
474
 * Note: Lieux d'activité are NOT synced here. They are only imported once during inscription.
475
 *
476
 * @param userId - The user ID to sync
477
 * @param dataspaceData - The Dataspace API response (null = no-op)
478
 * @param wasConseillerNumerique - Previous CN status (for transition logic)
479
 */
UNCOV
480
export const syncFromDataspaceCore = async ({
×
481
  userId,
482
  dataspaceData,
483
  wasConseillerNumerique = false,
×
484
}: {
485
  userId: string
486
  dataspaceData: DataspaceMediateur | null
487
  wasConseillerNumerique?: boolean
488
}): Promise<{
489
  success: boolean
490
  noOp: boolean
491
  changes: SyncChanges
492
  coordinateurId: string | null
493
}> => {
UNCOV
494
  const changes: SyncChanges = {
×
495
    conseillerNumeriqueCreated: false,
496
    conseillerNumeriqueRemoved: false,
497
    coordinateurCreated: false,
498
    coordinateurUpdated: false,
499
    structuresSynced: 0,
500
    structuresRemoved: 0,
501
  }
502

503
  // Null response = NO-OP
UNCOV
504
  if (dataspaceData === null) {
×
UNCOV
505
    return {
×
506
      success: true,
507
      noOp: true,
508
      changes,
509
      coordinateurId: null,
510
    }
511
  }
512

UNCOV
513
  const isConseillerNumeriqueInApi = dataspaceData.is_conseiller_numerique
×
UNCOV
514
  const isCoordinateurInApi = dataspaceData.is_coordinateur
×
515

UNCOV
516
  let coordinateurId: string | null = null
×
517

518
  // --- Update User base fields ---
UNCOV
519
  await prismaClient.user.update({
×
520
    where: { id: userId },
521
    data: {
522
      dataspaceId: dataspaceData.id,
523
      dataspaceUserIdPg: dataspaceData.pg_id,
524
      lastSyncedFromDataspace: new Date(),
525
      isConseillerNumerique: isConseillerNumeriqueInApi,
526
    },
527
  })
528

529
  // --- Coordinateur: Only create if is_coordinateur is true (never delete) ---
UNCOV
530
  if (isCoordinateurInApi && isConseillerNumeriqueInApi) {
×
531
    const {
532
      coordinateurId: upsertedCoordinateurId,
533
      created: coordinateurCreated,
UNCOV
534
    } = await upsertCoordinateur({
×
535
      userId,
536
    })
UNCOV
537
    coordinateurId = upsertedCoordinateurId
×
UNCOV
538
    if (coordinateurCreated) {
×
UNCOV
539
      changes.coordinateurCreated = true
×
540
    }
541
  }
542

543
  // --- Conseiller Numérique Transitions (structures employeuses) ---
UNCOV
544
  if (isConseillerNumeriqueInApi) {
×
545
    // Dataspace is source of truth - sync structures
UNCOV
546
    if (!wasConseillerNumerique) {
×
UNCOV
547
      changes.conseillerNumeriqueCreated = true
×
548
    }
549

550
    const { structureIds, removed } =
UNCOV
551
      await syncStructuresEmployeusesFromDataspace({
×
552
        userId,
553
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
554
      })
UNCOV
555
    changes.structuresSynced = structureIds.length
×
UNCOV
556
    changes.structuresRemoved = removed
×
UNCOV
557
  } else if (wasConseillerNumerique && !isConseillerNumeriqueInApi) {
×
558
    // Transition: Was CN, no longer is
559
    // Do ONE LAST sync to update contracts, then local becomes source of truth
UNCOV
560
    changes.conseillerNumeriqueRemoved = true
×
561

562
    const { structureIds, removed } =
UNCOV
563
      await syncStructuresEmployeusesFromDataspace({
×
564
        userId,
565
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
566
      })
UNCOV
567
    changes.structuresSynced = structureIds.length
×
UNCOV
568
    changes.structuresRemoved = removed
×
569
  }
570
  // else: Not CN and not becoming CN → local is source of truth, don't touch emplois
571

UNCOV
572
  return {
×
573
    success: true,
574
    noOp: false,
575
    changes,
576
    coordinateurId,
577
  }
578
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc