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

inclusion-numerique / coop-mediation-numerique / 624c8220-3b75-4588-af5c-02533eb4e889

21 May 2026 12:12PM UTC coverage: 6.955% (-0.05%) from 7.009%
624c8220-3b75-4588-af5c-02533eb4e889

push

circleci

web-flow
MEP 2026-05-21

469 of 10876 branches covered (4.31%)

Branch coverage included in aggregate %.

0 of 1486 new or added lines in 32 files covered. (0.0%)

68 existing lines in 9 files now uncovered.

1466 of 16944 relevant lines covered (8.65%)

35.41 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

NEW
7
const normalizeNom = (s: string): string =>
×
NEW
8
  s
×
9
    .toLowerCase()
10
    .normalize('NFD')
11
    .replace(/[̀-ͯ]/g, '')
12
    .replace(/\s+/g, ' ')
13
    .trim()
14

15
// Mots-clés désignant un service spécifique d'une entité plus large.
16
// Si l'un des deux noms en contient un et pas l'autre, ce sont des entités
17
// distinctes (ex: EPN Fleury vs Commune de Fleury).
NEW
18
const SERVICE_KEYWORDS = [
×
19
  'epn',
20
  'mediatheque',
21
  'bibliotheque',
22
  'ccas',
23
  'cias',
24
  'centre social',
25
  'maison quartier',
26
  'maison de quartier',
27
  'france services',
28
  'mjc',
29
  'espace numerique',
30
  'cyber espace',
31
  'cyberbase',
32
  'pole emploi',
33
  'mission locale',
34
  'point information',
35
  'point info',
36
  'fablab',
37
]
38

NEW
39
const detectServiceKeywords = (s: string): Set<string> => {
×
NEW
40
  const found = new Set<string>()
×
NEW
41
  for (const kw of SERVICE_KEYWORDS) {
×
NEW
42
    if (s.includes(kw)) found.add(kw)
×
43
  }
NEW
44
  return found
×
45
}
46

NEW
47
const hasAsymmetricServiceKeyword = (a: string, b: string): boolean => {
×
NEW
48
  const ka = detectServiceKeywords(a)
×
NEW
49
  const kb = detectServiceKeywords(b)
×
NEW
50
  if (ka.size === 0 && kb.size === 0) return false
×
NEW
51
  for (const k of ka) if (!kb.has(k)) return true
×
NEW
52
  for (const k of kb) if (!ka.has(k)) return true
×
NEW
53
  return false
×
54
}
55

NEW
56
const isContainedName = (a: string, b: string): boolean => {
×
NEW
57
  const na = normalizeNom(a)
×
NEW
58
  const nb = normalizeNom(b)
×
NEW
59
  if (hasAsymmetricServiceKeyword(na, nb)) return false
×
NEW
60
  return na === nb || na.includes(nb) || nb.includes(na)
×
61
}
62

63
export type StructureInput = {
64
  coopId?: string | null
65
  siret: string | null
66
  nom: string
67
  adresse: string
68
  codePostal: string
69
  codeInsee: string
70
  commune: string
71
  // Optional fields
72
  nomReferent?: string | null
73
  courrielReferent?: string | null
74
  telephoneReferent?: string | null
75
  creationParId?: string | null
76
}
77

NEW
78
const findExistingBySiretOrNom = async ({
×
79
  siret,
80
  nom,
81
  codeInsee,
82
}: {
83
  siret: string | null
84
  nom: string
85
  codeInsee: string
86
}): Promise<{ id: string } | null> => {
NEW
87
  const where = siret
×
88
    ? { siret, codeInsee, suppression: null }
89
    : { nom, codeInsee, suppression: null }
90

NEW
91
  const existing = await prismaClient.structure.findFirst({
×
92
    where,
93
    select: { id: true, suppression: true },
94
    orderBy: { creation: 'desc' },
95
  })
96

NEW
97
  if (existing) {
×
NEW
98
    await undeleteStructureIfDeleted(
×
99
      existing as { id: string; suppression: Date | null },
100
    )
NEW
101
    return existing
×
102
  }
103

104
  // Fallback: same SIRET, any codeInsee, with contained name match.
105
  // Handles codeInsee divergence between Dataspace and coop.
106
  // The asymmetric-service-keyword check inside isContainedName prevents
107
  // matching an EPN against its parent town hall (same SIRET, different role).
NEW
108
  if (siret) {
×
NEW
109
    const candidatesBySiret = await prismaClient.structure.findMany({
×
110
      where: { siret, suppression: null },
111
      select: { id: true, nom: true, suppression: true },
112
      orderBy: { creation: 'desc' },
113
    })
114

NEW
115
    const match = candidatesBySiret.find((s) => isContainedName(s.nom, nom))
×
116

NEW
117
    if (match) {
×
NEW
118
      await undeleteStructureIfDeleted(match)
×
NEW
119
      return match
×
120
    }
121
  }
122

NEW
123
  return null
×
124
}
125

UNCOV
126
const undeleteStructureIfDeleted = async ({
×
127
  id,
128
  suppression,
129
}: {
130
  id: string
131
  suppression: Date | null
132
}) => {
UNCOV
133
  if (suppression) {
×
UNCOV
134
    await prismaClient.structure.update({
×
135
      where: { id },
136
      data: {
137
        suppression: null,
138
        suppressionParId: null,
139
      },
140
    })
141
  }
142
}
143

144
/**
145
 * Generic helper to find or create a structure following this hierarchy:
146
 * 1. Find existing Structure by coopId (surest match)
147
 * 2. Find existing Structure by SIRET + codeInsee
148
 * 3. Find StructureCartographieNationale by pivot (SIRET) → create Structure from it
149
 * 4. Find existing Structure by nom + codeInsee (if no SIRET)
150
 * 5. Fallback: Geocode via searchAdresse (BAN API) and create
151
 *
152
 * This is reusable for both V1 imports and Dataspace imports.
153
 */
