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

inclusion-numerique / coop-mediation-numerique / 327e9be6-2e04-49bd-93a0-d8e55d9dde58

21 Jan 2026 12:55PM UTC coverage: 7.441% (-2.6%) from 10.027%
327e9be6-2e04-49bd-93a0-d8e55d9dde58

push

circleci

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

release

469 of 9630 branches covered (4.87%)

Branch coverage included in aggregate %.

24 of 1556 new or added lines in 160 files covered. (1.54%)

1049 existing lines in 152 files now uncovered.

1330 of 14546 relevant lines covered (9.14%)

41.16 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
 */
NEW
55
export const buildAdresseFromDataspace = (adresse: {
×
56
  numero_voie: number | null
57
  nom_voie: string | null
58
  repetition: string | null
59
}): string => {
NEW
60
  const parts: string[] = []
×
61

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

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

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

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

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

NEW
87
  const now = new Date()
×
88

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

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

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

104
  // No active contract - return the most recent one by date_debut
NEW
105
  return contrats.toSorted(
×
106
    (a, b) =>
NEW
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
 */
NEW
115
export const getEmploiEndDate = (contrat: DataspaceContrat): Date | null => {
×
116
  // If contract was ruptured, use rupture date
NEW
117
  if (contrat.date_rupture) {
×
NEW
118
    return new Date(contrat.date_rupture)
×
119
  }
120

121
  // If contract has ended, use end date
NEW
122
  const dateFin = new Date(contrat.date_fin)
×
NEW
123
  if (dateFin < new Date()) {
×
NEW
124
    return dateFin
×
125
  }
126

127
  // Contract is still active
NEW
128
  return null
×
129
}
130

131
/**
132
 * Prepare structure data from Dataspace for EmployeStructure sync
133
 * Returns structure IDs and contract info for each structure
134
 */
NEW
135
export const prepareStructuresFromDataspace = async (
×
136
  structuresEmployeuses: DataspaceStructureEmployeuse[],
137
): Promise<PreparedStructure[]> => {
NEW
138
  const prepared: PreparedStructure[] = []
×
139

NEW
140
  for (const structureEmployeuse of structuresEmployeuses) {
×
NEW
141
    const adresse = buildAdresseFromDataspace(structureEmployeuse.adresse)
×
142

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

NEW
160
    const contracts = structureEmployeuse.contrats ?? []
×
NEW
161
    prepared.push({
×
162
      structureId: structure.id,
163
      contracts,
164
      contract: getActiveOrMostRecentContract(contracts),
165
    })
166
  }
167

NEW
168
  return prepared
×
169
}
170

171
// ============================================================================
172
// Core Sync Operations
173
// ============================================================================
174

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

200
  // Step 2: Perform all EmployeStructure operations in a single transaction
NEW
201
  const result = await prismaClient.$transaction(async (transaction) => {
×
NEW
202
    let removedCount = 0
×
203

204
    // Get all existing emplois for this user
NEW
205
    const existingEmplois = await transaction.employeStructure.findMany({
×
206
      where: { userId },
207
      select: {
208
        id: true,
209
        structureId: true,
210
        debut: true,
211
        fin: true,
212
        suppression: true,
213
      },
214
    })
215

216
    // Create a map for quick lookup
NEW
217
    const emploisByStructureId = new Map(
×
NEW
218
      existingEmplois.map((emploi) => [emploi.structureId, emploi]),
×
219
    )
220

221
    // Process each structure from Dataspace
NEW
222
    for (const { structureId, contracts, contract } of preparedStructures) {
×
NEW
223
      const existingEmploi = emploisByStructureId.get(structureId)
×
224

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

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

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

NEW
304
    return { removedCount }
×
305
  })
306

NEW
307
  return { structureIds, removed: result.removedCount }
×
308
}
309

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

NEW
324
  if (existingCoordinateur) {
×
NEW
325
    return { coordinateurId: existingCoordinateur.id, created: false }
×
326
  }
327

328
  // Create new coordinateur
NEW
329
  const newCoordinateur = await prismaClient.coordinateur.create({
×
330
    data: {
331
      userId,
332
    },
333
    select: { id: true },
334
  })
335

NEW
336
  return { coordinateurId: newCoordinateur.id, created: true }
×
337
}
338

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

NEW
353
  if (existingMediateur) {
×
NEW
354
    return { mediateurId: existingMediateur.id, created: false }
×
355
  }
356

357
  // Create new mediateur
NEW
358
  const newMediateur = await prismaClient.mediateur.create({
×
359
    data: {
360
      userId,
361
    },
362
    select: { id: true },
363
  })
364

NEW
365
  return { mediateurId: newMediateur.id, created: true }
×
366
}
367

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

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

