diff --git a/.changeset/chatty-bananas-sink.md b/.changeset/chatty-bananas-sink.md new file mode 100644 index 000000000..661ba5b03 --- /dev/null +++ b/.changeset/chatty-bananas-sink.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +Add the option to import fields from RVO in farm create wizard and also at the fields page of the farm diff --git a/.changeset/public-llamas-clap.md b/.changeset/public-llamas-clap.md new file mode 100644 index 000000000..0b55389fd --- /dev/null +++ b/.changeset/public-llamas-clap.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-docs": minor +--- + +Add the reference for fdm-rvo diff --git a/.changeset/tangy-ways-win.md b/.changeset/tangy-ways-win.md new file mode 100644 index 000000000..c5a114156 --- /dev/null +++ b/.changeset/tangy-ways-win.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-rvo": minor +--- + +Initial version of `fdm-rvo` that provides the logic for fdm-app to connect to RVO and sync fields with fdm-core diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a308e15ea..19cae138f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,7 @@ jobs: - name: Publish Snapshot if: github.ref == 'refs/heads/development' run: | + echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> .npmrc pnpm changeset version --snapshot pnpm changeset publish --tag development env: @@ -68,6 +69,7 @@ jobs: if: github.ref == 'refs/heads/main' shell: bash run: | + echo "//npm.pkg.github.com/:_authToken=\${NODE_AUTH_TOKEN}" >> .npmrc # Build packages pnpm build --filter="!@nmi-agro/fdm-app" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 05142f36d..58129db2f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,7 +59,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - registry-url: 'https://npm.pkg.github.com' cache: 'pnpm' - name: Install Dependencies @@ -152,7 +151,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - registry-url: 'https://npm.pkg.github.com' cache: 'pnpm' - name: Install Dependencies @@ -232,7 +230,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - registry-url: 'https://npm.pkg.github.com' cache: 'pnpm' - name: Install Dependencies @@ -252,3 +249,95 @@ jobs: files: ./fdm-data/coverage/coverage-final.json flags: fdm-data token: ${{ secrets.CODECOV_TOKEN }} + + # Label of the container job for fdm-rvo + test-rvo: + name: rvo + # Containers must run in Linux based operating systems + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [24] + permissions: + contents: read + packages: write + # Docker Hub image that `container-job` executes in + container: node:${{ matrix.node-version }}-bookworm-slim + + # Service containers to run with `container-job` + services: + # Label used to access the service container + postgres: + # Docker Hub image with postgis extension + image: postgis/postgis:17-3.5 + # Provide the password for postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + # Include dependencies for codecov + - name: Install system dependencies + run: apt-get update && apt-get install -y git curl gpg + + # Downloads a copy of the code in your repository before running CI tests + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Cache turbo build setup + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Setup pnpm 10 + uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm i + + - name: Build fdm-data + run: pnpm build + working-directory: ./fdm-data + + - name: Build fdm-core + run: pnpm build + working-directory: ./fdm-core + + - name: Run tests with coverage + run: pnpm test-coverage + working-directory: ./fdm-rvo + env: + # The hostname used to communicate with the PostgreSQL service container + POSTGRES_HOST: postgres + # The default PostgreSQL port + POSTGRES_PORT: 5432 + # the default usernam + POSTGRES_USER: postgres + # the default password + POSTGRES_PASSWORD: postgres + # the default database + POSTGRES_DB: postgres + + - name: fdm-rvo - Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./fdm-rvo/coverage/coverage-final.json + flags: fdm-rvo + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 57a18f9ac..d823162bb 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -33,7 +33,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - registry-url: 'https://npm.pkg.github.com' cache: 'pnpm' - name: Install Dependencies diff --git a/.npmrc b/.npmrc index 79eba4f62..476e68926 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,2 @@ sync-injected-deps-after-scripts[]=build link-workspace-packages=true -@nmi-agro:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} diff --git a/fdm-app/.env.example b/fdm-app/.env.example index e29bdc443..a3fae6077 100644 --- a/fdm-app/.env.example +++ b/fdm-app/.env.example @@ -68,6 +68,13 @@ GOOGLE_CLIENT_SECRET= MS_CLIENT_ID= MS_CLIENT_SECRET= +# RVO OAuth Credentials (Optional: Leave empty to disable RVO Integration) +# Required: No +RVO_CLIENT_ID= +RVO_CLIENT_NAME= +RVO_PKIO_PRIVATE_KEY= +RVO_REDIRECT_URI= + # ------------------------------------- # Map & Data Configuration # ------------------------------------- diff --git a/fdm-app/app/components/blocks/fertilizer/utils.server.ts b/fdm-app/app/components/blocks/fertilizer/utils.server.ts new file mode 100644 index 000000000..f77db68ad --- /dev/null +++ b/fdm-app/app/components/blocks/fertilizer/utils.server.ts @@ -0,0 +1,31 @@ +import { getFertilizerParametersDescription, type Fertilizer } from "@nmi-agro/fdm-core" + +/** + * Retrieves RVO label and type mappings used across fertilizer forms and summaries. + * Centralizes the assembly of RVO metadata from the parameter descriptions and available fertilizers. + * + * @param fertilizers - Optional array of fertilizers to build the RVO-to-Type mapping for dynamic badge colors. + * @returns An object containing: + * - `rvoLabels`: A record mapping RVO codes to their descriptive labels (in Dutch). + * - `rvoToType`: A record mapping RVO codes to fertilizer types (manure, compost, etc.). + */ +export async function getRvoMappings(fertilizers: Partial[] = []) { + const fertilizerParameterDescription = + await getFertilizerParametersDescription("NL-nl") + const p_type_rvo_options = + fertilizerParameterDescription.find((x) => x.parameter === "p_type_rvo") + ?.options ?? [] + + const rvoLabels = Object.fromEntries( + p_type_rvo_options.map((opt) => [String(opt.value), opt.label]), + ) + + const rvoToType: Record = {} + for (const f of fertilizers) { + if (f.p_type_rvo && f.p_type) { + rvoToType[f.p_type_rvo] = f.p_type + } + } + + return { rvoLabels, rvoToType } +} diff --git a/fdm-app/app/components/blocks/fertilizer/utils.ts b/fdm-app/app/components/blocks/fertilizer/utils.ts index 5193b1747..fd408b4a1 100644 --- a/fdm-app/app/components/blocks/fertilizer/utils.ts +++ b/fdm-app/app/components/blocks/fertilizer/utils.ts @@ -64,44 +64,6 @@ export function buildFertilizerDefaults( } } -/** - * Retrieves RVO label and type mappings used across fertilizer forms and summaries. - * Centralizes the assembly of RVO metadata from the parameter descriptions and available fertilizers. - * - * @param fertilizers - Optional array of fertilizers to build the RVO-to-Type mapping for dynamic badge colors. - * @returns An object containing: - * - `rvoLabels`: A record mapping RVO codes to their descriptive labels (in Dutch). - * - `rvoToType`: A record mapping RVO codes to fertilizer types (manure, compost, etc.). - */ -export async function getRvoMappings(fertilizers: Partial[] = []) { - if (typeof window !== "undefined") { - return { rvoLabels: {}, rvoToType: {} } - } - - const { getFertilizerParametersDescription } = await import( - "@nmi-agro/fdm-core" - ) - - const fertilizerParameterDescription = - await getFertilizerParametersDescription("NL-nl") - const p_type_rvo_options = - fertilizerParameterDescription.find((x) => x.parameter === "p_type_rvo") - ?.options ?? [] - - const rvoLabels = Object.fromEntries( - p_type_rvo_options.map((opt) => [String(opt.value), opt.label]), - ) - - const rvoToType: Record = {} - for (const f of fertilizers) { - if (f.p_type_rvo && f.p_type) { - rvoToType[f.p_type_rvo] = f.p_type - } - } - - return { rvoLabels, rvoToType } -} - /** * Builds the payload for adding a fertilizer to the catalogue. * diff --git a/fdm-app/app/components/blocks/fields/columns.tsx b/fdm-app/app/components/blocks/fields/columns.tsx index fd2547a0e..e9b0d97e7 100644 --- a/fdm-app/app/components/blocks/fields/columns.tsx +++ b/fdm-app/app/components/blocks/fields/columns.tsx @@ -183,7 +183,9 @@ export const columns: ColumnDef[] = [ const field = row.original return (

- {`${field.a_som_loi.toFixed(2)} %`} + {field.a_som_loi !== null && field.a_som_loi !== undefined + ? `${field.a_som_loi.toFixed(2)} %` + : "-"}

) }, diff --git a/fdm-app/app/components/blocks/rvo/connect-card.tsx b/fdm-app/app/components/blocks/rvo/connect-card.tsx new file mode 100644 index 000000000..20987d55e --- /dev/null +++ b/fdm-app/app/components/blocks/rvo/connect-card.tsx @@ -0,0 +1,137 @@ +import { Link, Form } from "react-router" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" +import { Button } from "~/components/ui/button" +import { + Loader2, + FlaskConical, + CheckCircle2, + ExternalLink, +} from "lucide-react" + +interface RvoConnectCardProps { + b_businessid_farm: string | null + b_id_farm: string + isImporting: boolean + isRvoConfigured: boolean +} + +export function RvoConnectCard({ + b_businessid_farm, + b_id_farm, + isImporting, + isRvoConfigured, +}: RvoConnectCardProps) { + return ( + + + Percelen ophalen bij RVO + + + Experimentele functie + + Deze functie is nog in ontwikkeling. Laat ons het weten + als je feedback hebt! + + + + Lees hieronder wat u nodig heeft om te verbinden. + + + +
+

+ Voorwaarden voor gebruik: +

+
    +
  • + U heeft een geldig KvK-nummer gekoppeld aan uw + account. +
  • +
  • + U heeft een eHerkenning account met machtiging voor + dit KvK-nummer. +
  • +
  • + U geeft ons toestemming om perceelsgegevens op te + halen. +
  • +
+
+ +
+
+

+ KvK Nummer +

+ {b_businessid_farm ? ( +
+ + + {b_businessid_farm} + +
+ ) : ( +
+ Geen KvK-nummer gevonden. Voeg deze toe in de + bedrijfsgegevens. +
+ )} +
+
+

+ Wat gebeurt er? +

+

+ Na het klikken op "Verbinden met RVO" wordt u + doorgestuurd naar de inlogpagina van RVO. Na + authenticatie met eHerkenning keert u terug naar + deze pagina om de verschillen te beoordelen. +

+
+
+
+ + {!b_businessid_farm ? ( + + ) : ( +
+ + +
+ )} +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/rvo/error-alert.tsx b/fdm-app/app/components/blocks/rvo/error-alert.tsx new file mode 100644 index 000000000..4a9c24b28 --- /dev/null +++ b/fdm-app/app/components/blocks/rvo/error-alert.tsx @@ -0,0 +1,106 @@ +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" +import { Button } from "~/components/ui/button" +import { AlertTriangle, RefreshCw } from "lucide-react" +import { Link } from "react-router" + +interface RvoErrorAlertProps { + error: string | Error + onRetry?: () => void + retryPath?: string +} + +export function RvoErrorAlert({ + error, + onRetry, + retryPath, +}: RvoErrorAlertProps) { + const rawMessage = typeof error === "string" ? error : error.message + let friendlyTitle = "Er is iets misgegaan" + let friendlyMessage = + "Er is een onverwachte fout opgetreden. Probeer het later opnieuw." + + // Map technical errors to user-friendly messages + if ( + rawMessage.includes("TVS Authorize Endpoint") || + rawMessage.includes("TVS Token Endpoint") || + rawMessage.includes("Client Name is required") || + rawMessage.includes("PKIO Private Key") || + rawMessage.includes("configuration is missing") + ) { + friendlyTitle = "Configuratiefout" + friendlyMessage = + "De RVO koppeling is niet correct geconfigureerd op deze server. Neem contact op met de beheerder." + } else if ( + rawMessage.includes("Failed to obtain access token") || + rawMessage.includes("Access token is missing") + ) { + friendlyTitle = "Authenticatie mislukt" + friendlyMessage = + "Het is niet gelukt om in te loggen bij RVO of uw sessie is verlopen. Probeer opnieuw verbinding te maken." + } else if (rawMessage.includes("Request failed: 500")) { + friendlyTitle = "RVO Storing" + friendlyMessage = + "De RVO webservice geeft een interne serverfout (500). Dit ligt meestal aan RVO. Probeer het later opnieuw." + } else if ( + rawMessage.includes("Request failed: 401") || + rawMessage.includes("Request failed: 403") + ) { + friendlyTitle = "Geen toegang" + friendlyMessage = + "U heeft geen toegang tot de gegevens van dit bedrijf bij RVO. Controleer of u de juiste eHerkenning machtigingen heeft voor dit KvK-nummer." + } else if (rawMessage.includes("Request failed")) { + friendlyTitle = "Communicatiefout" + friendlyMessage = `Er is een fout opgetreden bij het ophalen van gegevens bij RVO. (${rawMessage})` + } else if (rawMessage.includes("Zod") || rawMessage.includes("parse")) { + friendlyTitle = "Gegevensfout" + friendlyMessage = + "De gegevens ontvangen van RVO kwamen niet overeen met het verwachte formaat." + } else if (rawMessage.includes("b_businessid_farm is not available")) { + friendlyTitle = "Geen KvK nummer" + friendlyMessage = + "Dit bedrijf heeft geen KvK nummer geconfigureerd. Voeg dit toe in de bedrijfsinstellingen." + } + + return ( + + + + {friendlyTitle} + + +
+

{friendlyMessage}

