Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/few-boxes-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-app": patch
---

Enable `bln` catalogue for measures when creating a new farm
5 changes: 5 additions & 0 deletions .changeset/hip-oranges-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-core": minor
---

Add BLN3 measures tables and CRUD layer. New schema tables `measures_catalogue`, `measures`, and `measure_adopting` follow the action-asset model. Exports `addMeasure`, `getMeasure`, `getMeasures`, `getMeasuresForFarm`, `getMeasuresFromCatalogue`, `updateMeasure`, `removeMeasure`, `syncMeasuresCatalogueArray`, `enableMeasureCatalogue`, `disableMeasureCatalogue`, `isMeasureCatalogueEnabled`, `getEnabledMeasureCatalogues`, and the `Measure` / `MeasureCatalogue` types. `syncCatalogues` now accepts an optional `nmiApiKey` to populate the measures catalogue. All existing farms have the `bln` catalogue enabled by default via migration.
5 changes: 5 additions & 0 deletions .changeset/social-news-like.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-data": minor
---

Add measures catalogue module for BLN3 integration. Exports `getMeasuresCatalogue(catalogueName, nmiApiKey)` as a dispatcher (mirroring `getFertilizersCatalogue`), with BLN3 implemented in `measures/catalogues/bln.ts`. Adding future catalogues (e.g. ANLb) only requires a new file and extending the `CatalogueMeasureName` union. Also exports `hashMeasure` and the `CatalogueMeasure`, `CatalogueMeasureItem`, `CatalogueMeasureName` types using pandex naming conventions (`m_id`, `m_source`, `m_name`, etc.).
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ services:
- POSTGRES_PASSWORD=YOUR_POSTGRES_PASSWORD # Replace with your actual PostgreSQL password
- PUBLIC_MAP_PROVIDER=osm # Options: "maptiler" | "osm"
- PUBLIC_MAPTILER_API_KEY=YOUR_MAPTILER_API_KEY # Replace with your actual MapTiler API key if using "maptiler"
- NMI_API_KEY= # Optional, but required for soil estimates, nutrient advice, soil analysis extraction, and BLN3 measures catalogue sync
- BETTER_AUTH_SECRET=YOUR_BETTER_AUTH_SECRET # Replace with your Better Auth secret
- BETTER_AUTH_URL=YOUR_DOMAIN # Replace with your domain
- GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID # Replace with your Google Client ID
Expand Down
7 changes: 7 additions & 0 deletions fdm-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ PUBLIC_MAPTILER_API_KEY=
# leave this blank.
GEMINI_API_KEY=

# NMI API key for accessing NMI services.
# Used for: soil parameter estimates (atlas), soil analysis PDF extraction,
# nutrient advice, mineralization, calculator, AI-driven planning (Gerrit),
# and syncing the BLN3 measures catalogue on startup.
# Required: No (but many features are disabled without it)
NMI_API_KEY=

# -------------------------------------
# Analytics & Error Tracking (Optional)
# -------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion fdm-app/app/lib/fdm-migrate.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ const fdm = drizzle(client, {
logger: false,
schema: schema,
})
await syncCatalogues(fdm).catch((error) =>
const nmiApiKey = process.env.NMI_API_KEY
await syncCatalogues(fdm, { nmiApiKey }).catch((error) =>
console.error("Error in syncing catalogues 🚨:", error),
)

Expand Down
7 changes: 7 additions & 0 deletions fdm-app/app/routes/farm.create._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
addOrganicCertification,
enableCultivationCatalogue,
enableFertilizerCatalogue,
enableMeasureCatalogue,
getFertilizersFromCatalogue,
setGrazingIntention,
} from "@nmi-agro/fdm-core"
Expand Down Expand Up @@ -886,6 +887,12 @@ export async function action({ request }: ActionFunctionArgs) {
b_id_farm,
"brp",
),
enableMeasureCatalogue(
fdm,
session.principal_id,
b_id_farm,
"bln",
),
)