NEW
398
    if (
×
399
      !lieuActivite.adresse.code_insee ||
×
400
      !lieuActivite.adresse.code_postal ||
401
      !lieuActivite.adresse.nom_commune ||
402
      !lieuActivite.adresse.nom_voie ||
403
      !lieuActivite.adresse.nom_voie.trim()
404
    ) {
NEW
405
      continue
×
406
    }
407

NEW
408
    const adresse = buildAdresseFromDataspace(lieuActivite.adresse)
×
409

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

NEW
428
    structureIds.push(structure.id)
×
429

430
    // Create mediateurEnActivite link if not exists
NEW
431
    const existingActivite = await prismaClient.mediateurEnActivite.findFirst({
×
432
      where: {
433
        mediateurId,
434
        structureId: structure.id,
435
        suppression: null,
436
        fin: null,
437
      },
438
      select: {
439
        id: true,
440
      },
441
    })
442

NEW
443
    if (!existingActivite) {
×
NEW
444
      await prismaClient.mediateurEnActivite.create({
×
445
        data: {
446
          id: v4(),
447
          mediateurId,
448
          structureId: structure.id,
449
          debut: new Date(),
450
        },
451
      })
452
    }
453
  }
454

NEW
455
  return { structureIds }
×
456
}
457

458
// ============================================================================
459
// Main Sync Core Function
460
// ============================================================================
461

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

498
  // Null response = NO-OP
NEW
499
  if (dataspaceData === null) {
×
NEW
500
    return {
×
501
      success: true,
502
      noOp: true,
503
      changes,
504
      coordinateurId: null,
505
    }
506
  }
507

NEW
508
  const isConseillerNumeriqueInApi = dataspaceData.is_conseiller_numerique
×
NEW
509
  const isCoordinateurInApi = dataspaceData.is_coordinateur
×
510

NEW
511
  let coordinateurId: string | null = null
×
512

513
  // --- Update User base fields ---
NEW
514
  await prismaClient.user.update({
×
515
    where: { id: userId },
516
    data: {
517
      dataspaceId: dataspaceData.id,
518
      lastSyncedFromDataspace: new Date(),
519
      isConseillerNumerique: isConseillerNumeriqueInApi,
520
    },
521
  })
522

523
  // --- Coordinateur: Only create if is_coordinateur is true (never delete) ---
NEW
524
  if (isCoordinateurInApi && isConseillerNumeriqueInApi) {
×
525
    const {
526
      coordinateurId: upsertedCoordinateurId,
527
      created: coordinateurCreated,
NEW
528
    } = await upsertCoordinateur({
×
529
      userId,
530
    })
NEW
531
    coordinateurId = upsertedCoordinateurId
×
NEW
532
    if (coordinateurCreated) {
×
NEW
533
      changes.coordinateurCreated = true
×
534
    }
535
  }
536

537
  // --- Conseiller Numérique Transitions (structures employeuses) ---
NEW
538
  if (isConseillerNumeriqueInApi) {
×
539
    // Dataspace is source of truth - sync structures
NEW
540
    if (!wasConseillerNumerique) {
×
NEW
541
      changes.conseillerNumeriqueCreated = true
×
542
    }
543

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

556
    const { structureIds, removed } =
NEW
557
      await syncStructuresEmployeusesFromDataspace({
×
558
        userId,
559
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
560
      })
NEW
561
    changes.structuresSynced = structureIds.length
×
NEW
562
    changes.structuresRemoved = removed
×
563
  }
564
  // else: Not CN and not becoming CN → local is source of truth, don't touch emplois
565

NEW
566
  return {
×
567
    success: true,
568
    noOp: false,
569
    changes,
570
    coordinateurId,
571
  }
572
}
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