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

inclusion-numerique / coop-mediation-numerique / 4871e98e-f7f3-41a4-9570-0c4048921da6

01 Apr 2026 03:30PM UTC coverage: 6.869% (-3.8%) from 10.692%
4871e98e-f7f3-41a4-9570-0c4048921da6

Pull #468

circleci

marc-gavanier
feat(lieux-activite): extend visibility toggle to all mediateurs

- Rename cartography reference to "cartographie nationale des lieux
  d'inclusion numérique"
- Remove isConseillerNumerique condition for visibility toggle
- Simplify labels and add 24h update notice
Pull Request #468: feat(lieux-activite): extend visibility toggle to all mediateurs

470 of 10506 branches covered (4.47%)

Branch coverage included in aggregate %.

1355 of 16063 relevant lines covered (8.44%)

37.29 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 { updateBrevoContact } from '@app/web/external-apis/brevo/updateBrevoContact'
2
import type {
3
  DataspaceContrat,
4
  DataspaceLieuActivite,
5
  DataspaceMediateur,
6
  DataspaceStructureEmployeuse,
7
} from '@app/web/external-apis/dataspace/dataspaceApiClient'
8
import { getContractStatus } from '@app/web/features/dataspace/getContractStatus'
9
import { findOrCreateStructure } from '@app/web/features/structures/findOrCreateStructure'
10
import { prismaClient } from '@app/web/prismaClient'
11
import { dateAsIsoDay } from '@app/web/utils/dateAsIsoDay'
12
import { v4 } from 'uuid'
13

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

26
// ============================================================================
27
// Types
28
// ============================================================================
29

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

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

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

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

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

68
const isDefinedStructureEmployeuse = (
×
69
  structureEmployeuse: DataspaceStructureEmployeuse | null,
70
): structureEmployeuse is DataspaceStructureEmployeuse =>
71
  structureEmployeuse !== null
×
72

73
// ============================================================================
74
// Helper Functions
75
// ============================================================================
76

77
/**
78
 * Build full address from Dataspace address format
79
 */
80
export const buildAdresseFromDataspace = (adresse: {
×
81
  numero_voie: number | null
82
  nom_voie: string | null
83
  repetition: string | null
84
}): string => {
85
  const parts: string[] = []
×
86

87
  if (adresse.numero_voie) {
×
88
    parts.push(adresse.numero_voie.toString())
×
89
  }
90

91
  if (adresse.repetition && adresse.repetition !== 'null') {
×
92
    parts.push(adresse.repetition)
×
93
  }
94

95
  if (adresse.nom_voie && adresse.nom_voie !== 'null') {
×
96
    parts.push(adresse.nom_voie)
×
97
  }
98

99
  return parts.join(' ').trim()
×
100
}
101

102
/**
103
 * Get the active or most recent contract from a list of contracts
104
 */
105
export const getActiveOrMostRecentContract = (
×
106
  contrats: DataspaceContrat[],
107
): DataspaceContrat | null => {
108
  if (contrats.length === 0) {
×
109
    return null
×
110
  }
111

112
  const now = new Date()
×
113

114
  // Find active contract (started, not ended, not ruptured)
115
  const activeContract = contrats.find(
×
116
    (contrat) => getContractStatus({ contrat, date: now }).isActive,
×
117
  )
118

119
  if (activeContract) {
×
120
    return activeContract
×
121
  }
122

123
  // No active contract - return the most recent one by date_debut
124
  const contractsWithDebut = contrats.filter(
×
125
    (
126
      contrat,
127
    ): contrat is DataspaceContrat & {
128
      date_debut: string
129
    } => contrat.date_debut !== null,
×
130
  )
131
  return contractsWithDebut.toSorted(
×
132
    (a, b) =>
133
      new Date(b.date_debut).getTime() - new Date(a.date_debut).getTime(),
×
134
  )[0]
135
}
136

137
/**
138
 * Get the end date for an emploi based on contract
139
 * Only date_rupture from Dataspace is mapped to emploi fin.
140
 * date_fin does not impact emploi fin in our model.
141
 */
