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

inclusion-numerique / coop-mediation-numerique / 605d24cb-457b-4888-9d6f-f1777ed8bf22

18 May 2026 07:26PM UTC coverage: 7.009% (-0.5%) from 7.539%
605d24cb-457b-4888-9d6f-f1777ed8bf22

push

circleci

web-flow
Merge pull request #492 from inclusion-numerique/feat/deduplicate-structures

Feat/deduplicate structures

469 of 10788 branches covered (4.35%)

Branch coverage included in aggregate %.

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

1 existing line in 1 file now uncovered.

1466 of 16819 relevant lines covered (8.72%)

35.68 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
export type StructureInput = {
8
  coopId?: string | null
9
  siret: string | null
10
  nom: string
11
  adresse: string
12
  codePostal: string
13
  codeInsee: string
14
  commune: string
15
  // Optional fields
16
  nomReferent?: string | null
17
  courrielReferent?: string | null
18
  telephoneReferent?: string | null
19
  creationParId?: string | null
20
}
21

NEW
22
const findExistingBySiretOrNom = async ({
×
23
  siret,
24
  nom,
25
  codeInsee,
26
}: {
27
  siret: string | null
28
  nom: string
29
  codeInsee: string
30
}): Promise<{ id: string } | null> => {
NEW
31
  const where = siret
×
32
    ? { siret, codeInsee, suppression: null }
33
    : { nom, codeInsee, suppression: null }
34

NEW
35
  const existing = await prismaClient.structure.findFirst({
×
36
    where,
37
    select: { id: true, suppression: true },
38
    orderBy: { creation: 'desc' },
39
  })
40

NEW
41
  if (existing) {
×
NEW
42
    await undeleteStructureIfDeleted(
×
43
      existing as { id: string; suppression: Date | null },
44
    )
NEW
45
    return existing
×
46
  }
47

NEW
48
  return null
×
49
}
50

UNCOV
51
const undeleteStructureIfDeleted = async ({
×
52
  id,
53
  suppression,
54
}: {
55
  id: string
56
  suppression: Date | null
57
}) => {
58
  if (suppression) {
×
59
    await prismaClient.structure.update({
×
60
      where: { id },
61
      data: {
62
        suppression: null,
63
        suppressionParId: null,
64
      },
65
    })
66
  }
67
}
68

69
/**
70
 * Generic helper to find or create a structure following this hierarchy:
71
 * 1. Find existing Structure by coopId (surest match)
72
 * 2. Find existing Structure by SIRET + codeInsee
73
 * 3. Find StructureCartographieNationale by pivot (SIRET) → create Structure from it
74
 * 4. Find existing Structure by nom + codeInsee (if no SIRET)
75
 * 5. Fallback: Geocode via searchAdresse (BAN API) and create
76
 *
77
 * This is reusable for both V1 imports and Dataspace imports.
78
 */
