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

inclusion-numerique / coop-mediation-numerique / 167dd59e-aee5-4dff-929d-c321ef1902af

16 Feb 2026 09:09AM UTC coverage: 7.114% (-0.06%) from 7.176%
167dd59e-aee5-4dff-929d-c321ef1902af

push

circleci

hugues-m
Merge branch 'dev'

# Conflicts:
#	apps/web/src/features/dataspace/syncFromDataspaceCore.ts

469 of 10005 branches covered (4.69%)

Branch coverage included in aggregate %.

0 of 161 new or added lines in 17 files covered. (0.0%)

5 existing lines in 3 files now uncovered.

1330 of 15283 relevant lines covered (8.7%)

39.17 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 { getContractStatus } from '@app/web/features/dataspace/getContractStatus'
8
import { findOrCreateStructure } from '@app/web/features/structures/findOrCreateStructure'
9
import { prismaClient } from '@app/web/prismaClient'
10
import { dateAsIsoDay } from '@app/web/utils/dateAsIsoDay'
11
import { v4 } from 'uuid'
12

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

24
// ============================================================================
25
// Types
26
// ============================================================================
27

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

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

44
/**
45
 * Represents a single contract with its structure, ready for sync
46
 * One EmployeStructure record will be created for each PreparedContract
47
 */
48
type PreparedContract = {
49
  structureId: string
50
  contract: DataspaceContrat
51
}
52

53
type ExistingEmploiForSync = {
54
  id: string
55
  structureId: string
56
  debut: Date | null
57
  fin: Date | null
58
  suppression: Date | null
59
  creation: Date
60
}
61

NEW
62
const hasDebutDate = (
×
63
  emploi: ExistingEmploiForSync,
NEW
64
): emploi is ExistingEmploiForSync & { debut: Date } => emploi.debut !== null
×
65

66
// ============================================================================
67
// Helper Functions
68
// ============================================================================
69

70
/**
71
 * Build full address from Dataspace address format
72
 */
73
export const buildAdresseFromDataspace = (adresse: {
×
74
  numero_voie: number | null
75
  nom_voie: string | null
76
  repetition: string | null
77
}): string => {
78
  const parts: string[] = []
×
79

80
  if (adresse.numero_voie) {
×
81
    parts.push(adresse.numero_voie.toString())
×
82
  }
83

84
  if (adresse.repetition && adresse.repetition !== 'null') {
×
85
    parts.push(adresse.repetition)
×
86
  }
87

88
  if (adresse.nom_voie && adresse.nom_voie !== 'null') {
×
89
    parts.push(adresse.nom_voie)
×
90
  }
91

92
  return parts.join(' ').trim()
×
93
}
94

95
/**
96
 * Get the active or most recent contract from a list of contracts
97
 */
98
export const getActiveOrMostRecentContract = (
×
99
  contrats: DataspaceContrat[],
100
): DataspaceContrat | null => {
101
  if (contrats.length === 0) {
×
102
    return null
×
103
  }
104

105
  const now = new Date()
×
106

107
  // Find active contract (started, not ended, not ruptured)
108
  const activeContract = contrats.find(
×
109
    (contrat) => getContractStatus({ contrat, date: now }).isActive,
×
110
  )
111

112
  if (activeContract) {
×
113
    return activeContract
×
114
  }
115

116
  // No active contract - return the most recent one by date_debut
117
  return contrats.toSorted(
×
118
    (a, b) =>
119
      new Date(b.date_debut).getTime() - new Date(a.date_debut).getTime(),
×
120
  )[0]
121
}
122

123
/**
124
 * Get the end date for an emploi based on contract
125
 * Only date_rupture from Dataspace is mapped to emploi fin.
126
 * date_fin does not impact emploi fin in our model.
127
 */
128
export const getEmploiEndDate = (contrat: DataspaceContrat): Date | null => {
×
129
  return contrat.date_rupture ? new Date(contrat.date_rupture) : null
×
130
}
131

