diff --git a/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx b/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx index 2b4f37088..11a67e9dc 100644 --- a/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx +++ b/fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx @@ -7,10 +7,9 @@ import { FlaskConical, } from "lucide-react" import { useEffect, useRef, useState } from "react" +import { useWatch } from "react-hook-form" import { Form, NavLink, useActionData, useNavigation } from "react-router" import { RemixFormProvider, useRemixForm } from "remix-hook-form" -import { parseDbf } from "shpjs" -import { toast as notify } from "sonner" import { z } from "zod" import { cn } from "@/app/lib/utils" import { Dropzone } from "~/components/custom/dropzone" @@ -36,7 +35,6 @@ import { FormMessage, } from "~/components/ui/form" import { Spinner } from "~/components/ui/spinner" - import { MijnPercelenUploadAnimation } from "./upload-animation" type UploadState = "idle" | "animating" | "success" | "error" @@ -46,11 +44,12 @@ const ANIMATION_ENABLED = true // Switch for the animation export function MijnPercelenUploadForm({ b_id_farm, calendar, + backUrl = `/farm/create/${b_id_farm}/${calendar}`, }: { b_id_farm: string calendar: string + backUrl?: string }) { - const [fieldNames, setFieldNames] = useState([]) const [uploadState, setUploadState] = useState("idle") const uploadStartTime = useRef(null) @@ -60,6 +59,7 @@ export function MijnPercelenUploadForm({ mode: "onTouched", resolver: zodResolver(FormSchema), defaultValues: { + intent: "upload", shapefile: [], }, }) @@ -111,7 +111,11 @@ export function MijnPercelenUploadForm({ } }, [uploadState, form.reset]) - const selectedFiles = form.watch("shapefile") + const selectedFiles = useWatch({ + control: form.control, + name: "shapefile", + defaultValue: [], + }) const selectedFileExtensions = selectedFiles.map((file) => getFileExtension(file.name), @@ -129,29 +133,6 @@ export function MijnPercelenUploadForm({ const handleFilesSet = async (validFiles: File[]) => { form.setValue("shapefile", validFiles) setUploadState("idle") - - const dbfFile = validFiles.find( - (file) => getFileExtension(file.name) === ".dbf", - ) - if (dbfFile) { - try { - const dbfBuffer = await dbfFile.arrayBuffer() - const dbfData = parseDbf(dbfBuffer) as any[] - let unnamedCount = 0 - const names = dbfData.map((row) => { - const trimmedNaam = - typeof row?.NAAM === "string" ? row.NAAM.trim() : "" - return trimmedNaam || `Naamloos perceel ${++unnamedCount}` - }) - setFieldNames(names) - } catch (error) { - console.error("Failed to parse DBF file:", error) - notify.error("Kon het DBF bestand niet verwerken") - setFieldNames([]) - } - } else { - setFieldNames([]) - } } const disabledForm = ( @@ -240,7 +221,7 @@ export function MijnPercelenUploadForm({ return (
{uploadState === "animating" && ANIMATION_ENABLED ? ( - + {disabledForm} ) : uploadState === "animating" && !ANIMATION_ENABLED ? ( @@ -271,6 +252,11 @@ export function MijnPercelenUploadForm({ encType="multipart/form-data" >
+
+ + +
+ +
+ +
+
+ + + )} + + ) +} diff --git a/fdm-app/app/components/blocks/rvo/import-review-table.tsx b/fdm-app/app/components/blocks/rvo/import-review-table.tsx index 97b6269d4..36b7b581c 100644 --- a/fdm-app/app/components/blocks/rvo/import-review-table.tsx +++ b/fdm-app/app/components/blocks/rvo/import-review-table.tsx @@ -8,14 +8,24 @@ import { type ColumnDef, flexRender, getCoreRowModel, + type RowData, useReactTable, } from "@tanstack/react-table" import { area } from "@turf/area" import { format, parseISO } from "date-fns" -import { Archive, ArrowLeftRight, Check, Plus, Trash2, X } from "lucide-react" +import { + Archive, + ArrowLeftRight, + Check, + Info, + Plus, + Trash2, + X, +} from "lucide-react" import { useMemo } from "react" import { clientConfig } from "@/app/lib/config" import { Badge } from "~/components/ui/badge" +import { Checkbox } from "~/components/ui/checkbox" import { Select, SelectContent, @@ -41,15 +51,27 @@ import { acquiringMethodOptions } from "~/lib/constants" import { cn } from "~/lib/utils" declare module "@tanstack/react-table" { - interface TableMeta { + interface TableMeta { + calendar: string userChoices: UserChoiceMap + flags?: ImportReviewFlags + /** Function to replace a review item. `getItemId(replacement)` will return the same value as the original. */ + onItemChange?: (id: string, item: RvoImportReviewItem) => void onChoiceChange: (id: string, action: ImportReviewAction) => void } } +export interface ImportReviewFlags { + b_bufferstrip_info_available?: boolean +} + interface RvoImportReviewTableProps { data: RvoImportReviewItem[] + calendar: string userChoices: UserChoiceMap + flags?: ImportReviewFlags + /** Function to replace a review item. `getItemId(replacement)` will return the same value as the original. Replacements won't work if this is not provided. */ + onItemChange?: (id: string, action: RvoImportReviewItem) => void onChoiceChange: (id: string, action: ImportReviewAction) => void } @@ -87,7 +109,9 @@ const DiffCell = ({ // If MATCH, just show one value if (status === "MATCH") { return ( - {formatter(local)} + + {formatter(local)} + ) } @@ -314,7 +338,9 @@ export const columns: ColumnDef>[] = [ status={item.status} action={action} formatter={(val) => ( - {val || "Naamloos"} + + {val || "Naamloos"} + )} /> ) @@ -419,21 +445,38 @@ export const columns: ColumnDef>[] = [ cell: ({ row, table }) => { const item = row.original const id = getItemId(item) - const { userChoices } = table.options.meta! + const { calendar, userChoices } = table.options.meta! const action = userChoices[id] as ImportReviewAction - return ( + const local = item.localField + ? item.localField.b_end + ? formatDate(item.localField.b_end) + : "-" + : undefined + const remote = item.rvoField + ? item.rvoField.properties.EndDate + ? formatDate(item.rvoField.properties.EndDate) + : "-" + : undefined + + return item.status === "EXPIRED_LOCAL" ? ( val || "-"} + /> + ) : ( + val || "-"} @@ -526,6 +569,66 @@ export const columns: ColumnDef>[] = [ ) }, }, + { + id: "bufferstrook_editable", + header: () => ( + + + Bufferstrook + + + +
+ Shapefile bevat geen informatie over bufferstroken. De + getoonde waarden zijn schattingen of de huidige + ingevulde status voor het perceel. +
+
+
+ ), + cell: ({ row, table }) => { + if (!row.original.rvoField) { + // Bufferstrip status shouldn't be editable for local fields + return ( +
+ {row.original.localField?.b_bufferstrip ? "Ja" : "Nee"} +
+ ) + } + const value = + row.original.rvoField.properties.mestData?.IndBufferstrook === + "J" + function handleUpdateValue(newValue: boolean) { + if (table.options.meta?.onItemChange && row.original.rvoField) { + table.options.meta.onItemChange(getItemId(row.original), { + ...row.original, + rvoField: { + ...row.original.rvoField, + properties: { + ...row.original.rvoField?.properties, + mestData: { + ...row.original.rvoField?.properties + .mestData, + IndBufferstrook: newValue ? "J" : "N", + }, + }, + }, + } as RvoImportReviewItem) + } + } + + return ( + // biome-ignore lint/a11y/noLabelWithoutControl: input is nested inside the label + + ) + }, + }, { id: "actions", header: () => ( @@ -631,7 +734,10 @@ export const columns: ColumnDef>[] = [ export function RvoImportReviewTable({ data, + calendar, userChoices, + flags, + onItemChange, onChoiceChange, }: RvoImportReviewTableProps) { const sortedData = useMemo(() => { @@ -654,14 +760,27 @@ export function RvoImportReviewTable({ }) }, [data]) + const b_bufferstrip_info_available = + flags?.b_bufferstrip_info_available ?? true + const columnVisibility = { + bufferstrook: b_bufferstrip_info_available, + bufferstrook_editable: !b_bufferstrip_info_available, + } + const table = useReactTable({ data: sortedData, columns, getCoreRowModel: getCoreRowModel(), meta: { userChoices, + calendar, + flags, + onItemChange, onChoiceChange, }, + state: { + columnVisibility: columnVisibility, + }, }) return ( diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rvo.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rvo.tsx index 69342d1d0..38605ae50 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rvo.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rvo.tsx @@ -17,11 +17,8 @@ import { getItemId } from "@nmi-agro/fdm-rvo/utils" import { AlertTriangle, Loader2 } from "lucide-react" import { useEffect, useState } from "react" import { - type ActionFunctionArgs, data, Form, - type LoaderFunctionArgs, - type MetaFunction, redirect, useActionData, useLoaderData, @@ -70,12 +67,13 @@ import { generateAuthUrl, processRvoImport, } from "~/lib/rvo.server" +import type { Route } from "./+types/farm.$b_id_farm.$calendar.rvo" -export const meta: MetaFunction = ({ params }) => { +export const meta: Route.MetaFunction = ({ params }) => { return [{ title: `Percelen ophalen bij RVO - Bedrijf ${params.b_id_farm}` }] } -export async function loader({ request, params }: LoaderFunctionArgs) { +export async function loader({ request, params }: Route.LoaderArgs) { const { b_id_farm, calendar: yearString } = params if (!b_id_farm) { throw new Response("Farm ID is required", { status: 400 }) @@ -532,6 +530,7 @@ export default function RvoImportReviewPage() {
{ + return [ + { + title: `Shapefile uploaden - Bedrijf | ${clientConfig.name}`, + }, + { + name: "description", + content: "Upload een shapefile om percelen te importeren.", + }, + ] +} + +export { loader } + +export function action(ctx: Route.LoaderArgs) { + const { b_id_farm, calendar } = ctx.params + return genericAction(ctx, `/farm/${b_id_farm}/${calendar}/rotation`) +} + +export default function UpdateWithMijnPercelenPage() { + const loaderData = useLoaderData() + + return ( + +
+ + Bedrijf + + + + {loaderData.b_name_farm ?? "Geen bedrijf geselecteerd"} + + + Shapefile uploaden +
+ +
+ ) +} diff --git a/fdm-app/app/routes/farm.$b_id_farm._index.tsx b/fdm-app/app/routes/farm.$b_id_farm._index.tsx index eec997ce0..e159a1a40 100644 --- a/fdm-app/app/routes/farm.$b_id_farm._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm._index.tsx @@ -10,6 +10,7 @@ import { BookOpenText, ChevronUp, CloudDownload, + CloudUpload, DownloadIcon, FileStack, Home, @@ -450,43 +451,83 @@ export default function FarmDashboardIndex() { {loaderData.isRvoConfigured && ( - - - -
-
- -
-
- - Ophalen bij - RVO - - - Importeer - percelen - vanuit RVO. - -
+ + + +
+
+
- - - +
+ + Ophalen bij RVO + + + Importeer + percelen vanuit + RVO. + +
+
+
+
+
+ )} + + + +
+
+ +
+
+ + RVO Shapefile + uploaden + + + Importeer nieuwe of + bijgewerkte percelen + door een shapefile + van RVO Mijn + Percelen te + uploaden. + +
+
+
+
+
diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar._index.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar._index.tsx index bcdf80dd2..75c9c89a4 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar._index.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar._index.tsx @@ -63,6 +63,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export default function ChooseFieldImportMethod() { const { farm, isRvoConfigured } = useLoaderData() + const showRvoOption = isRvoConfigured return ( diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rvo.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rvo.tsx index 497c8aeca..219ff3fc8 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rvo.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rvo.tsx @@ -399,6 +399,7 @@ export default function RvoImportCreatePage() {
{ ] } -export async function loader({ request, params }: LoaderFunctionArgs) { - // Get the Id and name of the farm - const b_id_farm = params.b_id_farm - if (!b_id_farm) { - throw data("Farm ID is required", { - status: 400, - statusText: "Farm ID is required", - }) - } - - // Get the session - const session = await getSession(request) +export { loader } - const farm = await getFarm(fdm, session.principal_id, b_id_farm) - if (!farm) { - throw data("Farm not found", { - status: 404, - statusText: "Farm not found", - }) - } - - const calendar = getCalendar(params) - - return { b_id_farm, b_name_farm: farm.b_name_farm, calendar } +export function action(ctx: Route.LoaderArgs) { + const { b_id_farm, calendar } = ctx.params + return genericAction(ctx, `/farm/create/${b_id_farm}/${calendar}/fields`) } -export default function UploadMijnPercelenPage() { - const { b_id_farm, calendar, b_name_farm } = useLoaderData() +export default function CreateWithMijnPercelenPage() { + const loaderData = useLoaderData() return (
- +
-
-
- -
-
+
) } - -interface RvoProperties { - SECTORID: string - SECTORVER: number - NEN3610ID: string - VOLGNR: number - NAAM: string | null | undefined - BEGINDAT: number - EINDDAT: number - GEWASCODE: string - GEWASOMSCH: string - TITEL: string - TITELOMSCH: string -} - -export async function action({ request, params }: ActionFunctionArgs) { - const fileStorage = createFsFileStorage("./uploads/shapefiles") - const storageKeys: string[] = [] - - try { - // Get the Id and name of the farm - const b_id_farm = params.b_id_farm - if (!b_id_farm) { - throw data("Farm ID is required", { - status: 400, - statusText: "Farm ID is required", - }) - } - - const session = await getSession(request) - const calendar = await getCalendar(params) - const nmiApiKey = getNmiApiKey() - - const uploadHandler = async (fileUpload: FileUpload) => { - const storageKey = crypto.randomUUID() - storageKeys.push(storageKey) - await fileStorage.set(storageKey, fileUpload) - const file = await fileStorage.get(storageKey) - if (file && "toFile" in file && typeof file.toFile === "function") { - return (file as unknown as { toFile: () => File }).toFile() - } - return file - } - - const formData = await parseFormData( - request, - { maxFileSize: 5 * 1024 * 1024 }, - uploadHandler, - ) - const files = formData.getAll("shapefile") as File[] - - const shp_file = files.find((f) => f.name.endsWith(".shp")) - const shx_file = files.find((f) => f.name.endsWith(".shx")) - const dbf_file = files.find((f) => f.name.endsWith(".dbf")) - const prj_file = files.find((f) => f.name.endsWith(".prj")) - - if (!shp_file || !shx_file || !dbf_file || !prj_file) { - return dataWithWarning( - {}, - "Een .shp, .shx, .dbf en .prj bestand zijn verplicht.", - ) - } - - const shpBuffer = await shp_file.arrayBuffer() - const shxBuffer = await shx_file.arrayBuffer() - const dbfBuffer = await dbf_file.arrayBuffer() - const prj_text = await prj_file.text() - - let shapefile: FeatureCollection - try { - shapefile = (await combine([ - parseShp(shpBuffer, shxBuffer), - parseDbf(dbfBuffer), - ])) as FeatureCollection - } catch (_error) { - return dataWithWarning({}, "Shapefile is ongeldig.") - } - - if (shapefile.features.length === 0) { - return dataWithWarning({}, "Shapefile bevat geen percelen.") - } - - const source_proj = prj_text - const dest_proj = "EPSG:4326" - - const converter = proj4(source_proj, dest_proj) - - const features = shapefile.features.map( - (feature: Feature) => { - const new_coords = feature.geometry.coordinates.map( - (ring: number[][]) => { - return ring.map((coord: number[]) => { - return converter.forward(coord) - }) - }, - ) - feature.geometry.coordinates = new_coords - return feature - }, - ) - - let unnamedCount = 0 - for (const feature of features) { - const { properties, geometry } = feature - const { - SECTORID, - SECTORVER, - NEN3610ID, - VOLGNR, - NAAM, - BEGINDAT, - EINDDAT, - GEWASCODE, - GEWASOMSCH, - TITEL, - TITELOMSCH, - } = properties - - if ( - !SECTORID || - !SECTORVER || - !NEN3610ID || - !VOLGNR || - NAAM === undefined || - !BEGINDAT || - !EINDDAT || - !GEWASCODE || - !GEWASOMSCH || - !TITEL || - !TITELOMSCH - ) { - return dataWithWarning( - {}, - "De shapefile bevat niet de vereiste RVO attributen.", - ) - } - - const b_geometry = polygon(geometry.coordinates) - const trimmedNaam = typeof NAAM === "string" ? NAAM.trim() : "" - const b_name = trimmedNaam || `Naamloos perceel ${++unnamedCount}` - const b_start = new Date(BEGINDAT) - const b_end = EINDDAT === 253402297199 ? null : new Date(EINDDAT) - const b_lu_catalogue = `nl_${GEWASCODE}` - const b_acquiring_method = `nl_${TITEL}` - const b_id_source = SECTORID - - const fieldId = await addField( - fdm, - session.principal_id, - b_id_farm, - b_name, - b_id_source, - b_geometry.geometry, - b_start, - b_acquiring_method, - b_end, - ) - - const cultivationDefaultDates = await getDefaultDatesOfCultivation( - fdm, - session.principal_id, - b_id_farm, - b_lu_catalogue, - Number(calendar), - ) - const b_lu_start = cultivationDefaultDates.b_lu_start - const b_lu_end = cultivationDefaultDates.b_lu_end - await addCultivation( - fdm, - session.principal_id, - b_lu_catalogue, - fieldId, - b_lu_start, - b_lu_end, - ) - - if (nmiApiKey) { - const estimates = await getSoilParameterEstimates( - b_geometry, - nmiApiKey, - ) - - await addSoilAnalysis( - fdm, - session.principal_id, - undefined, - estimates.a_source, - fieldId, - estimates.a_depth_lower, - undefined, - estimates, - ) - } - } - - return redirectWithSuccess( - `/farm/create/${b_id_farm}/${calendar}/fields`, - { - message: "Percelen zijn succesvol geïmporteerd! 🎉", - }, - ) - } catch (error) { - throw handleActionError(error) - } finally { - for (const key of storageKeys) { - await fileStorage.remove(key) - } - } -} diff --git a/fdm-app/package.json b/fdm-app/package.json index c7ce755db..0b3869ae5 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -40,6 +40,7 @@ "@turf/boolean-point-in-polygon": "^7.3.5", "@turf/centroid": "^7.3.5", "@turf/helpers": "^7.3.5", + "@turf/length": "^7.3.5", "@turf/simplify": "^7.3.5", "better-auth": "catalog:", "chrono-node": "^2.9.0", @@ -80,7 +81,6 @@ "remix-hook-form": "7.1.1", "remix-toast": "^4.0.0", "remix-utils": "^9.3.1", - "shpjs": "^6.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index 2924b7af0..65db9b7d7 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -129,6 +129,7 @@ export { } from "./fertilizer-application-unit-conversion" export { addField, + determineIfFieldIsBuffer, getField, getFields, listAvailableAcquiringMethods, diff --git a/fdm-rvo/package.json b/fdm-rvo/package.json index b405a917f..e3739a84f 100644 --- a/fdm-rvo/package.json +++ b/fdm-rvo/package.json @@ -37,6 +37,12 @@ "types": "./dist/utils.d.ts", "default": "./dist/utils.js" } + }, + "./shapefile": { + "import": { + "types": "./dist/shapefile.d.ts", + "default": "./dist/shapefile.js" + } } }, "files": [ @@ -53,11 +59,14 @@ "dependencies": { "@nmi-agro/fdm-core": "workspace:^", "@nmi-agro/rvo-connector": "^2.2.3", + "@types/geojson": "^7946.0.16", "@turf/area": "^7.3.5", "@turf/bbox": "^7.3.5", "@turf/helpers": "^7.3.5", "@turf/intersect": "^7.3.5", "@turf/union": "^7.3.5", + "proj4": "^2.20.4", + "shpjs": "^6.2.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/fdm-rvo/src/index.ts b/fdm-rvo/src/index.ts index f764511cf..c8c962806 100644 --- a/fdm-rvo/src/index.ts +++ b/fdm-rvo/src/index.ts @@ -30,5 +30,6 @@ export * from "./auth" export * from "./compare" export * from "./data" export * from "./process" +export * from "./shapefile" export * from "./types" export * from "./utils" diff --git a/fdm-rvo/src/process.ts b/fdm-rvo/src/process.ts index c6826db55..6405cf579 100644 --- a/fdm-rvo/src/process.ts +++ b/fdm-rvo/src/process.ts @@ -52,15 +52,16 @@ export async function processRvoImport( year: number, onFieldAdded?: (tx: FdmType, b_id: string, geometry: any) => Promise, ) { - for (const item of rvoImportReviewData) { - const id = getItemId(item) - const action = userChoices[id] + await fdm.transaction(async (tx) => { + const addedFields: Array<{ b_id: string; geometry: any }> = [] + async function handleItem(item: RvoImportReviewItem) { + const id = getItemId(item) + const action = userChoices[id] - if (!action || action === "IGNORE" || action === "NO_ACTION") { - continue - } + if (!action || action === "IGNORE" || action === "NO_ACTION") { + return + } - await fdm.transaction(async (tx: FdmType) => { switch (action) { case "ADD_REMOTE": if (item.rvoField) { @@ -105,9 +106,10 @@ export async function processRvoImport( defaultDates.b_lu_end, ) - if (onFieldAdded) { - await onFieldAdded(tx, b_id, item.rvoField.geometry) - } + addedFields.push({ + b_id: b_id, + geometry: item.rvoField.geometry, + }) } break case "UPDATE_FROM_REMOTE": @@ -221,6 +223,16 @@ export async function processRvoImport( } break } - }) - } + } + + await Promise.all(rvoImportReviewData.map((item) => handleItem(item))) + + if (onFieldAdded) { + await Promise.all( + addedFields.map(({ b_id, geometry }) => + onFieldAdded(tx, b_id, geometry), + ), + ) + } + }) } diff --git a/fdm-rvo/src/shapefile.test.ts b/fdm-rvo/src/shapefile.test.ts new file mode 100644 index 000000000..6def61a9d --- /dev/null +++ b/fdm-rvo/src/shapefile.test.ts @@ -0,0 +1,316 @@ +import { geometry } from "@turf/helpers" +import type { Geometry, Polygon } from "geojson" +import proj4 from "proj4" +import * as shpjs from "shpjs" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + convertShapefileFeatureIntoRvoField, + getRvoFieldsFromShapefile, +} from "./shapefile" + +vi.mock("shpjs", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + combine: vi.fn(actual.combine), + parseShp: vi.fn(actual.parseShp), + parseDbf: vi.fn(actual.parseDbf), + } +}) + +const createMockGeometry = () => + geometry("Polygon", [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0], + ], + ]) + +const MOCK_PROPERTIES = { + // Relevant + SECTORID: "test_b_id_source", // b_id_source + NAAM: " Field 1 ", // b_name + BEGINDAT: 1704067200000, // b_start + EINDDAT: 1706659200000, // b_end + GEWASCODE: "02", // b_lu_catalogue[1] + TITEL: "Geliberaliseerde pacht, 6 jaar of korter", // b_acquiring_method + + // Irrelevant + SECTORVER: 1, + NEN3610ID: "unique", + VOLGNR: 1, + GEWASOMSCH: "Krokus, bloembollen en -knollen", + TITELOMSCH: "Purchased by the test farm", +} + +describe("getRvoFieldsFromShapefile", () => { + beforeEach(async () => { + vi.resetAllMocks() + }) + + it("should get RvoField objects from Shapefile", async () => { + vi.mocked(shpjs.parseShp).mockResolvedValueOnce([createMockGeometry()]) + vi.mocked(shpjs.parseDbf).mockResolvedValueOnce([MOCK_PROPERTIES]) + + const parsed = await getRvoFieldsFromShapefile( + new File([], "shapefile.shp"), + undefined, + new File([], "shapefile.dbf"), + undefined, + ) + + expect(parsed).toHaveLength(1) + }) + + it("should project field geometry", async () => { + vi.mocked(shpjs.parseShp).mockResolvedValueOnce([createMockGeometry()]) + vi.mocked(shpjs.parseDbf).mockResolvedValueOnce([MOCK_PROPERTIES]) + + const parsed = await getRvoFieldsFromShapefile( + new File([], "shapefile.shp"), + undefined, + new File([], "shapefile.dbf"), + // Identity transform + new File(["EPSG:3785"], "shapefile.prj"), + ) + + const projector = proj4("EPSG:3785", "EPSG:4326") + const expectedCoords = [ + createMockGeometry().coordinates[0].map((coord) => + projector.forward(coord), + ), + ] + + expect(parsed).toHaveLength(1) + + expect((parsed[0].geometry as Geometry).type).toBe("Polygon") + expect((parsed[0].geometry as Polygon).coordinates).toStrictEqual( + expectedCoords, + ) + }) + + it("should throw an error with invalid geometry", async () => { + vi.mocked(shpjs.parseShp).mockRejectedValueOnce( + new Error("Failed to parse shp"), + ) + + vi.mocked(shpjs.parseDbf).mockResolvedValueOnce([]) + + await expect( + getRvoFieldsFromShapefile( + new File([], "invalid.shp"), + undefined, + new File([], "invalid.dbf"), + new File([], "invalid.prj"), + ), + ).rejects.toThrow("Shapefile is not valid") + }) + + it("should throw an error with invalid attributes", async () => { + vi.mocked(shpjs.parseShp).mockResolvedValueOnce([]) + + vi.mocked(shpjs.parseDbf).mockRejectedValueOnce( + new Error("Failed to parse shp"), + ) + + await expect( + getRvoFieldsFromShapefile( + new File([], "invalid.shp"), + undefined, + new File([], "invalid.dbf"), + new File([], "invalid.prj"), + ), + ).rejects.toThrow("Shapefile is not valid") + }) + + it("should throw an error with no fields", async () => { + vi.mocked(shpjs.parseShp).mockResolvedValueOnce([]) + vi.mocked(shpjs.parseDbf).mockResolvedValueOnce([]) + + await expect( + getRvoFieldsFromShapefile( + new File([], "invalid.shp"), + undefined, + new File([], "invalid.shx"), + new File([], "invalid.prj"), + ), + ).rejects.toThrow("Shapefile does not contain any fields") + }) + + it("should handle multi-polygon geometry", async () => { + const MOCK_MULTIPOLYGON = geometry("MultiPolygon", [ + [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0], + ], + [ + [1, 0], + [2, 0], + [2, 1], + [1, 1], + [1, 0], + ], + ], + ]) + + vi.mocked(shpjs.parseShp).mockResolvedValueOnce([MOCK_MULTIPOLYGON]) + vi.mocked(shpjs.parseDbf).mockResolvedValueOnce([MOCK_PROPERTIES]) + + const parsed = await getRvoFieldsFromShapefile( + new File([], "shapefile.shp"), + undefined, + new File([], "shapefile.dbf"), + undefined, + ) + + expect(parsed).toHaveLength(1) + + expect((parsed[0].geometry as Geometry).type).toBe("MultiPolygon") + expect((parsed[0].geometry as Polygon).coordinates).toStrictEqual( + MOCK_MULTIPOLYGON.coordinates, + ) + }) + + it("should handle not-supported geometry", async () => { + const MOCK_UNSUPPORTED_GEOMETRY = geometry("Point", [0, 0]) + vi.mocked(shpjs.parseShp).mockResolvedValueOnce([ + MOCK_UNSUPPORTED_GEOMETRY, + ]) + vi.mocked(shpjs.parseDbf).mockResolvedValueOnce([MOCK_PROPERTIES]) + + await expect( + getRvoFieldsFromShapefile( + new File([], "shapefile.shp"), + undefined, + new File([], "shapefile.dbf"), + undefined, + ), + ).rejects.toThrow("Shapefile is not valid") + }) + + it("should accept array buffers and strings", async () => { + vi.mocked(shpjs.parseShp).mockResolvedValueOnce([createMockGeometry()]) + vi.mocked(shpjs.parseDbf).mockResolvedValueOnce([MOCK_PROPERTIES]) + + await expect( + getRvoFieldsFromShapefile( + new ArrayBuffer(), + new ArrayBuffer(), + new ArrayBuffer(), + "EPSG:3785", + ), + ).resolves.toBeDefined() + }) +}) + +describe("convertShapefileFeatureIntoRvoField", () => { + beforeEach(async () => { + vi.resetAllMocks() + }) + + it("should map the data fields correctly", () => { + const parsed = convertShapefileFeatureIntoRvoField({ + type: "Feature", + geometry: createMockGeometry(), + properties: MOCK_PROPERTIES, + }) + + expect(parsed.properties.CropFieldID).toBe("test_b_id_source") + expect(parsed.properties.CropFieldDesignator).toBe("Field 1") + expect(new Date(parsed.properties.BeginDate).getTime()).toBe( + 1704067200000, + ) + expect(new Date(parsed.properties.EndDate ?? "").getTime()).toBe( + 1706659200000, + ) + expect(parsed.properties.CropTypeCode).toBe("02") + expect(parsed.properties.UseTitleCode).toBe( + "Geliberaliseerde pacht, 6 jaar of korter", + ) + }) + + it("should trim NAAM", () => { + const parsed = convertShapefileFeatureIntoRvoField({ + type: "Feature", + geometry: createMockGeometry(), + properties: { ...MOCK_PROPERTIES, NAAM: " Test Name " }, + }) + + expect(parsed.properties.CropFieldDesignator).toBe("Test Name") + }) + + it("should handle null ending date", () => { + const parsed = convertShapefileFeatureIntoRvoField({ + type: "Feature", + geometry: createMockGeometry(), + properties: { ...MOCK_PROPERTIES, EINDDAT: 253402297199 }, + }) + + expect(parsed.properties.EndDate).toBeUndefined() + }) + + it("should throw an error if there are missing but required properties", () => { + const { NAAM, ...otherProps } = MOCK_PROPERTIES + + expect(() => + convertShapefileFeatureIntoRvoField({ + type: "Feature", + geometry: createMockGeometry(), + properties: otherProps, + }), + ).toThrow("Field does not have the required attributes") + }) + + it("should accept falsy values for required props", () => { + const geometry = createMockGeometry() + const parsed = convertShapefileFeatureIntoRvoField({ + type: "Feature", + geometry: geometry, + properties: { + SECTORID: "", // b_id_source + NAAM: null, // b_name + BEGINDAT: 0, // b_start + EINDDAT: 0, // b_end + GEWASCODE: "", // b_lu_catalogue[1] + TITEL: "", // b_acquiring_method + SECTORVER: 0, + NEN3610ID: "", + VOLGNR: 0, + GEWASOMSCH: "", + TITELOMSCH: "", + }, + }) + + expect(parsed.properties.CropFieldID).toBe("") + expect(parsed.properties.CropFieldDesignator).toBe("") + expect(new Date(parsed.properties.BeginDate).getTime()).toBe(0) + expect(new Date(parsed.properties.EndDate ?? "").getTime()).toBe(0) + expect(parsed.properties.CropTypeCode).toBe("") + expect(parsed.properties.UseTitleCode).toBe("") + }) + + it("should not accept invalid dates", () => { + const geometry = createMockGeometry() + expect(() => + convertShapefileFeatureIntoRvoField({ + type: "Feature", + geometry: geometry, + properties: { ...MOCK_PROPERTIES, BEGINDAT: -1e20 }, + }), + ).toThrow("Field does not have the required attributes") + expect(() => + convertShapefileFeatureIntoRvoField({ + type: "Feature", + geometry: geometry, + properties: { ...MOCK_PROPERTIES, EINDDAT: -1e20 }, + }), + ).toThrow("Field does not have the required attributes") + }) +}) diff --git a/fdm-rvo/src/shapefile.ts b/fdm-rvo/src/shapefile.ts new file mode 100644 index 000000000..22b707bc7 --- /dev/null +++ b/fdm-rvo/src/shapefile.ts @@ -0,0 +1,213 @@ +import { multiPolygon, polygon } from "@turf/helpers" +import type { + Feature, + FeatureCollection, + Geometry, + MultiPolygon, + Polygon, + Position, +} from "geojson" +import proj4 from "proj4" +import { combine, parseDbf, parseShp } from "shpjs" +import type { RvoField } from "./types" + +interface RvoProperties { + SECTORID: string + SECTORVER: number + NEN3610ID: string + VOLGNR: number + NAAM: string | null | undefined + BEGINDAT: number + EINDDAT: number + GEWASCODE: string + GEWASOMSCH: string + TITEL: string + TITELOMSCH: string +} + +type FileInterface = Blob | ArrayBuffer + +/** + * Parses the files found in a MijnPercelen Shapefile export and compiles a GeoJSON feature collection where each feature's properties represent the field properties registered by RVO. + * @param shp_file Shapefile or ArrayBuffer to parse + * @param _shx_file Shapefile index or ArrayBuffer file to parse, the library might be able to optimize lookups in the shp file using this + * @param prj_file Projection definition file or ArrayBuffer for coordinates found in the shp file, if not provided EPSG:4326 is assumed + * @returns an array of geometries which can be passed to the shpjs combine function + */ +export async function parseShapefileGeometry( + shp_file: FileInterface, + shx_file: FileInterface | undefined, + prj_file: Blob | string | undefined, +): Promise<(Polygon | MultiPolygon)[]> { + try { + const [shpData, shxData, projection] = await Promise.all([ + shp_file instanceof Blob ? shp_file.arrayBuffer() : shp_file, + shx_file instanceof Blob ? shx_file.arrayBuffer() : shx_file, + prj_file instanceof Blob ? prj_file.text() : prj_file, + ]) + + const geometries: Geometry[] = await parseShp(shpData, shxData) + + const projector = projection + ? proj4(projection, "EPSG:4326") + : undefined + + return geometries.map((geometry) => { + const transformRing = (ring: Position[][]) => + projector + ? ring.map((coords) => + coords.map((coord) => projector.forward(coord)), + ) + : ring + + if (geometry.type === "MultiPolygon") { + return multiPolygon(geometry.coordinates.map(transformRing)) + .geometry + } + + if (geometry.type === "Polygon") { + return polygon(transformRing(geometry.coordinates)).geometry + } + + throw new Error("Non-polygonal geometry encountered") + }) + } catch (_error) { + throw new Error("Shapefile is not valid", { cause: _error }) + } +} + +/** + * Parses the dbf file that is part of the MijnPercelen Shapefile export + * @param dbf_file DBase file or ArrayBuffer to parse + * @returns an array of objects representing the rows in the dbf file + */ +export async function parseShapefileAttributes( + dbf_file: FileInterface, +): Promise[]> { + try { + return await parseDbf( + dbf_file instanceof Blob ? await dbf_file.arrayBuffer() : dbf_file, + undefined, + ) + } catch (_error) { + throw new Error("Shapefile is not valid", { cause: _error }) + } +} + +/** + * Converts a feature found in a Shapefile to a RvoField object to be used with the RVO import system + * + * `mestData` is not available in Shapefiles and will not be available in the result, thus no buffer strip information + * + * @param shapefileFeatures + * @returns a RvoField object + * @throws if any of the required properties are missing + */ +export function convertShapefileFeatureIntoRvoField( + feature: Feature>, +): RvoField { + const isDefined = (x: unknown) => x !== null && typeof x !== "undefined" + const { properties, geometry } = feature + const { + SECTORID, + SECTORVER, + NEN3610ID, + VOLGNR, + NAAM, + BEGINDAT, + EINDDAT, + GEWASCODE, + GEWASOMSCH, + TITEL, + TITELOMSCH, + } = properties + + let beginDate: string | undefined + let endDate: string | undefined + try { + if (isDefined(BEGINDAT)) { + beginDate = new Date(BEGINDAT).toISOString() + } + } catch (_) {} + try { + // 253402297199 (the "null" date) can successfully be converted to a JS Date object + // so just doing that is easier + if (isDefined(EINDDAT)) { + endDate = new Date(EINDDAT).toISOString() + } + } catch (_) {} + + if ( + !isDefined(SECTORID) || + !isDefined(SECTORVER) || + !isDefined(NEN3610ID) || + !isDefined(VOLGNR) || + NAAM === undefined || // null is accepted + !isDefined(beginDate) || + !isDefined(endDate) || + !isDefined(GEWASCODE) || + !isDefined(GEWASOMSCH) || + !isDefined(TITEL) || + !isDefined(TITELOMSCH) + ) { + throw new Error("Field does not have the required attributes") + } + + const trimmedNaam = typeof NAAM === "string" ? NAAM.trim() : "" + + return { + type: "Feature", + geometry: geometry, + properties: { + CropFieldID: SECTORID, // b_id_source + CropFieldVersion: "1.0.0", // not needed + CropFieldDesignator: trimmedNaam, // b_name + BeginDate: beginDate, // b_start + Country: "nl", // b_lu_catalogue[0] + CropTypeCode: GEWASCODE, // b_lu_catalogue[1] + UseTitleCode: TITEL, // b_acquiring_method + ThirdPartyCropFieldID: undefined, // not needed + EndDate: EINDDAT !== 253402297199 ? endDate : undefined, // b_end + VarietyCode: undefined, // not needed + CropProductionPurposeCode: undefined, // not needed + FieldUseCode: undefined, // not needed + RegulatorySoiltypeCode: undefined, // not needed + CropFieldCause: undefined, // not needed + }, + } +} + +/** + * Parses the files found in a MijnPercelen Shapefile export and compiles a GeoJSON feature collection where each feature's properties represent the field properties registered by RVO. + * + * `mestData` is not available in Shapefiles and will not be available in the result, thus no buffer strip information + * + * @param shp_file Shapefile or ArrayBuffer to parse + * @param shx_file Shapefile index or ArrayBuffer file to parse, the library might be able to optimize lookups in the shp file using this + * @param dbf_file DBase file or ArrayBuffer to parse containing field properties registered by RVO + * @param prj_file Projection definition file or ArrayBuffer for coordinates found in the shp file, if not provided EPSG:4326 is assumed + * @returns List of RvoField objects + */ +export async function getRvoFieldsFromShapefile( + shp_file: FileInterface, + shx_file: FileInterface | undefined, + dbf_file: FileInterface, + prj_file: Blob | string | undefined, +): Promise { + const geometries = await parseShapefileGeometry( + shp_file, + shx_file, + prj_file, + ) + const attributes = await parseShapefileAttributes(dbf_file) + const shapefile: FeatureCollection< + Polygon, + Partial + > = combine([geometries, attributes]) + + if (shapefile.features.length === 0) { + throw new Error("Shapefile does not contain any fields") + } + + return shapefile.features.map(convertShapefileFeatureIntoRvoField) +} diff --git a/fdm-rvo/src/shpjs.d.ts b/fdm-rvo/src/shpjs.d.ts new file mode 100644 index 000000000..f7f7c9ba2 --- /dev/null +++ b/fdm-rvo/src/shpjs.d.ts @@ -0,0 +1 @@ +declare module "shpjs" diff --git a/fdm-rvo/tsdown.config.ts b/fdm-rvo/tsdown.config.ts index 83dcf6a12..2fcdc196a 100644 --- a/fdm-rvo/tsdown.config.ts +++ b/fdm-rvo/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown" export default defineConfig({ - entry: ["src/index.ts", "src/types.ts", "src/utils.ts"], + entry: ["src/index.ts", "src/types.ts", "src/utils.ts", "src/shapefile.ts"], format: "esm", outDir: "dist", dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be59c78f6..c9de0c922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,7 +78,7 @@ importers: version: link:../fdm-data posthog-node: specifier: ^5.30.8 - version: 5.30.8 + version: 5.31.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -179,12 +179,15 @@ importers: '@turf/helpers': specifier: ^7.3.5 version: 7.3.5 + '@turf/length': + specifier: ^7.3.5 + version: 7.3.5 '@turf/simplify': specifier: ^7.3.5 version: 7.3.5 better-auth: specifier: 'catalog:' - version: 1.6.9(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(knex@3.2.8(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(sqlite3@5.1.7))(kysely@0.28.16)(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(mysql2@3.20.0(@types/node@25.6.0))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5) + version: 1.6.9(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(knex@3.2.8(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(sqlite3@5.1.7))(kysely@0.28.16)(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(mysql2@3.20.0(@types/node@25.6.0))(next@16.2.3(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5) chrono-node: specifier: ^2.9.0 version: 2.9.0 @@ -299,9 +302,6 @@ importers: remix-utils: specifier: ^9.3.1 version: 9.3.1(@standard-schema/spec@1.1.0)(react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) - shpjs: - specifier: ^6.2.0 - version: 6.2.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -426,7 +426,7 @@ importers: version: 7946.0.16 better-auth: specifier: 'catalog:' - version: 1.6.9(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(knex@3.2.8(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(sqlite3@5.1.7))(kysely@0.28.16)(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(mysql2@3.20.0(@types/node@25.6.0))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5) + version: 1.6.9(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(knex@3.2.8(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(sqlite3@5.1.7))(kysely@0.28.16)(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(mysql2@3.20.0(@types/node@25.6.0))(next@16.2.3(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5) decimal.js: specifier: ^10.6.0 version: 10.6.0 @@ -601,6 +601,15 @@ importers: '@turf/union': specifier: ^7.3.5 version: 7.3.5 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 + proj4: + specifier: ^2.20.4 + version: 2.20.8 + shpjs: + specifier: ^6.2.0 + version: 6.2.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -777,16 +786,16 @@ packages: resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} engines: {node: '>=20.0.0'} - '@azure/msal-browser@5.9.0': - resolution: {integrity: sha512-CzE+4PefDSJWj26zU7G1bKchlGRRHMBFreG4tAlGuzyI8hAPiYGobaJvZBgZBf6L63iphX7VH+ityL8VgEQz9Q==} + '@azure/msal-browser@5.8.0': + resolution: {integrity: sha512-X7IZV77bN56l7sbLjkcbQJX1t3U4tgxqztDr/XFbUcUfKk+z2FavcLgKP+OYUNj0wl/pEEtV9lldW9siY8BuHQ==} engines: {node: '>=0.8.0'} - '@azure/msal-common@16.5.2': - resolution: {integrity: sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==} + '@azure/msal-common@16.5.1': + resolution: {integrity: sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==} engines: {node: '>=0.8.0'} - '@azure/msal-node@5.1.5': - resolution: {integrity: sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==} + '@azure/msal-node@5.1.4': + resolution: {integrity: sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==} engines: {node: '>=20'} '@babel/code-frame@7.29.0': @@ -3523,8 +3532,8 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@opentelemetry/context-async-hooks@2.7.0': - resolution: {integrity: sha512-MWXggArM+Y11mPS8VOrqxOj+YMGQSRuvhM91eSBX4xFpJa05mpkeVvM8pPux5ElkEjV5RMgrkisrlP/R83SpBQ==} + '@opentelemetry/context-async-hooks@2.6.1': + resolution: {integrity: sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -3761,6 +3770,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/resources@2.6.1': + resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/resources@2.7.0': resolution: {integrity: sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3791,12 +3806,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.7.0': - resolution: {integrity: sha512-Vd7h95av/LYRsAVN7wbprvvJnHkq7swMXAo7Uad0Uxf9jl6NSReLa0JNivrcc5BVIx/vl2t+cgdVQQbnVhsR9w==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.1.0': resolution: {integrity: sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3809,14 +3818,20 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.6.1': + resolution: {integrity: sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.7.0': resolution: {integrity: sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.7.0': - resolution: {integrity: sha512-RrFHOXw0IYp/OThew6QORdybnnLitUAUMCJKcQNBYS0hDkCYarO2vTkVxfrGxCIqd5XHSMvbCpBd/T8ZMw8oSg==} + '@opentelemetry/sdk-trace-node@2.6.1': + resolution: {integrity: sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -5769,6 +5784,9 @@ packages: '@turf/clone@7.3.5': resolution: {integrity: sha512-qfIaHj3410QEcTpiCRnTzhq8YrUp2gWrUIPLBAEFykopNxJkq1du1VrRzvuAo36ap2UV7KppkS6wGNypbcxswQ==} + '@turf/distance@7.3.5': + resolution: {integrity: sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA==} + '@turf/helpers@7.3.5': resolution: {integrity: sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==} @@ -5778,6 +5796,9 @@ packages: '@turf/invariant@7.3.5': resolution: {integrity: sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==} + '@turf/length@7.3.5': + resolution: {integrity: sha512-Bi+vEP54wt1ly3BRcCOP0nd2kGTYEhGk6haQxTpkrqr3XtmqDh8c3NowSgseN2cegIZRjwCOEC8eSsZ0JemJdA==} + '@turf/meta@7.3.5': resolution: {integrity: sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==} @@ -8658,10 +8679,6 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip-address@10.1.1: - resolution: {integrity: sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -10753,8 +10770,8 @@ packages: rxjs: optional: true - posthog-node@5.30.8: - resolution: {integrity: sha512-9NLSgI/Mdlh/F4MAyP8S4BoHR+JjCgZlzZc0OlFovS5M3SVyydQBs9AZmGvFIpNUI+woGDbvyoPFv4l89BxOtw==} + posthog-node@5.31.0: + resolution: {integrity: sha512-vRhLERIOG6gIrfz5zfSUTiIrf4G8yWZPuEUP8TyvIusedMwxUXitCU9HN3uZfzkkOgvgC5MUAr/eN3reO9WQMA==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -11676,8 +11693,8 @@ packages: resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} engines: {node: '>= 10'} - socks@2.8.8: - resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} sonner@2.0.7: @@ -12438,10 +12455,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valibot@1.3.1: @@ -13123,8 +13142,8 @@ snapshots: '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 - '@azure/msal-browser': 5.9.0 - '@azure/msal-node': 5.1.5 + '@azure/msal-browser': 5.8.0 + '@azure/msal-node': 5.1.4 open: 10.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -13168,16 +13187,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-browser@5.9.0': + '@azure/msal-browser@5.8.0': dependencies: - '@azure/msal-common': 16.5.2 + '@azure/msal-common': 16.5.1 - '@azure/msal-common@16.5.2': {} + '@azure/msal-common@16.5.1': {} - '@azure/msal-node@5.1.5': + '@azure/msal-node@5.1.4': dependencies: - '@azure/msal-common': 16.5.2 + '@azure/msal-common': 16.5.1 jsonwebtoken: 9.0.3 + uuid: 8.3.2 '@babel/code-frame@7.29.0': dependencies: @@ -15733,14 +15753,14 @@ snapshots: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 - '@google-cloud/opentelemetry-cloud-monitoring-exporter@0.21.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.7.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)': + '@google-cloud/opentelemetry-cloud-monitoring-exporter@0.21.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)': dependencies: '@google-cloud/opentelemetry-resource-util': 3.0.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) '@google-cloud/precise-date': 4.0.0 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1) google-auth-library: 9.15.1(encoding@0.1.13) googleapis: 137.1.0(encoding@0.1.13) transitivePeerDependencies: @@ -15806,7 +15826,7 @@ snapshots: '@google/adk@1.1.0(@grpc/grpc-js@1.14.3)(@mikro-orm/mariadb@6.6.11(@mikro-orm/core@6.6.13)(pg@8.20.0))(@mikro-orm/mssql@6.6.11(@azure/core-client@1.10.1)(@mikro-orm/core@6.6.13)(mariadb@3.4.5)(pg@8.20.0))(@mikro-orm/mysql@6.6.11(@mikro-orm/core@6.6.13)(@types/node@25.6.0)(mariadb@3.4.5)(pg@8.20.0))(@mikro-orm/postgresql@6.6.11(@mikro-orm/core@6.6.13)(mariadb@3.4.5))(@mikro-orm/sqlite@6.6.11(@mikro-orm/core@6.6.13)(mariadb@3.4.5)(pg@8.20.0))(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)': dependencies: '@a2a-js/sdk': 0.3.13(@grpc/grpc-js@1.14.3)(express@4.22.1) - '@google-cloud/opentelemetry-cloud-monitoring-exporter': 0.21.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.7.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) + '@google-cloud/opentelemetry-cloud-monitoring-exporter': 0.21.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) '@google-cloud/opentelemetry-cloud-trace-exporter': 3.0.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) '@google-cloud/storage': 7.19.0(encoding@0.1.13) '@google/genai': 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) @@ -15826,9 +15846,9 @@ snapshots: '@opentelemetry/resource-detector-gcp': 0.40.3(@opentelemetry/api@1.9.0)(encoding@0.1.13) '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.205.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': 2.7.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.6.1(@opentelemetry/api@1.9.0) express: 4.22.1 google-auth-library: 10.6.2 js-yaml: 4.1.1 @@ -16610,7 +16630,7 @@ snapshots: '@opentelemetry/api-logs@0.205.0': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs@0.207.0': dependencies: @@ -16632,7 +16652,7 @@ snapshots: '@opentelemetry/api@1.9.1': {} - '@opentelemetry/context-async-hooks@2.7.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -16647,11 +16667,21 @@ snapshots: '@opentelemetry/semantic-conventions': 1.40.0 optional: true + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -16991,10 +17021,22 @@ snapshots: '@opentelemetry/semantic-conventions': 1.40.0 optional: true + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.0)': @@ -17047,14 +17089,8 @@ snapshots: '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/sdk-metrics@2.7.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0)': dependencies: @@ -17078,11 +17114,11 @@ snapshots: '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1)': @@ -17092,12 +17128,12 @@ snapshots: '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-node@2.7.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-node@2.6.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.7.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions@1.40.0': {} @@ -19125,6 +19161,13 @@ snapshots: '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@turf/distance@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/invariant': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/helpers@7.3.5': dependencies: '@types/geojson': 7946.0.16 @@ -19144,6 +19187,14 @@ snapshots: '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@turf/length@7.3.5': + dependencies: + '@turf/distance': 7.3.5 + '@turf/helpers': 7.3.5 + '@turf/meta': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/meta@7.3.5': dependencies: '@turf/helpers': 7.3.5 @@ -19950,7 +20001,7 @@ snapshots: batch@0.6.1: {} - better-auth@1.6.9(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(knex@3.2.8(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(sqlite3@5.1.7))(kysely@0.28.16)(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(mysql2@3.20.0(@types/node@25.6.0))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5): + better-auth@1.6.9(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(knex@3.2.8(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(sqlite3@5.1.7))(kysely@0.28.16)(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7))(mysql2@3.20.0(@types/node@25.6.0))(next@16.2.3(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.5): dependencies: '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(knex@3.2.8(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(sqlite3@5.1.7))(kysely@0.28.16)(mysql2@3.20.0(@types/node@25.6.0))(pg@8.20.0)(postgres@3.4.9)(sqlite3@5.1.7)) @@ -22429,9 +22480,6 @@ snapshots: ip-address@10.1.0: {} - ip-address@10.1.1: - optional: true - ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -24850,7 +24898,7 @@ snapshots: dependencies: '@posthog/core': 1.27.7 - posthog-node@5.30.8: + posthog-node@5.31.0: dependencies: '@posthog/core': 1.27.9 @@ -26073,14 +26121,14 @@ snapshots: dependencies: agent-base: 6.0.2 debug: 4.4.3 - socks: 2.8.8 + socks: 2.8.7 transitivePeerDependencies: - supports-color optional: true - socks@2.8.8: + socks@2.8.7: dependencies: - ip-address: 10.1.1 + ip-address: 10.1.0 smart-buffer: 4.2.0 optional: true