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

inclusion-numerique / coop-mediation-numerique / 8bf8349a-c6fc-4a81-9337-da195ab20b5a

22 May 2026 11:30AM UTC coverage: 6.949% (-0.006%) from 6.955%
8bf8349a-c6fc-4a81-9337-da195ab20b5a

push

circleci

web-flow
MEP 2026-05-22

Dev

469 of 10884 branches covered (4.31%)

Branch coverage included in aggregate %.

0 of 19 new or added lines in 2 files covered. (0.0%)

1 existing line in 1 file now uncovered.

1466 of 16961 relevant lines covered (8.64%)

35.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/apps/web/src/features/structures/findOrCreateStructure.ts
1
import { searchAdresse } from '@app/web/external-apis/apiAdresse'
2
import { banFeatureToAdresseBanData } from '@app/web/external-apis/ban/banFeatureToAdresseBanData'
3
import { prismaClient } from '@app/web/prismaClient'
4
import { toStructureFromCartoStructure } from '@app/web/structure/toStructureFromCartoStructure'
5
import { v4 } from 'uuid'
6

7
// Préfixes administratifs interchangeables, normalisés vers un token canonique.
8
// "commune de X", "mairie de X", "ville de X", "hôtel de ville de X" → "ville X"
9
// Permet à findOrCreateStructure de matcher "MAIRIE DU PRÊCHEUR" avec
10
// "COMMUNE DU PRECHEUR" pour le même SIRET, et ainsi d'éviter les doublons
11
// quand le Dataspace renvoie une variante de nom différente de la base.
NEW
12
const NOM_PREFIXES_NORMALIZATIONS: [RegExp, string][] = [
×
13
  [/^commune (?:de(?:s)?|du|de la|de l)\s+/, 'ville '],
14
  [/^com (?:de(?:s)?|du|de la|de l)\s+/, 'ville '],
15
  [/^mairie (?:de(?:s)?|du|de la|de l)\s+/, 'ville '],
16
  [/^ville (?:de(?:s)?|du|de la|de l)\s+/, 'ville '],
17
  [/^hotel de ville (?:de(?:s)?|du|de la|de l)\s+/, 'ville '],
18
  [/^conseil departemental (?:de(?:s)?|du|de la|de l)\s+/, 'departement '],
19
  [/^departement (?:de(?:s)?|du|de la|de l)\s+/, 'departement '],
20
  [/^communaute de communes?\s+/, 'cc '],
21
  [/^communaute d agglomeration\s+/, 'cagglo '],
22
  [/^communaute com\s+/, 'cc '],
23
  [/^conseil regional (?:de(?:s)?|du|de la|de l)\s+/, 'region '],
24
  [/^region\s+/, 'region '],
25
]
26

NEW
27
const baseNormalize = (s: string): string =>
×
UNCOV
28
  s
×
29
    .toLowerCase()
30
    .normalize('NFD')
31
    .replace(/[̀-ͯ]/g, '')
32
    .replace(/[^a-z0-9\s]/g, ' ')
33
    .replace(/\s+/g, ' ')
34
    .trim()
35

NEW
36
const normalizeNom = (s: string): string => {
×
NEW
37
  let n = baseNormalize(s)
×
NEW
38
  for (const [pattern, replacement] of NOM_PREFIXES_NORMALIZATIONS) {
×
NEW
39
    n = n.replace(pattern, replacement)
×
40
  }
NEW
41
  return n.trim()
×
42
}
43

44
// Mots-clés désignant un service spécifique d'une entité plus large.
45
// Si l'un des deux noms en contient un et pas l'autre, ce sont des entités
46
// distinctes (ex: EPN Fleury vs Commune de Fleury).
47
const SERVICE_KEYWORDS = [
×
48
  'epn',
49
  'mediatheque',
50
  'bibliotheque',
51
  'ccas',
52
  'cias',
53
  'centre social',
54
  'maison quartier',
55
  'maison de quartier',
56
  'france services',
57
  'mjc',
58
  'espace numerique',
59
  'cyber espace',
60
  'cyberbase',
61
  'pole emploi',
62
  'mission locale',
63
  'point information',
64
  'point info',
65
  'fablab',
66
]
67

68
const detectServiceKeywords = (s: string): Set<string> => {
×
69
  const found = new Set<string>()
×
70
  for (const kw of SERVICE_KEYWORDS) {
×
71
    if (s.includes(kw)) found.add(kw)
×
72
  }
73
  return found
×
74
}
75