await Promise.all(setupPromises)
Expand Down
241 changes: 240 additions & 1 deletion fdm-core/src/catalogues.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
import {
getCultivationCatalogue,
getFertilizersCatalogue,
getMeasuresCatalogue,
} from "@nmi-agro/fdm-data"
import { eq, isNotNull } from "drizzle-orm"
import { beforeEach, describe, expect, inject, it } from "vitest"
import { beforeEach, describe, expect, inject, it, vi } from "vitest"
import {
disableCultivationCatalogue,
disableFertilizerCatalogue,
disableMeasureCatalogue,
enableCultivationCatalogue,
enableFertilizerCatalogue,
enableMeasureCatalogue,
getEnabledCultivationCatalogues,
getEnabledFertilizerCatalogues,
getEnabledMeasureCatalogues,
isCultivationCatalogueEnabled,
isFertilizerCatalogueEnabled,
isMeasureCatalogueEnabled,
syncCatalogues,
syncMeasuresCatalogueArray,
} from "./catalogues"
import * as schema from "./db/schema"
import { addFarm } from "./farm"
import type { FdmType } from "./fdm.types"
import { createFdmServer } from "./fdm-server"

vi.mock("@nmi-agro/fdm-data", async (importOriginal) => {
const original =
await importOriginal<typeof import("@nmi-agro/fdm-data")>()
return {
...original,
getMeasuresCatalogue: vi.fn().mockResolvedValue([]),
}
})

describe("Catalogues", () => {
let fdm: FdmType
let principal_id: string
Expand Down Expand Up @@ -829,3 +844,227 @@ describe("Catalogues syncing", () => {
).toBe(true)
})
})

describe("Measures Catalogue Sync", () => {
let fdm: FdmType

const measureA = {
m_id: "bln_BM10",
m_source: "bln",
m_name: "Maatregel A",
m_description: "Beschrijving A",
m_summary: "Samenvatting A",
m_source_url: null,
m_conflicts: null,
}

const measureB = {
m_id: "bln_BM11",
m_source: "bln",
m_name: "Maatregel B",
m_description: null,
m_summary: null,
m_source_url: "https://example.com/BM11",
m_conflicts: ["bln_BM10"],
}

beforeEach(async () => {
const host = inject("host")
const port = inject("port")
const user = inject("user")
const password = inject("password")
const database = inject("database")
fdm = createFdmServer(host, port, user, password, database)
})

it("should update entry with null hash (treats as stale)", async () => {
await syncMeasuresCatalogueArray(fdm, [measureA])

// Simulate a row with null hash (e.g., from an old migration)
await fdm
.update(schema.measuresCatalogue)
.set({ hash: null })
.where(eq(schema.measuresCatalogue.m_id, measureA.m_id))

await syncMeasuresCatalogueArray(fdm, [measureA])

const rows = await fdm
.select({ hash: schema.measuresCatalogue.hash })
.from(schema.measuresCatalogue)
.where(eq(schema.measuresCatalogue.m_id, measureA.m_id))
expect(rows[0].hash).not.toBeNull()
})

it("should insert new catalogue entries", async () => {
await syncMeasuresCatalogueArray(fdm, [measureA, measureB])

const rows = await fdm
.select()
.from(schema.measuresCatalogue)
.where(
eq(schema.measuresCatalogue.m_source, "bln"),
)

const ids = rows.map((r) => r.m_id)
expect(ids).toContain("bln_BM10")
expect(ids).toContain("bln_BM11")
})

it("should update changed entries (hash mismatch)", async () => {
await syncMeasuresCatalogueArray(fdm, [measureA])

const updated = { ...measureA, m_name: "Maatregel A — updated" }
await syncMeasuresCatalogueArray(fdm, [updated])

const rows = await fdm
.select()
.from(schema.measuresCatalogue)
.where(eq(schema.measuresCatalogue.m_id, "bln_BM10"))
expect(rows[0].m_name).toBe("Maatregel A — updated")
expect(rows[0].updated).not.toBeNull()
})

it("should skip unchanged entries (hash match)", async () => {
await syncMeasuresCatalogueArray(fdm, [measureA])

const rowsBefore = await fdm
.select({ updated: schema.measuresCatalogue.updated })
.from(schema.measuresCatalogue)
.where(eq(schema.measuresCatalogue.m_id, "bln_BM10"))

await syncMeasuresCatalogueArray(fdm, [measureA])

const rowsAfter = await fdm
.select({ updated: schema.measuresCatalogue.updated })
.from(schema.measuresCatalogue)
.where(eq(schema.measuresCatalogue.m_id, "bln_BM10"))

// updated timestamp should not have changed
expect(rowsAfter[0].updated).toEqual(rowsBefore[0].updated)
})

it("syncCatalogues without nmiApiKey should not call getMeasuresCatalogue", async () => {
vi.mocked(getMeasuresCatalogue).mockClear()

await syncCatalogues(fdm) // no nmiApiKey

expect(vi.mocked(getMeasuresCatalogue)).not.toHaveBeenCalled()
})

it("syncCatalogues with nmiApiKey should sync measures catalogue", async () => {
vi.mocked(getMeasuresCatalogue).mockResolvedValue([
{
m_id: "bln_SYNC1",
m_source: "bln",
m_name: "Test Sync Measure",
m_description: null,
m_summary: null,
m_source_url: null,
m_conflicts: null,
},
])

await syncCatalogues(fdm, { nmiApiKey: "test-key" })

expect(vi.mocked(getMeasuresCatalogue)).toHaveBeenCalledWith(
"bln",
"test-key",
)
const rows = await fdm
.select({ m_id: schema.measuresCatalogue.m_id })
.from(schema.measuresCatalogue)
.where(eq(schema.measuresCatalogue.m_id, "bln_SYNC1"))
expect(rows).toHaveLength(1)
})
})

