• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
You are now the owner of this repo.

inclusion-numerique / coop-mediation-numerique / eb13771c-78ed-464f-a6ad-f5f351160114

12 Feb 2026 08:14AM UTC coverage: 7.176% (-3.1%) from 10.253%
eb13771c-78ed-464f-a6ad-f5f351160114

push

circleci

hugues-m
feat: change dataspace contract end date rules for sync

(cherry picked from commit ecb6908c1)

469 of 9914 branches covered (4.73%)

Branch coverage included in aggregate %.

0 of 12 new or added lines in 3 files covered. (0.0%)

1207 existing lines in 143 files now uncovered.

1330 of 15156 relevant lines covered (8.78%)

39.5 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
// ============================================================================
54
// Helper Functions
55
// ============================================================================
56

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

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

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

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

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

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

92
  const now = new Date()
×
93

94
  // Find active contract (started, not ended, not ruptured)
95
  const activeContract = contrats.find(
×
96
    (contrat) => getContractStatus({ contrat, date: now }).isActive,
×
97
  )
98

99
  if (activeContract) {
×
100
    return activeContract
×
101
  }
102

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

110
/**
111
 * Get the end date for an emploi based on contract
112
 * Only date_rupture from Dataspace is mapped to emploi fin.
113
 * date_fin does not impact emploi fin in our model.
114
 */
UNCOV
115
export const getEmploiEndDate = (contrat: DataspaceContrat): Date | null => {
×
NEW
116
  return contrat.date_rupture ? new Date(contrat.date_rupture) : null
×
117
}
118

119
/**
120
 * Prepare contract data from Dataspace for EmployeStructure sync
121
 * Returns one PreparedContract for each contract in each structure
122
 * Creates one EmployeStructure record per contract
123
 */