76
const hasAsymmetricServiceKeyword = (a: string, b: string): boolean => {
×
77
  const ka = detectServiceKeywords(a)
×
78
  const kb = detectServiceKeywords(b)
×
79
  if (ka.size === 0 && kb.size === 0) return false
×
80
  for (const k of ka) if (!kb.has(k)) return true
×
81
  for (const k of kb) if (!ka.has(k)) return true
×
82
  return false
×
83
}
84

85
const isContainedName = (a: string, b: string): boolean => {
×
86
  const na = normalizeNom(a)
×
87
  const nb = normalizeNom(b)
×
88
  if (hasAsymmetricServiceKeyword(na, nb)) return false
×
89
  return na === nb || na.includes(nb) || nb.includes(na)
×
90
}
91

92
export type StructureInput = {
93
  coopId?: string | null
94
  siret: string | null
95
  nom: string
96
  adresse: string
97
  codePostal: string
98
  codeInsee: string
99
  commune: string
100
  // Optional fields
101
  nomReferent?: string | null
102
  courrielReferent?: string | null
103
  telephoneReferent?: string | null
104
  creationParId?: string | null
105
}
106

107
const findExistingBySiretOrNom = async ({
×
108
  siret,
109
  nom,
110
  codeInsee,
111
}: {
112
  siret: string | null
113
  nom: string
114
  codeInsee: string
115
}): Promise<{ id: string } | null> => {
116
  const where = siret
×
117
    ? { siret, codeInsee, suppression: null }
118
    : { nom, codeInsee, suppression: null }
119

120
  const existing = await prismaClient.structure.findFirst({
×
121
    where,
122
    select: { id: true, suppression: true },
123
    orderBy: { creation: 'desc' },
124
  })
125

126
  if (existing) {
×
127
    await undeleteStructureIfDeleted(
×
128
      existing as { id: string; suppression: Date | null },
129
    )
130
    return existing
×
131
  }
132

133
  // Fallback: same SIRET, any codeInsee, with contained name match.
134
  // Handles codeInsee divergence between Dataspace and coop.
135
  // The asymmetric-service-keyword check inside isContainedName prevents
136
  // matching an EPN against its parent town hall (same SIRET, different role).
137
  if (siret) {
×
138
    const candidatesBySiret = await prismaClient.structure.findMany({
×
139
      where: { siret, suppression: null },
140
      select: { id: true, nom: true, suppression: true },
141
      orderBy: { creation: 'desc' },
142
    })
143

144
    const match = candidatesBySiret.find((s) => isContainedName(s.nom, nom))
×
145

146
    if (match) {
×
147
      await undeleteStructureIfDeleted(match)
×
148
      return match
×
149
    }
150
  }
151

152
  return null
×
153
}
154

155
const undeleteStructureIfDeleted = async ({
×
156
  id,
157
  suppression,
158
}: {
159
  id: string
160
  suppression: Date | null
161
}) => {
162
  if (suppression) {
×
163
    await prismaClient.structure.update({
×
164
      where: { id },
165
      data: {
166
        suppression: null,
167
        suppressionParId: null,
168
      },
169
    })
170
  }
171
}
172

173
/**
174
 * Generic helper to find or create a structure following this hierarchy:
175
 * 1. Find existing Structure by coopId (surest match)
176
 * 2. Find existing Structure by SIRET + codeInsee
177
 * 3. Find StructureCartographieNationale by pivot (SIRET) → create Structure from it
178
 * 4. Find existing Structure by nom + codeInsee (if no SIRET)
179
 * 5. Fallback: Geocode via searchAdresse (BAN API) and create
180
 *
181
 * This is reusable for both V1 imports and Dataspace imports.
182
 */