+ {retryPath ? ( + + ) : onRetry ? ( + + ) : null} +
+ {import.meta.env.MODE === "development" && ( +
+ DEBUG: {rawMessage} +
+ )} +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/rvo/import-review-table.tsx b/fdm-app/app/components/blocks/rvo/import-review-table.tsx new file mode 100644 index 000000000..c296b5fbf --- /dev/null +++ b/fdm-app/app/components/blocks/rvo/import-review-table.tsx @@ -0,0 +1,724 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import type { + RvoImportReviewItem, + ImportReviewAction, + UserChoiceMap, +} from "@nmi-agro/fdm-rvo/types" +import { getItemId } from "@nmi-agro/fdm-rvo/utils" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import { Badge } from "~/components/ui/badge" +import { Check, Plus, Trash2, ArrowLeftRight, X, Archive } from "lucide-react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { area } from "@turf/turf" +import { format, parseISO } from "date-fns" +import { cn } from "~/lib/utils" +import { acquiringMethodOptions } from "~/lib/constants" +import { useMemo } from "react" +import { clientConfig } from "@/app/lib/config" + +declare module "@tanstack/react-table" { + interface TableMeta { + userChoices: UserChoiceMap + onChoiceChange: (id: string, action: ImportReviewAction) => void + } +} + +interface RvoImportReviewTableProps { + data: RvoImportReviewItem[] + userChoices: UserChoiceMap + onChoiceChange: (id: string, action: ImportReviewAction) => void +} + +function formatDate(dateString?: string | Date) { + if (!dateString) return "-" + try { + const date = + typeof dateString === "string" ? parseISO(dateString) : dateString + return format(date, "dd-MM-yyyy") + } catch { + return dateString.toString() + } +} + +function formatArea(geometry: any) { + if (!geometry) return "-" + const a = area(geometry) + return (a / 10000).toFixed(2) + " ha" +} + +// Helper to render diff cells +const DiffCell = ({ + local, + remote, + status, + action, + formatter = (v: any) => v, +}: { + local?: any + remote?: any + status: string + action: ImportReviewAction + formatter?: (v: any) => React.ReactNode +}) => { + // If MATCH, just show one value + if (status === "MATCH") { + return ( + {formatter(local)} + ) + } + + // NEW REMOTE -> Show remote without badge + if (status === "NEW_REMOTE") { + return ( + + {formatter(remote)} + + ) + } + + // NEW LOCAL -> Show local without badge + if (status === "NEW_LOCAL" || status === "EXPIRED_LOCAL") { + return ( + + {formatter(local)} + + ) + } + + // CONFLICT + if (status === "CONFLICT") { + // If values are effectively equal (deep check), show one + if (JSON.stringify(local) === JSON.stringify(remote)) { + return ( + + {formatter(local)} + + ) + } + + const useRemote = + action === "UPDATE_FROM_REMOTE" || action === "ADD_REMOTE" + const useLocal = action === "KEEP_LOCAL" + + return ( +
+ {local !== undefined && ( +
+ + {clientConfig.name} + + + + {formatter(local)} + +
+ )} + + {remote !== undefined && ( +
+ + RVO + + + + {formatter(remote)} + +
+ )} +
+ ) + } + + return null +} + +export const columns: ColumnDef>[] = [ + { + accessorKey: "status", + header: () => ( + + Status + + Geeft de vergelijkingsstatus weer tussen {clientConfig.name}{" "} + en RVO. + + + ), + cell: ({ row }) => { + const status = row.getValue("status") as string + switch (status) { + case "MATCH": + return ( + + + Gelijk + + Perceel komt in {clientConfig.name} en bij + RVO volledig overeen. + + + + ) + case "NEW_REMOTE": + return ( + + + Nieuw (RVO) + + Perceel bestaat bij RVO, maar niet in{" "} + {clientConfig.name}. + + + + ) + case "NEW_LOCAL": + return ( + + + + Nieuw ( {clientConfig.name}) + + + Perceel bestaat in {clientConfig.name}, maar + niet bij RVO. + + + + ) + case "EXPIRED_LOCAL": + return ( + + + + Niet meer actief + + + Perceel is in {clientConfig.name} nog + actief, maar komt niet meer voor bij RVO. + + + + ) + case "CONFLICT": + return ( + + + Verschil + + Perceel bestaat in beide, maar met + verschillende gegevens. + + + + ) + default: + return {status} + } + }, + }, + { + id: "perceel", + header: () => ( + + Perceel + + De naam en het RVO ID van het perceel. + + + ), + cell: ({ row, table }) => { + const item = row.original + const id = getItemId(item) + const { userChoices } = table.options.meta! + const action = userChoices[id] as ImportReviewAction + + return ( + ( + {val || "Naamloos"} + )} + /> + ) + }, + }, + { + id: "oppervlakte", + header: () => ( + + Oppervlakte + + De oppervlakte van het perceel in hectaren. + + + ), + cell: ({ row, table }) => { + const item = row.original + const id = getItemId(item) + const { userChoices } = table.options.meta! + const action = userChoices[id] as ImportReviewAction + + const localArea = item.localField + ? `${(item.localField.b_area ?? 0).toFixed(2)} ha` + : undefined + const remoteArea = item.rvoField + ? formatArea(item.rvoField.geometry) + : undefined + + return ( + + ) + }, + }, + { + id: "gewas", + header: () => ( + + Gewas + + Het gewas dat op 15 mei wordt geteeld. + + + ), + cell: ({ row, table }) => { + const item = row.original + const id = getItemId(item) + const { userChoices } = table.options.meta! + const action = userChoices[id] as ImportReviewAction + + return ( + val || "-"} + /> + ) + }, + }, + { + id: "ingangsdatum", + header: () => ( + + Ingangsdatum + + De datum vanaf wanneer het perceel actief is. + + + ), + cell: ({ row, table }) => { + const item = row.original + const id = getItemId(item) + const { userChoices } = table.options.meta! + const action = userChoices[id] as ImportReviewAction + + return ( + + ) + }, + }, + { + id: "einddatum", + header: () => ( + + Einddatum + + De datum waarop het perceel niet meer actief is. + + + ), + cell: ({ row, table }) => { + const item = row.original + const id = getItemId(item) + const { userChoices } = table.options.meta! + const action = userChoices[id] as ImportReviewAction + + return ( + val || "-"} + /> + ) + }, + }, + { + id: "gebruikstitel", + header: () => ( + + Gebruikstitel + + De vorm van gebruikstitel (bv. eigendom, pacht). + + + ), + cell: ({ row, table }) => { + const item = row.original + const id = getItemId(item) + const { userChoices } = table.options.meta! + const action = userChoices[id] as ImportReviewAction + + // Map RVO code to label using FDM dictionary + const rvoCode = item.rvoField?.properties.UseTitleCode + const rvoLabel = rvoCode + ? acquiringMethodOptions.find( + (opt) => opt.value === `nl_${rvoCode}`, + )?.label || rvoCode + : undefined + + // Map local acquiring method to label (simplified) + const localMethod = item.localField?.b_acquiring_method + // Assuming localMethod is english enum like 'purchase', 'lease'. Map to NL for consistency + const localLabel = + acquiringMethodOptions.find((opt) => opt.value === localMethod) + ?.label || localMethod + + return ( + val || "-"} + /> + ) + }, + }, + { + id: "bufferstrook", + header: () => ( + + Bufferstrook + + Geeft aan of het perceel bij RVO geregistreerd staat als + bufferstrook. + + + ), + cell: ({ row, table }) => { + const item = row.original + const { userChoices } = table.options.meta! + const action = userChoices[getItemId(item)] as ImportReviewAction + + const rvoBufferstrip = + item.rvoField?.properties.mestData?.IndBufferstrook + const rvoLabel = + rvoBufferstrip === undefined + ? undefined + : rvoBufferstrip === "J" + ? "Ja" + : "Nee" + + const localLabel = + item.localField?.b_bufferstrip === undefined + ? undefined + : item.localField.b_bufferstrip + ? "Ja" + : "Nee" + + return ( + val ?? "-"} + /> + ) + }, + }, + { + id: "actions", + header: () => ( + + Actie + + Kies welke actie moet worden uitgevoerd voor dit perceel. + + + ), + cell: ({ row, table }) => { + const item = row.original + const id = getItemId(item) + const { userChoices, onChoiceChange } = table.options.meta! + const currentChoice = userChoices[id] as ImportReviewAction + + if (item.status === "MATCH") { + return ( +
+ + Aanwezig +
+ ) + } + + return ( + + ) + }, + }, +] + +export function RvoImportReviewTable({ + data, + userChoices, + onChoiceChange, +}: RvoImportReviewTableProps) { + const sortedData = useMemo(() => { + return [...data].sort((a, b) => { + const getArea = (item: typeof a): number | null => { + if (item.rvoField?.geometry) { + return area(item.rvoField.geometry) / 10000 + } + if (Number.isFinite(item.localField?.b_area)) { + return item.localField.b_area as number + } + return null + } + const areaA = getArea(a) + const areaB = getArea(b) + if (areaA === null && areaB === null) return 0 + if (areaA === null) return 1 + if (areaB === null) return -1 + return areaB - areaA + }) + }, [data]) + + const table = useReactTable({ + data: sortedData, + columns, + getCoreRowModel: getCoreRowModel(), + meta: { + userChoices, + onChoiceChange, + }, + }) + + return ( + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + Geen resultaten. + + + )} + +
+
+
+ ) +} diff --git a/fdm-app/app/integrations/calculator.ts b/fdm-app/app/integrations/calculator.ts index 815c46611..7497919be 100644 --- a/fdm-app/app/integrations/calculator.ts +++ b/fdm-app/app/integrations/calculator.ts @@ -29,7 +29,7 @@ import { type Timeframe, } from "@nmi-agro/fdm-core" import { getDefaultCultivation } from "~/lib/cultivation-helpers" -import { getNmiApiKey } from "./nmi" +import { getNmiApiKey } from "./nmi.server" // Get nitrogen balance for a field export async function getNitrogenBalanceForField({ diff --git a/fdm-app/app/integrations/nmi.ts b/fdm-app/app/integrations/nmi.server.ts similarity index 100% rename from fdm-app/app/integrations/nmi.ts rename to fdm-app/app/integrations/nmi.server.ts diff --git a/fdm-app/app/integrations/rvo.server.ts b/fdm-app/app/integrations/rvo.server.ts new file mode 100644 index 000000000..65d4d1ec3 --- /dev/null +++ b/fdm-app/app/integrations/rvo.server.ts @@ -0,0 +1,123 @@ +import { serverConfig } from "../lib/config.server" +import { createCookie } from "react-router" +import { nanoid } from "nanoid" +import { createRvoClient } from "~/lib/rvo.server" +import { isOfOrigin } from "~/lib/url-utils" + +const sessionSecret = serverConfig.auth.fdm_session_secret +if (!sessionSecret?.trim() || sessionSecret === "undefined") { + throw new Error( + "FDM_SESSION_SECRET is missing or invalid. Cannot initialize RVO state cookie.", + ) +} + +export const rvoStateCookie = createCookie("rvo_state", { + path: "/", + httpOnly: true, + // "lax" is required (not "strict"): the OAuth callback is a cross-site + // top-level redirect from RVO back to our app, and "strict" would suppress + // the cookie, breaking CSRF verification for every user. + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 3600, // 1 hour + secrets: [sessionSecret], +}) + +/** + * Generates a signed OAuth state with a random nonce. + * @returns { state, cookieHeader } The base64 state string and the serialized cookie header. + */ +export async function createRvoState(farmId: string, returnUrl: string) { + const appOrigin = new URL(serverConfig.url).origin + const safeReturnUrl = isOfOrigin(returnUrl, appOrigin) ? returnUrl : "/" + + const nonce = nanoid() + const state = Buffer.from( + JSON.stringify({ + farmId, + returnUrl: safeReturnUrl, + nonce, + }), + ).toString("base64") + + return { + state, + cookieHeader: await rvoStateCookie.serialize(state), + } +} + +/** + * Verifies the OAuth state against the signed cookie and ensures the farm ID matches. + * @throws {Response} 403 if CSRF or farm ID validation fails. + */ +export async function verifyRvoState( + request: Request, + stateFromUrl: string, + expectedFarmId: string, +) { + const cookieHeader = request.headers.get("Cookie") + const stateFromCookie = await rvoStateCookie.parse(cookieHeader) + + if (!stateFromCookie || stateFromCookie !== stateFromUrl) { + throw new Response("Ongeldige state parameter (CSRF)", { + status: 403, + }) + } + + try { + const decodedState = JSON.parse( + Buffer.from(stateFromUrl, "base64").toString("utf-8"), + ) + if (decodedState.farmId !== expectedFarmId) { + throw new Response("Ongeldig bedrijfs-ID in state", { + status: 403, + }) + } + return decodedState + } catch (e) { + if (e instanceof Response) throw e + throw new Response("Ongeldig state formaat", { status: 400 }) + } +} + +export function getRvoCredentials(): RvoCredentials | undefined { + // Check if RVO is configured + const { clientId, redirectUri, clientName, pkioPrivateKey } = + serverConfig.integrations.rvo + const isValid = (v: string) => !!v?.trim() && v !== "undefined" + const rvoConfigured = + isValid(clientId) && + isValid(redirectUri) && + isValid(clientName) && + isValid(pkioPrivateKey) + if (!rvoConfigured) { + return undefined + } + + return { + clientId, + redirectUri, + clientName, + pkioPrivateKey, + } +} + +type RvoCredentials = { + clientId: string + redirectUri: string + clientName: string + pkioPrivateKey: string +} + +/** + * Creates an RvoClient configured from the given credentials and the current NODE_ENV. + */ +export function createConfiguredRvoClient(credentials: RvoCredentials) { + return createRvoClient( + credentials.clientId, + credentials.clientName, + credentials.redirectUri, + credentials.pkioPrivateKey, + process.env.NODE_ENV === "production" ? "production" : "acceptance", + ) +} diff --git a/fdm-app/app/lib/config.server.ts b/fdm-app/app/lib/config.server.ts index a1cae21bd..a0096e9c2 100644 --- a/fdm-app/app/lib/config.server.ts +++ b/fdm-app/app/lib/config.server.ts @@ -39,6 +39,12 @@ export const serverConfig: ServerConfig = { nmi: { api_key: String(process.env.NMI_API_KEY), }, + rvo: { + clientId: String(process.env.RVO_CLIENT_ID), + redirectUri: String(process.env.RVO_REDIRECT_URI), + clientName: String(process.env.RVO_CLIENT_NAME), + pkioPrivateKey: String(process.env.RVO_PKIO_PRIVATE_KEY), + }, }, // Analytics diff --git a/fdm-app/app/lib/constants.ts b/fdm-app/app/lib/constants.ts new file mode 100644 index 000000000..de234f0e7 --- /dev/null +++ b/fdm-app/app/lib/constants.ts @@ -0,0 +1,21 @@ +export const acquiringMethodOptions = [ + { value: "nl_01", label: "Eigendom" }, + { value: "nl_02", label: "Reguliere pacht" }, + { + value: "nl_03", + label: "In gebruik van een terreinbeherende organisatie", + }, + { + value: "nl_04", + label: "Tijdelijk gebruik in het kader van landinrichting", + }, + { value: "nl_07", label: "Overige exploitatievormen" }, + { value: "nl_09", label: "Erfpacht" }, + { value: "nl_10", label: "Pacht van geringe oppervlakten" }, + { value: "nl_11", label: "Natuurpacht" }, + { value: "nl_12", label: "Geliberaliseerde pacht, langer dan 6 jaar" }, + { value: "nl_13", label: "Geliberaliseerde pacht, 6 jaar of korter" }, + { value: "nl_61", label: "Reguliere pacht kortlopend" }, + { value: "nl_63", label: "Teeltpacht" }, + { value: "unknown", label: "Onbekend" }, +] diff --git a/fdm-app/app/lib/error.ts b/fdm-app/app/lib/error.ts index 80663f0b5..d67517efa 100644 --- a/fdm-app/app/lib/error.ts +++ b/fdm-app/app/lib/error.ts @@ -8,6 +8,29 @@ const errorIdSize = 8 // Number of characters in ID export const createErrorId = customAlphabet(customErrorAlphabet, errorIdSize) +/** + * Extracts a human-readable error message from any thrown value. + * + * React Router loaders/actions can throw `Response` objects (e.g. via + * `throw new Response("msg", { status: 400 })`). These are valid `Error` + * values in a try/catch, but `.message` is `undefined` on them — only `.text()` + * returns the body string. This helper handles all three cases: + * 1. `Response` → await `.text()`, fallback to `HTTP ` + * 2. `Error` → `.message` + * 3. anything → `String(e)` + */ +export async function extractErrorMessage(e: unknown): Promise { + if (e instanceof Response) { + try { + return (await e.text()) || `HTTP ${e.status}` + } catch { + return `HTTP ${e.status}` + } + } + if (e instanceof Error) return e.message + return String(e) +} + export function reportError( error: unknown, tags: Record = {}, diff --git a/fdm-app/app/lib/rvo.server.ts b/fdm-app/app/lib/rvo.server.ts new file mode 100644 index 000000000..ee848e36c --- /dev/null +++ b/fdm-app/app/lib/rvo.server.ts @@ -0,0 +1 @@ +export * from "@nmi-agro/fdm-rvo" diff --git a/fdm-app/app/routes/api.soil-analysis.extract.ts b/fdm-app/app/routes/api.soil-analysis.extract.ts index d7b774049..bb3ab2b59 100644 --- a/fdm-app/app/routes/api.soil-analysis.extract.ts +++ b/fdm-app/app/routes/api.soil-analysis.extract.ts @@ -1,5 +1,5 @@ import type { ActionFunctionArgs } from "react-router" -import { extractBulkSoilAnalyses } from "~/integrations/nmi" +import { extractBulkSoilAnalyses } from "~/integrations/nmi.server" import { getSession } from "~/lib/auth.server" /** diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx index b7dcc25a2..3209a694b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.atlas.fields.$centroid.tsx @@ -28,7 +28,10 @@ import { FieldDetailsAtlasSkeleton } from "~/components/blocks/atlas-fields/skel import { SoilTextureCard } from "~/components/blocks/atlas-fields/soil-texture" import { ErrorBlock } from "~/components/custom/error" import { Button } from "~/components/ui/button" -import { getNmiApiKey, getSoilParameterEstimates } from "~/integrations/nmi" +import { + getNmiApiKey, + getSoilParameterEstimates, +} from "~/integrations/nmi.server" import { getCalendar } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx index ed3b0b612..7d80f5d38 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx @@ -26,14 +26,14 @@ import { FormSchemaModify, } from "~/components/blocks/fertilizer-applications/formschema" import { FertilizerApplicationMetricsCard } from "~/components/blocks/fertilizer-applications/metrics" -import { getNmiApiKey } from "~/integrations/nmi" import { getSession } from "~/lib/auth.server" import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" -import { getDefaultCultivation } from "~/lib/cultivation-helpers" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { getNmiApiKey } from "~/integrations/nmi.server" +import { getDefaultCultivation } from "~/lib/cultivation-helpers" import { getNitrogenBalanceForField, getNorms, diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx index 7ff9925c3..d8d27e01c 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx @@ -16,7 +16,7 @@ import { FormSchema, SoilAnalysisUploadForm, } from "~/components/blocks/soil/form-upload" -import { extractSoilAnalysis } from "~/integrations/nmi" +import { extractSoilAnalysis } from "~/integrations/nmi.server" import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx index 1e448abf8..8ae551a1f 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx @@ -58,7 +58,7 @@ import { Separator } from "~/components/ui/separator" import { SidebarInset } from "~/components/ui/sidebar" import { Skeleton } from "~/components/ui/skeleton" import { getMapStyle } from "~/integrations/map" -import { getNmiApiKey, getSoilParameterEstimates } from "~/integrations/nmi" +import { getNmiApiKey, getSoilParameterEstimates } from "~/integrations/nmi.server" import { getSession } from "~/lib/auth.server" import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx index f1f6b4228..cd04af74e 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx @@ -30,7 +30,7 @@ import { clientConfig } from "~/lib/config" import { getDefaultCultivation } from "~/lib/cultivation-helpers" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" -import { getNmiApiKey } from "../integrations/nmi" +import { getNmiApiKey } from "../integrations/nmi.server" // Meta export const meta: MetaFunction = () => { 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 new file mode 100644 index 000000000..52c88751b --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rvo.tsx @@ -0,0 +1,681 @@ +import { + type ActionFunctionArgs, + Form, + type LoaderFunctionArgs, + type MetaFunction, + redirect, + useActionData, + useLoaderData, + useNavigation, + useParams, + useLocation, + data, +} from "react-router" +import { getSession } from "~/lib/auth.server" +import { extractErrorMessage } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { + generateAuthUrl, + fetchRvoFields, + compareFields, + exchangeToken, + processRvoImport, +} from "~/lib/rvo.server" +import { + type RvoImportReviewItem, + RvoImportReviewStatus, + type UserChoiceMap, + type ImportReviewAction, +} from "@nmi-agro/fdm-rvo/types" +import { getItemId } from "@nmi-agro/fdm-rvo/utils" +import { RvoImportReviewTable } from "~/components/blocks/rvo/import-review-table" +import { getFields, getFarm, getFarms } from "@nmi-agro/fdm-core" +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" +import { Button } from "~/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from "~/components/ui/dialog" +import { AlertTriangle, Loader2 } from "lucide-react" +import { useEffect, useState } from "react" +import { FarmContent } from "~/components/blocks/farm/farm-content" +import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" +import { SidebarInset } from "~/components/ui/sidebar" +import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" +import { + createConfiguredRvoClient, + createRvoState, + getRvoCredentials, + verifyRvoState, +} from "~/integrations/rvo.server" +import { RvoErrorAlert } from "~/components/blocks/rvo/error-alert" +import { + getNmiApiKey, + getSoilParameterEstimates, +} from "~/integrations/nmi.server" +import { + addSoilAnalysis, + getCultivations, + getCultivationsFromCatalogue, + type FdmType, +} from "@nmi-agro/fdm-core" +import { RvoConnectCard } from "~/components/blocks/rvo/connect-card" +import { clientConfig } from "~/lib/config" + +export const meta: MetaFunction = ({ params }) => { + return [{ title: `Percelen ophalen bij RVO - Bedrijf ${params.b_id_farm}` }] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { b_id_farm, calendar: yearString } = params + if (!b_id_farm) { + throw new Response("Farm ID is required", { status: 400 }) + } + const year = Number(yearString) + if (!Number.isInteger(year)) { + throw new Response("Ongeldig kalenderjaar", { status: 400 }) + } + + const session = await getSession(request) + const url = new URL(request.url) + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + + let rvoImportReviewData: RvoImportReviewItem[] = [] + let error: string | null = null + let b_businessid_farm: string | null = null + let b_name_farm: string | null = null + + // Check if RVO is configured + const rvoCredentials = getRvoCredentials() + const isRvoConfigured = rvoCredentials !== undefined + + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + if (farm) { + b_businessid_farm = farm.b_businessid_farm + b_name_farm = farm.b_name_farm + } + + const farms = await getFarms(fdm, session.principal_id) + + if (code && state) { + try { + if (!isRvoConfigured) { + throw new Response("RVO client is not configured.", { + status: 500, + }) + } + + // CSRF Verification + await verifyRvoState(request, state, b_id_farm) + + if (!farm || !farm.b_businessid_farm) { + throw new Response("b_businessid_farm is not available", { + status: 400, + }) + } + + const rvoClient = createConfiguredRvoClient(rvoCredentials) + + try { + await exchangeToken(rvoClient, code) + } catch (e: any) { + // Handle token exchange errors specifically for refreshes + const originalError = e?.message || "" + if ( + originalError.includes("invalid_grant") || + originalError.includes("expired") + ) { + throw new Error( + "De eHerkenning sessie is verlopen door een paginaverversing of een verouderde link. Klik op 'Verbinden met RVO' om opnieuw te verbinden.", + ) + } + throw e + } + + const rvoFields = await fetchRvoFields( + rvoClient, + yearString, + farm.b_businessid_farm, + ) + + const localFields = await getFields( + fdm, + session.principal_id, + b_id_farm, + ) + const localFieldsExtended = await Promise.all( + localFields.map(async (field) => { + const cultivations = await getCultivations( + fdm, + session.principal_id, + field.b_id, + { + start: new Date(`${yearString}-01-01`), + end: new Date(`${yearString}-12-31`), + }, + ) + return { ...field, cultivations } + }), + ) + + const cultivationsCatalogue = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + + rvoImportReviewData = compareFields( + localFieldsExtended, + rvoFields, + year, + cultivationsCatalogue, + ) + } catch (e: any) { + console.error("Error with importing from RVO:", e) + error = await extractErrorMessage(e) + } + } else if (!url.searchParams.has("start_import")) { + return { + b_id_farm, + rvoImportReviewData: [], + error: null, + showimportButton: true, + noRvoParcelsFound: false, + b_businessid_farm, + isRvoConfigured, + farms, + b_name_farm, + } + } + + const noRvoParcelsFound = !error && rvoImportReviewData.length === 0 + return data({ + b_id_farm, + rvoImportReviewData, + error, + showimportButton: noRvoParcelsFound, + noRvoParcelsFound, + b_businessid_farm, + isRvoConfigured, + farms, + b_name_farm, + calendar: yearString, + }) +} + +export default function RvoImportReviewPage() { + const { b_id_farm } = useParams() + const { + rvoImportReviewData, + error, + b_businessid_farm, + isRvoConfigured, + farms, + calendar, + showimportButton = false, + noRvoParcelsFound = false, + } = useLoaderData() + const actionData = useActionData() + const navigation = useNavigation() + const location = useLocation() + + const isImporting = + navigation.state === "submitting" && + navigation.formData?.get("intent") === "start_import" + const isApplying = + navigation.state === "submitting" && + navigation.formData?.get("intent") === "apply_changes" + + const [userChoices, setUserChoices] = useState({}) + + useEffect(() => { + const initialChoices: UserChoiceMap = {} + rvoImportReviewData.forEach((item) => { + const id = getItemId(item) + let defaultAction: ImportReviewAction + + switch (item.status) { + case RvoImportReviewStatus.NEW_REMOTE: + defaultAction = "ADD_REMOTE" + break + case RvoImportReviewStatus.NEW_LOCAL: + defaultAction = "REMOVE_LOCAL" + break + case RvoImportReviewStatus.EXPIRED_LOCAL: + defaultAction = "CLOSE_LOCAL" + break + case RvoImportReviewStatus.CONFLICT: + defaultAction = "UPDATE_FROM_REMOTE" + break + case RvoImportReviewStatus.MATCH: + defaultAction = "NO_ACTION" + break + } + initialChoices[id] = defaultAction + }) + setUserChoices(initialChoices) + }, [rvoImportReviewData]) + + // Warn the user before refreshing or leaving when data is present + useEffect(() => { + if (rvoImportReviewData.length > 0) { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault() + e.returnValue = + "Als u de pagina ververst, wordt de verbinding met RVO verbroken en moet u opnieuw inloggen met eHerkenning. Wilt u doorgaan?" + return e.returnValue + } + window.addEventListener("beforeunload", handleBeforeUnload) + return () => + window.removeEventListener("beforeunload", handleBeforeUnload) + } + }, [rvoImportReviewData]) + + const handleChoiceChange = (id: string, action: ImportReviewAction) => { + setUserChoices((prev: UserChoiceMap) => ({ ...prev, [id]: action })) + } + + const currentFarmName = + farms.find((farm) => farm.b_id_farm === b_id_farm)?.b_name_farm ?? "" + + const changes = Object.values(userChoices).reduce( + (acc, action) => { + if (action === "ADD_REMOTE") acc.add++ + if (action === "REMOVE_LOCAL") acc.remove++ + if (action === "UPDATE_FROM_REMOTE") acc.update++ + if (action === "CLOSE_LOCAL") acc.close++ + return acc + }, + { add: 0, remove: 0, update: 0, close: 0 }, + ) + const hasChanges = Object.values(changes).some((count) => count > 0) + + if (error) { + return ( + +
+ + + + Percelen ophalen bij RVO + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+ ) + } + + return ( + +
+ + + + Percelen ophalen bij RVO + +
+
+ {actionData?.message && ( +
+ + + {actionData.success ? "Succes" : "Fout"} + + + {actionData.message} + + +
+ )} + + {/* Config Warning */} + {!isRvoConfigured && ( +
+ + + + Percelen ophalen bij RVO is niet beschikbaar + + + De RVO koppeling is nog niet ingesteld op deze + server. Neem contact op met de beheerder om de + RVO credentials toe te voegen. + + +
+ )} + + {rvoImportReviewData.length === 0 ? ( +
+ {noRvoParcelsFound && ( + + Geen percelen gevonden + + Er zijn geen percelen gevonden voor dit + bedrijf bij RVO. Controleer het KvK-nummer + en probeer opnieuw. + + + )} + {showimportButton && ( + + )} +
+ ) : ( + <> + + +
+
+ + + + + + + + Wijzigingen toepassen + + + U staat op het punt de + volgende wijzigingen door te + voeren: + + +
+
    + {changes.add > 0 && ( +
  • + {changes.add}{" "} + {changes.add === 1 + ? "perceel" + : "percelen"}{" "} + toevoegen +
  • + )} + {changes.remove > 0 && ( +
  • + {changes.remove}{" "} + {changes.remove === + 1 + ? "perceel" + : "percelen"}{" "} + verwijderen +
  • + )} + {changes.update > 0 && ( +
  • + {changes.update}{" "} + {changes.update === + 1 + ? "perceel" + : "percelen"}{" "} + bijwerken +
  • + )} + {changes.close > 0 && ( +
  • + {changes.close}{" "} + {changes.close === 1 + ? "perceel" + : "percelen"}{" "} + afsluiten +
  • + )} +
+ {!hasChanges && ( +

+ Geen wijzigingen + geselecteerd. +

+ )} +
+ + + + +
+ + + + +
+
+
+
+
+
+ +
+
+
+ + )} +
+
+ ) +} + +export async function action({ request, params }: ActionFunctionArgs) { + const { b_id_farm, calendar: yearString } = params + if (!b_id_farm || !yearString) { + throw data("Farm ID is required", { + status: 400, + statusText: "Farm ID is required", + }) + } + const year = Number(yearString) + if (!Number.isInteger(year)) { + throw data("Ongeldig kalenderjaar", { + status: 400, + statusText: "Ongeldig kalenderjaar", + }) + } + + const session = await getSession(request) + const formData = await request.formData() + const intent = formData.get("intent") + + if (intent === "start_import") { + const rvoCredentials = getRvoCredentials() + const isRvoConfigured = rvoCredentials !== undefined + + if (!isRvoConfigured) { + throw new Response("RVO client is not configured.", { status: 500 }) + } + + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + if (!farm?.b_businessid_farm) { + throw new Response("Geen KvK-nummer gevonden voor dit bedrijf.", { + status: 400, + }) + } + + const rvoClient = createConfiguredRvoClient(rvoCredentials) + + const { state, cookieHeader } = await createRvoState( + b_id_farm, + request.url, + ) + + const authUrl = generateAuthUrl(rvoClient, state) + + // Set state in cookie and redirect + return redirect(authUrl, { + headers: { + "Set-Cookie": cookieHeader, + }, + }) + } + + if (intent === "apply_changes") { + const rvoImportReviewDataJson = formData.get("rvoImportReviewDataJson") + const userChoicesJson = formData.get("userChoices") + + let rvoImportReviewData: RvoImportReviewItem[] = [] + let userChoices: UserChoiceMap = {} + + if (!rvoImportReviewDataJson || !userChoicesJson) { + return { + success: false, + message: + "Geen data gevonden om te verwerken. Start 'percelen ophalen bij RVO' opnieuw.", + } + } + + try { + rvoImportReviewData = JSON.parse(String(rvoImportReviewDataJson)) + userChoices = JSON.parse(String(userChoicesJson)) + + // Basic validation: ensure we have an array of items + if (!Array.isArray(rvoImportReviewData)) { + throw new Error("Invalid review data format") + } + + const onFieldAdded = async ( + tx: FdmType, + b_id: string, + geometry: any, + ) => { + const nmiApiKey = getNmiApiKey() + if (nmiApiKey) { + try { + const soilEstimates = await getSoilParameterEstimates( + geometry, + nmiApiKey, + ) + await addSoilAnalysis( + tx, + session.principal_id, + undefined, + "nl-other-nmi", + b_id, + soilEstimates.a_depth_lower ?? 30, + undefined, + soilEstimates, + soilEstimates.a_depth_upper, + ) + } catch (e) { + console.warn( + `Failed to fetch soil estimates for field ${b_id}:`, + e, + ) + } + } + } + + await processRvoImport( + fdm, + session.principal_id, + b_id_farm, + rvoImportReviewData, + userChoices, + year, + onFieldAdded, + ) + return redirect(`/farm/${b_id_farm}`) + } catch (e: any) { + console.error("Error with processing RVO import: ", e) + return { + success: false, + message: `Error with processing RVO import: ${await extractErrorMessage(e)}`, + } + } + } + + return {} +} 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 1df1ec740..6535ace0e 100644 --- a/fdm-app/app/routes/farm.$b_id_farm._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm._index.tsx @@ -1,5 +1,10 @@ import { cowHead } from "@lucide/lab" -import { getFarm, getFarms, getFields } from "@nmi-agro/fdm-core" +import { + checkPermission, + getFarm, + getFarms, + getFields, +} from "@nmi-agro/fdm-core" import { ArrowRightLeft, BookOpenText, @@ -18,6 +23,7 @@ import { Square, Trash2, UserRoundCheck, + CloudDownload, } from "lucide-react" import { useState } from "react" import { @@ -55,6 +61,8 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { useCalendarStore } from "~/store/calendar" +import { getRvoCredentials } from "~/integrations/rvo.server" +import { cn } from "~/lib/utils" // Meta export const meta: MetaFunction = () => { @@ -116,6 +124,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Find unique roles const roles = [...new Set(farm.roles.map((role) => role.role))] + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) + + const rvoCredentials = getRvoCredentials() + const isRvoConfigured = rvoCredentials !== undefined + // Return the farm ID and session info return { b_id_farm: b_id_farm, @@ -124,6 +145,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { farmArea: Math.round(farmArea), farmOptions: farmOptions, roles: roles, + farmWritePermission, + isRvoConfigured, } } catch (error) { throw handleLoaderError(error) @@ -426,6 +449,44 @@ export default function FarmDashboardIndex() { + {loaderData.isRvoConfigured && ( + + + +
+
+ +
+
+ + Ophalen bij RVO + + + Importeer + percelen vanuit + RVO. + +
+
+
+
+
+ )} diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx index e58a24798..370797fb7 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx @@ -21,10 +21,8 @@ import type { z } from "zod" import { FertilizerForm } from "@/app/components/blocks/fertilizer/form" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { FormSchema } from "~/components/blocks/fertilizer/formschema" -import { - buildFertilizerDefaults, - getRvoMappings, -} from "~/components/blocks/fertilizer/utils" +import { buildFertilizerDefaults } from "~/components/blocks/fertilizer/utils" +import { getRvoMappings } from "~/components/blocks/fertilizer/utils.server" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" import { HeaderFertilizer } from "~/components/blocks/header/fertilizer" diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.$p_id.tsx index 9711ec68f..21914dd3b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.$p_id.tsx @@ -13,7 +13,7 @@ import { } from "react-router" import { FarmNewFertilizerBlock } from "~/components/blocks/fertilizer/new-fertilizer-page" import { FarmTitle } from "~/components/blocks/farm/farm-title" -import { getRvoMappings } from "~/components/blocks/fertilizer/utils" +import { getRvoMappings } from "~/components/blocks/fertilizer/utils.server" import { getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new._index.tsx index 49746de80..33e647e4b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new._index.tsx @@ -15,7 +15,7 @@ import { CommandList, } from "~/components/ui/command" import { FarmTitle } from "~/components/blocks/farm/farm-title" -import { getRvoMappings } from "~/components/blocks/fertilizer/utils" +import { getRvoMappings } from "~/components/blocks/fertilizer/utils.server" import { getSession } from "~/lib/auth.server" import { fdm } from "~/lib/fdm.server" import { cn } from "~/lib/utils" diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx index f6541051b..e02ad8ef0 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx @@ -15,10 +15,8 @@ import { redirectWithSuccess } from "remix-toast" import { FarmNewFertilizerBlock } from "~/components/blocks/fertilizer/new-fertilizer-page" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { FormSchema } from "~/components/blocks/fertilizer/formschema" -import { - buildCataloguePayload, - getRvoMappings, -} from "~/components/blocks/fertilizer/utils" +import { buildCataloguePayload } from "~/components/blocks/fertilizer/utils" +import { getRvoMappings } from "~/components/blocks/fertilizer/utils.server" import { getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleActionError, handleLoaderError } from "~/lib/error" diff --git a/fdm-app/app/routes/farm._index.tsx b/fdm-app/app/routes/farm._index.tsx index 469ad0756..920f97116 100644 --- a/fdm-app/app/routes/farm._index.tsx +++ b/fdm-app/app/routes/farm._index.tsx @@ -57,7 +57,7 @@ import { AccessFormSchema } from "~/lib/schemas/access.schema" // Meta export const meta: MetaFunction = () => { return [ - { title: `Bedrijf | ${clientConfig.name}` }, + { title: `Bedrijven | ${clientConfig.name}` }, { name: "description", content: "Beheer uw landbouwbedrijf en percelen.", 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 166cb01cd..48259aa37 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 @@ -1,5 +1,5 @@ import { getFarm } from "@nmi-agro/fdm-core" -import { Map as MapIcon, UploadCloud } from "lucide-react" +import { DownloadCloud, Map as MapIcon, UploadCloud } from "lucide-react" import type { LoaderFunctionArgs, MetaFunction } from "react-router" import { data, NavLink, useLoaderData } from "react-router" import { Header } from "~/components/blocks/header/base" @@ -21,7 +21,9 @@ import { import { SidebarInset } from "~/components/ui/sidebar" import { clientConfig } from "~/lib/config" import { getSession } from "../lib/auth.server" -import { fdm } from "../lib/fdm.server" +import { fdm } from "~/lib/fdm.server" +import { getRvoCredentials } from "~/integrations/rvo.server" +import { cn } from "~/lib/utils" // Meta export const meta: MetaFunction = () => { @@ -53,11 +55,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) } - return { farm } + // Check if RVO import is available + const isRvoConfigured = getRvoCredentials() !== undefined + + return { farm, isRvoConfigured } } export default function ChooseFieldImportMethod() { - const { farm } = useLoaderData() + const { farm, isRvoConfigured } = useLoaderData() return ( @@ -72,7 +77,64 @@ export default function ChooseFieldImportMethod() {