154
export const findOrCreateStructure = async ({
×
155
  coopId,
156
  siret,
157
  nom,
158
  adresse,
159
  codePostal,
160
  codeInsee,
161
  commune,
162
  nomReferent,
163
  courrielReferent,
164
  telephoneReferent,
165
  creationParId,
166
}: StructureInput): Promise<{ id: string }> => {
167
  // If coopId is provided, it is the surest way to find the structure
UNCOV
168
  if (coopId) {
×
UNCOV
169
    const existingStructure = await prismaClient.structure.findFirst({
×
170
      where: {
171
        id: coopId,
172
      },
173
      select: {
174
        id: true,
175
        suppression: true,
176
      },
177
    })
UNCOV
178
    if (existingStructure) {
×
UNCOV
179
      await undeleteStructureIfDeleted(existingStructure)
×
UNCOV
180
      return existingStructure
×
181
    }
182
  }
183

184
  // Step 1: Find existing Structure by SIRET + codeInsee
185
  if (siret) {
×
UNCOV
186
    const existingStructure = await prismaClient.structure.findFirst({
×
187
      where: {
188
        siret,
189
        codeInsee,
190
        suppression: null,
191
      },
192
      select: {
193
        id: true,
194
        suppression: true,
195
      },
196
      orderBy: [
197
        {
198
          suppression: {
199
            sort: 'desc',
200
            nulls: 'last',
201
          },
202
        },
203
        {
204
          creation: 'desc',
205
        },
206
      ],
207
    })
208

209
    if (existingStructure) {
×
210
      await undeleteStructureIfDeleted(existingStructure)
×
211
      return existingStructure
×
212
    }
213
  }
214

215
  // Step 2: Find StructureCartographieNationale by pivot (SIRET) - only if siret is provided
216
  if (siret) {
×
217
    const cartoStructure =
UNCOV
218
      await prismaClient.structureCartographieNationale.findFirst({
×
219
        where: {
220
          pivot: siret,
221
          codeInsee,
222
        },
223
      })
224

UNCOV
225
    if (cartoStructure) {
×
226
      // Guard: re-check before creating to prevent duplicates
NEW
227
      const existing = await findExistingBySiretOrNom({ siret, nom, codeInsee })
×
NEW
228
      if (existing) return existing
×
229

230
      // Create structure from cartographie nationale data (has coordinates)
UNCOV
231
      const structureData = toStructureFromCartoStructure(cartoStructure)
×
232

233
      // Override with referent info if provided
UNCOV
234
      const createdStructure = await prismaClient.structure.create({
×
235
        data: {
236
          ...structureData,
237
          nomReferent: nomReferent ?? null,
×
238
          courrielReferent: courrielReferent ?? structureData.courriels?.at(0),
×
239
          telephoneReferent: telephoneReferent ?? structureData.telephone,
×
240
          creationParId,
241
        },
242
        select: {
243
          id: true,
244
        },
245
      })
246

UNCOV
247
      return createdStructure
×
248
    }
249
  }
250

251
  // Step 3: Try to find existing structure by nom if no siret
UNCOV
252
  if (!siret) {
×
253
    const existingByNom = await prismaClient.structure.findFirst({
×
254
      where: {
255
        nom,
256
        codeInsee,
257
      },
258
      select: {
259
        id: true,
260
        suppression: true,
261
      },
262
      orderBy: [
263
        {
264
          suppression: {
265
            sort: 'desc',
266
            nulls: 'last',
267
          },
268
        },
269
        {
270
          creation: 'desc',
271
        },
272
      ],
273
    })
274

UNCOV
275
    if (existingByNom) {
×
UNCOV
276
      await undeleteStructureIfDeleted(existingByNom)
×
UNCOV
277
      return existingByNom
×
278
    }
279
  }
280

281
  // Step 4: Fallback - geocode via BAN API and create
282
  // Guard: re-check before creating to prevent duplicates
NEW
283
  const existingGuard = await findExistingBySiretOrNom({
×
284
    siret,
285
    nom,
286
    codeInsee,
287
  })
NEW
288
  if (existingGuard) return existingGuard
×
289

UNCOV
290
  const fullAdresse = `${adresse}, ${codePostal} ${commune}`
×
UNCOV
291
  const adresseResult = await searchAdresse(fullAdresse)
×
292

UNCOV
293
  if (adresseResult) {
×
UNCOV
294
    const banData = banFeatureToAdresseBanData(adresseResult)
×
295

296
    return prismaClient.structure.create({
×
297
      data: {
298
        id: v4(),
299
        siret,
300
        nom,
301
        adresse: banData.nom,
302
        commune: banData.commune,
303
        codePostal: banData.codePostal,
304
        codeInsee: banData.codeInsee,
305
        latitude: banData.latitude,
306
        longitude: banData.longitude,
307
        nomReferent,
308
        courrielReferent,
309
        telephoneReferent,
310
        creationParId,
311
      },
312
      select: {
313
        id: true,
314
      },
315
    })
316
  }
317

318
  // No geocoding result - create without coordinates
319
  return prismaClient.structure.create({
×
320
    data: {
321
      id: v4(),
322
      siret,
323
      nom,
324
      adresse,
325
      commune,
326
      codePostal,
327
      codeInsee,
328
      nomReferent,
329
      courrielReferent,
330
      telephoneReferent,
331
      creationParId,
332
    },
333
    select: {
334
      id: true,
335
    },
336
  })
337
}
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