From cdae9a73d08abe97faa96026a95a67b7002a857f Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Thu, 12 Feb 2026 09:14:41 +0100 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20am=C3=A9lioration=20des=20outils?= =?UTF-8?q?=20carto=20de=20d=C3=A9coupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/geometry.js | 189 ++++- server.js | 1382 ++++++++++++++++++++++++------------- 2 files changed, 1107 insertions(+), 464 deletions(-) diff --git a/lib/providers/geometry.js b/lib/providers/geometry.js index 67bb1131..236048ee 100644 --- a/lib/providers/geometry.js +++ b/lib/providers/geometry.js @@ -280,8 +280,195 @@ GROUP BY ri.comparison_result; } return null } + +/** + * Couper la bordure d'une parcelle avec PostGIS + * @param {Object} geometry - Géométrie GeoJSON de la parcelle en EPSG:4326 + * @param {number} distance - Distance de la bordure en mètres + * @param {boolean} allBorder - Si true, retourne toute la bordure, sinon bordure entre 2 points + * @param {boolean} isInverted - Si true, inverse le côté de la bordure + * @param {number[]} [startBorderPoint] - Point de début [lng, lat] + * @param {number[]} [endBorderPoint] - Point de fin [lng, lat] + */ +async function calculateParcelBorder ( + geometry, + distance, + allBorder, + isInverted, + startBorderPoint, + endBorderPoint +) { + let query + + if (allBorder) { + query = ` + WITH parcelle AS ( + SELECT ST_SetSRID(ST_GeomFromGeoJSON($1), 4326) AS geom + ), + parcelle_3857 AS ( + SELECT ST_Transform(geom, 3857) AS geom FROM parcelle + ), + parcelle_agrandie AS ( + SELECT ST_Buffer(geom, 0.01) AS geom FROM parcelle_3857 + ), + parcelle_sans_bordure AS ( + SELECT ST_Buffer(geom, -($2 + 0.01)) AS geom FROM parcelle_agrandie + ), + bordure_3857 AS ( + SELECT ST_Difference(pa.geom, psb.geom) AS geom + FROM parcelle_agrandie pa, parcelle_sans_bordure psb + ), + bordure AS ( + SELECT ST_Transform(geom, 4326) AS geom FROM bordure_3857 + ), + sans_bordure_3857 AS ( + SELECT ST_Difference(p.geom, b.geom) AS geom + FROM parcelle_3857 p, bordure_3857 b + ), + sans_bordure AS ( + SELECT ST_Transform(geom, 4326) AS geom FROM sans_bordure_3857 + ) + SELECT + ST_AsGeoJSON(sb.geom)::json AS parcelle_sans_bordure, + ST_AsGeoJSON(b.geom)::json AS bordure + FROM sans_bordure sb, bordure b + ` + + const result = await pool.query(query, [geometry, distance]) + + if (!result.rows[0]) { + throw new Error('Aucun résultat retourné par la requête') + } + + return { + parcelleSansBordure: result.rows[0].parcelle_sans_bordure, + bordure: result.rows[0].bordure + } + } else { + if (!startBorderPoint || !endBorderPoint) { + throw new Error('Les deux points sont requis') + } + + query = ` + WITH parcelle AS ( + SELECT ST_Transform( + ST_SetSRID(ST_GeomFromGeoJSON($1), 4326), + 3857 + ) AS geom + ), + contour AS ( + SELECT ST_ExteriorRing(geom) AS geom FROM parcelle + ), + points AS ( + SELECT + ST_Transform(ST_SetSRID(ST_MakePoint($3, $4), 4326), 3857) AS s, + ST_Transform(ST_SetSRID(ST_MakePoint($5, $6), 4326), 3857) AS e + ), + pos AS ( + SELECT + ST_LineLocatePoint(c.geom, p.s) AS sp, + ST_LineLocatePoint(c.geom, p.e) AS ep, + c.geom AS contour + FROM contour c, points p + ), + arc_normal AS ( + SELECT + ST_LineSubstring( + contour, + LEAST(sp, ep), + GREATEST(sp, ep) + ) AS geom + FROM pos + ), + arc_inverse AS ( + SELECT + ST_LineMerge( + ST_Collect( + ST_LineSubstring(contour, GREATEST(sp, ep), 1), + ST_LineSubstring(contour, 0, LEAST(sp, ep)) + ) + ) AS geom + FROM pos + ), + arc AS ( + SELECT geom FROM arc_normal WHERE $7 = 0 + UNION ALL + SELECT geom FROM arc_inverse WHERE $7 = 1 + ), + arc_offset_negatif AS ( + SELECT + ST_OffsetCurve(geom, (-1.0 * $2::float), 'quad_segs=8 join=round miter_limit=3') AS geom + FROM arc + ), + arc_offset_positif AS ( + SELECT + ST_OffsetCurve(geom, ($2::float), 'quad_segs=8 join=round miter_limit=3') AS geom + FROM arc + ), + arc_interieur AS ( + SELECT + CASE + WHEN ST_Within(ST_Centroid(aon.geom), p.geom) THEN aon.geom + ELSE aop.geom + END AS geom + FROM arc_offset_negatif aon, arc_offset_positif aop, parcelle p + ), + bordure_avec_fermeture AS ( + SELECT + ST_MakeLine(ARRAY[ + ST_StartPoint(a.geom), + ST_StartPoint(ai.geom) + ]) AS ligne_debut, + ST_MakeLine(ARRAY[ + ST_EndPoint(a.geom), + ST_EndPoint(ai.geom) + ]) AS ligne_fin, + a.geom AS arc_ext, + ST_Reverse(ai.geom) AS arc_int + FROM arc a, arc_interieur ai + ), + bordure_3857 AS ( + SELECT + ST_MakePolygon( + ST_LineMerge( + ST_Collect(ARRAY[arc_ext, ligne_fin, arc_int, ligne_debut]) + ) + ) AS geom + FROM bordure_avec_fermeture + ), + sans_bordure AS ( + SELECT ST_Difference(p.geom, b.geom) AS geom + FROM parcelle p, bordure_3857 b + ) + SELECT + ST_AsGeoJSON(ST_Transform(sb.geom, 4326))::json AS parcelle_sans_bordure, + ST_AsGeoJSON(ST_Transform(b.geom, 4326))::json AS bordure + FROM sans_bordure sb, bordure_3857 b` + + const result = await pool.query(query, [ + geometry, + distance, + startBorderPoint[0], + startBorderPoint[1], + endBorderPoint[0], + endBorderPoint[1], + isInverted ? 1 : 0 + ]) + + if (!result.rows[0]) { + throw new Error('Aucun résultat retourné') + } + console.log(result.rows[0]) + return { + parcelleSansBordure: result.rows[0].parcelle_sans_bordure, + bordure: result.rows[0].bordure + } + } +} + module.exports = { getRpg, verifyGeometry, - getGeometryEquals + getGeometryEquals, + calculateParcelBorder } diff --git a/server.js b/server.js index 8b56f22a..a8fca34b 100644 --- a/server.js +++ b/server.js @@ -30,7 +30,10 @@ if (reportErrors) { if (config.get('environment') === 'production') { sentryOptions.release = config.get('version') - } else if (config.get('environment') === 'staging' || config.get('environment') === 'test') { + } else if ( + config.get('environment') === 'staging' || + config.get('environment') === 'test' + ) { sentryOptions.release = process.env.SENTRY_RELEASE } @@ -57,23 +60,101 @@ const { PassThrough } = require('stream') const { createSigner } = require('fast-jwt') -const { fetchOperatorByNumeroBio, getUserProfileById, getUserProfileFromSSOToken, verifyNotificationAuthorization, fetchUserOperators, fetchCustomersByOc } = require('./lib/providers/agence-bio.js') -const { addRecordFeature, createFeaturesFromOther, patchFeatureCollection, updateAuditRecordState, updateFeature, createOrUpdateOperatorRecord, parcellaireStreamToDb, deleteSingleFeature, getRecords, deleteRecord, getOperatorLastRecord, searchControlBodyRecords, getDepartement, recordSorts, pinOperator, unpinOperator, consultOperator, getDashboardSummary, exportDataOcId, searchForAutocomplete, getImportPAC, hideImport, markFeatureControlled, markFeatureUncontrolled } = require('./lib/providers/cartobio.js') -const { generatePDF, getAttestationProduction } = require('./lib/providers/export-pdf.js') -const { evvLookup, evvParcellaire, pacageLookup, iterateOperatorLastRecords } = require('./lib/providers/cartobio.js') +const { + fetchOperatorByNumeroBio, + getUserProfileById, + getUserProfileFromSSOToken, + verifyNotificationAuthorization, + fetchUserOperators, + fetchCustomersByOc +} = require('./lib/providers/agence-bio.js') +const { + addRecordFeature, + createFeaturesFromOther, + patchFeatureCollection, + updateAuditRecordState, + updateFeature, + createOrUpdateOperatorRecord, + parcellaireStreamToDb, + deleteSingleFeature, + getRecords, + deleteRecord, + getOperatorLastRecord, + searchControlBodyRecords, + getDepartement, + recordSorts, + pinOperator, + unpinOperator, + consultOperator, + getDashboardSummary, + exportDataOcId, + searchForAutocomplete, + getImportPAC, + hideImport, + markFeatureControlled, + markFeatureUncontrolled +} = require('./lib/providers/cartobio.js') +const { + generatePDF, + getAttestationProduction +} = require('./lib/providers/export-pdf.js') +const { + evvLookup, + evvParcellaire, + pacageLookup, + iterateOperatorLastRecords +} = require('./lib/providers/cartobio.js') const { parseAnyGeographicalArchive } = require('./lib/providers/gdal.js') const { parseTelepacArchive } = require('./lib/providers/telepac.js') -const { parseGeofoliaArchive, geofoliaLookup, geofoliaParcellaire } = require('./lib/providers/geofolia.js') +const { + parseGeofoliaArchive, + geofoliaLookup, + geofoliaParcellaire +} = require('./lib/providers/geofolia.js') const { InvalidRequestApiError, NotFoundApiError } = require('./lib/errors.js') -const { mergeSchemas, swaggerConfig, CartoBioDecoratorsPlugin, dashboardSummarySchema, autocompleteSchema } = require('./lib/routes/index.js') -const { sandboxSchema, internalSchema, hiddenSchema } = require('./lib/routes/index.js') -const { operatorFromNumeroBio, operatorFromRecordId, protectedWithToken, routeWithRecordId, routeWithPacage, checkCertificationStatus } = require('./lib/routes/index.js') -const { operatorsSchema, certificationBodySearchSchema } = require('./lib/routes/index.js') -const { createFeatureSchema, createRecordSchema, deleteSingleFeatureSchema, patchFeatureCollectionSchema, patchRecordSchema, updateFeaturePropertiesSchema } = require('./lib/routes/records.js') +const { + mergeSchemas, + swaggerConfig, + CartoBioDecoratorsPlugin, + dashboardSummarySchema, + autocompleteSchema +} = require('./lib/routes/index.js') +const { + sandboxSchema, + internalSchema, + hiddenSchema +} = require('./lib/routes/index.js') +const { + operatorFromNumeroBio, + operatorFromRecordId, + protectedWithToken, + routeWithRecordId, + routeWithPacage, + checkCertificationStatus +} = require('./lib/routes/index.js') +const { + operatorsSchema, + certificationBodySearchSchema +} = require('./lib/routes/index.js') +const { + createFeatureSchema, + createRecordSchema, + deleteSingleFeatureSchema, + patchFeatureCollectionSchema, + patchRecordSchema, + updateFeaturePropertiesSchema +} = require('./lib/routes/records.js') const { geofoliaImportSchema } = require('./lib/routes/index.js') -const { verifyGeometry, getRpg, getGeometryEquals } = require('./lib/providers/geometry.js') +const { + verifyGeometry, + getRpg, + getGeometryEquals, + computeFullBorder, + computePartialBorder, + calculateParcelBorder +} = require('./lib/providers/geometry.js') const DURATION_ONE_MINUTE = 1000 * 60 const DURATION_ONE_HOUR = DURATION_ONE_MINUTE * 60 @@ -84,8 +165,15 @@ const { UnauthorizedApiError, errorHandler } = require('./lib/errors.js') const { normalizeRecord } = require('./lib/outputs/record') const { recordToApi } = require('./lib/outputs/api') const { isHandledError } = require('./lib/errors') -const { getPinnedOperators, getConsultedOperators, addRecordData } = require('./lib/outputs/operator.js') -const sign = createSigner({ key: config.get('jwtSecret'), expiresIn: DURATION_ONE_DAY * 30 }) +const { + getPinnedOperators, + getConsultedOperators, + addRecordData +} = require('./lib/outputs/operator.js') +const sign = createSigner({ + key: config.get('jwtSecret'), + expiresIn: DURATION_ONE_DAY * 30 +}) app.setErrorHandler(errorHandler) if (reportErrors) { @@ -95,7 +183,15 @@ if (reportErrors) { // Configure server app.register(fastifyCors, { origin: true, - allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Accept-Encoding', 'Authorization', 'If-Unmodified-Since'] + allowedHeaders: [ + 'Origin', + 'X-Requested-With', + 'Content-Type', + 'Accept', + 'Accept-Encoding', + 'Authorization', + 'If-Unmodified-Since' + ] }) // Accept incoming files and forms (GeoJSON, ZIP files, etc.) @@ -181,302 +277,473 @@ app.register(async (app) => { return reply.send({ version: config.get('version') }) }) - app.get('/api/v2/test', mergeSchemas(sandboxSchema, protectedWithToken({ oc: true, cartobio: true })), (_, reply) => { - return reply.send({ message: 'OK' }) - }) + app.get( + '/api/v2/test', + mergeSchemas( + sandboxSchema, + protectedWithToken({ oc: true, cartobio: true }) + ), + (_, reply) => { + return reply.send({ message: 'OK' }) + } + ) /** * @private */ - app.post('/api/v2/certification/search', mergeSchemas(certificationBodySearchSchema, protectedWithToken()), async (request, reply) => { - const { input, page, limit, filter } = request.body - const { id: ocId } = request.user.organismeCertificateur - - return reply.code(200).send(searchControlBodyRecords({ ocId, userId: request.user.id, input, page, limit, filter })) - }) + app.post( + '/api/v2/certification/search', + mergeSchemas(certificationBodySearchSchema, protectedWithToken()), + async (request, reply) => { + const { input, page, limit, filter } = request.body + const { id: ocId } = request.user.organismeCertificateur + + return reply.code(200).send( + searchControlBodyRecords({ + ocId, + userId: request.user.id, + input, + page, + limit, + filter + }) + ) + } + ) /** * @private */ - app.get('/api/v2/certification/autocomplete', mergeSchemas(autocompleteSchema, protectedWithToken()), async (request, reply) => { - const { search } = request.query - const { id: userId, organismeCertificateur } = request.user - - return reply.code(200).send(searchForAutocomplete(organismeCertificateur?.id, userId, search)) - }) + app.get( + '/api/v2/certification/autocomplete', + mergeSchemas(autocompleteSchema, protectedWithToken()), + async (request, reply) => { + const { search } = request.query + const { id: userId, organismeCertificateur } = request.user + + return reply + .code(200) + .send( + searchForAutocomplete(organismeCertificateur?.id, userId, search) + ) + } + ) /** * @private * Retrieve operators for a given user */ - app.get('/api/v2/operators', mergeSchemas(protectedWithToken({ cartobio: true }), operatorsSchema), async (request, reply) => { - const { id: userId } = request.user - const { search, limit, offset } = request.query - - return Promise.all( - [ + app.get( + '/api/v2/operators', + mergeSchemas(protectedWithToken({ cartobio: true }), operatorsSchema), + async (request, reply) => { + const { id: userId } = request.user + const { search, limit, offset } = request.query + + return Promise.all([ fetchUserOperators(userId), getPinnedOperators(request.user.id) - ] - ).then(([res, pinnedOperators]) => { - const paginatedOperators = res.operators - .filter((e) => { - if (!search) { - return true - } - - const userInput = search.toLowerCase().trim() - - return e.denominationCourante.toLowerCase().includes(userInput) || - e.numeroBio.toString().includes(userInput) || - e.nom.toLowerCase().includes(userInput) || - e.siret.toLowerCase().includes(userInput) + ]).then(([res, pinnedOperators]) => { + const paginatedOperators = res.operators + .filter((e) => { + if (!search) { + return true + } + + const userInput = search.toLowerCase().trim() + + return ( + e.denominationCourante.toLowerCase().includes(userInput) || + e.numeroBio.toString().includes(userInput) || + e.nom.toLowerCase().includes(userInput) || + e.siret.toLowerCase().includes(userInput) + ) + }) + .toSorted(recordSorts('fn', 'notifications', 'desc')) + .slice(offset, offset + limit) + .map((o) => ({ + ...o, + epingle: pinnedOperators.includes(+o.numeroBio) + })) + + return reply.code(200).send({ + nbTotal: res.operators.length, + operators: paginatedOperators }) - .toSorted(recordSorts('fn', 'notifications', 'desc')) - .slice(offset, offset + limit) - .map((o) => ({ ...o, epingle: pinnedOperators.includes(+o.numeroBio) })) - - return reply.code(200).send({ nbTotal: res.operators.length, operators: paginatedOperators }) - }) - }) + }) + } + ) /** * @private * Retrieve operators for a given user for their dashboard */ - app.get('/api/v2/operators/dashboard', mergeSchemas(protectedWithToken({ oc: true, cartobio: true })), async (request, reply) => { - const { id: userId } = request.user - const { id: ocId } = request.user.organismeCertificateur - - return Promise.all([getPinnedOperators(userId), getConsultedOperators(userId)]) - .then(async ([pinnedNumerobios, consultedNumerobio]) => { - const uniqueNumerobios = [...new Set([...pinnedNumerobios, ...consultedNumerobio])] - const operators = ( - await fetchCustomersByOc(ocId)) - .filter( - (operator) => - uniqueNumerobios.includes(operator.numeroBio) && - operator.notifications.certification_state !== 'ARRETEE' && - operator.notifications.organismeCertificateurId === ocId && - ['ENGAGEE', 'ENGAGEE FUTUR'].includes(operator.notifications.etatCertification) - ) + app.get( + '/api/v2/operators/dashboard', + mergeSchemas(protectedWithToken({ oc: true, cartobio: true })), + async (request, reply) => { + const { id: userId } = request.user + const { id: ocId } = request.user.organismeCertificateur + + return Promise.all([ + getPinnedOperators(userId), + getConsultedOperators(userId) + ]).then(async ([pinnedNumerobios, consultedNumerobio]) => { + const uniqueNumerobios = [ + ...new Set([...pinnedNumerobios, ...consultedNumerobio]) + ] + const operators = (await fetchCustomersByOc(ocId)).filter( + (operator) => + uniqueNumerobios.includes(operator.numeroBio) && + operator.notifications.certification_state !== 'ARRETEE' && + operator.notifications.organismeCertificateurId === ocId && + ['ENGAGEE', 'ENGAGEE FUTUR'].includes( + operator.notifications.etatCertification + ) + ) return Promise.all(operators.map((o) => addRecordData(o))).then( - (operatorsWithData) => reply.code(200).send({ - pinnedOperators: pinnedNumerobios.filter((numeroBio) => (operatorsWithData.find((o) => o.numeroBio === numeroBio))).map((numeroBio) => ({ ...operatorsWithData.find((o) => o.numeroBio === numeroBio), epingle: true })), - consultedOperators: consultedNumerobio.filter((numeroBio) => (operatorsWithData.find((o) => o.numeroBio === numeroBio))).map((numeroBio) => ({ ...operatorsWithData.find((o) => o.numeroBio === numeroBio), epingle: pinnedNumerobios.includes(numeroBio) })) - }) + (operatorsWithData) => + reply.code(200).send({ + pinnedOperators: pinnedNumerobios + .filter((numeroBio) => + operatorsWithData.find((o) => o.numeroBio === numeroBio) + ) + .map((numeroBio) => ({ + ...operatorsWithData.find((o) => o.numeroBio === numeroBio), + epingle: true + })), + consultedOperators: consultedNumerobio + .filter((numeroBio) => + operatorsWithData.find((o) => o.numeroBio === numeroBio) + ) + .map((numeroBio) => ({ + ...operatorsWithData.find((o) => o.numeroBio === numeroBio), + epingle: pinnedNumerobios.includes(numeroBio) + })) + }) ) }) - }) + } + ) /** * @private * Retrieve operators for a given user for their dashboard */ - app.post('/api/v2/operators/dashboard-summary', mergeSchemas(dashboardSummarySchema, protectedWithToken({ oc: true, cartobio: true })), async (request, reply) => { - const { departements, anneeReferenceControle } = request.body - const { id: ocId } = request.user.organismeCertificateur - - return reply.code(200).send(getDashboardSummary(ocId, departements, anneeReferenceControle)) - }) + app.post( + '/api/v2/operators/dashboard-summary', + mergeSchemas( + dashboardSummarySchema, + protectedWithToken({ oc: true, cartobio: true }) + ), + async (request, reply) => { + const { departements, anneeReferenceControle } = request.body + const { id: ocId } = request.user.organismeCertificateur + + return reply + .code(200) + .send(getDashboardSummary(ocId, departements, anneeReferenceControle)) + } + ) /** * @private * Retrieve an operator */ - app.get('/api/v2/operator/:numeroBio', mergeSchemas(protectedWithToken(), operatorFromNumeroBio), async (request, reply) => { - const pinnedOperators = await getPinnedOperators(request.user.id) + app.get( + '/api/v2/operator/:numeroBio', + mergeSchemas(protectedWithToken(), operatorFromNumeroBio), + async (request, reply) => { + const pinnedOperators = await getPinnedOperators(request.user.id) - request.operator.epingle = pinnedOperators.includes(+request.operator.numeroBio) + request.operator.epingle = pinnedOperators.includes( + +request.operator.numeroBio + ) - return reply.code(200).send(request.operator) - }) + return reply.code(200).send(request.operator) + } + ) /** * @private * Pin an operator */ - app.post('/api/v2/operator/:numeroBio/pin', mergeSchemas(protectedWithToken()), async (request, reply) => { - await pinOperator(request.params.numeroBio, request.user.id) + app.post( + '/api/v2/operator/:numeroBio/pin', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + await pinOperator(request.params.numeroBio, request.user.id) - return reply.code(200).send({ epingle: true }) - }) + return reply.code(200).send({ epingle: true }) + } + ) /** * @private * Unpin an operator */ - app.post('/api/v2/operator/:numeroBio/unpin', mergeSchemas(protectedWithToken()), async (request, reply) => { - await unpinOperator(request.params.numeroBio, request.user.id) + app.post( + '/api/v2/operator/:numeroBio/unpin', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + await unpinOperator(request.params.numeroBio, request.user.id) - return reply.code(200).send({ epingle: false }) - }) + return reply.code(200).send({ epingle: false }) + } + ) /** * @private * Mark an operator as consulted */ - app.post('/api/v2/operator/:numeroBio/consulte', mergeSchemas(protectedWithToken()), async (request, reply) => { - await consultOperator(request.params.numeroBio, request.user.id) + app.post( + '/api/v2/operator/:numeroBio/consulte', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + await consultOperator(request.params.numeroBio, request.user.id) - return reply.code(204).send() - }) + return reply.code(204).send() + } + ) /** /** * @private * Retrieve an operator records */ - app.get('/api/v2/operator/:numeroBio/records', mergeSchemas(protectedWithToken(), operatorFromNumeroBio), async (request, reply) => { - const records = await getRecords(request.params.numeroBio) + app.get( + '/api/v2/operator/:numeroBio/records', + mergeSchemas(protectedWithToken(), operatorFromNumeroBio), + async (request, reply) => { + const records = await getRecords(request.params.numeroBio) + + if ( + !request.user.organismeCertificateur || + request.user.organismeCertificateur.id === + request.operator.organismeCertificateur.id + ) { + return reply.code(200).send(records) + } - if (!request.user.organismeCertificateur || request.user.organismeCertificateur.id === request.operator.organismeCertificateur.id) { - return reply.code(200).send(records) + return reply + .code(200) + .send( + records.filter( + (r) => r.oc_id === request.user.organismeCertificateur.id + ) + ) } - - return reply.code(200).send(records.filter((r) => r.oc_id === request.user.organismeCertificateur.id)) - }) + ) /** * @private * Checks if operator can import a pac record from 2025 */ - app.get('/api/v2/operator/:numeroBio/importData', mergeSchemas(protectedWithToken()), async (request, reply) => { - const res = await getImportPAC(request.params.numeroBio) - return reply.code(200).send({ data: res }) - }) + app.get( + '/api/v2/operator/:numeroBio/importData', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + const res = await getImportPAC(request.params.numeroBio) + return reply.code(200).send({ data: res }) + } + ) /** * @private * Hide import PAC 2025 notif */ - app.patch('/api/v2/operator/:numeroBio/hideNotif', mergeSchemas(protectedWithToken()), async (request, reply) => { - await hideImport(request.params.numeroBio) - return reply.code(204).send() - }) + app.patch( + '/api/v2/operator/:numeroBio/hideNotif', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + await hideImport(request.params.numeroBio) + return reply.code(204).send() + } + ) /** * Retrieve a given Record */ - app.get('/api/v2/audits/:recordId', mergeSchemas(protectedWithToken(), operatorFromRecordId), (request, reply) => { - return reply.code(200).send(request.record) - }) + app.get( + '/api/v2/audits/:recordId', + mergeSchemas(protectedWithToken(), operatorFromRecordId), + (request, reply) => { + return reply.code(200).send(request.record) + } + ) /** * Retrieve a given Record */ - app.get('/api/v2/audits/:recordId/has-attestation-production', mergeSchemas(protectedWithToken(), operatorFromRecordId), async (request, reply) => { - const attestation = await getAttestationProduction(request.record.record_id) - - return reply.code(200).send({ hasAttestationProduction: !!attestation }) - }) + app.get( + '/api/v2/audits/:recordId/has-attestation-production', + mergeSchemas(protectedWithToken(), operatorFromRecordId), + async (request, reply) => { + const attestation = await getAttestationProduction( + request.record.record_id + ) + + return reply.code(200).send({ hasAttestationProduction: !!attestation }) + } + ) /** * @private * Marque une parcelle comme controlée */ - app.post('/api/v2/audits/:recordId/:id/controlee', mergeSchemas(protectedWithToken()), async (request, reply) => { - await markFeatureControlled(request.params.recordId, request.params.id, request.user.id) - - return reply.code(200).send({ controlee: true }) - }) + app.post( + '/api/v2/audits/:recordId/:id/controlee', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + await markFeatureControlled( + request.params.recordId, + request.params.id, + request.user.id + ) + + return reply.code(200).send({ controlee: true }) + } + ) /** - * @private + * @private * Marque une parcelle comme non controlée - */ - app.post('/api/v2/audits/:recordId/:id/non-controlee', mergeSchemas(protectedWithToken()), async (request, reply) => { - await markFeatureUncontrolled(request.params.recordId, request.params.id, request.user.id) - - return reply.code(200).send({ controlee: false }) - }) + */ + app.post( + '/api/v2/audits/:recordId/:id/non-controlee', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + await markFeatureUncontrolled( + request.params.recordId, + request.params.id, + request.user.id + ) + + return reply.code(200).send({ controlee: false }) + } + ) /** * Create a new Record for a given Operator */ - app.post('/api/v2/operator/:numeroBio/records', mergeSchemas( - createRecordSchema, - operatorFromNumeroBio, - checkCertificationStatus, - protectedWithToken() - ), async (request, reply) => { - const { numeroBio } = request.params - const { id: ocId, nom: ocLabel } = request.operator.organismeCertificateur - const record = await createOrUpdateOperatorRecord( - { numerobio: numeroBio, oc_id: ocId, oc_label: ocLabel, ...request.body }, - { user: request.user, copyParcellesData: request.body.importPrevious, previousRecordId: request.body.recordId } - ) - return reply.code(200).send(normalizeRecord(record)) - }) + app.post( + '/api/v2/operator/:numeroBio/records', + mergeSchemas( + createRecordSchema, + operatorFromNumeroBio, + checkCertificationStatus, + protectedWithToken() + ), + async (request, reply) => { + const { numeroBio } = request.params + const { id: ocId, nom: ocLabel } = + request.operator.organismeCertificateur + const record = await createOrUpdateOperatorRecord( + { + numerobio: numeroBio, + oc_id: ocId, + oc_label: ocLabel, + ...request.body + }, + { + user: request.user, + copyParcellesData: request.body.importPrevious, + previousRecordId: request.body.recordId + } + ) + return reply.code(200).send(normalizeRecord(record)) + } + ) /** * Delete a given Record */ - app.delete('/api/v2/audits/:recordId', mergeSchemas(protectedWithToken(), routeWithRecordId), async (request, reply) => { - const { user, record } = request - await deleteRecord({ user, record }) - return reply.code(204).send() - }) + app.delete( + '/api/v2/audits/:recordId', + mergeSchemas(protectedWithToken(), routeWithRecordId), + async (request, reply) => { + const { user, record } = request + await deleteRecord({ user, record }) + return reply.code(204).send() + } + ) /** * Partial update Record's metadata (top-level properties except features) * It also keep track of new HistoryEvent along the way, depending who and when you update feature properties */ - app.patch('/api/v2/audits/:recordId', mergeSchemas( - protectedWithToken(), - patchRecordSchema, - operatorFromRecordId, - routeWithRecordId - ), (request, reply) => { - const { body: patch, user, record, operator } = request - - return updateAuditRecordState({ user, record, operator }, patch) - .then(record => reply.code(200).send(normalizeRecord(record))) - }) + app.patch( + '/api/v2/audits/:recordId', + mergeSchemas( + protectedWithToken(), + patchRecordSchema, + operatorFromRecordId, + routeWithRecordId + ), + (request, reply) => { + const { body: patch, user, record, operator } = request + + return updateAuditRecordState({ user, record, operator }, patch).then( + (record) => reply.code(200).send(normalizeRecord(record)) + ) + } + ) /** * Add new feature entries to an existing collection */ - app.post('/api/v2/audits/:recordId/parcelles', mergeSchemas( - protectedWithToken(), - createFeatureSchema, - routeWithRecordId, - operatorFromRecordId - ), (request, reply) => { - const { feature } = request.body - const { user, record, operator } = request - - return addRecordFeature({ user, record, operator }, feature) - .then(record => reply.code(200).send(normalizeRecord(record))) - }) + app.post( + '/api/v2/audits/:recordId/parcelles', + mergeSchemas( + protectedWithToken(), + createFeatureSchema, + routeWithRecordId, + operatorFromRecordId + ), + (request, reply) => { + const { feature } = request.body + const { user, record, operator } = request + + return addRecordFeature({ user, record, operator }, feature).then( + (record) => reply.code(200).send(normalizeRecord(record)) + ) + } + ) /** * Get features of specific record id */ - app.get('/api/v2/audits/:recordId/parcelles', mergeSchemas( - protectedWithToken(), - operatorFromRecordId - ), (request, reply) => { - const { record } = request - return reply.code(200).send(record.parcelles) - }) + app.get( + '/api/v2/audits/:recordId/parcelles', + mergeSchemas(protectedWithToken(), operatorFromRecordId), + (request, reply) => { + const { record } = request + return reply.code(200).send(record.parcelles) + } + ) /** * Partial update a feature collection (ie: mass action from the collection screen) * * Matching features are updated, features not present in payload or database are ignored */ - app.patch('/api/v2/audits/:recordId/parcelles', mergeSchemas( - protectedWithToken(), - patchFeatureCollectionSchema, - routeWithRecordId, - operatorFromRecordId - ), (request, reply) => { - const { body: featureCollection, user, record, operator } = request - - return patchFeatureCollection({ user, record, operator }, featureCollection.features) - .then(record => reply.code(200).send(normalizeRecord(record))) - }) + app.patch( + '/api/v2/audits/:recordId/parcelles', + mergeSchemas( + protectedWithToken(), + patchFeatureCollectionSchema, + routeWithRecordId, + operatorFromRecordId + ), + (request, reply) => { + const { body: featureCollection, user, record, operator } = request + + return patchFeatureCollection( + { user, record, operator }, + featureCollection.features + ).then((record) => reply.code(200).send(normalizeRecord(record))) + } + ) /** * Partial update a single feature (ie: feature form from an editing modal) @@ -484,358 +751,547 @@ app.register(async (app) => { * Absent properties are kept as is, new properties are added, existing properties are updated * ('culture' field is not a special case, it's just a regular property that can be replaced) */ - app.patch('/api/v2/audits/:recordId/parcelles/:featureId', mergeSchemas( - protectedWithToken(), - updateFeaturePropertiesSchema, - routeWithRecordId, - operatorFromRecordId - ), (request, reply) => { - const { body: feature, user, record, operator } = request - const { featureId } = request.params - - return updateFeature({ featureId, user, record, operator }, feature) - .then(record => reply.code(200).send(normalizeRecord(record))) - }) + app.patch( + '/api/v2/audits/:recordId/parcelles/:featureId', + mergeSchemas( + protectedWithToken(), + updateFeaturePropertiesSchema, + routeWithRecordId, + operatorFromRecordId + ), + (request, reply) => { + const { body: feature, user, record, operator } = request + const { featureId } = request.params + + return updateFeature({ featureId, user, record, operator }, feature).then( + (record) => reply.code(200).send(normalizeRecord(record)) + ) + } + ) /** * Delete a single feature */ - app.delete('/api/v2/audits/:recordId/parcelles/:featureId', mergeSchemas( - protectedWithToken(), - deleteSingleFeatureSchema, - routeWithRecordId, - operatorFromRecordId - ), (request, reply) => { - const { user, record, operator } = request - const { reason } = request.body - const { featureId } = request.params - - return deleteSingleFeature({ featureId, user, record, operator }, { reason }) - .then(record => reply.code(200).send(normalizeRecord(record))) - }) - - app.put('/api/v2/audits/:recordId/parcelles', mergeSchemas(protectedWithToken(), routeWithRecordId, operatorFromRecordId), (request, reply) => { - const { user, record, operator } = request - const { features, from } = request.body - - return createFeaturesFromOther(user, record, operator, features, from) - .then(record => reply.code(200).send(normalizeRecord(record))) - }) + app.delete( + '/api/v2/audits/:recordId/parcelles/:featureId', + mergeSchemas( + protectedWithToken(), + deleteSingleFeatureSchema, + routeWithRecordId, + operatorFromRecordId + ), + (request, reply) => { + const { user, record, operator } = request + const { reason } = request.body + const { featureId } = request.params + + return deleteSingleFeature( + { featureId, user, record, operator }, + { reason } + ).then((record) => reply.code(200).send(normalizeRecord(record))) + } + ) + + app.put( + '/api/v2/audits/:recordId/parcelles', + mergeSchemas(protectedWithToken(), routeWithRecordId, operatorFromRecordId), + (request, reply) => { + const { user, record, operator } = request + const { features, from } = request.body + + return createFeaturesFromOther( + user, + record, + operator, + features, + from + ).then((record) => reply.code(200).send(normalizeRecord(record))) + } + ) /** * Turn a Telepac XML or Telepac zipped Shapefile into a workeable FeatureCollection * It's essentially used during an import process to preview its content * @private */ - app.post('/api/v2/convert/telepac/geojson', mergeSchemas(protectedWithToken({ oc: true, cartobio: true })), async (request, reply) => { - return parseTelepacArchive(request.file()) - .then(geojson => reply.send(geojson)) - }) + app.post( + '/api/v2/convert/telepac/geojson', + mergeSchemas(protectedWithToken({ oc: true, cartobio: true })), + async (request, reply) => { + return parseTelepacArchive(request.file()).then((geojson) => + reply.send(geojson) + ) + } + ) /** * Turn a Geofolia file into a workeable FeatureCollection * It's essentially used during an import process to preview its content * @private */ - app.post('/api/v2/convert/geofolia/geojson', mergeSchemas(protectedWithToken()), async (request, reply) => { - const data = await request.file() - - return parseGeofoliaArchive(await data.toBuffer()) - .then(geojson => reply.send(geojson)) - }) + app.post( + '/api/v2/convert/geofolia/geojson', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + const data = await request.file() + + return parseGeofoliaArchive(await data.toBuffer()).then((geojson) => + reply.send(geojson) + ) + } + ) /** * Turn a geographical file workeable FeatureCollection * It's essentially used during an import process to preview its content * @private */ - app.post('/api/v2/convert/anygeo/geojson', mergeSchemas(protectedWithToken()), async (request, reply) => { - return parseAnyGeographicalArchive(request.file()) - .then(geojson => reply.send(geojson)) - }) + app.post( + '/api/v2/convert/anygeo/geojson', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + return parseAnyGeographicalArchive(request.file()).then((geojson) => + reply.send(geojson) + ) + } + ) /** * Retrieves all features associated to a PACAGE as a workeable FeatureCollection */ - app.get('/api/v2/import/pacage/:numeroPacage', mergeSchemas(protectedWithToken({ cartobio: true, oc: true }), routeWithPacage), async (request, reply) => { - const { numeroPacage } = request.params - - return pacageLookup({ numeroPacage }) - .then(featureCollection => reply.send(featureCollection)) - }) + app.get( + '/api/v2/import/pacage/:numeroPacage', + mergeSchemas( + protectedWithToken({ cartobio: true, oc: true }), + routeWithPacage + ), + async (request, reply) => { + const { numeroPacage } = request.params + + return pacageLookup({ numeroPacage }).then((featureCollection) => + reply.send(featureCollection) + ) + } + ) /** * Checks if an operator has Geofolink features * It triggers a data order, which has the benefit to break the waiting time in two */ - app.head('/api/v2/import/geofolia/:numeroBio', mergeSchemas(protectedWithToken({ cartobio: true, oc: true }), operatorFromNumeroBio, geofoliaImportSchema), async (request, reply) => { - const { siret } = request.operator - const { year } = request.query - - const isWellKnown = await geofoliaLookup(siret, year) - - return reply.code(isWellKnown === true ? 204 : 404).send() - }) + app.head( + '/api/v2/import/geofolia/:numeroBio', + mergeSchemas( + protectedWithToken({ cartobio: true, oc: true }), + operatorFromNumeroBio, + geofoliaImportSchema + ), + async (request, reply) => { + const { siret } = request.operator + const { year } = request.query + + const isWellKnown = await geofoliaLookup(siret, year) + + return reply.code(isWellKnown === true ? 204 : 404).send() + } + ) /** * Retrieves all features associated to a given SIRET linked to a numeroBio */ - app.get('/api/v2/import/geofolia/:numeroBio', mergeSchemas(protectedWithToken({ cartobio: true, oc: true }), operatorFromNumeroBio), async (request, reply) => { - const { siret } = request.operator - - const featureCollection = await geofoliaParcellaire(siret) + app.get( + '/api/v2/import/geofolia/:numeroBio', + mergeSchemas( + protectedWithToken({ cartobio: true, oc: true }), + operatorFromNumeroBio + ), + async (request, reply) => { + const { siret } = request.operator + + const featureCollection = await geofoliaParcellaire(siret) + + if (!featureCollection) { + return reply.code(202).send() + } - if (!featureCollection) { - return reply.code(202).send() + return reply.send(featureCollection) } - - return reply.send(featureCollection) - }) + ) /** * Retrieves all features associated to an EVV associated to a numeroBio * You still have to add geometries to the collection. * Features contains a 'cadastre' property with references to fetch */ - app.get('/api/v2/import/evv/:numeroEvv(\\d+)+:numeroBio(\\d+)', mergeSchemas(protectedWithToken({ cartobio: true, oc: true }), operatorFromNumeroBio), async (request, reply) => { - const { numeroEvv } = request.params - const { siret: expectedSiret } = request.operator - - if (!expectedSiret) { - throw new InvalidRequestApiError('Le numéro SIRET de l\'opérateur n\'est pas renseigné sur le portail de Notification de l\'Agence Bio. Il est indispensable pour sécuriser la collecte du parcellaire viticole auprès des Douanes.') - } + app.get( + '/api/v2/import/evv/:numeroEvv(\\d+)+:numeroBio(\\d+)', + mergeSchemas( + protectedWithToken({ cartobio: true, oc: true }), + operatorFromNumeroBio + ), + async (request, reply) => { + const { numeroEvv } = request.params + const { siret: expectedSiret } = request.operator + + if (!expectedSiret) { + throw new InvalidRequestApiError( + "Le numéro SIRET de l'opérateur n'est pas renseigné sur le portail de Notification de l'Agence Bio. Il est indispensable pour sécuriser la collecte du parcellaire viticole auprès des Douanes." + ) + } - return evvLookup({ numeroEvv }) - .then(({ siret }) => { - if (!siret) { - throw new NotFoundApiError('Ce numéro EVV est introuvable') - } else if (siret !== expectedSiret) { - throw new UnauthorizedApiError('les numéros SIRET du nCVI et de l\'opérateur Agence Bio ne correspondent pas.') - } - }) - .then(() => evvParcellaire({ numeroEvv })) - .then(featureCollection => { - if (featureCollection.features.length === 0) { - throw new NotFoundApiError('Ce numéro EVV ne retourne pas de parcelles.') - } + return evvLookup({ numeroEvv }) + .then(({ siret }) => { + if (!siret) { + throw new NotFoundApiError('Ce numéro EVV est introuvable') + } else if (siret !== expectedSiret) { + throw new UnauthorizedApiError( + "les numéros SIRET du nCVI et de l'opérateur Agence Bio ne correspondent pas." + ) + } + }) + .then(() => evvParcellaire({ numeroEvv })) + .then((featureCollection) => { + if (featureCollection.features.length === 0) { + throw new NotFoundApiError( + 'Ce numéro EVV ne retourne pas de parcelles.' + ) + } - return reply.send(featureCollection) - }) - }) + return reply.send(featureCollection) + }) + } + ) - app.post('/api/v2/certification/parcelles', mergeSchemas(protectedWithToken({ oc: true }), { - preParsing: async (request, reply, payload) => { - const stream = payload.pipe(stripBom()) + app.post( + '/api/v2/certification/parcelles', + mergeSchemas(protectedWithToken({ oc: true }), { + preParsing: async (request, reply, payload) => { + const stream = payload.pipe(stripBom()) - request.APIResult = await parcellaireStreamToDb(stream, request.organismeCertificateur) - request.headers['content-length'] = '2' - return new PassThrough().end('{}') - } - }), (request, reply) => { - const { count, errors, warnings } = request.APIResult + request.APIResult = await parcellaireStreamToDb( + stream, + request.organismeCertificateur + ) + request.headers['content-length'] = '2' + return new PassThrough().end('{}') + } + }), + (request, reply) => { + const { count, errors, warnings } = request.APIResult + + if (errors.length > 0) { + return reply.code(400).send({ + nbObjetTraites: count, + nbObjetAcceptes: count - errors.length, + nbObjetRefuses: errors.length, + listeProblemes: errors.map( + ([index, message]) => `[#${index}] ${message}` + ), + listeWarning: + warnings && warnings.length > 0 + ? warnings.map(([index, message]) => `[#${index}] ${message}`) + : [] + }) + } - if (errors.length > 0) { - return reply.code(400).send({ + return reply.code(202).send({ nbObjetTraites: count, - nbObjetAcceptes: count - errors.length, - nbObjetRefuses: errors.length, - listeProblemes: errors.map(([index, message]) => `[#${index}] ${message}`), - listeWarning: warnings && warnings.length > 0 ? warnings.map(([index, message]) => `[#${index}] ${message}`) : [] + listeWarning: + warnings && warnings.length > 0 + ? warnings.map(([index, message]) => `[#${index}] ${message}`) + : [] }) } + ) + + app.get( + '/api/v2/certification/parcellaires', + mergeSchemas(protectedWithToken({ oc: true })), + async (request, reply) => { + reply.header('Content-Type', 'application/json') + const { limit, start, anneeAudit, statut, anneeReferenceControle } = + request.query + const finalLimit = limit ? Number(limit) : null + const finalStart = start ? Number(start) : 0 + + const records = await iterateOperatorLastRecords( + request.organismeCertificateur.id, + { + anneeAudit, + statut, + anneeReferenceControle, + limit: finalLimit, + start: finalStart + } + ) + + const apiRecords = await Promise.all(records.map((r) => recordToApi(r))) + const links = {} + + const host = request.hostname + const baseUrl = `https://${host}${request.url.split('?')[0]}` + + if (finalLimit !== null && finalLimit > 0) { + if (finalStart > 0) { + const prevParams = new URLSearchParams(request.query) + prevParams.set('start', Math.max(0, finalStart - finalLimit)) + prevParams.set('limit', finalLimit) + links.prev = `${baseUrl}?${prevParams.toString()}` + } else { + links.prev = null + } - return reply.code(202).send({ - nbObjetTraites: count, - listeWarning: warnings && warnings.length > 0 ? warnings.map(([index, message]) => `[#${index}] ${message}`) : [] - }) - }) - - app.get('/api/v2/certification/parcellaires', mergeSchemas(protectedWithToken({ oc: true })), async (request, reply) => { - reply.header('Content-Type', 'application/json') - const { limit, start, anneeAudit, statut, anneeReferenceControle } = request.query - const finalLimit = limit ? Number(limit) : null - const finalStart = start ? Number(start) : 0 - - const records = await iterateOperatorLastRecords( - request.organismeCertificateur.id, - { - anneeAudit, - statut, - anneeReferenceControle, - limit: finalLimit, - start: finalStart - } - ) - - const apiRecords = await Promise.all(records.map(r => recordToApi(r))) - const links = {} - - const host = request.hostname - const baseUrl = `https://${host}${request.url.split('?')[0]}` - - if (finalLimit !== null && finalLimit > 0) { - if (finalStart > 0) { - const prevParams = new URLSearchParams(request.query) - prevParams.set('start', Math.max(0, finalStart - finalLimit)) - prevParams.set('limit', finalLimit) - links.prev = `${baseUrl}?${prevParams.toString()}` + if (records.length === finalLimit) { + const nextParams = new URLSearchParams(request.query) + nextParams.set('start', finalStart + finalLimit) + nextParams.set('limit', finalLimit) + links.next = `${baseUrl}?${nextParams.toString()}` + } else { + links.next = null + } } else { - links.prev = null - } + if (finalStart > 0) { + const prevParams = new URLSearchParams(request.query) + prevParams.delete('start') + links.prev = `${baseUrl}?${prevParams.toString()}` + } else { + links.prev = null + } - if (records.length === finalLimit) { - const nextParams = new URLSearchParams(request.query) - nextParams.set('start', finalStart + finalLimit) - nextParams.set('limit', finalLimit) - links.next = `${baseUrl}?${nextParams.toString()}` - } else { links.next = null } - } else { - if (finalStart > 0) { - const prevParams = new URLSearchParams(request.query) - prevParams.delete('start') - links.prev = `${baseUrl}?${prevParams.toString()}` - } else { - links.prev = null - } - links.next = null + return reply.code(200).send({ data: apiRecords, _links: links }) } + ) + + app.get( + '/api/v2/certification/parcellaire/:numeroBio', + mergeSchemas(protectedWithToken({ oc: true }), operatorFromNumeroBio), + async (request, reply) => { + const record = await getOperatorLastRecord(request.params.numeroBio, { + anneeAudit: request.query.anneeAudit, + statut: request.query.statut + }) + return reply.code(200).send(await recordToApi(record)) + } + ) + + app.get( + '/api/v2/pdf/:numeroBio/:recordId', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + const force = request.query.force_refresh === 'true' ?? false + + try { + const gen = generatePDF( + request.params.numeroBio, + request.params.recordId, + force + ) + const numberParcelle = (await gen.next()).value - return reply.code(200).send({ data: apiRecords, _links: links }) - }) - - app.get('/api/v2/certification/parcellaire/:numeroBio', mergeSchemas(protectedWithToken({ oc: true }), operatorFromNumeroBio), async (request, reply) => { - const record = await getOperatorLastRecord(request.params.numeroBio, { - anneeAudit: request.query.anneeAudit, - statut: request.query.statut - }) - return reply.code(200).send(await recordToApi(record)) - }) - - app.get('/api/v2/pdf/:numeroBio/:recordId', mergeSchemas(protectedWithToken()), async (request, reply) => { - const force = request.query.force_refresh === 'true' ?? false - - try { - const gen = generatePDF(request.params.numeroBio, request.params.recordId, force) - const numberParcelle = (await gen.next()).value - - console.log(numberParcelle) - if (numberParcelle > 80) { - reply.code(204).send() - } + console.log(numberParcelle) + if (numberParcelle > 80) { + reply.code(204).send() + } - const pdf = (await gen.next()).value - if (numberParcelle <= 80) { - return reply.code(200).send(pdf) + const pdf = (await gen.next()).value + if (numberParcelle <= 80) { + return reply.code(200).send(pdf) + } + } catch (e) { + return reply.code(400).send({ message: e.message }) } - } catch (e) { - return reply.code(400).send({ message: e.message }) } - }) - - app.get('/api/v2/user/verify', mergeSchemas(protectedWithToken({ oc: true, cartobio: true }), sandboxSchema, internalSchema), (request, reply) => { - const { user, organismeCertificateur } = request - - return reply.send(user ?? organismeCertificateur) - }) + ) + + app.get( + '/api/v2/user/verify', + mergeSchemas( + protectedWithToken({ oc: true, cartobio: true }), + sandboxSchema, + internalSchema + ), + (request, reply) => { + const { user, organismeCertificateur } = request + + return reply.send(user ?? organismeCertificateur) + } + ) /** * Exchange a notification.agencebio.org token for a CartoBio token */ - app.get('/api/v2/user/exchangeToken', internalSchema, async (request, reply) => { - const { error, decodedToken, token } = verifyNotificationAuthorization(request.headers.authorization) - - if (error) { - return new UnauthorizedApiError('impossible de vérifier ce jeton', { cause: error }) - } - - const [operator, userProfile] = await Promise.all([ - fetchOperatorByNumeroBio(decodedToken.numeroBio, token), - getUserProfileById(decodedToken.userId, token) - ]) + app.get( + '/api/v2/user/exchangeToken', + internalSchema, + async (request, reply) => { + const { error, decodedToken, token } = verifyNotificationAuthorization( + request.headers.authorization + ) + + if (error) { + return new UnauthorizedApiError('impossible de vérifier ce jeton', { + cause: error + }) + } - const sign = createSigner({ key: config.get('jwtSecret'), expiresIn: DURATION_ONE_HOUR * 2 }) + const [operator, userProfile] = await Promise.all([ + fetchOperatorByNumeroBio(decodedToken.numeroBio, token), + getUserProfileById(decodedToken.userId, token) + ]) - return reply.send({ - operator, - // @todo use Notification pubkey and time based token to passthrough the requests to both Agence Bio and CartoBio APIs - token: sign(userProfile) - }) - }) + const sign = createSigner({ + key: config.get('jwtSecret'), + expiresIn: DURATION_ONE_HOUR * 2 + }) - app.get('/api/v2/departements', mergeSchemas(protectedWithToken()), async (request, reply) => { - const departements = await getDepartement() - return reply.code(200).send(departements) - }) + return reply.send({ + operator, + // @todo use Notification pubkey and time based token to passthrough the requests to both Agence Bio and CartoBio APIs + token: sign(userProfile) + }) + } + ) + + app.get( + '/api/v2/departements', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + const departements = await getDepartement() + return reply.code(200).send(departements) + } + ) // usefull only in dev mode - app.get('/auth-provider/agencebio/login', hiddenSchema, (request, reply) => reply.redirect('/api/auth-provider/agencebio/login')) - app.get('/api/auth-provider/agencebio/callback', mergeSchemas(sandboxSchema, hiddenSchema), async (request, reply) => { - // forwards to the UI the user-selected tab - const { mode = '', returnto = '' } = stateCache.get(request.query.state) - const { token } = await app.agenceBioOAuth2.getAccessTokenFromAuthorizationCodeFlow(request) - const userProfile = await getUserProfileFromSSOToken(token.access_token) - const cartobioToken = sign(userProfile) - - return reply.redirect(`${config.get('frontendUrl')}/login?mode=${mode}&returnto=${returnto}#token=${cartobioToken}`) - }) - - app.post('/api/v2/exportParcellaire', mergeSchemas(protectedWithToken({ oc: true, cartobio: true })), async (request, reply) => { - const data = await exportDataOcId(request.user.organismeCertificateur.id, request.body.payload, request.user.id) - if (data === null) { - throw new Error("Une erreur s'est produite, impossible d'exporter les parcellaires") + app.get('/auth-provider/agencebio/login', hiddenSchema, (request, reply) => + reply.redirect('/api/auth-provider/agencebio/login') + ) + app.get( + '/api/auth-provider/agencebio/callback', + mergeSchemas(sandboxSchema, hiddenSchema), + async (request, reply) => { + // forwards to the UI the user-selected tab + const { mode = '', returnto = '' } = stateCache.get(request.query.state) + const { token } = + await app.agenceBioOAuth2.getAccessTokenFromAuthorizationCodeFlow( + request + ) + const userProfile = await getUserProfileFromSSOToken(token.access_token) + const cartobioToken = sign(userProfile) + + return reply.redirect( + `${config.get( + 'frontendUrl' + )}/login?mode=${mode}&returnto=${returnto}#token=${cartobioToken}` + ) } - return reply.code(200).send(data) - }) - - app.post('/api/v2/geometry/:recordId/add', mergeSchemas( - protectedWithToken() - ), (request, reply) => { - const { payload: feature } = request.body - return verifyGeometry(feature.geometry, request.params.recordId, feature.properties?.id ?? '') - .then(record => reply.code(200).send((record))) - }) + ) + + app.post( + '/api/v2/exportParcellaire', + mergeSchemas(protectedWithToken({ oc: true, cartobio: true })), + async (request, reply) => { + const data = await exportDataOcId( + request.user.organismeCertificateur.id, + request.body.payload, + request.user.id + ) + if (data === null) { + throw new Error( + "Une erreur s'est produite, impossible d'exporter les parcellaires" + ) + } + return reply.code(200).send(data) + } + ) + + app.post( + '/api/v2/geometry/:recordId/add', + mergeSchemas(protectedWithToken()), + (request, reply) => { + const { payload: feature } = request.body + return verifyGeometry( + feature.geometry, + request.params.recordId, + feature.properties?.id ?? '' + ).then((record) => reply.code(200).send(record)) + } + ) }) -app.post('/api/v2/geometry/rpg', mergeSchemas( - protectedWithToken() -), (request, reply) => { - const { extent, surface, codeCulture } = request.body - return getRpg(extent, surface, codeCulture) - .then(data => { +app.post( + '/api/v2/geometry/rpg', + mergeSchemas(protectedWithToken()), + (request, reply) => { + const { extent, surface, codeCulture } = request.body + return getRpg(extent, surface, codeCulture).then((data) => { if (data) { - return reply.code(200).send((data)) + return reply.code(200).send(data) } return reply.code(404).send() }) -}) + } +) + +app.post( + '/api/v3/geometry/border-cut', + mergeSchemas(protectedWithToken()), + async (request, reply) => { + const { + geometry, + distance, + allBorder, + isInverted, + startBorderPoint, + endBorderPoint + } = request.body + const result = calculateParcelBorder( + JSON.stringify(geometry), + distance, + allBorder, + isInverted, + startBorderPoint, + endBorderPoint + ) + if (!result) { + return reply.code(400).send() + } -app.post('/api/v2/geometry/geometryEquals', mergeSchemas( - protectedWithToken() -), (request, reply) => { - const { payload } = request.body - return getGeometryEquals(payload) - .then(data => reply.code(200).send((data))) -}) + return result + } +) + +app.post( + '/api/v2/geometry/geometryEquals', + mergeSchemas(protectedWithToken()), + (request, reply) => { + const { payload } = request.body + return getGeometryEquals(payload).then((data) => + reply.code(200).send(data) + ) + } +) if (require.main === module) { - db.query('SHOW server_version;').then(async ({ rows }) => { - const { server_version: pgVersion } = rows[0] - console.log(`Postgres connection established, v${pgVersion}`) + db.query('SHOW server_version;').then( + async ({ rows }) => { + const { server_version: pgVersion } = rows[0] + console.log(`Postgres connection established, v${pgVersion}`) - await app.ready() - await app.swagger() + await app.ready() + await app.swagger() - const address = await app.listen({ - host: config.get('host'), - port: config.get('port') - }) + const address = await app.listen({ + host: config.get('host'), + port: config.get('port') + }) - console.log(`Running env:${config.get('env')} on ${address}`) - }, () => console.error('Failed to connect to database')) + console.log(`Running env:${config.get('env')} on ${address}`) + }, + () => console.error('Failed to connect to database') + ) } module.exports = app From 469f55fcc5c328ba8d8799d0a36f3ba1ae87aacd Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Thu, 12 Feb 2026 10:16:25 +0100 Subject: [PATCH 02/14] fix: utilisation de buffer --- lib/providers/geometry.js | 197 ++++++++++++++++++++------------------ 1 file changed, 104 insertions(+), 93 deletions(-) diff --git a/lib/providers/geometry.js b/lib/providers/geometry.js index 236048ee..c1d8fd70 100644 --- a/lib/providers/geometry.js +++ b/lib/providers/geometry.js @@ -350,100 +350,111 @@ async function calculateParcelBorder ( } query = ` - WITH parcelle AS ( - SELECT ST_Transform( - ST_SetSRID(ST_GeomFromGeoJSON($1), 4326), - 3857 - ) AS geom - ), - contour AS ( - SELECT ST_ExteriorRing(geom) AS geom FROM parcelle - ), - points AS ( - SELECT - ST_Transform(ST_SetSRID(ST_MakePoint($3, $4), 4326), 3857) AS s, - ST_Transform(ST_SetSRID(ST_MakePoint($5, $6), 4326), 3857) AS e - ), - pos AS ( - SELECT - ST_LineLocatePoint(c.geom, p.s) AS sp, - ST_LineLocatePoint(c.geom, p.e) AS ep, - c.geom AS contour - FROM contour c, points p - ), - arc_normal AS ( - SELECT - ST_LineSubstring( - contour, - LEAST(sp, ep), - GREATEST(sp, ep) - ) AS geom - FROM pos - ), - arc_inverse AS ( - SELECT - ST_LineMerge( - ST_Collect( - ST_LineSubstring(contour, GREATEST(sp, ep), 1), - ST_LineSubstring(contour, 0, LEAST(sp, ep)) - ) - ) AS geom - FROM pos - ), - arc AS ( - SELECT geom FROM arc_normal WHERE $7 = 0 - UNION ALL - SELECT geom FROM arc_inverse WHERE $7 = 1 - ), - arc_offset_negatif AS ( - SELECT - ST_OffsetCurve(geom, (-1.0 * $2::float), 'quad_segs=8 join=round miter_limit=3') AS geom - FROM arc - ), - arc_offset_positif AS ( - SELECT - ST_OffsetCurve(geom, ($2::float), 'quad_segs=8 join=round miter_limit=3') AS geom - FROM arc - ), - arc_interieur AS ( - SELECT +WITH parcelle AS ( + SELECT ST_Transform( + ST_SetSRID(ST_GeomFromGeoJSON($1), 4326), + 3857 + ) AS geom +), + +contour AS ( + SELECT ST_ExteriorRing(geom) AS geom + FROM parcelle +), + +points AS ( + SELECT + ST_Transform(ST_SetSRID(ST_MakePoint($3, $4), 4326), 3857) AS s, + ST_Transform(ST_SetSRID(ST_MakePoint($5, $6), 4326), 3857) AS e +), + +points_proches AS ( + SELECT + ST_ClosestPoint(c.geom, p.s) AS start_point, + ST_ClosestPoint(c.geom, p.e) AS end_point, + c.geom AS contour + FROM contour c, points p +), + +pos AS ( + SELECT + ST_LineLocatePoint(contour, start_point) AS sp, + ST_LineLocatePoint(contour, end_point) AS ep, + contour + FROM points_proches +), + +arc AS ( + SELECT + CASE + WHEN $7 = 0 THEN + ST_LineSubstring(contour, LEAST(sp, ep), GREATEST(sp, ep)) + ELSE CASE - WHEN ST_Within(ST_Centroid(aon.geom), p.geom) THEN aon.geom - ELSE aop.geom - END AS geom - FROM arc_offset_negatif aon, arc_offset_positif aop, parcelle p - ), - bordure_avec_fermeture AS ( - SELECT - ST_MakeLine(ARRAY[ - ST_StartPoint(a.geom), - ST_StartPoint(ai.geom) - ]) AS ligne_debut, - ST_MakeLine(ARRAY[ - ST_EndPoint(a.geom), - ST_EndPoint(ai.geom) - ]) AS ligne_fin, - a.geom AS arc_ext, - ST_Reverse(ai.geom) AS arc_int - FROM arc a, arc_interieur ai - ), - bordure_3857 AS ( - SELECT - ST_MakePolygon( - ST_LineMerge( - ST_Collect(ARRAY[arc_ext, ligne_fin, arc_int, ligne_debut]) - ) - ) AS geom - FROM bordure_avec_fermeture - ), - sans_bordure AS ( - SELECT ST_Difference(p.geom, b.geom) AS geom - FROM parcelle p, bordure_3857 b - ) - SELECT - ST_AsGeoJSON(ST_Transform(sb.geom, 4326))::json AS parcelle_sans_bordure, - ST_AsGeoJSON(ST_Transform(b.geom, 4326))::json AS bordure - FROM sans_bordure sb, bordure_3857 b` + WHEN LEAST(sp, ep) < 0.001 THEN + ST_LineSubstring(contour, GREATEST(sp, ep), 1.0) + WHEN GREATEST(sp, ep) > 0.999 THEN + ST_LineSubstring(contour, 0.0, LEAST(sp, ep)) + ELSE + ST_LineMerge( + ST_Union( + ST_LineSubstring(contour, GREATEST(sp, ep), 1.0), + ST_LineSubstring(contour, 0.0, LEAST(sp, ep)) + ) + ) + END + END AS geom + FROM pos + WHERE ST_Length(contour) > 0 +), + +arc_buffer AS ( + SELECT + ST_Buffer( + geom, + ABS($2::float), + 'endcap=flat join=round quad_segs=8' + ) AS geom + FROM arc + WHERE geom IS NOT NULL +), + +bordure_3857 AS ( + SELECT + ST_Intersection(ab.geom, p.geom) AS geom + FROM arc_buffer ab, parcelle p + WHERE ST_Intersects(ab.geom, p.geom) +), + +bordure_valide AS ( + SELECT + CASE + WHEN ST_IsValid(geom) THEN geom + ELSE ST_MakeValid(geom) + END AS geom + FROM bordure_3857 + WHERE geom IS NOT NULL +), + +sans_bordure AS ( + SELECT + COALESCE( + ST_Difference(p.geom, b.geom), + p.geom + ) AS geom + FROM parcelle p + LEFT JOIN arc_buffer b ON true +) + +SELECT + ST_AsGeoJSON(ST_Transform(sb.geom, 4326))::json AS parcelle_sans_bordure, + COALESCE( + ST_AsGeoJSON(ST_Transform(b.geom, 4326))::json, + NULL + ) AS bordure +FROM sans_bordure sb +LEFT JOIN bordure_valide b ON true; +` const result = await pool.query(query, [ geometry, From 3c9824fd4c3be352c21c58084d79e2e53a723077 Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Thu, 12 Feb 2026 10:20:53 +0100 Subject: [PATCH 03/14] fix: import --- server.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/server.js b/server.js index a8fca34b..5e838fec 100644 --- a/server.js +++ b/server.js @@ -151,8 +151,6 @@ const { verifyGeometry, getRpg, getGeometryEquals, - computeFullBorder, - computePartialBorder, calculateParcelBorder } = require('./lib/providers/geometry.js') From 108d6f1171171ee9af1411c885fa082bdac057d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:07:17 +0000 Subject: [PATCH 04/14] chore(deps): bump tar Bumps and [tar](https://github.com/isaacs/node-tar). These dependencies needed to be updated together. Updates `tar` from 7.4.3 to 7.4.3 - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.4.3) Updates `tar` from 7.5.7 to 7.5.9 - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.4.3) --- updated-dependencies: - dependency-name: tar dependency-version: 7.4.3 dependency-type: indirect - dependency-name: tar dependency-version: 7.5.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14b4bd27..fd82ffb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11886,9 +11886,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From 37ac12998cc2ea3a09e01e86863ae5d037ce271b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:07:19 +0000 Subject: [PATCH 05/14] chore(deps-dev): bump fast-xml-parser from 4.4.1 to 5.3.6 Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 4.4.1 to 5.3.6. - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v4.4.1...v5.3.6) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 5.3.6 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 30 +++++++++++++++++------------- package.json | 2 +- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14b4bd27..210c5cec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.3.1", "fast-jwt": "^5.0.6", - "fast-xml-parser": "^4.4.1", + "fast-xml-parser": "^5.3.6", "fastify": "^5.7.3", "file-type": "^16.5.4", "form-data": "^4.0.4", @@ -6333,22 +6333,19 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", - "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" - }, - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" } ], + "license": "MIT", "dependencies": { - "strnum": "^1.0.5" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -11778,10 +11775,17 @@ } }, "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "dev": true + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" }, "node_modules/strtok3": { "version": "6.3.0", diff --git a/package.json b/package.json index e17561e9..d6a43079 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.3.1", "fast-jwt": "^5.0.6", - "fast-xml-parser": "^4.4.1", + "fast-xml-parser": "^5.3.6", "fastify": "^5.7.3", "file-type": "^16.5.4", "form-data": "^4.0.4", From 2278b5ea20327a6875604eb3a26b1fcd837432f3 Mon Sep 17 00:00:00 2001 From: GregoireDucharme Date: Thu, 19 Feb 2026 17:49:40 +0100 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20Supprime=20le=20z=20des=20coordon?= =?UTF-8?q?=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/gdal.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/providers/gdal.js b/lib/providers/gdal.js index e5b70d3b..fd533328 100644 --- a/lib/providers/gdal.js +++ b/lib/providers/gdal.js @@ -330,7 +330,7 @@ function stripZFromGeometry (geometry) { for (let i = 0; i < geometry.points.count(); i++) { const pt = geometry.points.get(i) if (!isNaN(pt.x) && !isNaN(pt.y)) { - cleanedLine.points.add({ x: pt.x, y: pt.y, z: isNaN(pt.z) ? 0 : pt.z }) + cleanedLine.points.add({ x: pt.x, y: pt.y }) } } return cleanedLine } @@ -343,7 +343,7 @@ function stripZFromGeometry (geometry) { for (let j = 0; j < oldRing.points.count(); j++) { const pt = oldRing.points.get(j) if (!isNaN(pt.x) && !isNaN(pt.y)) { - newRing.points.add({ x: pt.x, y: pt.y, z: isNaN(pt.z) ? 0 : pt.z }) + newRing.points.add({ x: pt.x, y: pt.y }) } } cleanedPolygon.rings.add(newRing) From c745367ef705801e9d734dfa67b88311813a3184 Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Tue, 24 Feb 2026 10:08:40 +0100 Subject: [PATCH 07/14] fix: buffer --- lib/providers/geometry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/geometry.js b/lib/providers/geometry.js index c1d8fd70..bfd62a28 100644 --- a/lib/providers/geometry.js +++ b/lib/providers/geometry.js @@ -413,7 +413,7 @@ arc_buffer AS ( ST_Buffer( geom, ABS($2::float), - 'endcap=flat join=round quad_segs=8' + 'endcap=square join=round quad_segs=8' ) AS geom FROM arc WHERE geom IS NOT NULL From 79bdc390e7234040bc8123d9a797c75bee2e0aba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:36:42 +0000 Subject: [PATCH 08/14] chore(deps): bump basic-ftp from 5.0.5 to 5.2.0 Bumps [basic-ftp](https://github.com/patrickjuchli/basic-ftp) from 5.0.5 to 5.2.0. - [Release notes](https://github.com/patrickjuchli/basic-ftp/releases) - [Changelog](https://github.com/patrickjuchli/basic-ftp/blob/master/CHANGELOG.md) - [Commits](https://github.com/patrickjuchli/basic-ftp/compare/v5.0.5...v5.2.0) --- updated-dependencies: - dependency-name: basic-ftp dependency-version: 5.2.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98e15419..e984eb75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4209,9 +4209,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "license": "MIT", "engines": { "node": ">=10.0.0" From 574f1abb4ec1f1e163bbce2cee4fa37705f64bc9 Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Mon, 2 Mar 2026 11:37:31 +0100 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20redirection=20=C3=A0=20partir=20d?= =?UTF-8?q?u=20portail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/server.js b/server.js index 4dc73f63..adef25b9 100644 --- a/server.js +++ b/server.js @@ -841,4 +841,31 @@ if (require.main === module) { }, () => console.error('Failed to connect to database')) } +app.get('/api/external/exploitations/:numeroBio', (req, res) => { + const { numeroBio } = req.params + + if (!numeroBio || isNaN(Number(numeroBio)) || Number(numeroBio) <= 0) { + return res.status(400).send('numeroBio invalide') + } + + const state = randomUUID() + stateCache.set(state, { + returnto: `/exploitations/${numeroBio}` + }) + + const ssoHost = config.get('notifications.sso.host') + const clientId = config.get('notifications.sso.clientId') + const callbackUri = config.get('notifications.sso.callbackUri') + + const authUrl = new URL(`${ssoHost}/oauth2/auth`) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('redirect_uri', callbackUri) + authUrl.searchParams.set('scope', 'openid') + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('login_hint', 'skip_consent') + + return res.redirect(authUrl.toString()) +}) + module.exports = app From 3602633ead83858011f4ea690e9740abc91d4085 Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Tue, 10 Mar 2026 08:46:01 +0100 Subject: [PATCH 10/14] feat: ajout d'un profile administrateur --- lib/providers/agence-bio.js | 70 +++++++++++++++++++++++++++++++++++++ lib/providers/cartobio.js | 27 +++++++++++++- server.js | 12 +++++-- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/lib/providers/agence-bio.js b/lib/providers/agence-bio.js index 08df3ce0..b1a18e99 100644 --- a/lib/providers/agence-bio.js +++ b/lib/providers/agence-bio.js @@ -178,6 +178,60 @@ async function _getOperatorsByOc ({ serviceToken, oc }) { }) } +/** + * Returs operators for admin profile + * +*/ +async function _getOperatorsForAdmin ({ serviceToken, input = '' }) { + const limit = 10000 + + const fetchPage = (searchParams) => + get(`${config.get('notifications.endpoint')}/api/operateurs/cartobio`, { + headers: { + Authorization: serviceToken, + Origin + }, + searchParams: { limit, ...searchParams } + }).json() + + const fetchAllPages = async (baseParams) => { + const data = await fetchPage(baseParams) + if (data.nbTotal === data.operateurs.length) { + return data.operateurs + } + + const requests = [] + for (let page = 1; page * limit < data.nbTotal; page++) { + requests.push(fetchPage({ ...baseParams, page: page + 1 })) + } + + const results = await Promise.all(requests) + return results.reduce((all, res) => all.concat(res.operateurs), data.operateurs) + } + + const isInteger = Number.isInteger(Number(input)) + + const results = await Promise.all([ + fetchAllPages({ nom: input }), + ...(isInteger + ? [ + fetchAllPages({ numeroBio: input }), + fetchAllPages({ siret: input }) + ] + : []) + ]) + + const seen = new Set() + return results + .flat() + .filter((op) => { + if (seen.has(op.numeroBio)) return false + seen.add(op.numeroBio) + return true + }) + .map(normalizeOperator) +} + /** * Returns operators related to an OC, and eventual filters (numeroBio, or pacage) * @@ -189,6 +243,17 @@ const getOperatorsByOc = memo(_getOperatorsByOc, { cacheKey: JSON.stringify }) +/** + * Returns operators related to admin, and eventual filters (numeroBio, or pacage) + * + * @param {{serviceToken: String, oc: number, numeroBio: String?, pacage: String?, nom: String?, siret: String?, input: String? }} params + * @returns {Promise} + */ +const getOperatorsForAdmin = memo(_getOperatorsForAdmin, { + maxAge: 10 * ONE_MINUTE, + cacheKey: JSON.stringify +}) + /** * Returns operators for a given user * @param userId @@ -215,6 +280,10 @@ async function fetchCustomersByOc (oc) { return (await getOperatorsByOc({ serviceToken, oc })).filter((operator) => operator.notifications != null) } +async function fetchCustomersByAdmin ({ input = '' }) { + return (await getOperatorsForAdmin({ serviceToken, input })).filter((operator) => operator.notifications != null) +} + /** * @param { string } input * @param { number } oc @@ -325,6 +394,7 @@ module.exports = { fetchOperatorByNumeroBio, fetchUserOperators, fetchCustomersByOc, + fetchCustomersByAdmin, fetchCustomersByOcWithRecords, getUserProfileById, getUserProfileFromSSOToken, diff --git a/lib/providers/cartobio.js b/lib/providers/cartobio.js index 2cb369f4..70ef0807 100644 --- a/lib/providers/cartobio.js +++ b/lib/providers/cartobio.js @@ -14,7 +14,7 @@ const { SocksProxyAgent } = require('socks-proxy-agent') const pool = require('../db.js') const config = require('../config.js') const { EtatProduction, CertificationState, EventType /* TMP, RegionBounds */ } = require('../enums') -const { parsePacDetailsFromComment, /* TMP fetchOperatorByNumeroBio, */ fetchCustomersByOc, fetchCustomersByOcWithRecords, fetchUserOperators } = require('./agence-bio.js') +const { parsePacDetailsFromComment, /* TMP fetchOperatorByNumeroBio, */ fetchCustomersByOc, fetchCustomersByOcWithRecords, fetchUserOperators, fetchCustomersByAdmin } = require('./agence-bio.js') const { normalizeRecord, normalizeRecordSummary, normalizeEtatProduction } = require('../outputs/record.js') const { randomUUID } = require('crypto') const { fromCodePacStrict } = require('@agencebio/rosetta-cultures') @@ -1523,6 +1523,30 @@ async function searchControlBodyRecords ({ ocId, userId, input, page, filter, li } } +/** + * @param {{ocId: number, userId: number, input: string, page: number, filter: any, limit?: number }} params + * @returns {Promise<{pagination: { page: number, total: number, page_max: number }, records: AgenceBioNormalizedOperatorWithRecord[]}>} + */ +async function searchControlBodyRecordsAdmin ({ input, page, filter, limit = 7 }) { + const records = await fetchCustomersByAdmin({ input, ...filter }) + + const pagination = { + page, + total: records.length, + page_max: Math.max(Math.ceil(records.length / limit), 1) + } + + const pageRecords = records + .sort((a, b) => sortRecord(a, b, filter.sort)) + .slice((pagination.page - 1) * limit, pagination.page * limit) + .map((record) => addRecordData(record)) + + return { + pagination, + records: await Promise.all(pageRecords) + } +} + /** * @param {Number} ocId * @param {Number} userId @@ -2241,5 +2265,6 @@ module.exports = { getImportPAC, hideImport, getFeaturesFromRecordId, + searchControlBodyRecordsAdmin, ...(process.env.NODE_ENV === 'test' ? { evvClient } : {}) } diff --git a/server.js b/server.js index 4dc73f63..d8babf59 100644 --- a/server.js +++ b/server.js @@ -60,8 +60,8 @@ const { PassThrough } = require('stream') const { createSigner } = require('fast-jwt') -const { fetchOperatorByNumeroBio, getUserProfileById, getUserProfileFromSSOToken, verifyNotificationAuthorization, fetchUserOperators, fetchCustomersByOc } = require('./lib/providers/agence-bio.js') -const { addRecordFeature, createFeaturesFromOther, patchFeatureCollection, updateAuditRecordState, updateFeature, createOrUpdateOperatorRecord, parcellaireStreamToDb, deleteSingleFeature, getRecords, deleteRecord, getOperatorLastRecord, searchControlBodyRecords, getDepartement, recordSorts, pinOperator, unpinOperator, consultOperator, getDashboardSummary, exportDataOcId, searchForAutocomplete, getImportPAC, hideImport, markFeatureControlled, markFeatureUncontrolled } = require('./lib/providers/cartobio.js') +const { fetchOperatorByNumeroBio, getUserProfileById, getUserProfileFromSSOToken, verifyNotificationAuthorization, fetchUserOperators, fetchCustomersByOc, fetchCustomersByAdmin } = require('./lib/providers/agence-bio.js') +const { addRecordFeature, createFeaturesFromOther, patchFeatureCollection, updateAuditRecordState, updateFeature, createOrUpdateOperatorRecord, parcellaireStreamToDb, deleteSingleFeature, getRecords, deleteRecord, getOperatorLastRecord, searchControlBodyRecords, getDepartement, recordSorts, pinOperator, unpinOperator, consultOperator, getDashboardSummary, exportDataOcId, searchForAutocomplete, getImportPAC, hideImport, markFeatureControlled, markFeatureUncontrolled, searchControlBodyRecordsAdmin } = require('./lib/providers/cartobio.js') const { generatePDF, getAttestationProduction } = require('./lib/providers/export-pdf.js') const { evvLookup, evvParcellaire, pacageLookup, iterateOperatorLastRecords } = require('./lib/providers/cartobio.js') const { parseAnyGeographicalArchive } = require('./lib/providers/gdal.js') @@ -198,6 +198,14 @@ app.register(async (app) => { return reply.code(200).send(searchControlBodyRecords({ ocId, userId: request.user.id, input, page, limit, filter })) }) + /** + * @private + */ + app.post('/api/v2/certification/adminsearch', mergeSchemas(certificationBodySearchSchema, protectedWithToken()), async (request, reply) => { + const { input, page, limit, filter } = request.body + return reply.code(200).send(searchControlBodyRecordsAdmin({ input, page, limit, filter })) + }) + /** * @private */ From ca2a922cff596b54829750c7c8e664f1f7ea973d Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Tue, 10 Mar 2026 08:54:05 +0100 Subject: [PATCH 11/14] fix: import inutile --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index d8babf59..2f92a060 100644 --- a/server.js +++ b/server.js @@ -60,7 +60,7 @@ const { PassThrough } = require('stream') const { createSigner } = require('fast-jwt') -const { fetchOperatorByNumeroBio, getUserProfileById, getUserProfileFromSSOToken, verifyNotificationAuthorization, fetchUserOperators, fetchCustomersByOc, fetchCustomersByAdmin } = require('./lib/providers/agence-bio.js') +const { fetchOperatorByNumeroBio, getUserProfileById, getUserProfileFromSSOToken, verifyNotificationAuthorization, fetchUserOperators, fetchCustomersByOc } = require('./lib/providers/agence-bio.js') const { addRecordFeature, createFeaturesFromOther, patchFeatureCollection, updateAuditRecordState, updateFeature, createOrUpdateOperatorRecord, parcellaireStreamToDb, deleteSingleFeature, getRecords, deleteRecord, getOperatorLastRecord, searchControlBodyRecords, getDepartement, recordSorts, pinOperator, unpinOperator, consultOperator, getDashboardSummary, exportDataOcId, searchForAutocomplete, getImportPAC, hideImport, markFeatureControlled, markFeatureUncontrolled, searchControlBodyRecordsAdmin } = require('./lib/providers/cartobio.js') const { generatePDF, getAttestationProduction } = require('./lib/providers/export-pdf.js') const { evvLookup, evvParcellaire, pacageLookup, iterateOperatorLastRecords } = require('./lib/providers/cartobio.js') From f5f095f27fffed33ef0ae0ba691c81fd9e0dc1e3 Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Tue, 10 Mar 2026 10:21:18 +0100 Subject: [PATCH 12/14] fix: test --- lib/providers/agence-bio.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/providers/agence-bio.js b/lib/providers/agence-bio.js index b1a18e99..18780d9a 100644 --- a/lib/providers/agence-bio.js +++ b/lib/providers/agence-bio.js @@ -281,7 +281,8 @@ async function fetchCustomersByOc (oc) { } async function fetchCustomersByAdmin ({ input = '' }) { - return (await getOperatorsForAdmin({ serviceToken, input })).filter((operator) => operator.notifications != null) + const operateurs = (await getOperatorsForAdmin({ serviceToken, input })).filter((operator) => operator.notifications != null) + return getFilterData(operateurs, false) } /** From 2a65977078ae494717bed390a14ef2d28b8c9cca Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Tue, 10 Mar 2026 15:37:48 +0100 Subject: [PATCH 13/14] fix: passage en v3 route redirection --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index adef25b9..ee404b77 100644 --- a/server.js +++ b/server.js @@ -841,7 +841,7 @@ if (require.main === module) { }, () => console.error('Failed to connect to database')) } -app.get('/api/external/exploitations/:numeroBio', (req, res) => { +app.get('/api/v3/external/exploitations/:numeroBio', (req, res) => { const { numeroBio } = req.params if (!numeroBio || isNaN(Number(numeroBio)) || Number(numeroBio) <= 0) { From 5a4f80b09803529449b2bae841a6c4ec0641ab81 Mon Sep 17 00:00:00 2001 From: Hugoobx Date: Thu, 12 Mar 2026 14:41:10 +0100 Subject: [PATCH 14/14] fix: rajout des communes inconnues dans le pdf --- lib/providers/generate-pdf-content.js | 3 ++- lib/providers/utils-pdf.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/providers/generate-pdf-content.js b/lib/providers/generate-pdf-content.js index 0b9d2f60..e5f9712e 100644 --- a/lib/providers/generate-pdf-content.js +++ b/lib/providers/generate-pdf-content.js @@ -168,7 +168,7 @@ async function createPdfContent (numeroBio, recordId, parcelles, currentOperator

${item.name ? 'Parcelle ' + item.name : ''}${item.name && item.nbilot ? ' - ' : ''}${item.nbilot ? 'Ilot ' + item.nbilot + (item.nbp ? ' parcelle ' + item.nbp : '') : ''}${item.name == null && item.nbilot == null && item.refcad ? item.refcad.map((e) => formatRefCad(e)).join(' ; ') : ''}

-
Commune (code commune) :
${item.communename ? item.communename : '-'} (${item.commune ? item.commune : '-'})
+
Commune (code commune) :
${item.communename && item.communename !== 'inconnu' ? item.communename : '-'} (${item.commune ? item.commune : '-'}) ${item.communename === 'inconnu' ? 'Commune inconnue' : ''}
Date d'engagement :
${item.engagement_date ? formatDate(item.engagement_date) : '-'}
@@ -236,6 +236,7 @@ async function createPdfContent (numeroBio, recordId, parcelles, currentOperator .map-container img{max-width:87.5%;margin-left:3.5%} @media print{.page{height:auto;min-height:auto}} .row1{width:30%}.row2{width:30%}.row3{width:22.5%}.row4{width:17.5%} + .font-10{font-size:10px} diff --git a/lib/providers/utils-pdf.js b/lib/providers/utils-pdf.js index 579d7c2e..7088b20b 100644 --- a/lib/providers/utils-pdf.js +++ b/lib/providers/utils-pdf.js @@ -118,7 +118,7 @@ async function getAllParcelles (recordId) { cp.cultures, cp.conversion_niveau, cp.commune, - c.nom as communename, + COALESCE(c.nom,'inconnu') as communename, cp.created, cp.engagement_date, cp.numero_ilot_pac AS nbilot, @@ -133,7 +133,7 @@ async function getAllParcelles (recordId) { ST_Y(ST_Centroid(cp.geometry)) AS centerY, COALESCE(SUM(ST_Area(to_legal_projection(cp.geometry)) / 10000), 0) AS superficie_totale_ha FROM cartobio_parcelles cp - JOIN communes c ON c.code = commune + LEFT JOIN communes c ON c.code = commune WHERE record_id = $1 AND cp.deleted_at IS NULL GROUP BY cp.id, cp.name, cp.cultures, cp.conversion_niveau, cp.created, cp.numero_ilot_pac, cp.numero_parcelle_pac, cp.geometry,cp.engagement_date,cp.commune,cp.reference_cadastre,c.nom; `,