132
/**
133
 * Prepare contract data from Dataspace for EmployeStructure sync
134
 * Returns one PreparedContract for each contract in each structure
135
 * Creates one EmployeStructure record per contract
136
 */
137
export const prepareContractsFromDataspace = async (
×
138
  structuresEmployeuses: DataspaceStructureEmployeuse[],
139
): Promise<PreparedContract[]> => {
140
  const prepared: PreparedContract[] = []
×
141

142
  for (const structureEmployeuse of structuresEmployeuses) {
×
143
    const contracts = structureEmployeuse.contrats ?? []
×
144

145
    // Skip structures without contracts
146
    if (contracts.length === 0) {
×
147
      continue
×
148
    }
149

150
    // Find or create structure (outside transaction - structures are stable)
NEW
151
    const structure = await getOrCreateStructureFromDataspace({
×
152
      structureEmployeuse,
153
    })
154

155
    // Create one PreparedContract for each contract
156
    for (const contract of contracts) {
×
157
      prepared.push({
×
158
        structureId: structure.id,
159
        contract,
160
      })
161
    }
162
  }
163

164
  return prepared
×
165
}
166

NEW
167
const getOrCreateStructureIdForTemporaryContract = async ({
×
168
  structureEmployeuse,
169
}: {
170
  structureEmployeuse: DataspaceStructureEmployeuse
171
}): Promise<string> => {
NEW
172
  const structure = await getOrCreateStructureFromDataspace({
×
173
    structureEmployeuse,
174
  })
175

NEW
176
  return structure.id
×
177
}
178

NEW
179
const getOrCreateStructureFromDataspace = async ({
×
180
  structureEmployeuse,
181
}: {
182
  structureEmployeuse: DataspaceStructureEmployeuse
183
}) => {
NEW
184
  const adresse = buildAdresseFromDataspace(structureEmployeuse.adresse)
×
NEW
185
  return findOrCreateStructure({
×
186
    coopId: structureEmployeuse.ids?.coop,
187
    siret: structureEmployeuse.siret,
188
    nom: structureEmployeuse.nom,
189
    adresse,
190
    codePostal: structureEmployeuse.adresse.code_postal,
191
    codeInsee: structureEmployeuse.adresse.code_insee,
192
    commune: structureEmployeuse.adresse.nom_commune,
193
    nomReferent: structureEmployeuse.contact
×
194
      ? `${structureEmployeuse.contact.prenom} ${structureEmployeuse.contact.nom}`.trim()
195
      : null,
196
    courrielReferent:
197
      structureEmployeuse.contact?.courriels?.mail_gestionnaire ?? null,
×
198
    telephoneReferent: structureEmployeuse.contact?.telephone ?? null,
×
199
  })
200
}
201

202
// ============================================================================
203
// Core Sync Operations
204
// ============================================================================
205

206
/**
207
 * Generate a unique key for an emploi based on structureId and debut date
208
 * Used to match existing emplois with contracts from Dataspace
209
 */
210
const getEmploiKey = (structureId: string, debut: Date): string =>
×
211
  `${structureId}:${dateAsIsoDay(debut)}`
×
212

213
/**
214
 * Sync ALL contracts from Dataspace data as EmployeStructure records
215
 * After sync, user has exactly one EmployeStructure for each contract in Dataspace.
216
 * - Creates EmployeStructure for each contract in Dataspace
217
 * - Updates existing EmployeStructure if fin date changed
218
 * - Soft-deletes EmployeStructure records for contracts NOT in Dataspace
219
 * - If structures exist but no valid contracts remain after filtering,
220
 *   one temporary emploi is kept/created with debut=null and fin=null
221
 *
222
 * Matching logic: An emploi is matched to a contract by structureId + debut date
223
 *
224
 * All EmployeStructure operations are performed in a single transaction.
225
 * Only called when is_conseiller_numerique: true in Dataspace API
226
 */