79
export const findOrCreateStructure = async ({
×
80
  coopId,
81
  siret,
82
  nom,
83
  adresse,
84
  codePostal,
85
  codeInsee,
86
  commune,
87
  nomReferent,
88
  courrielReferent,
89
  telephoneReferent,
90
  creationParId,
91
}: StructureInput): Promise<{ id: string }> => {
92
  // If coopId is provided, it is the surest way to find the structure
93
  if (coopId) {
×
94
    const existingStructure = await prismaClient.structure.findFirst({
×
95
      where: {
96
        id: coopId,
97
      },
98
      select: {
99
        id: true,
100
        suppression: true,
101
      },
102
    })
103
    if (existingStructure) {
×
104
      await undeleteStructureIfDeleted(existingStructure)
×
105
      return existingStructure
×
106
    }
107
  }
108

109
  // Step 1: Find existing Structure by SIRET + codeInsee
110
  if (siret) {
×
111
    const existingStructure = await prismaClient.structure.findFirst({
×
112
      where: {
113
        siret,
114
        codeInsee,
115
        suppression: null,
116
      },
117
      select: {
118
        id: true,
119
        suppression: true,
120
      },
121
      orderBy: [
122
        {
123
          suppression: {
124
            sort: 'desc',
125
            nulls: 'last',
126
          },
127
        },
128
        {
129
          creation: 'desc',
130
        },
131
      ],
132
    })
133

134
    if (existingStructure) {
×
135
      await undeleteStructureIfDeleted(existingStructure)
×
136
      return existingStructure
×
137
    }
138
  }
139

140
  // Step 2: Find StructureCartographieNationale by pivot (SIRET) - only if siret is provided
141
  if (siret) {
×
142
    const cartoStructure =
143
      await prismaClient.structureCartographieNationale.findFirst({
×
144
        where: {
145
          pivot: siret,
146
          codeInsee,
147
        },
148
      })
149

150
    if (cartoStructure) {
×
151
      // Guard: re-check before creating to prevent duplicates
NEW
152
      const existing = await findExistingBySiretOrNom({ siret, nom, codeInsee })
×
NEW
153
      if (existing) return existing
×
154

155
      // Create structure from cartographie nationale data (has coordinates)
156
      const structureData = toStructureFromCartoStructure(cartoStructure)
×
157

158
      // Override with referent info if provided
159
      const createdStructure = await prismaClient.structure.create({
×
160
        data: {
161
          ...structureData,
162
          nomReferent: nomReferent ?? null,
×
163
          courrielReferent: courrielReferent ?? structureData.courriels?.at(0),
×
164
          telephoneReferent: telephoneReferent ?? structureData.telephone,
×
165
          creationParId,
166
        },
167
        select: {
168
          id: true,
169
        },
170
      })
171

172
      return createdStructure
×
173
    }
174
  }
175

176
  // Step 3: Try to find existing structure by nom if no siret
177
  if (!siret) {
×
178
    const existingByNom = await prismaClient.structure.findFirst({
×
179
      where: {
180
        nom,
181
        codeInsee,
182
      },
183
      select: {
184
        id: true,
185
        suppression: true,
186
      },
187
      orderBy: [
188
        {
189
          suppression: {
190
            sort: 'desc',
191
            nulls: 'last',
192
          },
193
        },
194
        {
195
          creation: 'desc',
196
        },
197
      ],
198
    })
199

200
    if (existingByNom) {
×
201
      await undeleteStructureIfDeleted(existingByNom)
×
202
      return existingByNom
×
203
    }
204
  }
205

206
  // Step 4: Fallback - geocode via BAN API and create
207
  // Guard: re-check before creating to prevent duplicates
NEW
208
  const existingGuard = await findExistingBySiretOrNom({
×
209
    siret,
210
    nom,
211
    codeInsee,
212
  })
NEW
213
  if (existingGuard) return existingGuard
×
214

215
  const fullAdresse = `${adresse}, ${codePostal} ${commune}`
×
216
  const adresseResult = await searchAdresse(fullAdresse)
×
217

218
  if (adresseResult) {
×
219
    const banData = banFeatureToAdresseBanData(adresseResult)
×
220

221
    return prismaClient.structure.create({
×
222
      data: {
223
        id: v4(),
224
        siret,
225
        nom,
226
        adresse: banData.nom,
227
        commune: banData.commune,
228
        codePostal: banData.codePostal,
229
        codeInsee: banData.codeInsee,
230
        latitude: banData.latitude,
231
        longitude: banData.longitude,
232
        nomReferent,
233
        courrielReferent,
234
        telephoneReferent,
235
        creationParId,
236
      },
237
      select: {
238
        id: true,
239
      },
240
    })
241
  }
242

243
  // No geocoding result - create without coordinates
244
  return prismaClient.structure.create({
×
245
    data: {
246
      id: v4(),
247
      siret,
248
      nom,
249
      adresse,
250
      commune,
251
      codePostal,
252
      codeInsee,
253
      nomReferent,
254
      courrielReferent,
255
      telephoneReferent,
256
      creationParId,
257
    },
258
    select: {
259
      id: true,
260
    },
261
  })
262
}
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