142
export const getEmploiEndDate = (contrat: DataspaceContrat): Date | null => {
×
143
  return contrat.date_rupture ? new Date(contrat.date_rupture) : null
×
144
}
145

146
/**
147
 * Prepare contract data from Dataspace for EmployeStructure sync
148
 * Returns one PreparedContract for each contract in each structure
149
 * Creates one EmployeStructure record per contract
150
 */
151
export const prepareContractsFromDataspace = async (
×
152
  structuresEmployeuses: DataspaceStructureEmployeuse[],
153
): Promise<PreparedContract[]> => {
154
  const prepared: PreparedContract[] = []
×
155

156
  for (const structureEmployeuse of structuresEmployeuses) {
×
157
    const contractsWithDebut = (structureEmployeuse.contrats ?? []).filter(
×
158
      (
159
        contract,
160
      ): contract is DataspaceContrat & {
161
        date_debut: string
162
      } => contract.date_debut !== null,
×
163
    )
164

165
    // Skip structures without contracts
166
    if (contractsWithDebut.length === 0) {
×
167
      continue
×
168
    }
169

170
    // Find or create structure (outside transaction - structures are stable)
171
    const structure = await getOrCreateStructureFromDataspace({
×
172
      structureEmployeuse,
173
    })
174

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

184
  return prepared
×
185
}
186

187
const getOrCreateStructureIdForTemporaryContract = async ({
×
188
  structureEmployeuse,
189
}: {
190
  structureEmployeuse: DataspaceStructureEmployeuse
191
}): Promise<string> => {
192
  const structure = await getOrCreateStructureFromDataspace({
×
193
    structureEmployeuse,
194
  })
195

196
  return structure.id
×
197
}
198

199
const getOrCreateStructureFromDataspace = async ({
×
200
  structureEmployeuse,
201
}: {
202
  structureEmployeuse: DataspaceStructureEmployeuse
203
}) => {
204
  const adresse = buildAdresseFromDataspace(structureEmployeuse.adresse)
×
205
  return findOrCreateStructure({
×
206
    coopId: structureEmployeuse.ids?.coop,
207
    siret: structureEmployeuse.siret,
208
    nom: structureEmployeuse.nom,
209
    adresse,
210
    codePostal: structureEmployeuse.adresse.code_postal,
211
    codeInsee: structureEmployeuse.adresse.code_insee,
212
    commune: structureEmployeuse.adresse.nom_commune,
213
    nomReferent: structureEmployeuse.contact
×
214
      ? `${structureEmployeuse.contact.prenom} ${structureEmployeuse.contact.nom}`.trim()
215
      : null,
216
    courrielReferent:
217
      structureEmployeuse.contact?.courriels?.mail_gestionnaire ?? null,
×
218
    telephoneReferent: structureEmployeuse.contact?.telephone ?? null,
×
219
  })
220
}
221

222
// ============================================================================
223
// Core Sync Operations
224
// ============================================================================
225

226
/**
227
 * Generate a unique key for an emploi based on structureId and debut date
228
 * Used to match existing emplois with contracts from Dataspace
229
 */
230
const getEmploiKey = (structureId: string, debut: Date): string =>
×
231
  `${structureId}:${dateAsIsoDay(debut)}`
×
232

233
/**
234
 * Sync ALL contracts from Dataspace data as EmployeStructure records
235
 * After sync, user has exactly one EmployeStructure for each contract in Dataspace.
236
 * - Creates EmployeStructure for each contract in Dataspace
237
 * - Updates existing EmployeStructure if fin date changed
238
 * - Soft-deletes EmployeStructure records for contracts NOT in Dataspace
239
 * - If structures exist but no valid contracts remain after filtering,
240
 *   one temporary emploi is kept/created with debut=null and fin=null
241
 *
242
 * Matching logic: An emploi is matched to a contract by structureId + debut date
243
 *
244
 * All EmployeStructure operations are performed in a single transaction.
245
 * Only called when is_conseiller_numerique: true in Dataspace API
246
 */
247
export const syncStructuresEmployeusesFromDataspace = async ({
×
248
  userId,
249
  structuresEmployeuses,
250
}: {
251
  userId: string
252
  structuresEmployeuses: (DataspaceStructureEmployeuse | null)[]
253
}): Promise<{ structureIds: string[]; removed: number }> => {
254
  const nonNullStructures = structuresEmployeuses.filter(
×
255
    isDefinedStructureEmployeuse,
256
  )
257

258
  // Step 1: Prepare all contracts (find/create structures) outside transaction
259
  // This already filters out structures without contracts
260
  const preparedContracts =
261
    await prepareContractsFromDataspace(nonNullStructures)
×
262

263
  const syncDate = new Date()
×
264
  const hasRunningRealContract = nonNullStructures.some((structureEmployeuse) =>
×
265
    (structureEmployeuse.contrats ?? []).some(
×
266
      (contract) =>
267
        contract.date_debut !== null &&
×
268
        getContractStatus({
269
          contrat: contract,
270
          date: syncDate,
271
        }).isActive,
272
    ),
273
  )
274

275
  const firstStructureWithNullDebutContract = nonNullStructures.find(
×
276
    (structureEmployeuse) =>
277
      (structureEmployeuse.contrats ?? []).some(
×
278
        (contract) => contract.date_debut === null,
×
279
      ),
280
  )
281

282
  const firstStructureWithEmptyContracts = nonNullStructures.find(
×
283
    (structureEmployeuse) => (structureEmployeuse.contrats ?? []).length === 0,
×
284
  )
285

286
  const temporaryContractTargetStructure =
287
    firstStructureWithNullDebutContract ?? firstStructureWithEmptyContracts
×
288

289
  const temporaryContractStructureId =
290
    preparedContracts.length === 0 &&
×
291
    !hasRunningRealContract &&
292
    temporaryContractTargetStructure
293
      ? await getOrCreateStructureIdForTemporaryContract({
294
          structureEmployeuse: temporaryContractTargetStructure,
295
        })
296
      : null
297

298
  // Collect unique structure IDs for the return value
299
  const structureIds = [
×
300
    ...new Set(
301
      [
302
        ...preparedContracts.map((pc) => pc.structureId),
×
303
        temporaryContractStructureId,
304
      ].filter((structureId) => typeof structureId === 'string'),
×
305
    ),
306
  ]
307

308
  // Step 2: Perform all EmployeStructure operations in a single transaction
309
  const result = await prismaClient.$transaction(async (transaction) => {
×
310
    // Get all existing emplois for this user (including soft-deleted for reactivation)
311
    const existingEmplois: ExistingEmploiForSync[] =
312
      await transaction.employeStructure.findMany({
×
313
        where: { userId },
314
        select: {
315
          id: true,
316
          structureId: true,
317
          debut: true,
318
          fin: true,
319
          suppression: true,
320
          creation: true,
321
        },
322
      })
323

324
    // Create a map for quick lookup by structureId + debut for real contracts.
325
    // Temporary contracts use debut=null and are handled separately.
326
    const emploisByKey = new Map<
×
327
      string,
328
      ExistingEmploiForSync & { debut: Date }
329
    >()
330
    const realEmplois = existingEmplois.filter(hasDebutDate)
×
331
    // If multiple emplois collide on the same key, keep the most recently created one.
332
    const realEmploisByCreationDesc = realEmplois.toSorted(
×
333
      (a, b) => b.creation.getTime() - a.creation.getTime(),
×
334
    )
335
    for (const emploi of realEmploisByCreationDesc) {
×
336
      const key = getEmploiKey(emploi.structureId, emploi.debut)
×
337
      if (!emploisByKey.has(key)) {
×
338
        emploisByKey.set(key, emploi)
×
339
      }
340
    }
341

342
    const temporaryEmplois = existingEmplois
×
343
      .filter((emploi) => emploi.debut === null)
×
344
      .toSorted((a, b) => b.creation.getTime() - a.creation.getTime())
×
345

346
    // Track which emploi IDs should remain active after sync
347
    const emploiIdsToKeep: string[] = []
×
348

349
    // Process each contract from Dataspace
350
    for (const { structureId, contract } of preparedContracts) {
×
351
      const creationDate = new Date(contract.date_debut)
×
352
      const endDate = getEmploiEndDate(contract)
×
353
      const key = getEmploiKey(structureId, creationDate)
×
354

355
      const existingEmploi = emploisByKey.get(key)
×
356

357
      if (existingEmploi) {
×
358
        emploiIdsToKeep.push(existingEmploi.id)
×
359

360
        // Check if we need to update the emploi
361
        const needsUpdate =
362
          existingEmploi.fin?.getTime() !== endDate?.getTime() ||
×
363
          existingEmploi.suppression !== null // Reactivate if it was soft-deleted
364

365
        if (needsUpdate) {
×
366
          await transaction.employeStructure.update({
×
367
            where: { id: existingEmploi.id },
368
            data: {
369
              fin: endDate,
370
              // Contracts present in Dataspace are never soft-deleted.
371
              suppression: null,
372
            },
373
          })
374
        }
375
      } else {
376
        // Create new emploi for this contract
377
        const newEmploi = await transaction.employeStructure.create({
×
378
          data: {
379
            userId,
380
            structureId,
381
            debut: creationDate,
382
            fin: endDate,
383
            suppression: null,
384
          },
385
          select: { id: true },
386
        })
387
        emploiIdsToKeep.push(newEmploi.id)
×
388
      }
389
    }
390

391
    // If Dataspace has structures but no valid contracts, keep exactly one
392
    // temporary contract (debut=null, fin=null) on the first structure.
393
    if (preparedContracts.length === 0 && temporaryContractStructureId) {
×
394
      const temporaryEmploiToKeep = temporaryEmplois[0]
×
395

396
      if (temporaryEmploiToKeep) {
×
397
        emploiIdsToKeep.push(temporaryEmploiToKeep.id)
×
398

399
        const needsUpdate =
400
          temporaryEmploiToKeep.structureId !== temporaryContractStructureId ||
×
401
          temporaryEmploiToKeep.fin !== null ||
402
          temporaryEmploiToKeep.suppression !== null
403

404
        if (needsUpdate) {
×
405
          await transaction.employeStructure.update({
×
406
            where: { id: temporaryEmploiToKeep.id },
407
            data: {
408
              structureId: temporaryContractStructureId,
409
              fin: null,
410
              suppression: null,
411
            },
412
          })
413
        }
414
      } else {
415
        const newTemporaryEmploi = await transaction.employeStructure.create({
×
416
          data: {
417
            userId,
418
            structureId: temporaryContractStructureId,
419
            debut: null,
420
            fin: null,
421
            suppression: null,
422
          },
423
          select: { id: true },
424
        })
425
        emploiIdsToKeep.push(newTemporaryEmploi.id)
×
426
      }
427
    }
428

429
    // Soft-delete EmployeStructure records for contracts NOT in Dataspace
430
    // Set suppression date to mark them as ended
431
    const now = new Date()
×
432
    const softDeleteResult = await transaction.employeStructure.updateMany({
×
433
      where: {
434
        userId,
435
        id: {
436
          notIn: emploiIdsToKeep,
437
        },
438
        suppression: null, // Only soft-delete those not already deleted
439
      },
440
      data: {
441
        suppression: now,
442
        fin: now,
443
      },
444
    })
445

446
    return { removedCount: softDeleteResult.count }
×
447
  })
448

449
  return { structureIds, removed: result.removedCount }
×
450
}
451

452
/**
453
 * Create Coordinateur if not exists (never delete)
454
 * Only creates if is_coordinateur is true from Dataspace
455
 */
456
export const upsertCoordinateur = async ({
×
457
  userId,
458
}: {
459
  userId: string
460
}): Promise<{ coordinateurId: string; created: boolean }> => {
461
  const existingCoordinateur = await prismaClient.coordinateur.findUnique({
×
462
    where: { userId },
463
    select: { id: true },
464
  })
465

466
  if (existingCoordinateur) {
×
467
    return { coordinateurId: existingCoordinateur.id, created: false }
×
468
  }
469

470
  // Create new coordinateur
471
  const newCoordinateur = await prismaClient.coordinateur.create({
×
472
    data: {
473
      userId,
474
    },
475
    select: { id: true },
476
  })
477

478
  return { coordinateurId: newCoordinateur.id, created: true }
×
479
}
480

481
/**
482
 * Create Mediateur if not exists (never delete)
483
 * Only creates if explicitly called (typically during first-time lieux import)
484
 */
485
export const upsertMediateur = async ({
×
486
  userId,
487
}: {
488
  userId: string
489
}): Promise<{ mediateurId: string; created: boolean }> => {
490
  const existingMediateur = await prismaClient.mediateur.findUnique({
×
491
    where: { userId },
492
    select: { id: true },
493
  })
494

495
  if (existingMediateur) {
×
496
    return { mediateurId: existingMediateur.id, created: false }
×
497
  }
498

499
  // Create new mediateur
500
  const newMediateur = await prismaClient.mediateur.create({
×
501
    data: {
502
      userId,
503
    },
504
    select: { id: true },
505
  })
506

507
  return { mediateurId: newMediateur.id, created: true }
×
508
}
509

510
/**
511
 * Import lieux d'activité from Dataspace data for a mediateur (one-time import)
512
 * Creates MediateurEnActivite links for each lieu
513
 * This is NOT part of regular sync - only called during inscription
514
 */
515
export const importLieuxActiviteFromDataspace = async ({
×
516
  mediateurId,
517
  lieuxActivite,
518
}: {
519
  mediateurId: string
520
  lieuxActivite: DataspaceLieuActivite[]
521
}): Promise<{ structureIds: string[] }> => {
522
  const structureIds: string[] = []
×
523

524
  for (const lieuActivite of lieuxActivite) {
×
525
    // Some lieux activite from dataspace are lacking required data, we ignore them :
526
    // e.g :     {
527
    //   "nom": "Médiathèque de Saint-Quentin-la-Poterie",
528
    //   "siret": null,
529
    //   "adresse": {
530
    //     "nom_voie": null,
531
    //     "code_insee": null,
532
    //     "repetition": null,
533
    //     "code_postal": null,
534
    //     "nom_commune": null,
535
    //     "numero_voie": null
536
    //   },
537
    //   "contact": null
538
    // },
539

540
    if (
×
541
      !lieuActivite.adresse.code_insee ||
×
542
      !lieuActivite.adresse.code_postal ||
543
      !lieuActivite.adresse.nom_commune ||
544
      !lieuActivite.adresse.nom_voie ||
545
      !lieuActivite.adresse.nom_voie.trim()
546
    ) {
547
      continue
×
548
    }
549

550
    const adresse = buildAdresseFromDataspace(lieuActivite.adresse)
×
551

552
    // Find or create structure
553
    const structure = await findOrCreateStructure({
×
554
      siret: lieuActivite.siret,
555
      nom: lieuActivite.nom,
556
      adresse,
557
      codePostal: lieuActivite.adresse.code_postal,
558
      codeInsee: lieuActivite.adresse.code_insee,
559
      commune: lieuActivite.adresse.nom_commune,
560
      nomReferent: lieuActivite.contact
×
561
        ? `${lieuActivite.contact.prenom} ${lieuActivite.contact.nom}`.trim()
562
        : null,
563
      courrielReferent:
564
        lieuActivite.contact?.courriels?.mail_gestionnaire ??
×
565
        lieuActivite.contact?.courriels?.mail_pro ??
566
        null,
567
      telephoneReferent: lieuActivite.contact?.telephone ?? null,
×
568
    })
569

570
    structureIds.push(structure.id)
×
571

572
    // Create mediateurEnActivite link if not exists
573
    const existingActivite = await prismaClient.mediateurEnActivite.findFirst({
×
574
      where: {
575
        mediateurId,
576
        structureId: structure.id,
577
        suppression: null,
578
        fin: null,
579
      },
580
      select: {
581
        id: true,
582
      },
583
    })
584

585
    if (!existingActivite) {
×
586
      await prismaClient.mediateurEnActivite.create({
×
587
        data: {
588
          id: v4(),
589
          mediateurId,
590
          structureId: structure.id,
591
          debut: new Date(),
592
        },
593
      })
594
    }
595
  }
596

597
  return { structureIds }
×
598
}
599

600
// ============================================================================
601
// Main Sync Core Function
602
// ============================================================================
603

604
/**
605
 * Core idempotent sync from Dataspace data
606
 *
607
 * This function handles:
608
 * 1. Coordinateur creation (only if both is_coordinateur and
609
 *    is_conseiller_numerique are true, never delete)
610
 * 2. Structures employeuses sync (only if is_conseiller_numerique is true)
611
 *
612
 * Note: Lieux d'activité are NOT synced here. They are only imported once during inscription.
613
 *
614
 * @param userId - The user ID to sync
615
 * @param dataspaceData - The Dataspace API response (null = no-op)
616
 * @param wasConseillerNumerique - Previous CN status (for transition logic)
617
 */
618
export const syncFromDataspaceCore = async ({
×
619
  userId,
620
  dataspaceData,
621
  wasConseillerNumerique = false,
×
622
}: {
623
  userId: string
624
  dataspaceData: DataspaceMediateur | null
625
  wasConseillerNumerique?: boolean
626
}): Promise<{
627
  success: boolean
628
  noOp: boolean
629
  changes: SyncChanges
630
  coordinateurId: string | null
631
}> => {
632
  const changes: SyncChanges = {
×
633
    conseillerNumeriqueCreated: false,
634
    conseillerNumeriqueRemoved: false,
635
    coordinateurCreated: false,
636
    coordinateurUpdated: false,
637
    structuresSynced: 0,
638
    structuresRemoved: 0,
639
  }
640

641
  // Null response = NO-OP
642
  if (dataspaceData === null) {
×
643
    return {
×
644
      success: true,
645
      noOp: true,
646
      changes,
647
      coordinateurId: null,
648
    }
649
  }
650

651
  const isConseillerNumeriqueInApi = dataspaceData.is_conseiller_numerique
×
652
  const isCoordinateurInApi = dataspaceData.is_coordinateur
×
653

654
  let coordinateurId: string | null = null
×
655

656
  // --- Update User base fields ---
657
  await prismaClient.user.update({
×
658
    where: { id: userId },
659
    data: {
660
      dataspaceId: dataspaceData.id,
661
      dataspaceUserIdPg: dataspaceData.pg_id,
662
      lastSyncedFromDataspace: new Date(),
663
      isConseillerNumerique: isConseillerNumeriqueInApi,
664
    },
665
  })
666

667
  // --- Coordinateur: Only create if coordo is in dispositif (never delete) ---
668
  if (isCoordinateurInApi && isConseillerNumeriqueInApi) {
×
669
    const {
670
      coordinateurId: upsertedCoordinateurId,
671
      created: coordinateurCreated,
672
    } = await upsertCoordinateur({
×
673
      userId,
674
    })
675
    coordinateurId = upsertedCoordinateurId
×
676
    if (coordinateurCreated) {
×
677
      changes.coordinateurCreated = true
×
678
    }
679
  }
680

681
  // --- Conseiller Numérique Transitions (structures employeuses) ---
682
  if (isConseillerNumeriqueInApi) {
×
683
    // Dataspace is source of truth - sync structures
684
    if (!wasConseillerNumerique) {
×
685
      changes.conseillerNumeriqueCreated = true
×
686
    }
687

688
    const { structureIds, removed } =
689
      await syncStructuresEmployeusesFromDataspace({
×
690
        userId,
691
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
692
      })
693
    changes.structuresSynced = structureIds.length
×
694
    changes.structuresRemoved = removed
×
695
  } else if (wasConseillerNumerique && !isConseillerNumeriqueInApi) {
×
696
    changes.conseillerNumeriqueRemoved = true
×
697

698
    const { structureIds, removed } =
699
      await syncStructuresEmployeusesFromDataspace({
×
700
        userId,
701
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
702
      })
703
    changes.structuresSynced = structureIds.length
×
704
    changes.structuresRemoved = removed
×
705
  }
706

707
  if (
×
708
    changes.conseillerNumeriqueCreated ||
×
709
    changes.conseillerNumeriqueRemoved ||
710
    changes.coordinateurCreated
711
  ) {
712
    await updateBrevoContact(userId)
×
713
  }
714

715
  return {
×
716
    success: true,
717
    noOp: false,
718
    changes,
719
    coordinateurId,
720
  }
721
}
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