227
export const syncStructuresEmployeusesFromDataspace = async ({
×
228
  userId,
229
  structuresEmployeuses,
230
}: {
231
  userId: string
232
  structuresEmployeuses: DataspaceStructureEmployeuse[]
233
}): Promise<{ structureIds: string[]; removed: number }> => {
234
  // Step 1: Prepare all contracts (find/create structures) outside transaction
235
  // This already filters out structures without contracts
236
  const preparedContracts = await prepareContractsFromDataspace(
×
237
    structuresEmployeuses,
238
  )
239

240
  const temporaryContractStructureId =
NEW
241
    preparedContracts.length === 0 && structuresEmployeuses.length > 0
×
242
      ? await getOrCreateStructureIdForTemporaryContract({
243
          structureEmployeuse: structuresEmployeuses[0],
244
        })
245
      : null
246

247
  // Collect unique structure IDs for the return value
248
  const structureIds = [
×
249
    ...new Set(
250
      [
NEW
251
        ...preparedContracts.map((pc) => pc.structureId),
×
252
        temporaryContractStructureId,
NEW
253
      ].filter((structureId) => typeof structureId === 'string'),
×
254
    ),
255
  ]
256

257
  // Step 2: Perform all EmployeStructure operations in a single transaction
258
  const result = await prismaClient.$transaction(async (transaction) => {
×
259
    // Get all existing emplois for this user (including soft-deleted for reactivation)
260
    const existingEmplois: ExistingEmploiForSync[] =
NEW
261
      await transaction.employeStructure.findMany({
×
262
        where: { userId },
263
        select: {
264
          id: true,
265
          structureId: true,
266
          debut: true,
267
          fin: true,
268
          suppression: true,
269
          creation: true,
270
        },
271
      })
272

273
    // Create a map for quick lookup by structureId + debut for real contracts.
274
    // Temporary contracts use debut=null and are handled separately.
NEW
275
    const emploisByKey = new Map<
×
276
      string,
277
      ExistingEmploiForSync & { debut: Date }
278
    >()
NEW
279
    const realEmplois = existingEmplois.filter(hasDebutDate)
×
280
    // If multiple emplois collide on the same key, keep the most recently created one.
NEW
281
    const realEmploisByCreationDesc = realEmplois.toSorted(
×
NEW
282
      (a, b) => b.creation.getTime() - a.creation.getTime(),
×
283
    )
NEW
284
    for (const emploi of realEmploisByCreationDesc) {
×
NEW
285
      const key = getEmploiKey(emploi.structureId, emploi.debut)
×
NEW
286
      if (!emploisByKey.has(key)) {
×
NEW
287
        emploisByKey.set(key, emploi)
×
288
      }
289
    }
290

NEW
291
    const temporaryEmplois = existingEmplois
×
NEW
292
      .filter((emploi) => emploi.debut === null)
×
NEW
293
      .toSorted((a, b) => b.creation.getTime() - a.creation.getTime())
×
294

295
    // Track which emploi IDs should remain active after sync
296
    const emploiIdsToKeep: string[] = []
×
297

298
    // Process each contract from Dataspace
299
    for (const { structureId, contract } of preparedContracts) {
×
300
      const creationDate = new Date(contract.date_debut)
×
301
      const endDate = getEmploiEndDate(contract)
×
302
      const key = getEmploiKey(structureId, creationDate)
×
303

304
      const existingEmploi = emploisByKey.get(key)
×
305

306
      if (existingEmploi) {
×
307
        emploiIdsToKeep.push(existingEmploi.id)
×
308

309
        // Check if we need to update the emploi
310
        const needsUpdate =
311
          existingEmploi.fin?.getTime() !== endDate?.getTime() ||
×
312
          existingEmploi.suppression !== null // Reactivate if it was soft-deleted
313

314
        if (needsUpdate) {
×
315
          await transaction.employeStructure.update({
×
316
            where: { id: existingEmploi.id },
317
            data: {
318
              fin: endDate,
319
              // Contracts present in Dataspace are never soft-deleted.
320
              suppression: null,
321
            },
322
          })
323
        }
324
      } else {
325
        // Create new emploi for this contract
326
        const newEmploi = await transaction.employeStructure.create({
×
327
          data: {
328
            userId,
329
            structureId,
330
            debut: creationDate,
331
            fin: endDate,
332
            suppression: null,
333
          },
334
          select: { id: true },
335
        })
336
        emploiIdsToKeep.push(newEmploi.id)
×
337
      }
338
    }
339

340
    // If Dataspace has structures but no valid contracts, keep exactly one
341
    // temporary contract (debut=null, fin=null) on the first structure.
NEW
342
    if (preparedContracts.length === 0 && temporaryContractStructureId) {
×
NEW
343
      const temporaryEmploiToKeep = temporaryEmplois[0]
×
344

NEW
345
      if (temporaryEmploiToKeep) {
×
NEW
346
        emploiIdsToKeep.push(temporaryEmploiToKeep.id)
×
347

348
        const needsUpdate =
NEW
349
          temporaryEmploiToKeep.structureId !== temporaryContractStructureId ||
×
350
          temporaryEmploiToKeep.fin !== null ||
351
          temporaryEmploiToKeep.suppression !== null
352

NEW
353
        if (needsUpdate) {
×
NEW
354
          await transaction.employeStructure.update({
×
355
            where: { id: temporaryEmploiToKeep.id },
356
            data: {
357
              structureId: temporaryContractStructureId,
358
              fin: null,
359
              suppression: null,
360
            },
361
          })
362
        }
363
      } else {
NEW
364
        const newTemporaryEmploi = await transaction.employeStructure.create({
×
365
          data: {
366
            userId,
367
            structureId: temporaryContractStructureId,
368
            debut: null,
369
            fin: null,
370
            suppression: null,
371
          },
372
          select: { id: true },
373
        })
NEW
374
        emploiIdsToKeep.push(newTemporaryEmploi.id)
×
375
      }
376
    }
377

378
    // Soft-delete EmployeStructure records for contracts NOT in Dataspace
379
    // Set suppression date to mark them as ended
380
    const now = new Date()
×
381
    const softDeleteResult = await transaction.employeStructure.updateMany({
×
382
      where: {
383
        userId,
384
        id: {
385
          notIn: emploiIdsToKeep,
386
        },
387
        suppression: null, // Only soft-delete those not already deleted
388
      },
389
      data: {
390
        suppression: now,
391
        fin: now,
392
      },
393
    })
394

395
    return { removedCount: softDeleteResult.count }
×
396
  })
397

398
  return { structureIds, removed: result.removedCount }
×
399
}
400

