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

inclusion-numerique / coop-mediation-numerique / 82e0ecb8-5c73-49c1-a124-01e965ce67a7

06 Feb 2026 09:29AM UTC coverage: 7.371% (-3.1%) from 10.44%
82e0ecb8-5c73-49c1-a124-01e965ce67a7

push

circleci

hugues-m
feat: sync v1 users with dataspace

(cherry picked from commit f1ffd85bb)

469 of 9668 branches covered (4.85%)

Branch coverage included in aggregate %.

1330 of 14738 relevant lines covered (9.02%)

40.62 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 { dateAsIsoDay } from '@app/web/utils/dateAsIsoDay'
10
import { v4 } from 'uuid'
11

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

23
// ============================================================================
24
// Types
25
// ============================================================================
26

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

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

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

52
// ============================================================================
53
// Helper Functions
54
// ============================================================================
55

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

66
  if (adresse.numero_voie) {
×
67
    parts.push(adresse.numero_voie.toString())
×
68
  }
69

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

74
  if (adresse.nom_voie && adresse.nom_voie !== 'null') {
×
75
    parts.push(adresse.nom_voie)
×
76
  }
77

78
  return parts.join(' ').trim()
×
79
}
80

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

91
  const now = new Date()
×
92

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

101
    return !hasNotStarted && !hasEnded && !isRuptured
×
102
  })
103

104
  if (activeContract) {
×
105
    return activeContract
×
106
  }
107

108
  // No active contract - return the most recent one by date_debut
109
  return contrats.toSorted(
×
110
    (a, b) =>
111
      new Date(b.date_debut).getTime() - new Date(a.date_debut).getTime(),
×
112
  )[0]
113
}
114

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

125
  // If date_fin is null, contract has no end date (CDI without termination)
126
  if (!contrat.date_fin) {
×
127
    return null
×
128
  }
129

130
  // If contract has ended, use end date
131
  const dateFin = new Date(contrat.date_fin)
×
132
  if (dateFin < new Date()) {
×
133
    return dateFin
×
134
  }
135

136
  // Contract is still active
137
  return null
×
138
}
139

140
/**
141
 * Prepare contract data from Dataspace for EmployeStructure sync
142
 * Returns one PreparedContract for each contract in each structure
143
 * Creates one EmployeStructure record per contract
144
 */
145
export const prepareContractsFromDataspace = async (
×
146
  structuresEmployeuses: DataspaceStructureEmployeuse[],
147
): Promise<PreparedContract[]> => {
148
  const prepared: PreparedContract[] = []
×
149

150
  for (const structureEmployeuse of structuresEmployeuses) {
×
151
    const contracts = structureEmployeuse.contrats ?? []
×
152

153
    // Skip structures without contracts
154
    if (contracts.length === 0) {
×
155
      continue
×
156
    }
157

158
    const adresse = buildAdresseFromDataspace(structureEmployeuse.adresse)
×
159

160
    // Find or create structure (outside transaction - structures are stable)
161
    const structure = await findOrCreateStructure({
×
162
      coopId: structureEmployeuse.ids?.coop,
163
      siret: structureEmployeuse.siret,
164
      nom: structureEmployeuse.nom,
165
      adresse,
166
      codePostal: structureEmployeuse.adresse.code_postal,
167
      codeInsee: structureEmployeuse.adresse.code_insee,
168
      commune: structureEmployeuse.adresse.nom_commune,
169
      nomReferent: structureEmployeuse.contact
×
170
        ? `${structureEmployeuse.contact.prenom} ${structureEmployeuse.contact.nom}`.trim()
171
        : null,
172
      courrielReferent:
173
        structureEmployeuse.contact?.courriels?.mail_gestionnaire ?? null,
×
174
      telephoneReferent: structureEmployeuse.contact?.telephone ?? null,
×
175
    })
176

177
    // Create one PreparedContract for each contract
178
    for (const contract of contracts) {
×
179
      prepared.push({
×
180
        structureId: structure.id,
181
        contract,
182
      })
183
    }
184
  }
185

186
  return prepared
×
187
}
188

189
// ============================================================================
190
// Core Sync Operations
191
// ============================================================================
192

193
/**
194
 * Generate a unique key for an emploi based on structureId and debut date
195
 * Used to match existing emplois with contracts from Dataspace
196
 */
197
const getEmploiKey = (structureId: string, debut: Date): string =>
×
198
  `${structureId}:${dateAsIsoDay(debut)}`