describe("Measure Catalogues", () => {
let fdm: FdmType
let principal_id: string
let b_id_farm: string

beforeEach(async () => {
const host = inject("host")
const port = inject("port")
const user = inject("user")
const password = inject("password")
const database = inject("database")
fdm = createFdmServer(host, port, user, password, database)
principal_id = "test_principal"
b_id_farm = await addFarm(
fdm,
principal_id,
"Test Farm",
"123456",
"123 Farm Lane",
"12345",
)
})

it("should enable and check measure catalogue", async () => {
await enableMeasureCatalogue(fdm, principal_id, b_id_farm, "bln")
expect(
await isMeasureCatalogueEnabled(fdm, principal_id, b_id_farm, "bln"),
).toBe(true)
})

it("should disable measure catalogue", async () => {
await enableMeasureCatalogue(fdm, principal_id, b_id_farm, "bln")
await disableMeasureCatalogue(fdm, principal_id, b_id_farm, "bln")
expect(
await isMeasureCatalogueEnabled(fdm, principal_id, b_id_farm, "bln"),
).toBe(false)
})

it("should return enabled measure catalogues", async () => {
await enableMeasureCatalogue(fdm, principal_id, b_id_farm, "bln")
const enabled = await getEnabledMeasureCatalogues(
fdm,
principal_id,
b_id_farm,
)
expect(enabled).toContain("bln")
})

it("should return empty array when no measure catalogues are enabled", async () => {
const enabled = await getEnabledMeasureCatalogues(
fdm,
principal_id,
b_id_farm,
)
expect(enabled).toEqual([])
})

it("should not disable a different source", async () => {
await enableMeasureCatalogue(fdm, principal_id, b_id_farm, "bln")
await disableMeasureCatalogue(fdm, principal_id, b_id_farm, "other")
expect(
await isMeasureCatalogueEnabled(fdm, principal_id, b_id_farm, "bln"),
).toBe(true)
})

it("should throw when permission check fails", async () => {
await expect(
enableMeasureCatalogue(fdm, "wrong_principal", b_id_farm, "bln"),
).rejects.toThrow()
})

it("should throw when permission check fails for getEnabledMeasureCatalogues", async () => {
await expect(
getEnabledMeasureCatalogues(fdm, "wrong_principal", b_id_farm),
).rejects.toThrow()
})

it("should throw when permission check fails for disableMeasureCatalogue", async () => {
await expect(
disableMeasureCatalogue(fdm, "wrong_principal", b_id_farm, "bln"),
).rejects.toThrow()
})

it("should throw when permission check fails for isMeasureCatalogueEnabled", async () => {
await expect(
isMeasureCatalogueEnabled(fdm, "wrong_principal", b_id_farm, "bln"),
).rejects.toThrow()
})
})

Loading
Loading