401
/**
402
 * Create Coordinateur if not exists (never delete)
403
 * Only creates if is_coordinateur is true from Dataspace
404
 */
405
export const upsertCoordinateur = async ({
×
406
  userId,
407
}: {
408
  userId: string
409
}): Promise<{ coordinateurId: string; created: boolean }> => {
410
  const existingCoordinateur = await prismaClient.coordinateur.findUnique({
×
411
    where: { userId },
412
    select: { id: true },
413
  })
414

415
  if (existingCoordinateur) {
×
416
    return { coordinateurId: existingCoordinateur.id, created: false }
×
417
  }
418

419
  // Create new coordinateur
420
  const newCoordinateur = await prismaClient.coordinateur.create({
×
421
    data: {
422
      userId,
423
    },
424
    select: { id: true },
425
  })
426

427
  return { coordinateurId: newCoordinateur.id, created: true }
×
428
}
429

430
/**
431
 * Create Mediateur if not exists (never delete)
432
 * Only creates if explicitly called (typically during first-time lieux import)
433
 */
434
export const upsertMediateur = async ({
×
435
  userId,
436
}: {
437
  userId: string
438
}): Promise<{ mediateurId: string; created: boolean }> => {
439
  const existingMediateur = await prismaClient.mediateur.findUnique({
×
440
    where: { userId },
441
    select: { id: true },
442
  })
443

444
  if (existingMediateur) {
×
445
    return { mediateurId: existingMediateur.id, created: false }
×
446
  }
447

448
  // Create new mediateur
449
  const newMediateur = await prismaClient.mediateur.create({
×
450
    data: {
451
      userId,
452
    },
453
    select: { id: true },
454
  })
455

456
  return { mediateurId: newMediateur.id, created: true }
×
457
}
458