×
199

200
/**
201
 * Sync ALL contracts from Dataspace data as EmployeStructure records
202
 * After sync, user has exactly one EmployeStructure for each contract in Dataspace.
203
 * - Creates EmployeStructure for each contract in Dataspace
204
 * - Updates existing EmployeStructure if fin date changed
205
 * - Removes EmployeStructure records for contracts NOT in Dataspace
206
 * - Structures without contracts are IGNORED (no employment link created)
207
 *
208
 * Matching logic: An emploi is matched to a contract by structureId + debut date
209
 *
210
 * All EmployeStructure operations are performed in a single transaction.
211
 * Only called when is_conseiller_numerique: true in Dataspace API
212
 */
213
export const syncStructuresEmployeusesFromDataspace = async ({
×
214
  userId,
215
  structuresEmployeuses,
216
}: {
217
  userId: string
218
  structuresEmployeuses: DataspaceStructureEmployeuse[]
219
}): Promise<{ structureIds: string[]; removed: number }> => {
220
  // Step 1: Prepare all contracts (find/create structures) outside transaction
221
  // This already filters out structures without contracts
222
  const preparedContracts = await prepareContractsFromDataspace(
×
223
    structuresEmployeuses,
224
  )
225

226
  // Collect unique structure IDs for the return value
227
  const structureIds = [
×
228
    ...new Set(preparedContracts.map((pc) => pc.structureId)),
×
229
  ]
230

231
  // Step 2: Perform all EmployeStructure operations in a single transaction
232
  const result = await prismaClient.$transaction(async (transaction) => {
×
233
    // Get all existing emplois for this user (including soft-deleted for reactivation)
234
    const existingEmplois = await transaction.employeStructure.findMany({
×
235
      where: { userId },
236
      select: {
237
        id: true,
238
        structureId: true,
239
        debut: true,
240
        fin: true,
241
        suppression: true,
242
      },
243
    })
244

245
    // Create a map for quick lookup by structureId + debut
246
    const emploisByKey = new Map(
×
247
      existingEmplois.map((emploi) => [
×
248
        getEmploiKey(emploi.structureId, emploi.debut),
249
        emploi,
250
      ]),
251
    )
252

253
    // Track which emploi IDs should remain active after sync
254
    const emploiIdsToKeep: string[] = []
×
255

256
    // Process each contract from Dataspace
257
    for (const { structureId, contract } of preparedContracts) {
×
258
      const creationDate = new Date(contract.date_debut)
×
259
      const suppressionDate = getEmploiEndDate(contract)
×
260
      const key = getEmploiKey(structureId, creationDate)
×
261

262
      const existingEmploi = emploisByKey.get(key)
×
263

264
      if (existingEmploi) {
×
265
        emploiIdsToKeep.push(existingEmploi.id)
×
266

267
        // Check if we need to update the emploi
268
        const needsUpdate =
269
          existingEmploi.fin?.getTime() !== suppressionDate?.getTime() ||
×
270
          existingEmploi.suppression !== null // Reactivate if it was soft-deleted
271

272
        if (needsUpdate) {
×
273
          await transaction.employeStructure.update({
×
274
            where: { id: existingEmploi.id },
275
            data: {
276
              fin: suppressionDate,
277
              // Only set suppression if contract has ended, otherwise clear it (reactivate)
278
              suppression: suppressionDate,
279
            },
280
          })
281
        }
282
      } else {
283
        // Create new emploi for this contract
284
        const newEmploi = await transaction.employeStructure.create({
×
285
          data: {
286
            userId,
287
            structureId,
288
            debut: creationDate,
289
            fin: suppressionDate,
290
            suppression: suppressionDate,
291
          },
292
          select: { id: true },
293
        })
294
        emploiIdsToKeep.push(newEmploi.id)
×
295
      }
296
    }
297

298
    // Soft-delete EmployeStructure records for contracts NOT in Dataspace
299
    // Set suppression date to mark them as ended
300
    const now = new Date()
×
301
    const softDeleteResult = await transaction.employeStructure.updateMany({
×
302
      where: {
303
        userId,
304
        id: {
305
          notIn: emploiIdsToKeep,
306
        },
307
        suppression: null, // Only soft-delete those not already deleted
308
      },
309
      data: {
310
        suppression: now,
311
        fin: now,
312
      },
313
    })
314

315
    return { removedCount: softDeleteResult.count }
×
316
  })
317

318
  return { structureIds, removed: result.removedCount }
×
319
}
320

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