UNCOV
124
export const prepareContractsFromDataspace = async (
×
125
  structuresEmployeuses: DataspaceStructureEmployeuse[],
126
): Promise<PreparedContract[]> => {
UNCOV
127
  const prepared: PreparedContract[] = []
×
128

UNCOV
129
  for (const structureEmployeuse of structuresEmployeuses) {
×
UNCOV
130
    const contracts = structureEmployeuse.contrats ?? []
×
131

132
    // Skip structures without contracts
UNCOV
133
    if (contracts.length === 0) {
×
UNCOV
134
      continue
×
135
    }
136

UNCOV
137
    const adresse = buildAdresseFromDataspace(structureEmployeuse.adresse)
×
138

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

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

UNCOV
165
  return prepared
×
166
}
167

168
// ============================================================================
169
// Core Sync Operations
170
// ============================================================================
171

172
/**
173
 * Generate a unique key for an emploi based on structureId and debut date
174
 * Used to match existing emplois with contracts from Dataspace
175
 */
UNCOV
176
const getEmploiKey = (structureId: string, debut: Date): string =>
×
UNCOV
177
  `${structureId}:${dateAsIsoDay(debut)}`
×
178

179
/**
180
 * Sync ALL contracts from Dataspace data as EmployeStructure records
181
 * After sync, user has exactly one EmployeStructure for each contract in Dataspace.
182
 * - Creates EmployeStructure for each contract in Dataspace
183
 * - Updates existing EmployeStructure if fin date changed
184
 * - Soft-deletes EmployeStructure records for contracts NOT in Dataspace
185
 * - Structures without contracts are IGNORED (no employment link created)
186
 *
187
 * Matching logic: An emploi is matched to a contract by structureId + debut date
188
 *
189
 * All EmployeStructure operations are performed in a single transaction.
190
 * Only called when is_conseiller_numerique: true in Dataspace API
191
 */
UNCOV
192
export const syncStructuresEmployeusesFromDataspace = async ({
×
193
  userId,
194
  structuresEmployeuses,
195
}: {
196
  userId: string
197
  structuresEmployeuses: DataspaceStructureEmployeuse[]
198
}): Promise<{ structureIds: string[]; removed: number }> => {
199
  // Step 1: Prepare all contracts (find/create structures) outside transaction
200
  // This already filters out structures without contracts
UNCOV
201
  const preparedContracts = await prepareContractsFromDataspace(
×
202
    structuresEmployeuses,
203
  )
204

205
  // Collect unique structure IDs for the return value
UNCOV
206
  const structureIds = [
×
UNCOV
207
    ...new Set(preparedContracts.map((pc) => pc.structureId)),
×
208
  ]
209

210
  // Step 2: Perform all EmployeStructure operations in a single transaction
UNCOV
211
  const result = await prismaClient.$transaction(async (transaction) => {
×
212
    // Get all existing emplois for this user (including soft-deleted for reactivation)
UNCOV
213
    const existingEmplois = await transaction.employeStructure.findMany({
×
214
      where: { userId },
215
      select: {
216
        id: true,
217
        structureId: true,
218
        debut: true,
219
        fin: true,
220
        suppression: true,
221
      },
222
    })
223

224
    // Create a map for quick lookup by structureId + debut
UNCOV
225
    const emploisByKey = new Map(
×
UNCOV
226
      existingEmplois.map((emploi) => [
×
227
        getEmploiKey(emploi.structureId, emploi.debut),
228
        emploi,
229
      ]),
230
    )
231

232
    // Track which emploi IDs should remain active after sync
UNCOV
233
    const emploiIdsToKeep: string[] = []
×
234

235
    // Process each contract from Dataspace
UNCOV
236
    for (const { structureId, contract } of preparedContracts) {
×
UNCOV
237
      const creationDate = new Date(contract.date_debut)
×
NEW
238
      const endDate = getEmploiEndDate(contract)
×
UNCOV
239
      const key = getEmploiKey(structureId, creationDate)
×
240

UNCOV
241
      const existingEmploi = emploisByKey.get(key)
×
242

UNCOV
243
      if (existingEmploi) {
×
UNCOV
244
        emploiIdsToKeep.push(existingEmploi.id)
×
245

246
        // Check if we need to update the emploi
247
        const needsUpdate =
NEW
248
          existingEmploi.fin?.getTime() !== endDate?.getTime() ||
×
249
          existingEmploi.suppression !== null // Reactivate if it was soft-deleted
250

UNCOV
251
        if (needsUpdate) {
×
UNCOV
252
          await transaction.employeStructure.update({
×
253
            where: { id: existingEmploi.id },
254
            data: {
255
              fin: endDate,
256
              // Contracts present in Dataspace are never soft-deleted.
257
              suppression: null,
258
            },
259
          })
260
        }
261
      } else {
262
        // Create new emploi for this contract
UNCOV
263
        const newEmploi = await transaction.employeStructure.create({
×
264
          data: {
265
            userId,
266
            structureId,
267
            debut: creationDate,
268
            fin: endDate,
269
            suppression: null,
270
          },
271
          select: { id: true },
272
        })
UNCOV
273
        emploiIdsToKeep.push(newEmploi.id)
×
274
      }
275
    }
276

277
    // Soft-delete EmployeStructure records for contracts NOT in Dataspace
278
    // Set suppression date to mark them as ended
UNCOV
279
    const now = new Date()
×
UNCOV
280
    const softDeleteResult = await transaction.employeStructure.updateMany({
×
281
      where: {
282
        userId,
283
        id: {
284
          notIn: emploiIdsToKeep,
285
        },
286
        suppression: null, // Only soft-delete those not already deleted
287
      },
288
      data: {
289
        suppression: now,
290
        fin: now,
291
      },
292
    })
293

UNCOV
294
    return { removedCount: softDeleteResult.count }
×
295
  })
296

UNCOV
297
  return { structureIds, removed: result.removedCount }
×
298
}
299

300
/**
301
 * Create Coordinateur if not exists (never delete)
302
 * Only creates if is_coordinateur is true from Dataspace
303
 */
UNCOV
304
export const upsertCoordinateur = async ({
×
305
  userId,
306
}: {
307
  userId: string
308
}): Promise<{ coordinateurId: string; created: boolean }> => {
UNCOV
309
  const existingCoordinateur = await prismaClient.coordinateur.findUnique({
×
310
    where: { userId },
311
    select: { id: true },
312
  })
313

UNCOV
314
  if (existingCoordinateur) {
×
315
    return { coordinateurId: existingCoordinateur.id, created: false }
×
316
  }
317

318
  // Create new coordinateur
UNCOV
319
  const newCoordinateur = await prismaClient.coordinateur.create({
×
320
    data: {
321
      userId,
322
    },
323
    select: { id: true },
324
  })
325

UNCOV
326
  return { coordinateurId: newCoordinateur.id, created: true }
×
327
}
328

329
/**
330
 * Create Mediateur if not exists (never delete)
331
 * Only creates if explicitly called (typically during first-time lieux import)
332
 */
UNCOV
333
export const upsertMediateur = async ({
×
334
  userId,
335
}: {
336
  userId: string
337
}): Promise<{ mediateurId: string; created: boolean }> => {
338
  const existingMediateur = await prismaClient.mediateur.findUnique({
×
339
    where: { userId },
340
    select: { id: true },
341
  })
342

343
  if (existingMediateur) {
×
344
    return { mediateurId: existingMediateur.id, created: false }
×
345
  }
346

347
  // Create new mediateur
348
  const newMediateur = await prismaClient.mediateur.create({
×
349
    data: {
350
      userId,
351
    },
352
    select: { id: true },
353
  })
354

355
  return { mediateurId: newMediateur.id, created: true }
×
356
}
357

358
/**
359
 * Import lieux d'activité from Dataspace data for a mediateur (one-time import)
360
 * Creates MediateurEnActivite links for each lieu
361
 * This is NOT part of regular sync - only called during inscription
362
 */
UNCOV
363
export const importLieuxActiviteFromDataspace = async ({
×
364
  mediateurId,
365
  lieuxActivite,
366
}: {
367
  mediateurId: string
368
  lieuxActivite: DataspaceLieuActivite[]
369
}): Promise<{ structureIds: string[] }> => {
370
  const structureIds: string[] = []
×
371

372
  for (const lieuActivite of lieuxActivite) {
×
373
    // Some lieux activite from dataspace are lacking required data, we ignore them :
374
    // e.g :     {
375
    //   "nom": "Médiathèque de Saint-Quentin-la-Poterie",
376
    //   "siret": null,
377
    //   "adresse": {
378
    //     "nom_voie": null,
379
    //     "code_insee": null,
380
    //     "repetition": null,
381
    //     "code_postal": null,
382
    //     "nom_commune": null,
383
    //     "numero_voie": null
384
    //   },
385
    //   "contact": null
386
    // },
387

388
    if (
×
389
      !lieuActivite.adresse.code_insee ||
×
390
      !lieuActivite.adresse.code_postal ||
391
      !lieuActivite.adresse.nom_commune ||
392
      !lieuActivite.adresse.nom_voie ||
393
      !lieuActivite.adresse.nom_voie.trim()
394
    ) {
395
      continue
×
396
    }
397

398
    const adresse = buildAdresseFromDataspace(lieuActivite.adresse)
×
399

400
    // Find or create structure
401
    const structure = await findOrCreateStructure({
×
402
      siret: lieuActivite.siret,
403
      nom: lieuActivite.nom,
404
      adresse,
405
      codePostal: lieuActivite.adresse.code_postal,
406
      codeInsee: lieuActivite.adresse.code_insee,
407
      commune: lieuActivite.adresse.nom_commune,
408
      nomReferent: lieuActivite.contact
×
409
        ? `${lieuActivite.contact.prenom} ${lieuActivite.contact.nom}`.trim()
410
        : null,
411
      courrielReferent:
412
        lieuActivite.contact?.courriels?.mail_gestionnaire ??
×
413
        lieuActivite.contact?.courriels?.mail_pro ??
414
        null,
415
      telephoneReferent: lieuActivite.contact?.telephone ?? null,
×
416
    })
417

418
    structureIds.push(structure.id)
×
419

420
    // Create mediateurEnActivite link if not exists
421
    const existingActivite = await prismaClient.mediateurEnActivite.findFirst({
×
422
      where: {
423
        mediateurId,
424
        structureId: structure.id,
425
        suppression: null,
426
        fin: null,
427
      },
428
      select: {
429
        id: true,
430
      },
431
    })
432

433
    if (!existingActivite) {
×
434
      await prismaClient.mediateurEnActivite.create({
×
435
        data: {
436
          id: v4(),
437
          mediateurId,
438
          structureId: structure.id,
439
          debut: new Date(),
440
        },
441
      })
442
    }
443
  }
444

445
  return { structureIds }
×
446
}
447

448
// ============================================================================
449
// Main Sync Core Function
450
// ============================================================================
451

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

488
  // Null response = NO-OP
UNCOV
489
  if (dataspaceData === null) {
×
UNCOV
490
    return {
×
491
      success: true,
492
      noOp: true,
493
      changes,
494
      coordinateurId: null,
495
    }
496
  }
497

UNCOV
498
  const isConseillerNumeriqueInApi = dataspaceData.is_conseiller_numerique
×
UNCOV
499
  const isCoordinateurInApi = dataspaceData.is_coordinateur
×
500

UNCOV
501
  let coordinateurId: string | null = null
×
502

503
  // --- Update User base fields ---
UNCOV
504
  await prismaClient.user.update({
×
505
    where: { id: userId },
506
    data: {
507
      dataspaceId: dataspaceData.id,
508
      dataspaceUserIdPg: dataspaceData.pg_id,
509
      lastSyncedFromDataspace: new Date(),
510
      isConseillerNumerique: isConseillerNumeriqueInApi,
511
    },
512
  })
513

514
  // --- Coordinateur: Only create if is_coordinateur is true (never delete) ---
UNCOV
515
  if (isCoordinateurInApi && isConseillerNumeriqueInApi) {
×
516
    const {
517
      coordinateurId: upsertedCoordinateurId,
518
      created: coordinateurCreated,
UNCOV
519
    } = await upsertCoordinateur({
×
520
      userId,
521
    })
UNCOV
522
    coordinateurId = upsertedCoordinateurId
×
UNCOV
523
    if (coordinateurCreated) {
×
UNCOV
524
      changes.coordinateurCreated = true
×
525
    }
526
  }
527

528
  // --- Conseiller Numérique Transitions (structures employeuses) ---
UNCOV
529
  if (isConseillerNumeriqueInApi) {
×
530
    // Dataspace is source of truth - sync structures
UNCOV
531
    if (!wasConseillerNumerique) {
×
UNCOV
532
      changes.conseillerNumeriqueCreated = true
×
533
    }
534

535
    const { structureIds, removed } =
UNCOV
536
      await syncStructuresEmployeusesFromDataspace({
×
537
        userId,
538
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
539
      })
UNCOV
540
    changes.structuresSynced = structureIds.length
×
UNCOV
541
    changes.structuresRemoved = removed
×
UNCOV
542
  } else if (wasConseillerNumerique && !isConseillerNumeriqueInApi) {
×
543
    // Transition: Was CN, no longer is
544
    // Do ONE LAST sync to update contracts, then local becomes source of truth
UNCOV
545
    changes.conseillerNumeriqueRemoved = true
×
546

547
    const { structureIds, removed } =
UNCOV
548
      await syncStructuresEmployeusesFromDataspace({
×
549
        userId,
550
        structuresEmployeuses: dataspaceData.structures_employeuses ?? [],
×
551
      })
UNCOV
552
    changes.structuresSynced = structureIds.length
×
UNCOV
553
    changes.structuresRemoved = removed
×
554
  }
555
  // else: Not CN and not becoming CN → local is source of truth, don't touch emplois
556

UNCOV
557
  return {
×
558
    success: true,
559
    noOp: false,
560
    changes,
561
    coordinateurId,
562
  }
563
}
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