459
/**
460
 * Import lieux d'activité from Dataspace data for a mediateur (one-time import)
461
 * Creates MediateurEnActivite links for each lieu
462
 * This is NOT part of regular sync - only called during inscription
463
 */
464
export const importLieuxActiviteFromDataspace = async ({
×
465
  mediateurId,
466
  lieuxActivite,
467
}: {
468
  mediateurId: string
469
  lieuxActivite: DataspaceLieuActivite[]
470
}): Promise<{ structureIds: string[] }> => {
471
  const structureIds: string[] = []
×
472

473
  for (const lieuActivite of lieuxActivite) {
×
474
    // Some lieux activite from dataspace are lacking required data, we ignore them :
475
    // e.g :     {
476
    //   "nom": "Médiathèque de Saint-Quentin-la-Poterie",
477
    //   "siret": null,
478
    //   "adresse": {
479
    //     "nom_voie": null,
480
    //     "code_insee": null,
481
    //     "repetition": null,
482
    //     "code_postal": null,
483
    //     "nom_commune": null,
484
    //     "numero_voie": null
485
    //   },
486
    //   "contact": null
487
    // },
488

489
    if (
×
490
      !lieuActivite.adresse.code_insee ||
×
491
      !lieuActivite.adresse.code_postal ||
492
      !lieuActivite.adresse.nom_commune ||
493
      !lieuActivite.adresse.nom_voie ||
494
      !lieuActivite.adresse.nom_voie.trim()
495
    ) {
496
      continue
×
497
    }
498

499
    const adresse = buildAdresseFromDataspace(lieuActivite.adresse)
×
500

501
    // Find or create structure
502
    const structure = await findOrCreateStructure({
×
503
      siret: lieuActivite.siret,
504
      nom: lieuActivite.nom,
505
      adresse,
506
      codePostal: lieuActivite.adresse.code_postal,
507
      codeInsee: lieuActivite.adresse.code_insee,
508
      commune: lieuActivite.adresse.nom_commune,
509
      nomReferent: lieuActivite.contact
×
510
        ? `${lieuActivite.contact.prenom} ${lieuActivite.contact.nom}`.trim()
511
        : null,
512
      courrielReferent:
513
        lieuActivite.contact?.courriels?.mail_gestionnaire ??
×
514
        lieuActivite.contact?.courriels?.mail_pro ??
515
        null,
516
      telephoneReferent: lieuActivite.contact?.telephone ?? null,
×
517
    })
518

519
    structureIds.push(structure.id)
×
520

521
    // Create mediateurEnActivite link if not exists
522
    const existingActivite = await prismaClient.mediateurEnActivite.findFirst({
×
523
      where: {
524
        mediateurId,
525
        structureId: structure.id,
526
        suppression: null,
527
        fin: null,
528
      },
529
      select: {
530
        id: true,
531
      },
532
    })
533

534
    if (!existingActivite) {
×
535
      await prismaClient.mediateurEnActivite.create({
×
536
        data: {
537
          id: v4(),
538
          mediateurId,
539
          structureId: structure.id,
540
          debut: new Date(),
541
        },
542
      })
543
    }
544
  }
545

546
  return { structureIds }
×
547
}
548

549
// ============================================================================
550
// Main Sync Core Function
551
// ============================================================================
552