335
  if (existingCoordinateur) {
×
336
    return { coordinateurId: existingCoordinateur.id, created: false }
×
337
  }
338

339
  // Create new coordinateur
340
  const newCoordinateur = await prismaClient.coordinateur.create({
×
341
    data: {
342
      userId,
343
    },
344
    select: { id: true },
345
  })
346

347
  return { coordinateurId: newCoordinateur.id, created: true }
×
348
}
349

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

364
  if (existingMediateur) {
×
365
    return { mediateurId: existingMediateur.id, created: false }
×
366
  }
367

368
  // Create new mediateur
369
  const newMediateur = await prismaClient.mediateur.create({
×
370
    data: {
371
      userId,
372
    },
373
    select: { id: true },
374
  })
375

376
  return { mediateurId: newMediateur.id, created: true }
×
377
}
378

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

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

409
    if (
×
410
      !lieuActivite.adresse.code_insee ||
×
411
      !lieuActivite.adresse.code_postal ||
412
      !lieuActivite.adresse.nom_commune ||
413
      !lieuActivite.adresse.nom_voie ||
414
      !lieuActivite.adresse.nom_voie.trim()
415
    ) {
416
      continue
×
417
    }
418

419
    const adresse = buildAdresseFromDataspace(lieuActivite.adresse)
×
420

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

439
    structureIds.push(structure.id)
×
440

441
    // Create mediateurEnActivite link if not exists
442
    const existingActivite = await prismaClient.mediateurEnActivite.findFirst({
×
443
      where: {
444
        mediateurId,
445
        structureId: structure.id,
446
        suppression: null,
447
        fin: null,
448
      },
449
      select: {
450
        id: true,
451
      },
452
    })
453

454
    if (!existingActivite) {
×
455
      await prismaClient.mediateurEnActivite.create({
×
456
        data: {
457
          id: v4(),
458
          mediateurId,
459
          structureId: structure.id,
460
          debut: new Date(),
461
        },
462
      })
463
    }
464
  }
465

466
  return { structureIds }
×
467
}
468

469
// ============================================================================
470
// Main Sync Core Function
471
// ============================================================================
472

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

509
  // Null response = NO-OP
510
  if (dataspaceData === null) {
×
511
    return {
×
512
      success: true,
513
      noOp: true,
514
      changes,
515
      coordinateurId: null,
516
    }
517
  }
518

519
  const isConseillerNumeriqueInApi = dataspaceData.is_conseiller_numerique
×
520
  const isCoordinateurInApi = dataspaceData.is_coordinateur
×
521

522
  let coordinateurId: string | null = null
×
523

524
  // --- Update User base fields ---
525
  await prismaClient.user.update({
×
526
    where: { id: userId },
527
    data: {
528
      dataspaceId: dataspaceData.id,
529
      dataspaceUserIdPg: dataspaceData.pg_id,
530
      lastSyncedFromDataspace: new Date(),
531
      isConseillerNumerique: isConseillerNumeriqueInApi,
532
    },
533
  })
534

535
  // --- Coordinateur: Only create if is_coordinateur is true (never delete) ---
536
  if (isCoordinateurInApi && isConseillerNumeriqueInApi) {
×
537
    const {
538
      coordinateurId: upsertedCoordinateurId,
539
      created: coordinateurCreated,
540
    } = await upsertCoordinateur({
×
541
      userId,
542
    })
543
    coordinateurId = upsertedCoordinateurId
×
544
    if (coordinateurCreated) {
×
545
      changes.coordinateurCreated = true
×
546
    }
547
  }
548

549
  // --- Conseiller Numérique Transitions (structures employeuses) ---
550
  if (isConseillerNumeriqueInApi) {
×
551
    // Dataspace is source of truth - sync structures
552
    if (!wasConseillerNumerique) {
×
553
      changes.conseillerNumeriqueCreated = true
×
554
    }
555

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

568
    const { structureIds, removed } =
569
      await syncStructuresEmployeusesFromDataspace({
×
570
        userId,
571
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
572
      })
573
    changes.structuresSynced = structureIds.length
×
574
    changes.structuresRemoved = removed
×
575
  }
576
  // else: Not CN and not becoming CN → local is source of truth, don't touch emplois
577

578
  return {
×
579
    success: true,
580
    noOp: false,
581
    changes,
582
    coordinateurId,
583
  }
584
}
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