Hoe wil je de percelen van je bedrijf importeren?

-
+
+ {isRvoConfigured && ( + + + + Importeren vanuit RVO + + Importeer je percelen door via eHerkenning + toestemming te geven. + + + + + + + Wat heb ik nodig om percelen te + importeren vanuit RVO? + + +
    +
  1. + U heeft een geldig + KvK-nummer gekoppeld aan uw + account. +
  2. +
  3. + U heeft een eHerkenning + account met machtiging voor + dit KvK-nummer. +
  4. +
  5. + U geeft ons toestemming om + perceelsgegevens op te + halen. +
  6. +
+
+
+
+ + + +
+
+ )} diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx index 91e2df814..3d328fdd0 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx @@ -55,7 +55,7 @@ import { Separator } from "~/components/ui/separator" import { SidebarInset } from "~/components/ui/sidebar" import { Skeleton } from "~/components/ui/skeleton" import { getMapStyle } from "~/integrations/map" -import { getNmiApiKey, getSoilParameterEstimates } from "~/integrations/nmi" +import { getNmiApiKey, getSoilParameterEstimates } from "~/integrations/nmi.server" import { getSession } from "~/lib/auth.server" import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx index 8e8bb64e7..8e65da0e9 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx @@ -16,7 +16,7 @@ import { FormSchema, SoilAnalysisUploadForm, } from "~/components/blocks/soil/form-upload" -import { extractSoilAnalysis } from "~/integrations/nmi" +import { extractSoilAnalysis } from "~/integrations/nmi.server" import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" 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 new file mode 100644 index 000000000..ae766ca6a --- /dev/null +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.rvo.tsx @@ -0,0 +1,543 @@ +import { + type ActionFunctionArgs, + Form, + type LoaderFunctionArgs, + type MetaFunction, + redirect, + useActionData, + useLoaderData, + useNavigation, + useLocation, + data, +} from "react-router" +import { getSession } from "~/lib/auth.server" +import { extractErrorMessage } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { + generateAuthUrl, + fetchRvoFields, + compareFields, + exchangeToken, + processRvoImport, +} from "~/lib/rvo.server" +import type { + RvoImportReviewItem, + ImportReviewAction, + UserChoiceMap, +} from "@nmi-agro/fdm-rvo/types" +import { getItemId } from "@nmi-agro/fdm-rvo/utils" +import { RvoImportReviewTable } from "~/components/blocks/rvo/import-review-table" +import { type Cultivation, type Field, getFarm } from "@nmi-agro/fdm-core" +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert" +import { Button } from "~/components/ui/button" +import { AlertTriangle, Loader2 } from "lucide-react" +import { useEffect, useState } from "react" +import { FarmContent } from "~/components/blocks/farm/farm-content" +import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarmCreate } from "~/components/blocks/header/create-farm" +import { SidebarInset } from "~/components/ui/sidebar" +import { + BreadcrumbItem, + BreadcrumbSeparator, + BreadcrumbLink, +} from "~/components/ui/breadcrumb" +import { + getRvoCredentials, + createConfiguredRvoClient, + createRvoState, + verifyRvoState, +} from "~/integrations/rvo.server" +import { RvoErrorAlert } from "~/components/blocks/rvo/error-alert" +import { + getNmiApiKey, + getSoilParameterEstimates, +} from "~/integrations/nmi.server" +import { + addSoilAnalysis, + getCultivationsFromCatalogue, + type FdmType, +} from "@nmi-agro/fdm-core" +import { RvoConnectCard } from "~/components/blocks/rvo/connect-card" + +export const meta: MetaFunction = ({ params }) => { + const b_id_farm = params.b_id_farm + return [{ title: `Percelen ophalen bij RVO - Nieuw Bedrijf ${b_id_farm}` }] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { b_id_farm, calendar: yearString } = params + if (!b_id_farm || !yearString) { + throw new Response("Farm ID en kalender zijn verplicht", { + status: 400, + }) + } + const year = Number(yearString) + if (!Number.isInteger(year)) { + throw new Response("Ongeldig kalenderjaar", { status: 400 }) + } + + const session = await getSession(request) + const url = new URL(request.url) + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + + let rvoImportReviewData: RvoImportReviewItem[] = [] + let error: string | null = null + let b_businessid_farm: string | null = null + let b_name_farm: string | null | undefined = null + + // Check if RVO is configured + const rvoCredentials = getRvoCredentials() + const isRvoConfigured = rvoCredentials !== undefined + + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + if (farm) { + b_businessid_farm = farm.b_businessid_farm + b_name_farm = farm.b_name_farm + } + + if (code && state) { + try { + if (!isRvoConfigured) { + throw new Response("RVO client is not configured.", { + status: 500, + }) + } + + // CSRF Verification + await verifyRvoState(request, state, b_id_farm) + + if (!farm || !farm.b_businessid_farm) { + throw new Response( + "Geen KvK-nummer gevonden voor dit bedrijf.", + { status: 400 }, + ) + } + + const rvoClient = createConfiguredRvoClient(rvoCredentials) + + try { + await exchangeToken(rvoClient, code) + } catch (e: any) { + // Handle token exchange errors specifically for refreshes + const originalError = e?.message || "" + if ( + originalError.includes("invalid_grant") || + originalError.includes("expired") + ) { + throw new Error( + "De eHerkenning sessie is verlopen door een paginaverversing of een verouderde link. Klik op 'Verbinden met RVO' om opnieuw te verbinden.", + ) + } + throw e + } + + const rvoFields = await fetchRvoFields( + rvoClient, + yearString, + farm.b_businessid_farm, + ) + + const cultivationsCatalogue = await getCultivationsFromCatalogue( + fdm, + session.principal_id, + b_id_farm, + ) + + const localFieldsExtended: (Field & { + cultivations: Cultivation[] + })[] = [] // No existing fields to compare against yet in create wizard, so localFields is empty + rvoImportReviewData = compareFields( + localFieldsExtended, + rvoFields, + year, + cultivationsCatalogue, + ) + } catch (e: any) { + console.error("RVO Import Fout:", e) + error = await extractErrorMessage(e) + } + } else if (!url.searchParams.has("start_import")) { + return data({ + b_id_farm, + b_businessid_farm, + calendar: yearString, + rvoImportReviewData: [], + error: null, + showImportButton: true, + noRvoParcelsFound: false, + isRvoConfigured, + b_name_farm, + }) + } + + const noRvoParcelsFound = !error && rvoImportReviewData.length === 0 + return data({ + b_id_farm, + b_businessid_farm, + calendar: yearString, + rvoImportReviewData, + error, + showImportButton: noRvoParcelsFound, + noRvoParcelsFound, + isRvoConfigured, + b_name_farm, + }) +} + +export default function RvoImportCreatePage() { + const { + b_id_farm, + b_businessid_farm, + calendar, + rvoImportReviewData, + error, + showImportButton, + noRvoParcelsFound, + isRvoConfigured, + b_name_farm, + } = useLoaderData() + const actionData = useActionData() + const navigation = useNavigation() + const location = useLocation() + + const isImporting = + navigation.state === "submitting" && + navigation.formData?.get("intent") === "start_import" + const isSaving = + navigation.state === "submitting" && + navigation.formData?.get("intent") === "save_fields" + + const [userChoices, setUserChoices] = useState({}) + + useEffect(() => { + // Initialize user choices with defaults + const initialChoices: UserChoiceMap = {} + rvoImportReviewData.forEach((item) => { + const id = getItemId(item) + let defaultAction: ImportReviewAction + + switch (item.status) { + case "NEW_REMOTE": + defaultAction = "ADD_REMOTE" + break + // In creation wizard, other statuses are unlikely but good to handle defaults + default: + defaultAction = "NO_ACTION" + break + } + initialChoices[id] = defaultAction + }) + setUserChoices(initialChoices) + }, [rvoImportReviewData]) + + // Warn the user before refreshing or leaving when data is present + useEffect(() => { + if (rvoImportReviewData.length > 0) { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault() + e.returnValue = + "Als u de pagina ververst, wordt de verbinding met RVO verbroken en moet u opnieuw inloggen met eHerkenning. Wilt u doorgaan?" + return e.returnValue + } + window.addEventListener("beforeunload", handleBeforeUnload) + return () => + window.removeEventListener("beforeunload", handleBeforeUnload) + } + }, [rvoImportReviewData]) + + const handleChoiceChange = (id: string, action: ImportReviewAction) => { + setUserChoices((prev: UserChoiceMap) => ({ ...prev, [id]: action })) + } + + if (error) { + return ( + +
+ + + + + Percelen ophalen bij RVO + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+ ) + } + + return ( + +
+ + + + Percelen ophalen bij RVO + +
+
+ {actionData?.message && ( +
+ + + {actionData.success ? "Succes" : "Fout"} + + + {actionData.message} + + +
+ )} + {/* Config Warning */} + {!isRvoConfigured && ( +
+ + + + Percelen ophalen bij RVO is niet beschikbaar + + + De RVO koppeling is nog niet ingesteld op deze + server. Neem contact op met de beheerder om de + RVO toeggangegevens toe te voegen. + + +
+ )} + + {rvoImportReviewData.length === 0 ? ( +
+
+ {noRvoParcelsFound && ( + + + Geen percelen gevonden + + + Er zijn geen percelen gevonden voor dit + bedrijf bij RVO. Controleer het + KvK-nummer en probeer opnieuw. + + + )} + {showImportButton && ( + + )} +
+
+ ) : ( + <> + + + +
+
+
+ + + + +
+
+
+ +
+
+
+ + )} +
+
+ ) +} + +export async function action({ request, params }: ActionFunctionArgs) { + const { b_id_farm, calendar: yearString } = params + if (!b_id_farm || !yearString) { + throw new Response("b_id_farm and calendar are required", { + status: 400, + }) + } + const year = Number(yearString) + if (!Number.isInteger(year)) { + throw new Response("Ongeldig kalenderjaar", { status: 400 }) + } + + const session = await getSession(request) + const formData = await request.formData() + const intent = formData.get("intent") + + if (intent === "start_import") { + const rvoCredentials = getRvoCredentials() + const isRvoConfigured = rvoCredentials !== undefined + + if (!isRvoConfigured) { + throw new Response("RVO client is not available", { status: 500 }) + } + + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + if (!farm?.b_businessid_farm) { + throw new Response("Geen KvK-nummer gevonden voor dit bedrijf.", { + status: 400, + }) + } + + const rvoClient = createConfiguredRvoClient(rvoCredentials) + + const { state, cookieHeader } = await createRvoState( + b_id_farm, + request.url, + ) + + const authUrl = generateAuthUrl(rvoClient, state) + + return redirect(authUrl, { + headers: { + "Set-Cookie": cookieHeader, + }, + }) + } + + if (intent === "save_fields") { + const RvoImportReviewDataJson = formData.get("RvoImportReviewDataJson") + const userChoicesJson = formData.get("userChoices") + + let rvoImportReviewData: RvoImportReviewItem[] = [] + let userChoices: UserChoiceMap = {} + + if (!RvoImportReviewDataJson || !userChoicesJson) { + return { + success: false, + message: + "Geen data gevonden om te verwerken. Start de RVO import opnieuw.", + } + } + + try { + rvoImportReviewData = JSON.parse(String(RvoImportReviewDataJson)) + userChoices = JSON.parse(String(userChoicesJson)) + + if (!Array.isArray(rvoImportReviewData)) { + throw new Error("Invalid review data format") + } + + const onFieldAdded = async ( + tx: FdmType, + b_id: string, + geometry: any, + ) => { + const nmiApiKey = getNmiApiKey() + if (nmiApiKey) { + try { + const soilEstimates = await getSoilParameterEstimates( + geometry, + nmiApiKey, + ) + await addSoilAnalysis( + tx, + session.principal_id, + undefined, + "nl-other-nmi", + b_id, + soilEstimates.a_depth_lower ?? 30, + undefined, + soilEstimates, + soilEstimates.a_depth_upper, + ) + } catch (e) { + console.warn( + `Failed to fetch soil estimates for field ${b_id}:`, + e, + ) + } + } + } + + await processRvoImport( + fdm, + session.principal_id, + b_id_farm, + rvoImportReviewData, + userChoices, + year, + onFieldAdded, + ) + return redirect(`/farm/create/${b_id_farm}/${yearString}/fields`) + } catch (e: any) { + console.error("Error at saving RVO fields: ", e) + return { + success: false, + message: `Error at saving RVO fields: ${await extractErrorMessage(e)}`, + } + } + } + + return {} +} diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.upload.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.upload.tsx index b83fb382b..11e6a24b7 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.upload.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.upload.tsx @@ -22,7 +22,7 @@ import { MijnPercelenUploadForm } from "@/app/components/blocks/mijnpercelen/for import { Header } from "~/components/blocks/header/base" import { HeaderFarmCreate } from "~/components/blocks/header/create-farm" import { SidebarInset } from "~/components/ui/sidebar" -import { getNmiApiKey, getSoilParameterEstimates } from "~/integrations/nmi" +import { getNmiApiKey, getSoilParameterEstimates } from "~/integrations/nmi.server" import { getSession } from "~/lib/auth.server" import { getCalendar } from "~/lib/calendar" import { clientConfig } from "~/lib/config" diff --git a/fdm-app/app/types/config.d.ts b/fdm-app/app/types/config.d.ts index c4ea8bb0c..c32ee5ec1 100644 --- a/fdm-app/app/types/config.d.ts +++ b/fdm-app/app/types/config.d.ts @@ -34,6 +34,12 @@ export interface ServerConfig { nmi?: { api_key: string } + rvo: { + clientId: string + redirectUri: string + clientName: string + pkioPrivateKey: string + } } analytics: { sentry?: { diff --git a/fdm-app/package.json b/fdm-app/package.json index 9193929dd..0750fffd4 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -33,6 +33,7 @@ "@sentry/profiling-node": "^10.43.0", "@sentry/react-router": "^10.43.0", "@tailwindcss/vite": "^4.2.1", + "@nmi-agro/fdm-rvo": "workspace:^", "@tanstack/react-table": "^8.21.3", "@turf/boolean-intersects": "^7.3.4", "@turf/centroid": "^7.3.4", @@ -91,6 +92,7 @@ "@react-router/dev": "^7.13.1", "@react-router/fs-routes": "^7.13.1", "@tailwindcss/postcss": "^4.2.1", + "@nmi-agro/fdm-rvo": "workspace:*", "@types/geojson": "^7946.0.16", "@types/lodash.throttle": "^4.1.9", "@types/mapbox__geojson-extent": "^1.0.3", diff --git a/fdm-app/tsconfig.json b/fdm-app/tsconfig.json index dbe2a2d85..ea86cdefc 100644 --- a/fdm-app/tsconfig.json +++ b/fdm-app/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../tsconfig.apps.json", "include": [ "**/*.ts", "**/*.tsx", @@ -13,29 +14,15 @@ "app/types/public-env.d.ts" ], "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["@react-router/node", "vite/client"], - "isolatedModules": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "target": "ES2022", - "strict": true, - "allowJs": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "baseUrl": ".", "paths": { "@/*": ["./*"], "~/*": ["./app/*"], "@nmi-agro/fdm-core": ["../fdm-core/src/index.ts"], "@nmi-agro/fdm-data": ["../fdm-data/src/index.ts"], - "@nmi-agro/fdm-calculator": ["../fdm-calculator/src/index.ts"] + "@nmi-agro/fdm-calculator": ["../fdm-calculator/src/index.ts"], + "@nmi-agro/fdm-rvo": ["../fdm-rvo/src/index.ts"] }, - "rootDirs": [".", "./.react-router/types"], - - "noEmit": true + "rootDirs": [".", "./.react-router/types"] } } diff --git a/fdm-app/vite.config.ts b/fdm-app/vite.config.ts index 3e86083ae..409ace246 100644 --- a/fdm-app/vite.config.ts +++ b/fdm-app/vite.config.ts @@ -85,6 +85,7 @@ export default defineConfig((env) => { "@nmi-agro/fdm-core", "@nmi-agro/fdm-data", "@nmi-agro/fdm-calculator", + "@nmi-agro/fdm-rvo", ], }, } diff --git a/fdm-calculator/package.json b/fdm-calculator/package.json index 99b094cce..0b5144a88 100644 --- a/fdm-calculator/package.json +++ b/fdm-calculator/package.json @@ -61,5 +61,11 @@ "typescript": "catalog:", "vitest": "catalog:" }, + "engines": { + "node": ">=20.10" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, "packageManager": "pnpm@10.32.1" } diff --git a/fdm-calculator/tsconfig.build.json b/fdm-calculator/tsconfig.build.json index 2f6af4264..0fa75f2dc 100644 --- a/fdm-calculator/tsconfig.build.json +++ b/fdm-calculator/tsconfig.build.json @@ -1,9 +1,16 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist", + "paths": {} + }, "exclude": [ "**/*.test.ts", "**/*.spec.ts", "src/setup-test.ts", - "src/test-utils.ts" + "src/test-utils.ts", + "vitest.config.ts" ] } diff --git a/fdm-calculator/tsconfig.json b/fdm-calculator/tsconfig.json index 732170cd1..fd9966745 100644 --- a/fdm-calculator/tsconfig.json +++ b/fdm-calculator/tsconfig.json @@ -1,30 +1,11 @@ { + "extends": "../tsconfig.packages.json", "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "declaration": true, - "noEmit": false, - "emitDeclarationOnly": true, - "outDir": "./dist", - "types": ["vitest/globals", "node"], - - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "baseUrl": ".", + "noEmit": true, "paths": { "@nmi-agro/fdm-core": ["../fdm-core/src/index.ts"], "@nmi-agro/fdm-data": ["../fdm-data/src/index.ts"] } }, - "include": ["src", "src/**/*.test.ts"] + "include": ["src/**/*", "vitest.config.ts"] } diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index 6ae2bb4f2..ed3cf9905 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -118,6 +118,11 @@ export { updateField, } from "./field" export type { Field } from "./field.d" +export { + acquiringMethodOptions, + soilTypesOptions, + gwlClassesOptions, +} from "./db/schema" export { getGrazingIntention, getGrazingIntentions, diff --git a/fdm-core/tsconfig.build.json b/fdm-core/tsconfig.build.json index d31c25068..701b0128b 100644 --- a/fdm-core/tsconfig.build.json +++ b/fdm-core/tsconfig.build.json @@ -1,10 +1,18 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist", + "paths": {} + }, "exclude": [ "**/*.test.ts", "**/*.spec.ts", "src/setup-test.ts", "src/test-utils.ts", - "src/global-setup.ts" + "src/global-setup.ts", + "vitest.config.ts", + "drizzle.config.ts" ] } diff --git a/fdm-core/tsconfig.json b/fdm-core/tsconfig.json index 9f3de4232..447a77832 100644 --- a/fdm-core/tsconfig.json +++ b/fdm-core/tsconfig.json @@ -1,27 +1,10 @@ { + "extends": "../tsconfig.packages.json", "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "declaration": true, - "outDir": "./dist", - "emitDeclarationOnly": true, - - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "baseUrl": ".", + "noEmit": true, "paths": { "@nmi-agro/fdm-data": ["../fdm-data/src/index.ts"] } }, - "include": ["./src/**/*"] + "include": ["src/**/*", "vitest.config.ts", "drizzle.config.ts"] } diff --git a/fdm-data/package.json b/fdm-data/package.json index 4243b1611..75eb1e32a 100644 --- a/fdm-data/package.json +++ b/fdm-data/package.json @@ -47,6 +47,7 @@ "@rollup/plugin-commonjs": "catalog:", "@rollup/plugin-json": "catalog:", "@rollup/plugin-node-resolve": "catalog:", + "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", "rollup": "catalog:", "rollup-plugin-esbuild": "catalog:", diff --git a/fdm-data/tsconfig.build.json b/fdm-data/tsconfig.build.json index 2f6af4264..13228ffbe 100644 --- a/fdm-data/tsconfig.build.json +++ b/fdm-data/tsconfig.build.json @@ -1,5 +1,11 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist", + "paths": {} + }, "exclude": [ "**/*.test.ts", "**/*.spec.ts", diff --git a/fdm-data/tsconfig.json b/fdm-data/tsconfig.json index 9f478da64..b1590ed52 100644 --- a/fdm-data/tsconfig.json +++ b/fdm-data/tsconfig.json @@ -1,24 +1,8 @@ { + "extends": "../tsconfig.packages.json", "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "emitDeclarationOnly": true, - "declaration": true, - "outDir": "./dist", - - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, + "noEmit": true, "resolveJsonModule": true }, - "include": ["./src/**/*"] + "include": ["src/**/*", "vitest.config.ts"] } diff --git a/fdm-docs/blog/tags.yml b/fdm-docs/blog/tags.yml index ed2bc31a8..5e2ab6126 100644 --- a/fdm-docs/blog/tags.yml +++ b/fdm-docs/blog/tags.yml @@ -28,6 +28,11 @@ fdm-app: description: Posts related to the React web application to interact with FDM (@nmi-agro/fdm-app). permalink: /fdm-app +fdm-rvo: + label: fdm-rvo + description: Posts related to the RVO Synchronization Logic for FDM (@nmi-agro/fdm-rvo). + permalink: /fdm-rvo + update: label: Update description: General updates and improvements to the FDM ecosystem. diff --git a/fdm-docs/typedoc.json b/fdm-docs/typedoc.json index 504e21a12..4b87a0cd0 100644 --- a/fdm-docs/typedoc.json +++ b/fdm-docs/typedoc.json @@ -3,7 +3,8 @@ "entryPoints": [ "../fdm-core/src/index.ts", "../fdm-data/src/index.ts", - "../fdm-calculator/src/index.ts" + "../fdm-calculator/src/index.ts", + "../fdm-rvo/src/index.ts" ], "entryPointStrategy": "resolve", "plugin": ["typedoc-plugin-markdown"], diff --git a/fdm-rvo/.gitignore b/fdm-rvo/.gitignore new file mode 100644 index 000000000..72375ed48 --- /dev/null +++ b/fdm-rvo/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env \ No newline at end of file diff --git a/fdm-rvo/README.md b/fdm-rvo/README.md new file mode 100644 index 000000000..e25ab381d --- /dev/null +++ b/fdm-rvo/README.md @@ -0,0 +1,161 @@ +# @nmi-agro/fdm-rvo + +## RVO Synchronization Logic for FDM + +This package provides the core logic for synchronizing agricultural field data with the RVO (Rijksdienst voor Ondernemend Nederland) webservices. It wraps the `@nmi-agro/rvo-connector` to handle authentication and data fetching, and implements a robust field comparison mechanism to detect new, missing, and conflicting field data between local and RVO records. + +### Features + +- **RVO Authentication Flow**: Helpers for generating authorization URLs and exchanging authorization codes for access tokens using the `RvoClient`. +- **Field Data Fetching**: Retrieves agricultural field data from RVO, with GeoJSON parsing and validation. +- **RVO Import Review Engine**: + - Compares local FDM fields (`@nmi-agro/fdm-core`'s `Field` type) against RVO fields. + - Utilizes a two-tier matching strategy: ID-based matching followed by spatial (IoU) matching. + - Detects and categorizes fields as `MATCH`, `NEW_REMOTE` (in RVO but not local), `NEW_LOCAL` (in local but not RVO), `CONFLICT` (different properties in both), or `EXPIRED_LOCAL` (local field started before the year but missing in RVO). + - Identifies specific differing properties (`b_name`, `b_geometry`, `b_start`, `b_end`, `b_acquiring_method`, `b_lu_catalogue`, `b_bufferstrip`) for conflicts. +- **Type Safety**: Fully typed for a seamless development experience. + +### Installation + +```bash +pnpm add @nmi-agro/fdm-rvo +# Or if in a monorepo, ensure it's linked as a workspace dependency +``` + +### Usage + +#### 1. Configuration + +Ensure your `fdm-app` or consuming application has the following environment variables configured and exposed via its `serverConfig`: + +```env +RVO_CLIENT_ID= +RVO_CLIENT_NAME= +RVO_REDIRECT_URI= +RVO_PKIO_PRIVATE_KEY= +``` + +#### 2. Authentication Flow + +To ensure security, sensitive credentials like `RVO_PKIO_PRIVATE_KEY` and the `exchangeToken` step must **never** be exposed to or executed on the client-side (browser). Instead, use server-side routes (e.g., in React Router v7 / Remix) to handle the authentication flow. + +##### A. Initiate Authentication (Server-side Redirect) + +Create a server route (e.g., `app/routes/auth.rvo.tsx`) that initiates the redirect to RVO. + +```typescript +// app/routes/auth.rvo.tsx (Server-side) +import { redirect, type LoaderFunctionArgs } from "react-router"; +import { createRvoClient, generateAuthUrl } from "@nmi-agro/fdm-rvo"; +import { getEnv } from "~/lib/env.server"; // Your server-side env helper +import { getSession, commitSession } from "~/lib/session.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const env = getEnv(); + const { RVO_CLIENT_ID, RVO_CLIENT_NAME, RVO_REDIRECT_URI, RVO_PKIO_PRIVATE_KEY } = env; + + const rvoClient = createRvoClient( + RVO_CLIENT_ID, + RVO_CLIENT_NAME, + RVO_REDIRECT_URI, + RVO_PKIO_PRIVATE_KEY + ); + + // 1. Generate a secure random state to prevent CSRF + const state = crypto.randomUUID(); + const authUrl = generateAuthUrl(rvoClient, state); + + // 2. Persist state in a secure, server-side session + const session = await getSession(request.headers.get("Cookie")); + session.set("rvoState", state); + + return redirect(authUrl, { + headers: { "Set-Cookie": await commitSession(session) }, + }); +} +``` + +##### B. Handle Callback (Server-side Token Exchange) + +Create a callback route (matching your `RVO_REDIRECT_URI`) to exchange the code for an access token. + +```typescript +// app/routes/auth.rvo.callback.tsx (Server-side) +import { redirect, type LoaderFunctionArgs } from "react-router"; +import { createRvoClient, exchangeToken } from "@nmi-agro/fdm-rvo"; +import { commitSession, getSession } from "~/lib/session.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + const session = await getSession(request.headers.get("Cookie")); + const storedState = session.get("rvoState"); + + // 1. Verify state parameter against stored session state + if (!state || state !== storedState) { + throw new Error("Invalid or missing OAuth state parameter"); + } + + // 2. Clear state from session after successful verification + session.unset("rvoState"); + + if (!code) throw new Error("No authorization code received"); + + const env = process.env; // Or your env helper + const rvoClient = createRvoClient( + env.RVO_CLIENT_ID!, + env.RVO_CLIENT_NAME!, + env.RVO_REDIRECT_URI!, + env.RVO_PKIO_PRIVATE_KEY! + ); + + // 3. Exchange the code for an access token securely on the server + const accessToken = await exchangeToken(rvoClient, code); + + // 4. Store the accessToken in a secure, HTTP-only session cookie + session.set("rvoAccessToken", accessToken); + + return redirect("/dashboard", { + headers: { "Set-Cookie": await commitSession(session) }, + }); +} +``` + +#### 3. Fetching and Reconciling Fields + +Once the `accessToken` is stored in your server session, you can use it in loaders or actions to fetch and compare data. + +```typescript +// app/routes/farm.$id.sync.tsx (Server-side) +import { fetchRvoFields, compareFields } from "@nmi-agro/fdm-rvo"; +import { getRvoClient } from "~/lib/rvo.server"; // Helper that uses createRvoClient + +export async function loader({ request, params }: LoaderFunctionArgs) { + const session = await getSession(request.headers.get("Cookie")); + const accessToken = session.get("rvoAccessToken"); + if (!accessToken) return redirect("/auth/rvo"); + + const rvoClient = getRvoClient(); + const year = "2024"; + const kvkNumber = "12345678"; + + // Fetch RVO fields (server-side) + const rvoFields = await fetchRvoFields(rvoClient, year, kvkNumber); + + // Fetch local fields (server-side) + const localFields = await db.query.fields.findMany({ /* ... */ }); + + const rvoImportReviewResults = compareFields(localFields, rvoFields, Number(year)); + return { rvoImportReviewResults }; +} +``` + +### TypeDoc Generation + +To generate API documentation using TypeDoc, ensure your `tsconfig.json` and `typedoc.json` (if applicable) are configured correctly. The package introduction will be included from the JSDoc comment in `src/index.ts`. + +### Development + +For development and testing, ensure all required `@turf/*` dependencies are installed. diff --git a/fdm-rvo/package.json b/fdm-rvo/package.json new file mode 100644 index 000000000..25bcb15be --- /dev/null +++ b/fdm-rvo/package.json @@ -0,0 +1,79 @@ +{ + "name": "@nmi-agro/fdm-rvo", + "private": false, + "version": "0.1.0", + "description": "RVO Synchronization Logic for FDM", + "homepage": "https://svenvw.github.io/fdm/", + "repository": { + "type": "git", + "url": "git+https://github.com/nmi-agro/fdm.git" + }, + "bugs": "https://github.com/nmi-agro/fdm/issues/new", + "author": { + "name": "Sven Verweij", + "email": "37927107+SvenVw@users.noreply.github.com", + "url": "https://github.com/SvenVw" + }, + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./types": { + "import": { + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + } + }, + "./utils": { + "import": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.js" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "coverage": "vitest run --coverage", + "document": "typedoc", + "build": "rollup -c && tsc -p tsconfig.build.json", + "check-types": "tsc --noEmit", + "test": "vitest run", + "test-coverage": "vitest run --coverage" + }, + "dependencies": { + "@nmi-agro/fdm-core": "workspace:^", + "@nmi-agro/rvo-connector": "^2.2.1", + "@turf/area": "^7.3.4", + "@turf/bbox": "^7.3.4", + "@turf/helpers": "^7.3.4", + "@turf/intersect": "^7.3.4", + "@turf/union": "^7.3.4", + "zod": "^4.3.6" + }, + "devDependencies": { + "@nmi-agro/fdm-core": "workspace:*", + "@rollup/plugin-commonjs": "catalog:", + "@rollup/plugin-node-resolve": "catalog:", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "rollup": "catalog:", + "rollup-plugin-esbuild": "catalog:", + "typedoc": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "packageManager": "pnpm@10.32.1", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + } +} diff --git a/fdm-rvo/rollup.config.js b/fdm-rvo/rollup.config.js new file mode 100644 index 000000000..c0fbdf3b7 --- /dev/null +++ b/fdm-rvo/rollup.config.js @@ -0,0 +1,31 @@ +import commonjs from "@rollup/plugin-commonjs" +import resolve from "@rollup/plugin-node-resolve" +import esbuild from "rollup-plugin-esbuild" +import packageJson from "./package.json" with { type: "json" } + +const isProd = process.env.NODE_ENV === "production" + +const external = [ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.peerDependencies || {}), +] + +export default { + input: "src/index.ts", + output: { + dir: "dist", + format: "esm", + preserveModules: true, + entryFileNames: "[name].js", + sourcemap: isProd ? true : "inline", + }, + plugins: [ + resolve(), + commonjs(), + esbuild({ + minify: isProd, // Use esbuild's minifier in production + target: "node20", + }), + ], + external, +} diff --git a/fdm-rvo/src/auth.test.ts b/fdm-rvo/src/auth.test.ts new file mode 100644 index 000000000..bba20bc4e --- /dev/null +++ b/fdm-rvo/src/auth.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest" +import { createRvoClient, generateAuthUrl, exchangeToken } from "./auth" +import { RvoClient } from "@nmi-agro/rvo-connector" + +// Mock the external library +vi.mock("@nmi-agro/rvo-connector", () => { + const RvoClient = vi.fn() + RvoClient.prototype.getAuthorizationUrl = vi + .fn() + .mockReturnValue("https://example.com/auth") + RvoClient.prototype.exchangeAuthCode = vi + .fn() + .mockResolvedValue({ accessToken: "fake-token" }) + return { RvoClient } +}) + +describe("auth", () => { + it("createRvoClient should instantiate RvoClient", () => { + const client = createRvoClient("id", "name", "uri", "key") + expect(RvoClient).toHaveBeenCalled() + expect(client).toBeDefined() + }) + + it("generateAuthUrl should call getAuthorizationUrl", () => { + const mockClient = new RvoClient({} as any) + const url = generateAuthUrl(mockClient, "state123") + expect(mockClient.getAuthorizationUrl).toHaveBeenCalledWith({ + state: "state123", + services: ["opvragenBedrijfspercelen", "opvragenRegelingspercelenMest"], + }) + expect(url).toBe("https://example.com/auth") + }) + + it("exchangeToken should return access token", async () => { + const mockClient = new RvoClient({} as any) + const token = await exchangeToken(mockClient, "code123") + expect(mockClient.exchangeAuthCode).toHaveBeenCalledWith("code123") + expect(token).toBe("fake-token") + }) +}) diff --git a/fdm-rvo/src/auth.ts b/fdm-rvo/src/auth.ts new file mode 100644 index 000000000..6f13834ef --- /dev/null +++ b/fdm-rvo/src/auth.ts @@ -0,0 +1,81 @@ +import { RvoClient } from "@nmi-agro/rvo-connector" +import fs from "node:fs" + +/** + * Creates and configures an instance of the RVO Client. + * + * This client is the main entry point for interacting with RVO services. + * It handles OAuth authentication and API requests. + * + * @param clientId - The OAuth 2.0 Client ID issued by RVO. + * @param clientName - A human-readable name for your application, used in logs or RVO consent screens. + * @param redirectUri - The callback URL where RVO will redirect the user after login. Must match the registered URI. + * @param pkioPrivateKey - The private key (PKIO) used for signing client assertions in the OAuth flow. + * @param environment - The RVO environment to connect to. Defaults to "production". Use "acceptance" for testing. + * @returns An initialized `RvoClient` instance ready for authentication. + */ +export const createRvoClient = ( + clientId: string, + clientName: string, + redirectUri: string, + pkioPrivateKey: string, + environment: "acceptance" | "production" = "production", +) => { + let privateKey = pkioPrivateKey + if ( + pkioPrivateKey.startsWith("/") || + pkioPrivateKey.startsWith("./") || + pkioPrivateKey.startsWith("../") || + /^[a-zA-Z]:[/\\]/.test(pkioPrivateKey) + ) { + if (fs.existsSync(pkioPrivateKey)) { + privateKey = fs.readFileSync(pkioPrivateKey, "utf8") + } else { + throw new Error( + `PKIO private key file not found: ${pkioPrivateKey}`, + ) + } + } + + return new RvoClient({ + clientId, + clientName, + environment, + tvs: { + clientId, + redirectUri, + pkioPrivateKey: privateKey, + }, + }) +} + +/** + * Generates the authorization URL for the RVO OAuth 2.0 flow. + * + * This URL is where you should redirect the user to log in with eHerkenning. + * + * @param rvoClient - The initialized `RvoClient` instance. + * @param state - A unique, random string used to prevent CSRF attacks and maintain state (e.g., farm ID) across the redirect. + * @returns The full URL to redirect the user to. + */ +export const generateAuthUrl = (rvoClient: RvoClient, state: string) => { + return rvoClient.getAuthorizationUrl({ + state, + services: ["opvragenBedrijfspercelen", "opvragenRegelingspercelenMest"], + }) +} + +/** + * Exchanges an authorization code for an access token. + * + * This function should be called in the callback route after the user returns from RVO. + * + * @param rvoClient - The initialized `RvoClient` instance. + * @param code - The authorization code received in the query parameters of the callback URL. + * @returns A promise that resolves to the `accessToken` string. + * @throws Will throw an error if the token exchange fails (e.g., invalid code, network error). + */ +export const exchangeToken = async (rvoClient: RvoClient, code: string) => { + const tokenResponse = await rvoClient.exchangeAuthCode(code) + return tokenResponse.accessToken +} diff --git a/fdm-rvo/src/compare.test.ts b/fdm-rvo/src/compare.test.ts new file mode 100644 index 000000000..6fa7973ec --- /dev/null +++ b/fdm-rvo/src/compare.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect } from "vitest" +import { compareFields } from "./compare" +import { RvoImportReviewStatus, type RvoField } from "./types" + +// Shared helpers used across all describe blocks +const createLocalField = (overrides: Partial = {}): any => ({ + b_id: "local-1", + b_id_source: "rvo-1", + b_name: "Field 1", + b_geometry: { + type: "Polygon", + coordinates: [ + [ + [0, 0], + [0, 10], + [10, 10], + [10, 0], + [0, 0], + ], + ], + }, + b_start: new Date("2024-01-01"), + b_end: undefined, + b_acquiring_method: "nl_01", + cultivations: [], + ...overrides, +}) + +const createRvoField = (overrides: any = {}): RvoField => { + const { geometry, ...props } = overrides + return { + type: "Feature", + geometry: geometry || { + type: "Polygon", + coordinates: [ + [ + [0, 0], + [0, 10], + [10, 10], + [10, 0], + [0, 0], + ], + ], + }, + properties: { + CropFieldID: "rvo-1", + CropFieldVersion: "1", + CropFieldDesignator: "Field 1", + BeginDate: "2024-01-01", + EndDate: undefined, + Country: "NL", + CropTypeCode: "101", + UseTitleCode: "01", + ...props, + }, + } +} + +describe("compareFields", () => { + const calendar = 2025 + + describe("Tier 1: ID Match", () => { + it("should MATCH fields with same ID and identical properties", () => { + const local = createLocalField() + const rvo = createRvoField() + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.MATCH) + expect(result[0].diffs).toHaveLength(0) + expect(result[0].localField).toBe(local) + expect(result[0].rvoField).toBe(rvo) + }) + + it("should detect CONFLICT when name differs", () => { + const local = createLocalField({ b_name: "Old Name" }) + const rvo = createRvoField({ CropFieldDesignator: "New Name" }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.CONFLICT) + expect(result[0].diffs).toContain("b_name") + }) + + it("should detect CONFLICT when start date differs", () => { + const local = createLocalField({ b_start: new Date("2023-01-01") }) + const rvo = createRvoField({ BeginDate: "2024-01-01" }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.CONFLICT) + expect(result[0].diffs).toContain("b_start") + }) + + it("should detect CONFLICT when end date differs", () => { + const local = createLocalField({ b_end: undefined }) + const rvo = createRvoField({ EndDate: "2025-12-31" }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.CONFLICT) + expect(result[0].diffs).toContain("b_end") + }) + + it("should detect CONFLICT when geometry differs significantly", () => { + const local = createLocalField() + const rvo = createRvoField({ + geometry: { + type: "Polygon", + coordinates: [ + [ + [0, 0], + [0, 5], + [5, 5], + [5, 0], + [0, 0], + ], + ], // Quarter the size + }, + }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.CONFLICT) + expect(result[0].diffs).toContain("b_geometry") + }) + + it("should detect CONFLICT when cultivation differs", () => { + const local = createLocalField({ + cultivations: [ + { + b_lu_catalogue: "nl_101", + b_lu: "cult-1", + b_lu_name: "Grass", + b_lu_start: new Date(`${calendar}-01-01`), + b_lu_end: new Date(`${calendar}-12-31`), + }, + ], + }) + const rvo = createRvoField({ + CropTypeCode: "202", // Different crop code + }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.CONFLICT) + expect(result[0].diffs).toContain("b_lu_catalogue") + }) + + it("should detect CONFLICT when acquiring method differs", () => { + const local = createLocalField({ b_acquiring_method: "nl_02" }) + const rvo = createRvoField({ UseTitleCode: "01" }) // nl_01 ≠ nl_02 + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.CONFLICT) + expect(result[0].diffs).toContain("b_acquiring_method") + }) + + it("should detect CONFLICT when buffer strip status differs", () => { + const local = createLocalField({ b_bufferstrip: false }) + const rvo = createRvoField({ + mestData: { IndBufferstrook: "J" }, // true ≠ false + }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.CONFLICT) + expect(result[0].diffs).toContain("b_bufferstrip") + }) + }) + + describe("Tier 2: Spatial Match", () => { + it("should MATCH fields with different IDs but high spatial overlap (IoU > 0.99)", () => { + const local = createLocalField({ b_id_source: "old-id" }) + const rvo = createRvoField({ CropFieldID: "new-id" }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.MATCH) + expect(result[0].localField).toBe(local) + expect(result[0].rvoField).toBe(rvo) + }) + + it("should NOT match fields with low spatial overlap", () => { + const local = createLocalField({ b_id_source: "local-only" }) + // Shifted geometry, no overlap + const rvo = createRvoField({ + CropFieldID: "remote-only", + geometry: { + type: "Polygon", + coordinates: [ + [ + [100, 100], + [100, 110], + [110, 110], + [110, 100], + [100, 100], + ], + ], + }, + }) + + const result = compareFields([local], [rvo], calendar) + + expect(result).toHaveLength(2) + const expired = result.find( + (r) => r.status === RvoImportReviewStatus.EXPIRED_LOCAL, + ) + const newRemote = result.find( + (r) => r.status === RvoImportReviewStatus.NEW_REMOTE, + ) + + expect(expired).toBeDefined() + expect(newRemote).toBeDefined() + }) + }) + + describe("Orphaned Fields (Status determination)", () => { + it("should identify a field as NEW_LOCAL if it started in the import year", () => { + const local = createLocalField({ + b_start: new Date("2025-01-01"), + b_id_source: "local-only", + }) + const result = compareFields([local], [], calendar) + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.NEW_LOCAL) + }) + + it("should identify a field as EXPIRED_LOCAL if it started before import year and has no end date", () => { + const local = createLocalField({ + b_start: new Date("2024-01-01"), + b_id_source: "local-only", + }) + const result = compareFields([local], [], calendar) + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.EXPIRED_LOCAL) + }) + + it("should identify a field as EXPIRED_LOCAL if it started before import year and ends IN the import year", () => { + const local = createLocalField({ + b_start: new Date("2024-01-01"), + b_end: new Date("2025-06-01"), + b_id_source: "local-only", + }) + const result = compareFields([local], [], calendar) + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.EXPIRED_LOCAL) + }) + + it("should IGNORE a field if it ends BEFORE the import year", () => { + const local = createLocalField({ + b_start: new Date("2024-01-01"), + b_end: new Date("2024-12-31"), + b_id_source: "local-only", + }) + const result = compareFields([local], [], calendar) + expect(result).toHaveLength(0) + }) + }) + + describe("New Remote Fields", () => { + it("should identify NEW_REMOTE fields", () => { + const rvo = createRvoField({ CropFieldID: "new-remote" }) + const result = compareFields([], [rvo], calendar) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.NEW_REMOTE) + expect(result[0].rvoField).toBe(rvo) + }) + }) +}) + +describe("compareFields Edge Cases", () => { + const calendar = 2025 + + it("should handle IoU calculation when polygons touch (area 0)", () => { + // Two squares touching at x=10. + // S1: 0,0 to 10,10 + // S2: 10,0 to 20,10 + // BBoxes: 0,0,10,10 and 10,0,20,10. Overlap at x=10 line. + + const local = createLocalField({ + b_geometry: { + type: "Polygon", + coordinates: [ + [ + [0, 0], + [0, 10], + [10, 10], + [10, 0], + [0, 0], + ], + ], + }, + }) + const rvo = createRvoField({ + geometry: { + type: "Polygon", + coordinates: [ + [ + [10, 0], + [10, 10], + [20, 10], + [20, 0], + [10, 0], + ], + ], + }, + }) + local.b_id_source = "id1" + rvo.properties.CropFieldID = "id2" + + const result = compareFields([local], [rvo], calendar) + // IoU should be 0 (intersection area is 0) + expect(result).toHaveLength(2) + }) + + it("should use cultivation catalogue name when code exists", () => { + const local = createLocalField() + const rvo = createRvoField({ CropTypeCode: "101" }) + + const catalogue = [ + { + b_lu_catalogue: "nl_101", + b_lu_name: "Official Grass Name", + }, + ] as any + + const result = compareFields([local], [rvo], calendar, catalogue) + + expect(result).toHaveLength(1) + // Should match (if everything else matches) + // Check rvoCultivationInfo name + expect(result[0].rvoCultivation?.b_lu_name).toBe("Official Grass Name") + }) + + it("should use cultivation catalogue name for NEW_REMOTE when code exists", () => { + const rvo = createRvoField({ CropFieldID: "new", CropTypeCode: "101" }) + const catalogue = [ + { + b_lu_catalogue: "nl_101", + b_lu_name: "Official Grass Name", + }, + ] as any + + const result = compareFields([], [rvo], calendar, catalogue) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.NEW_REMOTE) + expect(result[0].rvoCultivation?.b_lu_name).toBe("Official Grass Name") + }) + + it("should handle missing RVO CropTypeCode", () => { + const local = createLocalField() + const rvo = createRvoField({ CropTypeCode: "" }) // Empty string -> falsy + + const result = compareFields([local], [rvo], calendar) + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.MATCH) + // Check if logic handles undefined rvoCultivationInfo + expect(result[0].rvoCultivation).toBeUndefined() + }) + + it("should handle RVO cultivation lookup failure (unknown code)", () => { + const local = createLocalField() + const rvo = createRvoField({ CropTypeCode: "999" }) // Unknown code + + const result = compareFields([local], [rvo], calendar, []) // Empty catalogue + + expect(result).toHaveLength(1) + // rvoCultivationInfo should use code as name + expect(result[0].rvoCultivation?.b_lu_name).toBe("nl_999") + }) + + it("should handle NEW_REMOTE with unknown crop code", () => { + const rvo = createRvoField({ CropFieldID: "new", CropTypeCode: "999" }) + const result = compareFields([], [rvo], calendar, []) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.NEW_REMOTE) + expect(result[0].rvoCultivation?.b_lu_name).toBe("nl_999") + }) + + it("should set rvoCultivation to undefined for NEW_REMOTE when CropTypeCode is missing", () => { + const rvo = createRvoField({ CropFieldID: "new-no-crop", CropTypeCode: "" }) + const result = compareFields([], [rvo], calendar, []) + + expect(result).toHaveLength(1) + expect(result[0].status).toBe(RvoImportReviewStatus.NEW_REMOTE) + expect(result[0].rvoCultivation).toBeUndefined() + }) +}) diff --git a/fdm-rvo/src/compare.ts b/fdm-rvo/src/compare.ts new file mode 100644 index 000000000..924f03cda --- /dev/null +++ b/fdm-rvo/src/compare.ts @@ -0,0 +1,350 @@ +import bbox from "@turf/bbox" +import type { + Field, + Cultivation, + CultivationCatalogue, +} from "@nmi-agro/fdm-core" +import { + type RvoField, + RvoImportReviewStatus, + type RvoImportReviewItem, + type FieldDiff, +} from "./types" +import { calculateIoU, bboxOverlap } from "./utils" + +// Threshold for IoU (Intersection over Union) to consider fields "the same" spatially. +// A value of 0.99 means the intersection area must be at least 99% of the union area. +const IOU_THRESHOLD = 0.99 + +function findActiveCultivation( + cultivations: Cultivation[], + calendar: number, +): Cultivation | undefined { + const referenceDate = new Date(`${calendar}-05-15`).getTime() + return cultivations.find((c) => { + if (!c.b_lu_start) return false + const start = c.b_lu_start.getTime() + const end = c.b_lu_end ? c.b_lu_end.getTime() : Number.POSITIVE_INFINITY + return start <= referenceDate && end >= referenceDate + }) +} + +/** + * Compares a list of local fields against a list of RVO fields to determine their import status. + * + * The matching strategy operates in two tiers: + * 1. **Tier 1: ID Match**: Checks if `localField.b_id_source` matches `rvoField.CropFieldID`. + * This is the most reliable method for fields that have been synced before. + * 2. **Tier 2: Spatial Match**: For fields unmatched by ID, it calculates the spatial overlap (IoU). + * If the overlap exceeds `IOU_THRESHOLD` (0.99), they are considered the same field. + * + * @param localFields - Array of fields currently in the local database. + * @param rvoFields - Array of fields retrieved from the RVO webservice. + * @returns An array of `RvoImportReviewItem` objects, each representing a field and its status (MATCH, CONFLICT, NEW_REMOTE, NEW_LOCAL). + */ +export function compareFields( + localFields: (Field & { cultivations?: Cultivation[] })[], + rvoFields: RvoField[], + calendar = new Date().getFullYear(), + cultivationsCatalogue: CultivationCatalogue[] = [], +): RvoImportReviewItem[] { + const results: RvoImportReviewItem[] = [] + const matchedRvoIds = new Set() + const matchedLocalIds = new Set() + + const processMatch = ( + local: Field & { cultivations?: Cultivation[] }, + rvo: RvoField, + ) => { + // Detect property differences + const diffs = detectDiffs(local, rvo) + + // Check for cultivation differences + const localCultivation = local.cultivations + ? findActiveCultivation(local.cultivations, calendar) + : undefined + const rvoCode = rvo.properties.CropTypeCode + ? `nl_${rvo.properties.CropTypeCode}` + : undefined + + let rvoCultivationInfo: + | { b_lu_catalogue: string; b_lu_name: string } + | undefined + let localCultivationInfo: + | { + b_lu_catalogue: string + b_lu: string + b_lu_name: string + } + | undefined + + if (localCultivation) { + localCultivationInfo = { + b_lu_catalogue: localCultivation.b_lu_catalogue, + b_lu: localCultivation.b_lu, + b_lu_name: localCultivation.b_lu_name, + } + } + + if (rvoCode) { + const rvoCatalogueEntry = cultivationsCatalogue.find( + (c) => c.b_lu_catalogue === rvoCode, + ) + rvoCultivationInfo = { + b_lu_catalogue: rvoCode, + b_lu_name: rvoCatalogueEntry + ? rvoCatalogueEntry.b_lu_name + : rvoCode, + } + } + + // If local has active cultivation and it differs from RVO, flag it + if (localCultivation && localCultivation.b_lu_catalogue !== rvoCode) { + diffs.push("b_lu_catalogue") + } + + return { + status: + diffs.length > 0 + ? RvoImportReviewStatus.CONFLICT + : RvoImportReviewStatus.MATCH, + localField: local, + rvoField: rvo, + localCultivation: localCultivationInfo, + rvoCultivation: rvoCultivationInfo, + diffs, + } + } + + // --------------------------------------------------------- + // Tier 1: Match by Source ID (CropFieldID) + // --------------------------------------------------------- + for (const local of localFields) { + if (local.b_id_source) { + const rvoMatch = rvoFields.find( + (r) => r.properties.CropFieldID === local.b_id_source, + ) + if (rvoMatch) { + matchedLocalIds.add(local.b_id) + matchedRvoIds.add(rvoMatch.properties.CropFieldID) + results.push(processMatch(local, rvoMatch)) + } + } + } + + // --------------------------------------------------------- + // Tier 2: Spatial Match (IoU) for remaining fields + // --------------------------------------------------------- + + // Prepare candidates: Only local fields that haven't been matched yet + const remainingLocals = localFields + .filter((f) => !matchedLocalIds.has(f.b_id)) + .map((f) => ({ + field: f, + // Pre-calculate BBox for performance (avoid recalc inside loop) + bbox: bbox(f.b_geometry as any), + })) + + for (const rvo of rvoFields) { + // Skip if this RVO field was already matched in Tier 1 + if (matchedRvoIds.has(rvo.properties.CropFieldID)) continue + + const rvoBbox = bbox(rvo.geometry) + let bestMatch: (Field & { cultivations?: Cultivation[] }) | null = null + let bestIoU = 0 + + // Optimization: Fast BBox overlap check before accurate IoU; exclude already-matched locals + const candidates = remainingLocals.filter( + (l) => + !matchedLocalIds.has(l.field.b_id) && bboxOverlap(l.bbox, rvoBbox), + ) + + // Find the best spatial match among candidates + for (const candidate of candidates) { + const iou = calculateIoU(candidate.field.b_geometry, rvo.geometry) + if (iou > bestIoU) { + bestIoU = iou + bestMatch = candidate.field + } + } + + // If the best match exceeds our threshold, link them + if (bestMatch && bestIoU > IOU_THRESHOLD) { + matchedRvoIds.add(rvo.properties.CropFieldID) + matchedLocalIds.add(bestMatch.b_id) + results.push(processMatch(bestMatch, rvo)) + } else { + // No match found -> This is a NEW field from RVO + const rvoCode = rvo.properties.CropTypeCode + ? `nl_${rvo.properties.CropTypeCode}` + : undefined + const rvoCatalogueEntry = rvoCode + ? cultivationsCatalogue.find( + (c) => c.b_lu_catalogue === rvoCode, + ) + : undefined + results.push({ + status: RvoImportReviewStatus.NEW_REMOTE, + rvoField: rvo, + rvoCultivation: rvoCode + ? { + b_lu_catalogue: rvoCode, + b_lu_name: rvoCatalogueEntry + ? rvoCatalogueEntry.b_lu_name + : rvoCode, + } + : undefined, + diffs: [], + }) + } + } + + // --------------------------------------------------------- + // Identify orphaned local fields + // --------------------------------------------------------- + for (const local of localFields) { + if (!matchedLocalIds.has(local.b_id)) { + const localCultivation = local.cultivations + ? findActiveCultivation(local.cultivations, calendar) + : undefined + + // Check if this field should be considered "expired" (closed) instead of just new local + // Conditions: + // 1. Started before the current import year + // 2. Currently open (no end date) or ends in/after this year (though if it ends in this year, it might be a match? No, if it was unmatched, it means RVO doesn't have it) + // Actually, if it ends *after* the start of this year, it's considered "active" in this year. + // If RVO doesn't have it, we should close it effectively ending it before this year starts. + const localStart = + local.b_start instanceof Date + ? local.b_start + : local.b_start + ? new Date(local.b_start) + : null + const importYearStart = new Date(calendar, 0, 1) // Jan 1st of import year + // A missing start date means the field has no known start, so treat as not-yet-started → NEW_LOCAL + const isStartedBeforeYear = + localStart !== null && localStart < importYearStart + + const localEnd = local.b_end + ? local.b_end instanceof Date + ? local.b_end + : new Date(local.b_end) + : null + // If it has no end date, OR the end date is after the start of the import year + const isOpenOrEndsInYear = !localEnd || localEnd >= importYearStart + + // If the field ended before the import year, it's a historical field that is already closed. + // We should ignore it. + if (!isOpenOrEndsInYear) { + continue + } + + const status = + isStartedBeforeYear && isOpenOrEndsInYear + ? RvoImportReviewStatus.EXPIRED_LOCAL + : RvoImportReviewStatus.NEW_LOCAL + + results.push({ + status, + localField: local, + localCultivation: localCultivation + ? { + b_lu_catalogue: localCultivation.b_lu_catalogue, + b_lu: localCultivation.b_lu, + b_lu_name: localCultivation.b_lu_name, + } + : undefined, + diffs: [], + }) + } + } + + return results +} + +/** + * Detects specific property differences between a matched pair of Local and RVO fields. + * + * Compares: + * - Name (`b_name` vs `CropFieldDesignator`) + * - Geometry (via IoU < 0.99) + * - Start Date (`b_start` vs `BeginDate`) + * - End Date (`b_end` vs `EndDate`) + * + * @param local - The local field object. + * @param rvo - The RVO field object. + * @returns An array of property names (`FieldDiff`) that differ. + */ +function detectDiffs(local: Field, rvo: RvoField): FieldDiff[] { + const diffs: FieldDiff[] = [] + + // 1. Name + // We check if RVO has a name (designator) and if it differs from local + if ( + local.b_name !== rvo.properties.CropFieldDesignator && + rvo.properties.CropFieldDesignator + ) { + diffs.push("b_name") + } + + // 2. Geometry + // We use a very strict IoU (0.99) to detect if the shape has been modified, even slightly. + // If IoU is less than this threshold, we flag it as a geometry difference. + // We don't require 1.0 because of potential minor floating point differences in coordinates. + const iou = calculateIoU(local.b_geometry, rvo.geometry) + if (iou < IOU_THRESHOLD) { + diffs.push("b_geometry") + } + + // 3. Dates (Start) + const localStart = + local.b_start instanceof Date + ? local.b_start.toISOString().split("T")[0] + : local.b_start + const rvoStart = rvo.properties.BeginDate + ? new Date(rvo.properties.BeginDate).toISOString().split("T")[0] + : null + + // Treat null/undefined as equal if both are missing + if (localStart !== rvoStart && (localStart !== null || rvoStart !== null)) { + diffs.push("b_start") + } + + // 4. Dates (End) + const localEnd = + local.b_end instanceof Date + ? local.b_end.toISOString().split("T")[0] + : typeof local.b_end === "string" + ? local.b_end + : null + const rvoEnd = rvo.properties.EndDate + ? new Date(rvo.properties.EndDate).toISOString().split("T")[0] + : null + + // Treat null/undefined as equal if both are missing + if (localEnd !== rvoEnd && (localEnd !== null || rvoEnd !== null)) { + diffs.push("b_end") + } + + // 5. Acquiring method + const rvoAcquiringMethod = rvo.properties.UseTitleCode + ? `nl_${rvo.properties.UseTitleCode}` + : null + if ( + local.b_acquiring_method !== rvoAcquiringMethod && + (local.b_acquiring_method !== null || rvoAcquiringMethod !== null) + ) { + diffs.push("b_acquiring_method") + } + + // 6. Buffer strip + // Only check when RVO MEST data is available (mestData present means MEST was fetched) + if (rvo.properties.mestData?.IndBufferstrook !== undefined) { + const rvoBufferstrip = rvo.properties.mestData.IndBufferstrook === "J" + if (local.b_bufferstrip !== rvoBufferstrip) { + diffs.push("b_bufferstrip") + } + } + + return diffs +} diff --git a/fdm-rvo/src/data.test.ts b/fdm-rvo/src/data.test.ts new file mode 100644 index 000000000..4a401f959 --- /dev/null +++ b/fdm-rvo/src/data.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi } from "vitest" +import { fetchRvoFields } from "./data" +import type { RvoClient } from "@nmi-agro/rvo-connector" + +// A simple square polygon used as test geometry +const POLYGON_A = { + type: "Polygon", + coordinates: [ + [ + [5.0, 52.0], + [5.0, 52.01], + [5.01, 52.01], + [5.01, 52.0], + [5.0, 52.0], + ], + ], +} + +// A nearly identical polygon (same field, minor floating point difference) +const POLYGON_A_SIMILAR = { + type: "Polygon", + coordinates: [ + [ + [5.0001, 52.0001], + [5.0001, 52.0099], + [5.0099, 52.0099], + [5.0099, 52.0001], + [5.0001, 52.0001], + ], + ], +} + +// A completely different polygon (no overlap) +const POLYGON_B = { + type: "Polygon", + coordinates: [ + [ + [6.0, 53.0], + [6.0, 53.01], + [6.01, 53.01], + [6.01, 53.0], + [6.0, 53.0], + ], + ], +} + +describe("fetchRvoFields", () => { + it("should merge mestData via spatial IoU join (Tier 2)", async () => { + const mockFeatures = [ + { + type: "Feature", + geometry: POLYGON_A, + properties: { + CropFieldID: "123", + CropFieldVersion: "1", + CropFieldDesignator: "Field A", + BeginDate: "2024-01-01", + Country: "NL", + CropTypeCode: "101", + UseTitleCode: "01", + }, + }, + ] + + const mockMestFeatures = [ + { + type: "Feature", + geometry: POLYGON_A_SIMILAR, + properties: { + MESTFieldid: "mest-1", + Bufferstrook: true, + Regelingsgebied: "Yes", + }, + }, + ] + + const mockClient = { + opvragenBedrijfspercelen: vi.fn().mockResolvedValue({ + features: mockFeatures, + }), + opvragenRegelingspercelenMest: vi.fn().mockResolvedValue({ + features: mockMestFeatures, + }), + } as unknown as RvoClient + + const result = await fetchRvoFields(mockClient, "2024", "12345678") + + expect(mockClient.opvragenBedrijfspercelen).toHaveBeenCalledWith({ + periodBeginDate: "2024-01-01", + periodEndDate: "2024-12-31", + farmId: "12345678", + outputFormat: "geojson", + }) + expect(mockClient.opvragenRegelingspercelenMest).toHaveBeenCalledWith({ + periodBeginDate: "2024-01-01", + periodEndDate: "2024-12-31", + farmId: "12345678", + outputFormat: "geojson", + }) + expect(result).toHaveLength(1) + expect(result[0].properties.CropFieldID).toBe("123") + expect(result[0].properties.mestData).toMatchObject({ + MESTFieldid: "mest-1", + Bufferstrook: true, + Regelingsgebied: "Yes", + }) + }) + + it("should merge mestData via designator match + IoU check (Tier 1)", async () => { + const mockFeatures = [ + { + type: "Feature", + geometry: POLYGON_A, + properties: { + CropFieldID: "123", + CropFieldVersion: "1", + CropFieldDesignator: "My Field", + BeginDate: "2024-01-01", + Country: "NL", + CropTypeCode: "101", + UseTitleCode: "01", + }, + }, + ] + + const mockMestFeatures = [ + { + type: "Feature", + geometry: POLYGON_A_SIMILAR, + properties: { + MESTFieldid: "mest-1", + Fielddesignator: "My Field", + Grondsoort: "1", + }, + }, + ] + + const mockClient = { + opvragenBedrijfspercelen: vi.fn().mockResolvedValue({ features: mockFeatures }), + opvragenRegelingspercelenMest: vi.fn().mockResolvedValue({ features: mockMestFeatures }), + } as unknown as RvoClient + + const result = await fetchRvoFields(mockClient, "2024", "12345678") + expect(result[0].properties.mestData).toMatchObject({ MESTFieldid: "mest-1", Fielddesignator: "My Field" }) + }) + + it("should not merge mestData if geometries do not overlap sufficiently", async () => { + const mockFeatures = [ + { + type: "Feature", + geometry: POLYGON_A, + properties: { + CropFieldID: "123", + CropFieldVersion: "1", + CropFieldDesignator: "Field A", + BeginDate: "2024-01-01", + Country: "NL", + CropTypeCode: "101", + UseTitleCode: "01", + }, + }, + ] + + const mockMestFeatures = [ + { + type: "Feature", + geometry: POLYGON_B, + properties: { + MESTFieldid: "mest-2", + Bufferstrook: false, + }, + }, + ] + + const mockClient = { + opvragenBedrijfspercelen: vi.fn().mockResolvedValue({ features: mockFeatures }), + opvragenRegelingspercelenMest: vi.fn().mockResolvedValue({ features: mockMestFeatures }), + } as unknown as RvoClient + + const result = await fetchRvoFields(mockClient, "2024", "12345678") + expect(result[0].properties.mestData).toBeUndefined() + }) + + it("should return empty array if no features found", async () => { + const mockClient = { + opvragenBedrijfspercelen: vi.fn().mockResolvedValue({ + features: [], + }), + opvragenRegelingspercelenMest: vi.fn().mockResolvedValue({ + features: [], + }), + } as unknown as RvoClient + + const result = await fetchRvoFields(mockClient, "2024", "12345678") + expect(result).toEqual([]) + }) + + it("should return empty array if response is malformed (no features array)", async () => { + const mockClient = { + opvragenBedrijfspercelen: vi.fn().mockResolvedValue({ + somethingElse: [], + }), + opvragenRegelingspercelenMest: vi.fn().mockResolvedValue({ + features: [], + }), + } as unknown as RvoClient + + const result = await fetchRvoFields(mockClient, "2024", "12345678") + expect(result).toEqual([]) + }) + + it("should throw error if validation fails", async () => { + const mockFeatures = [ + { + type: "Feature", + // Missing required properties + properties: { + CropFieldID: "123", + }, + }, + ] + + const mockClient = { + opvragenBedrijfspercelen: vi.fn().mockResolvedValue({ + features: mockFeatures, + }), + opvragenRegelingspercelenMest: vi.fn().mockResolvedValue({ + features: [], + }), + } as unknown as RvoClient + + await expect( + fetchRvoFields(mockClient, "2024", "12345678"), + ).rejects.toThrow() + }) +}) + diff --git a/fdm-rvo/src/data.ts b/fdm-rvo/src/data.ts new file mode 100644 index 000000000..c9722857f --- /dev/null +++ b/fdm-rvo/src/data.ts @@ -0,0 +1,145 @@ +import { RvoClient } from "@nmi-agro/rvo-connector" +import bbox from "@turf/bbox" +import { RvoFieldSchema, type RvoField } from "./types" +import { z } from "zod" +import { calculateIoU, bboxOverlap } from "./utils" + +// Minimum IoU to consider a MEST feature as matching a bedrijfsperceel. +// Set high (0.95) because both datasets represent the same physical parcel in RVO — +// geometries should be essentially identical, with only minor coordinate precision +// differences between the two RVO registration systems. +const MEST_IOU_THRESHOLD = 0.95 + +/** + * Fetches agricultural fields (bedrijfspercelen) from the RVO webservice for a specific year and farm. + * + * This function retrieves the fields in GeoJSON format and validates them against the `RvoFieldSchema`. + * + * @param rvoClient - An authenticated instance of `RvoClient` (must have a valid access token). + * @param year - The calendar year for which to retrieve the fields (e.g., 2024). + * @param kvkNumber - The Chamber of Commerce (KvK) number of the farm/organization. This acts as the identifier for the data request. + * @returns A promise that resolves to an array of validated `RvoField` objects. + * @throws Will throw a ZodError if the response from RVO does not match the expected schema. + * @throws Will throw an error if the API request fails. + */ +export async function fetchRvoFields( + rvoClient: RvoClient, + year: string, + kvkNumber: string, +): Promise { + // Request fields and mest fields from RVO API concurrently + // We request the full calendar year period + const [fieldsRaw, mestFieldsRaw] = await Promise.all([ + rvoClient.opvragenBedrijfspercelen({ + periodBeginDate: `${year}-01-01`, + periodEndDate: `${year}-12-31`, + farmId: kvkNumber, + outputFormat: "geojson", + }), + rvoClient + .opvragenRegelingspercelenMest({ + periodBeginDate: `${year}-01-01`, + periodEndDate: `${year}-12-31`, + farmId: kvkNumber, + outputFormat: "geojson", + }) + .catch((err) => { + // Catching in case this endpoint fails independently so we don't break the main flow. + console.warn("Failed to fetch RegelingspercelenMest:", err) + return { features: [] } + }), + ]) + + // The raw response is expected to be a GeoJSON FeatureCollection. + // We access the 'features' array to iterate over individual fields. + const features = (fieldsRaw as any).features || [] + const mestFeatures = (mestFieldsRaw as any).features || [] + + if (Array.isArray(features)) { + // Pre-compute bounding boxes for all MEST features (avoid recalc in inner loops) + const mestWithBbox = Array.isArray(mestFeatures) + ? mestFeatures + .filter((mf: any) => mf?.geometry) + .map((mf: any) => ({ + feature: mf, + bbox: bbox(mf.geometry) as number[], + })) + : [] + + const matchedMestIndices = new Set() + + for (const cropFeature of features) { + if (!cropFeature?.geometry) continue + + const cropBbox = bbox(cropFeature.geometry) as number[] + const cropDesignator: string = + cropFeature?.properties?.CropFieldDesignator ?? "" + + let mergedMestProps: any = null + + // ------------------------------------------------------- + // Tier 1: FieldDesignator name match + IoU sanity check + // ------------------------------------------------------- + if (cropDesignator) { + const nameMatches = mestWithBbox + .map((m, idx) => ({ ...m, idx })) + .filter( + ({ feature: mf, idx }) => + !matchedMestIndices.has(idx) && + mf?.properties?.Fielddesignator === cropDesignator, + ) + + if (nameMatches.length === 1) { + // Only match when exactly one MEST feature has the same name. + // Multiple matches indicate ambiguity; fall through to Tier 2 spatial matching. + const candidate = nameMatches[0] + const iou = calculateIoU( + cropFeature.geometry, + candidate.feature.geometry, + ) + if (iou >= MEST_IOU_THRESHOLD) { + mergedMestProps = candidate.feature.properties + matchedMestIndices.add(candidate.idx) + } + } + } + + // ------------------------------------------------------- + // Tier 2: Spatial IoU join (bbox pre-filter) + // ------------------------------------------------------- + if (!mergedMestProps) { + let bestIoU = 0 + let bestIdx = -1 + + for (let i = 0; i < mestWithBbox.length; i++) { + if (matchedMestIndices.has(i)) continue + const { feature: mf, bbox: mBbox } = mestWithBbox[i] + if (!bboxOverlap(cropBbox, mBbox)) continue + + const iou = calculateIoU(cropFeature.geometry, mf.geometry) + if (iou > bestIoU) { + bestIoU = iou + bestIdx = i + } + } + + if (bestIdx >= 0 && bestIoU >= MEST_IOU_THRESHOLD) { + mergedMestProps = mestWithBbox[bestIdx].feature.properties + matchedMestIndices.add(bestIdx) + } + } + + if (mergedMestProps) { + cropFeature.properties.mestData = mergedMestProps + } + } + + // Define a schema for an array of fields and parse the data. + // This ensures runtime type safety and filters out malformed records if configured. + const RvoFieldsArraySchema = z.array(RvoFieldSchema) + return RvoFieldsArraySchema.parse(features) + } + + // Return empty array if the response format is unexpected or contains no features. + return [] +} diff --git a/fdm-rvo/src/index.ts b/fdm-rvo/src/index.ts new file mode 100644 index 000000000..ad1433521 --- /dev/null +++ b/fdm-rvo/src/index.ts @@ -0,0 +1,34 @@ +/** + * # @nmi-agro/fdm-rvo: RVO Field Synchronization Logic + * + * This package provides the core logic for synchronizing agricultural field data with the + * RVO (Rijksdienst voor Ondernemend Nederland) webservices. It wraps the + * `@nmi-agro/rvo-connector` to handle authentication and data fetching, and implements a + * robust field comparison mechanism to detect new, missing, and conflicting field data + * between local and RVO records. + * + * ## Features + * + * - **RVO Authentication Flow**: Helpers for generating authorization URLs and exchanging + * authorization codes for access tokens using the `RvoClient`. + * - **Field Data Fetching**: Retrieves agricultural field data from RVO, with GeoJSON + * parsing and validation against `RvoFieldSchema`. + * - **Field RVO Import Review Engine**: + * - Compares local FDM fields (`@nmi-agro/fdm-core`'s `Field` type) against RVO fields. + * - Utilizes a two-tier matching strategy: ID-based matching followed by spatial + * (IoU) matching. + * - Detects and categorizes fields as `MATCH`, `NEW_REMOTE` (in RVO but not local), + * `NEW_LOCAL` (in local but not RVO), or `CONFLICT` (different properties in both). + * - Identifies specific differing properties (`b_name`, `b_geometry`, `b_start`, `b_end`) + * for conflicts, allowing granular resolution. + * - **Type Safety**: Fully typed for a seamless development experience. + * + * @packageDocumentation + */ + +export * from "./auth" +export * from "./compare" +export * from "./data" +export * from "./types" +export * from "./process" +export * from "./utils" diff --git a/fdm-rvo/src/process.test.ts b/fdm-rvo/src/process.test.ts new file mode 100644 index 000000000..2b3676d3c --- /dev/null +++ b/fdm-rvo/src/process.test.ts @@ -0,0 +1,452 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { processRvoImport } from "./process" +import { + addField, + updateField, + removeField, + addCultivation, + removeCultivation, + getDefaultDatesOfCultivation, +} from "@nmi-agro/fdm-core" +import { RvoImportReviewStatus, type RvoImportReviewItem } from "./types" + +// Mock fdm-core +vi.mock("@nmi-agro/fdm-core", () => ({ + addField: vi.fn(), + updateField: vi.fn(), + removeField: vi.fn(), + addCultivation: vi.fn(), + removeCultivation: vi.fn(), + getDefaultDatesOfCultivation: vi.fn(), + acquiringMethodOptions: [ + { value: "nl_01", label: "Eigendom" }, + { value: "nl_02", label: "Reguliere pacht" }, + { value: "nl_03", label: "In gebruik van een terreinbeherende organisatie" }, + { value: "nl_04", label: "Tijdelijk gebruik in het kader van landinrichting" }, + { value: "nl_07", label: "Overige exploitatievormen" }, + { value: "nl_09", label: "Erfpacht" }, + { value: "nl_10", label: "Pacht van geringe oppervlakten" }, + { value: "nl_11", label: "Natuurpacht" }, + { value: "nl_12", label: "Geliberaliseerde pacht, langer dan 6 jaar" }, + { value: "nl_13", label: "Geliberaliseerde pacht, 6 jaar of korter" }, + { value: "nl_61", label: "Reguliere pacht kortlopend" }, + { value: "nl_63", label: "Teeltpacht" }, + { value: "unknown", label: "Onbekend" }, + ], +})) + +describe("processRvoImport", () => { + const mockFdm = { + transaction: vi.fn(async (cb: (tx: any) => Promise) => cb(mockFdm)), + } as any + const principalId = "user-1" + const farmId = "farm-1" + const year = 2025 + + beforeEach(() => { + vi.clearAllMocks() + // Default mocks + ;(getDefaultDatesOfCultivation as any).mockResolvedValue({ + b_lu_start: new Date(`${year}-01-01`), + b_lu_end: new Date(`${year}-12-31`), + }) + ;(addField as any).mockResolvedValue("new-field-id") + }) + + it("should process ADD_REMOTE action", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.NEW_REMOTE, + rvoField: { + type: "Feature", + geometry: { type: "Polygon", coordinates: [] }, + properties: { + CropFieldID: "rvo-1", + CropFieldDesignator: "New Field", + BeginDate: "2025-01-01", + UseTitleCode: "01", + CropTypeCode: "101", + CropFieldVersion: "1", + Country: "NL", + }, + }, + diffs: [], + } + const choices = { "rvo-1": "ADD_REMOTE" as const } + const onFieldAdded = vi.fn() + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + onFieldAdded, + ) + + expect(addField).toHaveBeenCalledWith( + mockFdm, + principalId, + farmId, + "New Field", + "rvo-1", + expect.anything(), // geometry + expect.any(Date), // start + "nl_01", + undefined, // end + undefined, // b_bufferstrip (no mestData in test) + ) + expect(addCultivation).toHaveBeenCalled() + expect(onFieldAdded).toHaveBeenCalledWith( + mockFdm, + "new-field-id", + expect.anything(), + ) + }) + + it("should process UPDATE_FROM_REMOTE action", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.CONFLICT, + localField: { + b_id: "local-1", + b_name: "Old Name", + }, + localCultivation: { + b_lu_catalogue: "nl_101", + b_lu: "cult-1", + }, + rvoField: { + type: "Feature", + geometry: { type: "Polygon", coordinates: [] }, + properties: { + CropFieldID: "rvo-1", + CropFieldDesignator: "New Name", + BeginDate: "2025-01-01", + UseTitleCode: "01", + CropTypeCode: "101", + CropFieldVersion: "1", + Country: "NL", + }, + }, + diffs: ["b_name"], + } + const choices = { "local-1": "UPDATE_FROM_REMOTE" as const } + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + expect(updateField).toHaveBeenCalledWith( + mockFdm, + principalId, + "local-1", + "New Name", + "rvo-1", + expect.anything(), + expect.any(Date), + "nl_01", + undefined, + undefined, // b_bufferstrip (no mestData in test) + ) + // No cultivation change implies no cultivation update call unless localCultivation differs or is missing + expect(removeCultivation).not.toHaveBeenCalled() + expect(addCultivation).not.toHaveBeenCalled() + }) + + it("should add missing cultivation during UPDATE_FROM_REMOTE action", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.CONFLICT, + localField: { + b_id: "local-1", + }, + // localCultivation is missing + rvoField: { + type: "Feature", + geometry: { type: "Polygon", coordinates: [] }, + properties: { + CropFieldID: "rvo-1", + CropFieldDesignator: "Name", + BeginDate: "2025-01-01", + UseTitleCode: "01", + CropTypeCode: "101", + CropFieldVersion: "1", + Country: "NL", + }, + }, + diffs: ["b_geometry"], + } + const choices = { "local-1": "UPDATE_FROM_REMOTE" as const } + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + expect(updateField).toHaveBeenCalled() + expect(removeCultivation).not.toHaveBeenCalled() + expect(addCultivation).toHaveBeenCalledWith( + mockFdm, + principalId, + "nl_101", + "local-1", + expect.any(Date), + expect.any(Date), + ) + }) + + it("should process UPDATE_FROM_REMOTE action with cultivation change", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.CONFLICT, + localField: { b_id: "local-1" }, + localCultivation: { + b_lu_catalogue: "nl_202", // Different from rvo 101 + b_lu: "cult-1", + }, + rvoField: { + type: "Feature", + geometry: { type: "Polygon", coordinates: [] }, + properties: { + CropFieldID: "rvo-1", + CropFieldDesignator: "Name", + BeginDate: "2025-01-01", + UseTitleCode: "01", + CropTypeCode: "101", + CropFieldVersion: "1", + Country: "NL", + }, + }, + diffs: ["b_lu_catalogue"], + } + const choices = { "local-1": "UPDATE_FROM_REMOTE" as const } + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + expect(updateField).toHaveBeenCalled() + expect(removeCultivation).toHaveBeenCalledWith( + mockFdm, + principalId, + "cult-1", + ) + expect(addCultivation).toHaveBeenCalledWith( + mockFdm, + principalId, + "nl_101", + "local-1", + expect.any(Date), + expect.any(Date), + ) + }) + + it("should process REMOVE_LOCAL action", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.NEW_LOCAL, + localField: { b_id: "local-1" }, + diffs: [], + } + const choices = { "local-1": "REMOVE_LOCAL" as const } + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + expect(removeField).toHaveBeenCalledWith( + mockFdm, + principalId, + "local-1", + ) + }) + + it("should process CLOSE_LOCAL action", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.EXPIRED_LOCAL, + localField: { + b_id: "local-1", + b_name: "Field 1", + b_id_source: "rvo-1", + b_geometry: {}, + b_start: new Date("2024-01-01"), + b_acquiring_method: "purchase", + }, + diffs: [], + } + const choices = { "local-1": "CLOSE_LOCAL" as const } + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + // Should update field with end date = Dec 31st of previous year (2024) + const expectedCloseDate = new Date(year - 1, 11, 31) + expect(updateField).toHaveBeenCalledWith( + mockFdm, + principalId, + "local-1", + "Field 1", + "rvo-1", + {}, + item.localField?.b_start, + "purchase", + expectedCloseDate, + ) + }) + + it("should process CLOSE_LOCAL action even if b_start is a string", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.EXPIRED_LOCAL, + localField: { + b_id: "local-1", + b_name: "Field 1", + b_id_source: "rvo-1", + b_geometry: {}, + b_start: "2024-01-01", // String date + b_acquiring_method: "purchase", + }, + diffs: [], + } + const choices = { "local-1": "CLOSE_LOCAL" as const } + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + expect(updateField).toHaveBeenCalledWith( + mockFdm, + principalId, + "local-1", + "Field 1", + "rvo-1", + {}, + expect.any(Date), // Should convert to Date + "purchase", + expect.any(Date), + ) + }) + + it("should process KEEP_LOCAL action (do nothing)", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.CONFLICT, + localField: { b_id: "local-1" }, + diffs: ["b_name"], + } + const choices = { "local-1": "KEEP_LOCAL" as const } + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + expect(addField).not.toHaveBeenCalled() + expect(updateField).not.toHaveBeenCalled() + expect(removeField).not.toHaveBeenCalled() + }) + + it("should skip items with no action selected", async () => { + const item: RvoImportReviewItem = { + status: RvoImportReviewStatus.NEW_REMOTE, + rvoField: { properties: { CropFieldID: "rvo-1" } } as any, + diffs: [], + } + // No choice provided for rvo-1 + const choices = {} + + await processRvoImport( + mockFdm, + principalId, + farmId, + [item], + choices, + year, + ) + + expect(addField).not.toHaveBeenCalled() + expect(updateField).not.toHaveBeenCalled() + expect(removeField).not.toHaveBeenCalled() + }) + + it("should ignore actions when required fields are missing", async () => { + // ADD_REMOTE without rvoField + await processRvoImport( + mockFdm, + principalId, + farmId, + [{ status: RvoImportReviewStatus.NEW_REMOTE, diffs: [] }], + { unknown: "ADD_REMOTE" }, + year, + ) + expect(addField).not.toHaveBeenCalled() + + // UPDATE_FROM_REMOTE without rvoField + await processRvoImport( + mockFdm, + principalId, + farmId, + [ + { + status: RvoImportReviewStatus.CONFLICT, + localField: { b_id: "l1" }, + diffs: [], + }, + ], + { l1: "UPDATE_FROM_REMOTE" }, + year, + ) + expect(updateField).not.toHaveBeenCalled() + + // REMOVE_LOCAL without localField + await processRvoImport( + mockFdm, + principalId, + farmId, + [{ status: RvoImportReviewStatus.NEW_LOCAL, diffs: [] }], + { unknown: "REMOVE_LOCAL" }, + year, + ) + expect(removeField).not.toHaveBeenCalled() + + // CLOSE_LOCAL without localField + await processRvoImport( + mockFdm, + principalId, + farmId, + [{ status: RvoImportReviewStatus.EXPIRED_LOCAL, diffs: [] }], + { unknown: "CLOSE_LOCAL" }, + year, + ) + expect(updateField).not.toHaveBeenCalled() + }) +}) diff --git a/fdm-rvo/src/process.ts b/fdm-rvo/src/process.ts new file mode 100644 index 000000000..c01981bdb --- /dev/null +++ b/fdm-rvo/src/process.ts @@ -0,0 +1,226 @@ +import { + addField, + updateField, + removeField, + addCultivation, + removeCultivation, + getDefaultDatesOfCultivation, + acquiringMethodOptions, + type FdmType, +} from "@nmi-agro/fdm-core" +import type { RvoImportReviewItem, UserChoiceMap } from "./types" +import { getItemId } from "./utils" + +type AcquiringMethod = (typeof acquiringMethodOptions)[number]["value"] + +function parseBufferstrip(value: string | undefined): boolean | undefined { + if (value === "J") return true + if (value === "N") return false + return undefined +} + +function parseAcquiringMethod( + useTitleCode: string | undefined, +): AcquiringMethod { + const candidate = `nl_${useTitleCode}` + if (acquiringMethodOptions.some((opt) => opt.value === candidate)) { + return candidate as AcquiringMethod + } + return "unknown" +} + +/** + * Processes the RVO import review results by applying user-selected actions. + * + * Iterates through the provided review items and executes the corresponding action + * (add, update, remove) based on the `userChoices` map. + * + * @param fdm - The FDM client instance for database operations. + * @param principal_id - The ID of the principal (user) performing the import. + * @param b_id_farm - The ID of the farm the fields belong to. + * @param rvoImportReviewData - The list of review items resulting from the comparison. + * @param userChoices - A map where keys are item IDs and values are the chosen `ImportReviewAction`. + * @param year - The calendar year for the import context. + * @returns A promise that resolves when all actions have been processed. + */ +export async function processRvoImport( + fdm: FdmType, + principal_id: string, + b_id_farm: string, + rvoImportReviewData: RvoImportReviewItem[], + userChoices: UserChoiceMap, + year: number, + onFieldAdded?: (tx: FdmType, b_id: string, geometry: any) => Promise, +) { + for (const item of rvoImportReviewData) { + const id = getItemId(item) + const action = userChoices[id] + + if (!action || action === "IGNORE" || action === "NO_ACTION") { + continue + } + + await fdm.transaction(async (tx: FdmType) => { + switch (action) { + case "ADD_REMOTE": + if (item.rvoField) { + const b_bufferstrip = parseBufferstrip( + item.rvoField.properties.mestData?.IndBufferstrook, + ) + + const b_id = await addField( + tx, + principal_id, + b_id_farm, + item.rvoField.properties.CropFieldDesignator || + `RVO Perceel ${item.rvoField.properties.CropFieldID}`, + item.rvoField.properties.CropFieldID, + item.rvoField.geometry, + new Date(item.rvoField.properties.BeginDate), + parseAcquiringMethod( + item.rvoField.properties.UseTitleCode, + ), + item.rvoField.properties.EndDate + ? new Date(item.rvoField.properties.EndDate) + : undefined, + b_bufferstrip, + ) + + // Add cultivation from RVO + const b_lu_catalogue = `nl_${item.rvoField.properties.CropTypeCode}` + const defaultDates = await getDefaultDatesOfCultivation( + tx, + principal_id, + b_id_farm, + b_lu_catalogue, + year, + ) + + await addCultivation( + tx, + principal_id, + b_lu_catalogue, + b_id, + defaultDates.b_lu_start, + defaultDates.b_lu_end, + ) + + if (onFieldAdded) { + await onFieldAdded(tx, b_id, item.rvoField.geometry) + } + } + break + case "UPDATE_FROM_REMOTE": + if (item.localField && item.rvoField) { + const b_bufferstrip = parseBufferstrip( + item.rvoField.properties.mestData?.IndBufferstrook, + ) + + await updateField( + tx, + principal_id, + item.localField.b_id, + item.rvoField.properties.CropFieldDesignator || + item.localField.b_name, + item.rvoField.properties.CropFieldID, + item.rvoField.geometry, + new Date(item.rvoField.properties.BeginDate), + parseAcquiringMethod( + item.rvoField.properties.UseTitleCode, + ), + item.rvoField.properties.EndDate + ? new Date(item.rvoField.properties.EndDate) + : undefined, + b_bufferstrip, + ) + + // Update cultivation if different or add if missing + if (item.localCultivation) { + if ( + item.localCultivation.b_lu_catalogue !== + `nl_${item.rvoField.properties.CropTypeCode}` + ) { + // Remove old cultivation + await removeCultivation( + tx, + principal_id, + item.localCultivation.b_lu, + ) + + // Add new RVO cultivation + const b_lu_catalogue = `nl_${item.rvoField.properties.CropTypeCode}` + const defaultDates = + await getDefaultDatesOfCultivation( + tx, + principal_id, + b_id_farm, + b_lu_catalogue, + year, + ) + + await addCultivation( + tx, + principal_id, + b_lu_catalogue, + item.localField.b_id, + defaultDates.b_lu_start, + defaultDates.b_lu_end, + ) + } + } else { + // Add new RVO cultivation as it was missing locally + const b_lu_catalogue = `nl_${item.rvoField.properties.CropTypeCode}` + const defaultDates = + await getDefaultDatesOfCultivation( + tx, + principal_id, + b_id_farm, + b_lu_catalogue, + year, + ) + + await addCultivation( + tx, + principal_id, + b_lu_catalogue, + item.localField.b_id, + defaultDates.b_lu_start, + defaultDates.b_lu_end, + ) + } + } + break + case "KEEP_LOCAL": // Keep Local for Conflict + break + case "REMOVE_LOCAL": + if (item.localField) { + await removeField( + tx, + principal_id, + item.localField.b_id, + ) + } + break + case "CLOSE_LOCAL": + if (item.localField) { + // Close the field on Dec 31st of the previous year + const closeDate = new Date(year - 1, 11, 31) + await updateField( + tx, + principal_id, + item.localField.b_id, + item.localField.b_name, + item.localField.b_id_source, + item.localField.b_geometry, + item.localField.b_start instanceof Date + ? item.localField.b_start + : new Date(item.localField.b_start), + item.localField.b_acquiring_method, + closeDate, + ) + } + break + } + }) + } +} diff --git a/fdm-rvo/src/types.ts b/fdm-rvo/src/types.ts new file mode 100644 index 000000000..d2857c940 --- /dev/null +++ b/fdm-rvo/src/types.ts @@ -0,0 +1,214 @@ +import { z } from "zod" + +const MestCropDetailsSchema = z.object({ + Grondbedekking: z.union([z.string(), z.number()]).optional(), + Oppervlakte: z.union([z.string(), z.number()]).optional(), + Inzaaidatum: z.string().optional(), + GewasbeschermingVoorteelt: z.string().optional(), + descriptiveValues: z.record(z.string(), z.any()).optional(), +}).catchall(z.any()) + +/** + * Zod schema for properties returned by the RegelingspercelenMest endpoint. + * Matches `MestFieldProperties` from the rvo-connector. + */ +export const MestDataSchema = z.object({ + /** Unique identification of the parcel (e.g. AGRONL...). */ + MESTFieldid: z.string().optional(), + /** Version number of the MEST field. */ + MESTFieldVersion: z.string().optional(), + /** Start date of the field's validity (YYYY-MM-DDTHH:mm:ss). */ + BeginDate: z.string().optional(), + /** End date of the field's validity (YYYY-MM-DDTHH:mm:ss). */ + EndDate: z.string().optional(), + /** Date of the statement. */ + OpgaveDatum: z.string().optional(), + /** User-assigned name/designator for the field. */ + Fielddesignator: z.string().optional(), + /** Calculated area in hectares (4 decimals). */ + CalculatedArea: z.union([z.string(), z.number()]).optional(), + /** Proposed area in hectares. */ + VoorgesteldeOppervlakte: z.union([z.string(), z.number()]).optional(), + /** Declared area in hectares. */ + OpgegevenOppervlakte: z.union([z.string(), z.number()]).optional(), + /** Crop Type Code. Codelist: CL263 or CL411. */ + Grondbedekking: z.union([z.string(), z.number()]).optional(), + /** Use Title Code. Codelist: CL412. */ + GebruiksTitel: z.string().optional(), + /** Regulatory Soil Type Code. Codelist: CL405. */ + Grondsoort: z.string().optional(), + /** Natural land type code. */ + TypeGrond: z.string().optional(), + /** Indicator for buffer strips. */ + IndBufferstrook: z.enum(["J", "N"]).optional(), + /** Surface area of buffer strips. */ + BufferstrookOppervlakte: z.union([z.string(), z.number()]).optional(), + /** Sampling date. */ + BemonsteringDatum: z.string().optional(), + /** Sampling protocol code. */ + BemonsteringProtocol: z.string().optional(), + /** Indicator for phosphate differentiation. */ + IndFosfaatdifferentiatie: z.enum(["J", "N"]).optional(), + /** PCA CL2 value. */ + PCACL2Waarde: z.union([z.string(), z.number()]).optional(), + /** Pal value from 2021 onwards. */ + PalWaardeVanaf2021: z.union([z.string(), z.number()]).optional(), + /** Indicator for catch crop (nateelt) as manure requirement. */ + IndNateeltMest: z.string().optional(), + /** Cause of the update/mutation. */ + MESTFieldCause: z.string().optional(), + /** Previous crop (Voorteelt) details. */ + Voorteelt: z.union([MestCropDetailsSchema, z.array(MestCropDetailsSchema)]).optional(), + /** Catch crop (Nateelt) details. */ + Nateelt: z.union([MestCropDetailsSchema, z.array(MestCropDetailsSchema)]).optional(), + /** Quality indicators for this field. */ + QualityIndicatorType: z.any().optional(), + /** Human-readable labels and boolean mappings if `enrichResponse` was used */ + descriptiveValues: z.record(z.string(), z.any()).optional(), +}).catchall(z.any()) + +/** + * Zod schema for validating RVO Field data. + * + * This schema matches the GeoJSON Feature structure returned by the RVO connector. + * It validates the essential properties required for field synchronization. + * + * @remarks + * The geometry is typed as `z.any()` here to allow flexibility with GeoJSON types, + * but in practice, it will be a Polygon or MultiPolygon. + */ +export const RvoFieldSchema = z.object({ + /** Fixed type for GeoJSON Feature */ + type: z.literal("Feature"), + /** GeoJSON geometry of the field (Polygon or MultiPolygon) */ + geometry: z.any(), + /** Properties specific to the RVO crop field */ + properties: z.object({ + /** Unique identifier for the crop field (Gewasperceel ID) */ + CropFieldID: z.string(), + /** Optional third-party identifier */ + ThirdPartyCropFieldID: z.string().optional(), + /** Version identifier of the crop field data */ + CropFieldVersion: z.string(), + /** Name or designator of the field (e.g., "Perceel 1") */ + CropFieldDesignator: z.string(), + /** Start date of the crop field registration (ISO 8601 string) */ + BeginDate: z.string(), + /** End date of the crop field registration (ISO 8601 string), optional */ + EndDate: z.string().optional(), + /** Country code (e.g., "NL") */ + Country: z.string(), + /** Code representing the type of crop grown */ + CropTypeCode: z.union([z.string(), z.number()]), + /** Optional code for the specific variety of the crop */ + VarietyCode: z.union([z.string(), z.number()]).optional(), + /** Optional code for the production purpose */ + CropProductionPurposeCode: z.union([z.string(), z.number()]).optional(), + /** Optional code for field usage */ + FieldUseCode: z.union([z.string(), z.number()]).optional(), + /** Optional code for regulatory soil type */ + RegulatorySoiltypeCode: z.union([z.string(), z.number()]).optional(), + /** Code indicating the title/right of use (e.g., "01" for ownership, "02" for lease) */ + UseTitleCode: z.string(), + /** Optional cause for the field record */ + CropFieldCause: z.string().optional(), + /** Optional data from the RegelingspercelenMest endpoint */ + mestData: MestDataSchema.optional(), + }), +}) + +/** + * TypeScript type inferred from `RvoFieldSchema`. + * Represents a single agricultural field as retrieved from the RVO webservice. + */ +export type RvoField = z.infer + +/** + * Status of a field during the RVO Import Review process between local data and RVO data. + */ +export enum RvoImportReviewStatus { + /** The field exists in both systems and is identical (no conflicts). */ + MATCH = "MATCH", + /** The field exists in RVO but is missing locally. Suggests adding it to the local system. */ + NEW_REMOTE = "NEW_REMOTE", + /** The field exists locally but is missing in RVO. Suggests removing it or keeping it as local-only. */ + NEW_LOCAL = "NEW_LOCAL", + /** The field exists in both systems but has differing properties (e.g., geometry, name). */ + CONFLICT = "CONFLICT", + /** The field exists locally, started before the import year, and is missing in RVO for the import year. Suggests closing it. */ + EXPIRED_LOCAL = "EXPIRED_LOCAL", +} + +/** + * Identifiers for specific properties that differ between a local field and an RVO field. + * Used to highlight changes in the UI. + */ +export type FieldDiff = + | "b_name" // Name difference + | "b_geometry" // Spatial/Shape difference + | "b_start" // Start date difference + | "b_end" // End date difference + | "b_acquiring_method" // Method of acquisition difference (implied) + | "b_lu_catalogue" // Cultivation difference + | "b_bufferstrip" // Buffer strip status difference + +/** + * Represents the explicit decision made by the user for a RVO Import Review item. + */ +export enum UserRvoImportReviewDecision { + /** Use the RVO field's data (for CONFLICT, NEW_REMOTE) */ + USE_RVO = "USE_RVO", + /** Keep the local field's data (for CONFLICT, NEW_LOCAL) */ + KEEP_LOCAL = "KEEP_LOCAL", + /** Explicitly add a new RVO field */ + ADD = "ADD", + /** Explicitly remove a local field */ + REMOVE = "REMOVE", + /** Explicitly close a local field (set end date) */ + CLOSE = "CLOSE", + /** Ignore this RVO Import Review item (take no action) */ + IGNORE = "IGNORE", +} + +/** + * Represents the result of comparing a single field between the local database and RVO. + * + * @template TLocal - The type of the local field object (defaults to `any` if not specified, typically `Field` from fdm-core). + */ +export interface RvoImportReviewItem { + /** The RVO Import Review status (Match, New, Conflict, etc.) */ + status: RvoImportReviewStatus + /** The local field object, if it exists (undefined for NEW_REMOTE) */ + localField?: TLocal + /** The RVO field object, if it exists (undefined for NEW_LOCAL) */ + rvoField?: RvoField + /** The local cultivation on May 15th */ + localCultivation?: { + b_lu_catalogue: string + b_lu: string + b_lu_name?: string + } + /** The RVO cultivation based on CropTypeCode */ + rvoCultivation?: { b_lu_catalogue: string; b_lu_name?: string } + /** List of specific properties that differ (empty for MATCH, NEW_REMOTE, NEW_LOCAL) */ + diffs: FieldDiff[] +} + +/** + * Action to be taken for a specific review item during the import process. + */ +export type ImportReviewAction = + | "ADD_REMOTE" // Add the remote field to the local database + | "UPDATE_FROM_REMOTE" // Update the local field with remote data + | "KEEP_LOCAL" // Keep the local version, ignoring the remote conflict + | "REMOVE_LOCAL" // Remove the local field + | "CLOSE_LOCAL" // Close the local field (set end date) + | "IGNORE" // Do nothing for this item + | "NO_ACTION" // No specific action selected + +/** + * A map of user choices, keyed by the item ID (see `getItemId`). + * + * Each entry represents the action selected by the user for a specific review item. + */ +export type UserChoiceMap = Record diff --git a/fdm-rvo/src/utils.test.ts b/fdm-rvo/src/utils.test.ts new file mode 100644 index 000000000..e4b6b1392 --- /dev/null +++ b/fdm-rvo/src/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest" +import { getItemId } from "./utils" +import { RvoImportReviewStatus } from "./types" + +describe("getItemId", () => { + it("should return local ID if present", () => { + const item = { + status: RvoImportReviewStatus.MATCH, + localField: { b_id: "local-1" }, + diffs: [], + } as any + expect(getItemId(item)).toBe("local-1") + }) + + it("should return RVO ID if local not present", () => { + const item = { + status: RvoImportReviewStatus.NEW_REMOTE, + rvoField: { properties: { CropFieldID: "rvo-1" } }, + diffs: [], + } as any + expect(getItemId(item)).toBe("rvo-1") + }) + + it("should return a deterministic composite when neither field is present", () => { + const item = { + status: RvoImportReviewStatus.NEW_REMOTE, + diffs: [], + } as any + const id = getItemId(item) + // Must be a non-empty string that starts with the status value + expect(id).toContain(RvoImportReviewStatus.NEW_REMOTE) + // Must not be "unknown" — no collisions across degenerate items + expect(id).not.toBe("unknown") + }) +}) diff --git a/fdm-rvo/src/utils.ts b/fdm-rvo/src/utils.ts new file mode 100644 index 000000000..5551e0242 --- /dev/null +++ b/fdm-rvo/src/utils.ts @@ -0,0 +1,98 @@ +import bbox from "@turf/bbox" +import intersect from "@turf/intersect" +import union from "@turf/union" +import area from "@turf/area" +import { feature, featureCollection } from "@turf/helpers" +import type { RvoImportReviewItem } from "./types" + +/** + * Generates a stable unique identifier for a review item. + * + * Priority: + * 1. Local field DB id (`b_id`) — always present when `localField` exists. + * 2. RVO crop field id (`CropFieldID`) — always present when `rvoField` exists. + * 3. Deterministic composite from `status`, `CropFieldVersion`, and `BeginDate` + * — used only in the degenerate case where neither field is set. + * + * The returned value is stable across renders for the same item, making it + * safe to use as a React key and for `UserChoiceMap` identity comparisons. + * + * @param item - The review item to generate an ID for. + * @returns A unique string identifier for the item. + */ +export function getItemId(item: RvoImportReviewItem): string { + if (item.localField?.b_id) return item.localField.b_id + if (item.rvoField?.properties.CropFieldID) + return item.rvoField.properties.CropFieldID + + // Degenerate fallback: build a deterministic composite from whatever + // stable data is available so multiple items don't collapse to the same key. + return [ + item.status, + item.rvoField?.properties.CropFieldVersion, + item.rvoField?.properties.BeginDate, + item.localField ? "local" : "remote", + ] + .filter(Boolean) + .join(":") +} + +/** + * Calculates Intersection over Union (IoU) for two geometries. + * + * IoU is a standard metric for measuring the overlap between two shapes. + * Formula: Area(Intersection) / Area(Union) + * + * @param geom1 - The first geometry (GeoJSON). + * @param geom2 - The second geometry (GeoJSON). + * @returns A number between 0 (no overlap) and 1 (perfect match). Returns 0 on error. + */ +export function calculateIoU(geom1: any, geom2: any): number { + try { + const f1 = feature(geom1) + const f2 = feature(geom2) + + const intResult = intersect(featureCollection([f1, f2])) + if (!intResult) return 0 + + const unionResult = union(featureCollection([f1, f2])) + if (!unionResult) return 0 + + const areaInt = area(intResult) + const areaUnion = area(unionResult) + + if (areaUnion === 0) return 0 + return areaInt / areaUnion + } catch (e) { + console.error("Error calculating IoU", e) + return 0 + } +} + +/** + * Checks if two bounding boxes overlap. + * + * Used as a fast pre-filter before calculating expensive IoU operations. + * + * @param bbox1 - [minX, minY, maxX, maxY] + * @param bbox2 - [minX, minY, maxX, maxY] + * @returns True if boxes overlap, false otherwise. + */ +export function bboxOverlap(bbox1: number[], bbox2: number[]): boolean { + return !( + bbox1[2] < bbox2[0] || + bbox1[0] > bbox2[2] || + bbox1[3] < bbox2[1] || + bbox1[1] > bbox2[3] + ) +} + +/** + * Pre-computes the bounding box of a GeoJSON geometry. + * + * @param geometry - The GeoJSON geometry. + * @returns The bounding box as [minX, minY, maxX, maxY]. + */ +export function computeBbox(geometry: any): number[] { + return bbox(geometry) +} diff --git a/fdm-rvo/tsconfig.build.json b/fdm-rvo/tsconfig.build.json new file mode 100644 index 000000000..13228ffbe --- /dev/null +++ b/fdm-rvo/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist", + "paths": {} + }, + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts", + "src/setup-test.ts", + "src/test-utils.ts" + ] +} diff --git a/fdm-rvo/tsconfig.json b/fdm-rvo/tsconfig.json new file mode 100644 index 000000000..fd9966745 --- /dev/null +++ b/fdm-rvo/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.packages.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@nmi-agro/fdm-core": ["../fdm-core/src/index.ts"], + "@nmi-agro/fdm-data": ["../fdm-data/src/index.ts"] + } + }, + "include": ["src/**/*", "vitest.config.ts"] +} diff --git a/fdm-rvo/typedoc.json b/fdm-rvo/typedoc.json new file mode 100644 index 000000000..c4782f959 --- /dev/null +++ b/fdm-rvo/typedoc.json @@ -0,0 +1,10 @@ +{ + "entryPoints": ["src/index.ts"], + "out": "docs", + "readme": "README.md", + "name": "@nmi-agro/fdm-rvo", + "excludePrivate": true, + "excludeProtected": true, + "hideGenerator": true, + "plugin": ["typedoc-plugin-missing-exports"] +} diff --git a/package.json b/package.json index f001a0c14..8a69b38dc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "fdm-data", "fdm-docs", "fdm-app", - "fdm-calculator" + "fdm-calculator", + "fdm-rvo" ], "scripts": { "test": "turbo run test-coverage --filter=@nmi-agro/fdm-core --filter=@nmi-agro/fdm-calculator", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe3e4488c..0689d5ce6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,11 +19,11 @@ catalogs: specifier: ^16.0.3 version: 16.0.3 '@types/node': - specifier: ^25.4.0 + specifier: ^25.5.0 version: 25.5.0 '@vitest/coverage-v8': - specifier: 4.0.18 - version: 4.0.18 + specifier: 4.1.0 + version: 4.1.0 better-auth: specifier: ^1.5.4 version: 1.5.5 @@ -55,7 +55,7 @@ catalogs: specifier: ^7.3.1 version: 7.3.1 vitest: - specifier: ^4.0.18 + specifier: ^4.1.0 version: 4.1.1 packageExtensionsChecksum: sha256-VQuFGSJ2NQgmUfUYujaw/wyx7PoF3FcUJ5DmmDpAEDo= @@ -106,6 +106,9 @@ importers: '@nmi-agro/fdm-data': specifier: workspace:* version: link:../fdm-data + '@nmi-agro/fdm-rvo': + specifier: workspace:^ + version: link:../fdm-rvo '@react-email/components': specifier: ^1.0.8 version: 1.0.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -365,7 +368,7 @@ importers: version: 25.5.0 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.0.18(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.0(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) postgres: specifier: ^3.4.8 version: 3.4.8 @@ -444,7 +447,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.0.18(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.0(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -494,9 +497,12 @@ importers: '@rollup/plugin-node-resolve': specifier: 'catalog:' version: 16.0.3(rollup@4.60.0) + '@types/node': + specifier: 'catalog:' + version: 25.5.0 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.0.18(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.0(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) rollup: specifier: 'catalog:' version: 4.60.0 @@ -577,6 +583,61 @@ importers: specifier: 'catalog:' version: 5.9.3 + fdm-rvo: + dependencies: + '@nmi-agro/fdm-core': + specifier: workspace:^ + version: link:../fdm-core + '@nmi-agro/rvo-connector': + specifier: ^2.2.1 + version: 2.2.1 + '@turf/area': + specifier: ^7.3.4 + version: 7.3.4 + '@turf/bbox': + specifier: ^7.3.4 + version: 7.3.4 + '@turf/helpers': + specifier: ^7.3.4 + version: 7.3.4 + '@turf/intersect': + specifier: ^7.3.4 + version: 7.3.4 + '@turf/union': + specifier: ^7.3.4 + version: 7.3.4 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@rollup/plugin-commonjs': + specifier: 'catalog:' + version: 29.0.2(rollup@4.60.0) + '@rollup/plugin-node-resolve': + specifier: 'catalog:' + version: 16.0.3(rollup@4.60.0) + '@types/node': + specifier: 'catalog:' + version: 25.5.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.0(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + rollup: + specifier: 'catalog:' + version: 4.60.0 + rollup-plugin-esbuild: + specifier: 'catalog:' + version: 6.2.1(esbuild@0.27.4)(rollup@4.60.0) + typedoc: + specifier: 'catalog:' + version: 0.28.18(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + packages: '@algolia/abtesting@1.15.2': @@ -2725,6 +2786,10 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + '@nmi-agro/rvo-connector@2.2.1': + resolution: {integrity: sha512-iAZmKp0YCbQbZk4lrc426nigIMgKLj/WMpRmzFLGTHWrX4f61Sk62jwFh0cbXc5leQR9e2f6mAw/1nG0b1oUWQ==} + engines: {node: '>=18'} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -5610,11 +5675,11 @@ packages: maplibre-gl: optional: true - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + '@vitest/coverage-v8@4.1.0': + resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 + '@vitest/browser': 4.1.0 + vitest: 4.1.0 peerDependenciesMeta: '@vitest/browser': optional: true @@ -5633,8 +5698,8 @@ packages: vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} '@vitest/pretty-format@4.1.1': resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} @@ -5648,8 +5713,8 @@ packages: '@vitest/spy@4.1.1': resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} '@vitest/utils@4.1.1': resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} @@ -5869,8 +5934,8 @@ packages: resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} engines: {node: '>=0.10.0'} - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} @@ -6093,6 +6158,9 @@ packages: resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} engines: {node: '>=20.19.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6915,6 +6983,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -8098,10 +8169,20 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsts@2.7.1: resolution: {integrity: sha512-x2wSZHEBK20CY+Wy+BPE7MrFQHW6sIsdaGUMEqmGAio+3gFzQaBYPwLRonUfQf9Ak8pBieqj9tUofX1+WtAEIg==} engines: {node: '>= 12'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.40: resolution: {integrity: sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==} hasBin: true @@ -8251,9 +8332,30 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -9596,6 +9698,10 @@ packages: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -10905,6 +11011,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -11221,6 +11331,14 @@ packages: xml-utils@1.10.2: resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -14170,6 +14288,14 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nmi-agro/rvo-connector@2.2.1': + dependencies: + jsonwebtoken: 9.0.3 + proj4: 2.20.4 + qs: 6.15.0 + uuid: 13.0.0 + xml2js: 0.6.2 + '@noble/ciphers@1.3.0': {} '@noble/ciphers@2.1.1': {} @@ -17964,17 +18090,17 @@ snapshots: optionalDependencies: maplibre-gl: 5.21.0 - '@vitest/coverage-v8@4.0.18(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))': + '@vitest/coverage-v8@4.1.0(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.12 + '@vitest/utils': 4.1.0 + ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 + std-env: 4.0.0 tinyrainbow: 3.1.0 vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -17995,7 +18121,7 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.0': dependencies: tinyrainbow: 3.1.0 @@ -18017,9 +18143,10 @@ snapshots: '@vitest/spy@4.1.1': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 tinyrainbow: 3.1.0 '@vitest/utils@4.1.1': @@ -18273,7 +18400,7 @@ snapshots: assign-symbols@1.0.0: {} - ast-v8-to-istanbul@0.3.12: + ast-v8-to-istanbul@1.0.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -18509,6 +18636,8 @@ snapshots: bson@7.2.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bundle-name@4.1.0: @@ -19230,6 +19359,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) @@ -20628,8 +20761,32 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + jsts@2.7.1: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + katex@0.16.40: dependencies: commander: 8.3.0 @@ -20745,8 +20902,22 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} + lodash.once@4.1.1: {} + lodash.startcase@4.4.0: {} lodash.throttle@4.1.1: {} @@ -22429,6 +22600,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} query-selector-shadow-dom@1.0.1: {} @@ -23935,6 +24110,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@13.0.0: {} + uuid@8.3.2: {} uzip-module@1.0.3: @@ -24346,6 +24523,13 @@ snapshots: xml-utils@1.10.2: {} + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xtend@4.0.2: {} xxhash-wasm@1.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 75db514ac..16fde8e72 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - fdm-calculator - fdm-docs - fdm-app + - fdm-rvo catalog: '@dotenvx/dotenvx': ^1.54.1 @@ -11,8 +12,8 @@ catalog: '@rollup/plugin-json': ^6.1.0 '@rollup/plugin-node-resolve': ^16.0.3 '@rollup/plugin-typescript': ^12.3.0 - '@types/node': ^25.4.0 - '@vitest/coverage-v8': 4.0.18 + '@types/node': ^25.5.0 + '@vitest/coverage-v8': 4.1.0 better-auth: ^1.5.4 drizzle-kit: ^0.31.9 drizzle-orm: ^0.45.1 @@ -24,4 +25,4 @@ catalog: typescript: ^5.9.3 vite: ^7.3.1 vite-tsconfig-paths: ^5.1.4 - vitest: ^4.0.18 + vitest: ^4.1.0 diff --git a/tsconfig.apps.json b/tsconfig.apps.json new file mode 100644 index 000000000..2f5dbbd7c --- /dev/null +++ b/tsconfig.apps.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "jsx": "react-jsx", + "resolveJsonModule": true, + "esModuleInterop": true + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..38504b1ab --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": true + } +} diff --git a/tsconfig.packages.json b/tsconfig.packages.json new file mode 100644 index 000000000..341712659 --- /dev/null +++ b/tsconfig.packages.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "allowImportingTsExtensions": true + } +} diff --git a/tsconfig.typedoc.json b/tsconfig.typedoc.json index c0b19fdeb..06fd43c43 100644 --- a/tsconfig.typedoc.json +++ b/tsconfig.typedoc.json @@ -12,7 +12,8 @@ "include": [ "fdm-core/src/**/*", "fdm-data/src/**/*", - "fdm-calculator/src/**/*" + "fdm-calculator/src/**/*", + "fdm-rvo/src/**/*" ], "exclude": ["node_modules", "**/*.test.ts", "**/dist", "**/build"] }