553
/**
554
 * Core idempotent sync from Dataspace data
555
 *
556
 * This function handles:
557
 * 1. Coordinateur creation (only if is_coordinateur is true, never delete)
558
 * 2. Structures employeuses sync (only if is_conseiller_numerique is true)
559
 *
560
 * Note: Lieux d'activité are NOT synced here. They are only imported once during inscription.
561
 *
562
 * @param userId - The user ID to sync
563
 * @param dataspaceData - The Dataspace API response (null = no-op)
564
 * @param wasConseillerNumerique - Previous CN status (for transition logic)
565
 */
566
export const syncFromDataspaceCore = async ({
×
567
  userId,
568
  dataspaceData,
569
  wasConseillerNumerique = false,
×
570
}: {
571
  userId: string
572
  dataspaceData: DataspaceMediateur | null
573
  wasConseillerNumerique?: boolean
574
}): Promise<{
575
  success: boolean
576
  noOp: boolean
577
  changes: SyncChanges
578
  coordinateurId: string | null
579
}> => {
580
  const changes: SyncChanges = {
×
581
    conseillerNumeriqueCreated: false,
582
    conseillerNumeriqueRemoved: false,
583
    coordinateurCreated: false,
584
    coordinateurUpdated: false,
585
    structuresSynced: 0,
586
    structuresRemoved: 0,
587
  }
588

589
  // Null response = NO-OP
590
  if (dataspaceData === null) {
×
591
    return {
×
592
      success: true,
593
      noOp: true,
594
      changes,
595
      coordinateurId: null,
596
    }
597
  }
598

599
  const isConseillerNumeriqueInApi = dataspaceData.is_conseiller_numerique
×
600
  const isCoordinateurInApi = dataspaceData.is_coordinateur
×
601

602
  let coordinateurId: string | null = null
×
603

604
  // --- Update User base fields ---
605
  await prismaClient.user.update({
×
606
    where: { id: userId },
607
    data: {
608
      dataspaceId: dataspaceData.id,
609
      dataspaceUserIdPg: dataspaceData.pg_id,
610
      lastSyncedFromDataspace: new Date(),
611
      isConseillerNumerique: isConseillerNumeriqueInApi,
612
    },
613
  })
614

615
  // --- Coordinateur: Only create if is_coordinateur is true (never delete) ---
616
  if (isCoordinateurInApi && isConseillerNumeriqueInApi) {
×
617
    const {
618
      coordinateurId: upsertedCoordinateurId,
619
      created: coordinateurCreated,
620
    } = await upsertCoordinateur({
×
621
      userId,
622
    })
623
    coordinateurId = upsertedCoordinateurId
×
624
    if (coordinateurCreated) {
×
625
      changes.coordinateurCreated = true
×
626
    }
627
  }
628

629
  // --- Conseiller Numérique Transitions (structures employeuses) ---
630
  if (isConseillerNumeriqueInApi) {
×
631
    // Dataspace is source of truth - sync structures
632
    if (!wasConseillerNumerique) {
×
633
      changes.conseillerNumeriqueCreated = true
×
634
    }
635

636
    const { structureIds, removed } =
637
      await syncStructuresEmployeusesFromDataspace({
×
638
        userId,
639
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
640
      })
641
    changes.structuresSynced = structureIds.length
×
642
    changes.structuresRemoved = removed
×
643
  } else if (wasConseillerNumerique && !isConseillerNumeriqueInApi) {
×
644
    // Transition: Was CN, no longer is
645
    // Do ONE LAST sync to update contracts, then local becomes source of truth
646
    changes.conseillerNumeriqueRemoved = true
×
647

648
    const { structureIds, removed } =
649
      await syncStructuresEmployeusesFromDataspace({
×
650
        userId,
651
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
652
      })
653
    changes.structuresSynced = structureIds.length
×
654
    changes.structuresRemoved = removed
×
655
  }
656
  // else: Not CN and not becoming CN → local is source of truth, don't touch emplois
657

658
  return {
×
659
    success: true,
660
    noOp: false,
661
    changes,
662
    coordinateurId,
663
  }
664
}
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