183
export const findOrCreateStructure = async ({
×
184
  coopId,
185
  siret,
186
  nom,
187
  adresse,
188
  codePostal,
189
  codeInsee,
190
  commune,
191
  nomReferent,
192
  courrielReferent,
193
  telephoneReferent,
194
  creationParId,
195
}: StructureInput): Promise<{ id: string }> => {
196
  // If coopId is provided, it is the surest way to find the structure
197
  if (coopId) {
×
198
    const existingStructure = await prismaClient.structure.findFirst({
×
199
      where: {
200
        id: coopId,
201
      },
202
      select: {
203
        id: true,
204
        suppression: true,
205
      },
206
    })
207
    if (existingStructure) {
×
208
      await undeleteStructureIfDeleted(existingStructure)
×
209
      return existingStructure
×
210
    }
211
  }
212

213
  // Step 1: Find existing Structure by SIRET + codeInsee
214
  if (siret) {
×
215
    const existingStructure = await prismaClient.structure.findFirst({
×
216
      where: {
217
        siret,
218
        codeInsee,
219
        suppression: null,
220
      },
221
      select: {
222
        id: true,
223
        suppression: true,
224
      },
225
      orderBy: [
226
        {
227
          suppression: {
228
            sort: 'desc',
229
            nulls: 'last',
230
          },
231
        },
232
        {
233
          creation: 'desc',
234
        },
235
      ],
236
    })
237

238
    if (existingStructure) {
×
239
      await undeleteStructureIfDeleted(existingStructure)
×
240
      return existingStructure
×
241
    }
242
  }
243

244
  // Step 2: Find StructureCartographieNationale by pivot (SIRET) - only if siret is provided
245
  if (siret) {
×
246
    const cartoStructure =
247
      await prismaClient.structureCartographieNationale.findFirst({
×
248
        where: {
249
          pivot: siret,
250
          codeInsee,
251
        },
252
      })
253

254
    if (cartoStructure) {
×
255
      // Guard: re-check before creating to prevent duplicates
256
      const existing = await findExistingBySiretOrNom({ siret, nom, codeInsee })
×
257
      if (existing) return existing
×
258

259
      // Create structure from cartographie nationale data (has coordinates)
260
      const structureData = toStructureFromCartoStructure(cartoStructure)
×
261

262
      // Override with referent info if provided
263
      const createdStructure = await prismaClient.structure.create({
×
264
        data: {
265
          ...structureData,
266
          nomReferent: nomReferent ?? null,
×
267
          courrielReferent: courrielReferent ?? structureData.courriels?.at(0),
×
268
          telephoneReferent: telephoneReferent ?? structureData.telephone,
×
269
          creationParId,
270
        },
271
        select: {
272
          id: true,
273
        },
274
      })
275

276
      return createdStructure
×
277
    }
278
  }
279

280
  // Step 3: Try to find existing structure by nom if no siret
281
  if (!siret) {
×
282
    const existingByNom = await prismaClient.structure.findFirst({
×
283
      where: {
284
        nom,
285
        codeInsee,
286
      },
287
      select: {
288
        id: true,
289
        suppression: true,
290
      },
291
      orderBy: [
292
        {
293
          suppression: {
294
            sort: 'desc',
295
            nulls: 'last',
296
          },
297
        },
298
        {
299
          creation: 'desc',
300
        },
301
      ],
302
    })
303

304
    if (existingByNom) {
×
305
      await undeleteStructureIfDeleted(existingByNom)
×
306
      return existingByNom
×
307
    }
308
  }
309

310
  // Step 4: Fallback - geocode via BAN API and create
311
  // Guard: re-check before creating to prevent duplicates
312
  const existingGuard = await findExistingBySiretOrNom({
×
313
    siret,
314
    nom,
315
    codeInsee,
316
  })
317
  if (existingGuard) return existingGuard
×
318

319
  const fullAdresse = `${adresse}, ${codePostal} ${commune}`
×
320
  const adresseResult = await searchAdresse(fullAdresse)
×
321

322
  if (adresseResult) {
×
323
    const banData = banFeatureToAdresseBanData(adresseResult)
×
324

325
    return prismaClient.structure.create({
×
326
      data: {
327
        id: v4(),
328
        siret,
329
        nom,
330
        adresse: banData.nom,
331
        commune: banData.commune,
332
        codePostal: banData.codePostal,
333
        codeInsee: banData.codeInsee,
334
        latitude: banData.latitude,
335
        longitude: banData.longitude,
336
        nomReferent,
337
        courrielReferent,
338
        telephoneReferent,
339
        creationParId,
340
      },
341
      select: {
342
        id: true,
343
      },
344
    })
345
  }
346

347
  // No geocoding result - create without coordinates
348
  return prismaClient.structure.create({
×
349
    data: {
350
      id: v4(),
351
      siret,
352
      nom,
353
      adresse,
354
      commune,
355
      codePostal,
356
      codeInsee,
357
      nomReferent,
358
      courrielReferent,
359
      telephoneReferent,
360
      creationParId,
361
    },
362
    select: {
363
      id: true,
364
    },
365
  })
366
}
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