From ecd4d2184de555cbace8d031d0b63d121de9971f Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:14:43 +0100 Subject: [PATCH 01/25] feat: Add the possibilty for users to accept or reject an invitation to a farm, instead of having it automatically. This makes it also possible to invite non-registered users to get access to a farm after signing up. --- .changeset/farm-invitation-app.md | 9 + .changeset/farm-invitation-system.md | 12 + .../blocks/email/farm-invitation.tsx | 138 + fdm-app/app/lib/email.server.ts | 35 + fdm-app/app/lib/schemas/access.schema.ts | 9 +- .../farm.$b_id_farm.settings.access.tsx | 72 + fdm-app/app/routes/farm._index.tsx | 273 +- fdm-core/src/authentication.ts | 22 + .../db/migrations/0023_romantic_dagger.sql | 15 + .../src/db/migrations/meta/0022_snapshot.json | 4 +- .../src/db/migrations/meta/0023_snapshot.json | 3886 +++++++++++++++++ fdm-core/src/db/migrations/meta/_journal.json | 339 +- fdm-core/src/db/schema-authz.ts | 30 + fdm-core/src/farm.test.ts | 360 +- fdm-core/src/farm.ts | 757 +++- fdm-core/src/global-setup.ts | 1 + fdm-core/src/index.ts | 9 +- fdm-core/src/invitation.test.ts | 229 + fdm-core/src/invitation.ts | 85 + fdm-core/src/principal.d.ts | 2 + fdm-core/src/principal.ts | 4 + 21 files changed, 6090 insertions(+), 201 deletions(-) create mode 100644 .changeset/farm-invitation-app.md create mode 100644 .changeset/farm-invitation-system.md create mode 100644 fdm-app/app/components/blocks/email/farm-invitation.tsx create mode 100644 fdm-core/src/db/migrations/0023_romantic_dagger.sql create mode 100644 fdm-core/src/db/migrations/meta/0023_snapshot.json create mode 100644 fdm-core/src/invitation.test.ts create mode 100644 fdm-core/src/invitation.ts diff --git a/.changeset/farm-invitation-app.md b/.changeset/farm-invitation-app.md new file mode 100644 index 000000000..e98948b48 --- /dev/null +++ b/.changeset/farm-invitation-app.md @@ -0,0 +1,9 @@ +--- +"@svenvw/fdm-app": minor +--- + +Add the possibilty for users to accept or reject an invitation to a farm, instead of having it automatically. This makes it also possible to invite non-registered users to get access to a farm after signing up. + +- Overview page shows pending farm invitations with accept/decline actions +- Invitation email sent when a user is invited to a farm +- Farm access settings page handles accept/decline invitation intents diff --git a/.changeset/farm-invitation-system.md b/.changeset/farm-invitation-system.md new file mode 100644 index 000000000..c2148de42 --- /dev/null +++ b/.changeset/farm-invitation-system.md @@ -0,0 +1,12 @@ +--- +"@svenvw/fdm-core": minor +--- + +Instead of directly granting roles, `grantRoleToFarm` now creates a pending invitation (7-day expiry) that must be accepted by the target principal. + +**New functions:** +- `acceptFarmInvitation` — accepts a pending invitation and grants the role +- `declineFarmInvitation` — declines a pending invitation +- `listPendingInvitationsForFarm` — lists active invitations for a farm (requires share permission) +- `listPendingInvitationsForUser` — lists pending invitations for the current user, including farm name and org name +- `autoAcceptInvitationsForNewUser` — auto-accepts email-based invitations on email verification diff --git a/fdm-app/app/components/blocks/email/farm-invitation.tsx b/fdm-app/app/components/blocks/email/farm-invitation.tsx new file mode 100644 index 000000000..042e01083 --- /dev/null +++ b/fdm-app/app/components/blocks/email/farm-invitation.tsx @@ -0,0 +1,138 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Text, +} from "@react-email/components" +import { Tailwind } from "@react-email/tailwind" + +interface FarmInvitationEmailProps { + farmName: string + inviterName: string + targetEmail: string + invitationId: string + role: string + appName: string + appBaseUrl?: string + logoFileName?: string + /** If true, renders a "create account" CTA for unregistered users */ + isUnregistered?: boolean +} + +const roleLabels: Record = { + owner: "Eigenaar", + advisor: "Adviseur", + researcher: "Onderzoeker", +} + +export const FarmInvitationEmail = ({ + farmName, + inviterName, + targetEmail, + invitationId, + role, + appName, + appBaseUrl = "", + logoFileName = "/fdm-high-resolution-logo-transparent.png", + isUnregistered = false, +}: FarmInvitationEmailProps) => { + const logoPath = `${appBaseUrl}${logoFileName}` + const roleLabel = roleLabels[role] ?? role + const fontFamily = `"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif` + + return ( + + + + + + + {`${inviterName} heeft je uitgenodigd voor toegang tot bedrijf ${farmName} in ${appName}.`} + + + + +
+ {`${appName} +
+ + Uitnodiging voor bedrijfstoegang + + + Hallo {targetEmail}, + + + {inviterName} heeft je uitgenodigd om toegang te + krijgen tot het bedrijf {farmName} in{" "} + {appName} met de rol {roleLabel}. + + {isUnregistered ? ( + <> + + Maak een account aan om de uitnodiging te + accepteren. Na registratie en verificatie + van je e-mailadres wordt je toegang + automatisch verleend. + +
+ +
+ + ) : ( + <> + + Log in en accepteer of weiger de uitnodiging + via je dashboard. + +
+ +
+
+ + of open je dashboard + +
+ + )} + + Als je deze uitnodiging niet wilt accepteren, kun je + deze e-mail negeren. + + + Met vriendelijke groet,
Het {appName} team +
+
+ +
+ + ) +} diff --git a/fdm-app/app/lib/email.server.ts b/fdm-app/app/lib/email.server.ts index 6b356fadb..eff4641de 100644 --- a/fdm-app/app/lib/email.server.ts +++ b/fdm-app/app/lib/email.server.ts @@ -4,6 +4,7 @@ import type { User } from "better-auth" import { format } from "date-fns" import { nl } from "date-fns/locale" import postmark from "postmark" +import { FarmInvitationEmail } from "~/components/blocks/email/farm-invitation" import { InvitationEmail } from "~/components/blocks/email/invitation" import { MagicLinkEmail } from "~/components/blocks/email/magic-link" import { WelcomeEmail } from "~/components/blocks/email/welcome" @@ -70,6 +71,40 @@ export async function renderInvitationEmail( return email } +export async function renderFarmInvitationEmail( + targetEmail: string, + inviterName: string, + farmName: string, + invitationId: string, + role: string, + isUnregistered: boolean, +): Promise { + const emailHtml = await render( + FarmInvitationEmail({ + farmName, + inviterName, + targetEmail, + invitationId, + role, + appName: serverConfig.name, + appBaseUrl: serverConfig.url, + isUnregistered, + }), + { pretty: true }, + ) + + const subjectVerb = isUnregistered ? "nodigt je uit" : "heeft je uitgenodigd" + const email: Email = { + From: `"${serverConfig.mail?.postmark.sender_name}" <${serverConfig.mail?.postmark.sender_address}>`, + To: targetEmail, + Subject: `${inviterName} ${subjectVerb} voor toegang tot bedrijf ${farmName}`, + HtmlBody: emailHtml, + Tag: "invitation-farm", + } + + return email +} + export async function renderMagicLinkEmail( emailAddress: string, magicLinkUrl: string, diff --git a/fdm-app/app/lib/schemas/access.schema.ts b/fdm-app/app/lib/schemas/access.schema.ts index bd9d540af..417b67033 100644 --- a/fdm-app/app/lib/schemas/access.schema.ts +++ b/fdm-app/app/lib/schemas/access.schema.ts @@ -4,5 +4,12 @@ export const AccessFormSchema = z.object({ email: z.email().optional(), username: z.string().optional(), role: z.enum(["owner", "advisor", "researcher"]).optional(), - intent: z.enum(["invite_user", "update_role", "remove_user"]), + invitation_id: z.string().optional(), + intent: z.enum([ + "invite_user", + "update_role", + "remove_user", + "accept_farm_invitation", + "decline_farm_invitation", + ]), }) diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx index 129d5b6a8..d5438087b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx @@ -1,8 +1,11 @@ import { + acceptFarmInvitation, + declineFarmInvitation, getFarm, grantRoleToFarm, isAllowedToShareFarm, listPrincipalsForFarm, + lookupPrincipal, revokePrincipalFromFarm, updateRoleOfPrincipalAtFarm, } from "@svenvw/fdm-core" @@ -22,6 +25,10 @@ import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" import { AccessFormSchema } from "~/lib/schemas/access.schema" +import { + renderFarmInvitationEmail, + sendEmail, +} from "~/lib/email.server" // Meta export const meta: MetaFunction = () => { @@ -127,11 +134,76 @@ export async function action({ request, params }: ActionFunctionArgs) { formValues.role, ) + // Send invitation email + try { + const farm = await getFarm(fdm, session.user.id, b_id_farm) + const inviterName = session.userName + const normalizedTarget = formValues.username.toLowerCase().trim() + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedTarget) + + // Try to find the principal to get their email if they are registered + const matchedPrincipals = await lookupPrincipal(fdm, normalizedTarget) + const targetPrincipal = matchedPrincipals.find( + (p) => + p.username.toLowerCase() === normalizedTarget || + (isEmail && p.email?.toLowerCase() === normalizedTarget), + ) + + const targetEmail = isEmail + ? normalizedTarget + : targetPrincipal?.type === "user" + ? targetPrincipal.email + : null + + if (targetEmail) { + const isUnregistered = !targetPrincipal + const email = await renderFarmInvitationEmail( + targetEmail, + inviterName, + farm.b_name_farm ?? b_id_farm, + b_id_farm, + formValues.role, + isUnregistered, + ) + await sendEmail(email) + } + } catch (emailError) { + console.error("Error sending farm invitation email:", emailError) + } + return dataWithSuccess(null, { message: `${formValues.username} is uitgenodigd! 🎉`, }) } + if (formValues.intent === "accept_farm_invitation") { + if (!formValues.invitation_id) { + return handleActionError("missing: invitation_id") + } + await acceptFarmInvitation( + fdm, + formValues.invitation_id, + session.user.id, + ) + return dataWithSuccess(null, { + message: "Uitnodiging geaccepteerd! 🎉", + }) + } + + if (formValues.intent === "decline_farm_invitation") { + if (!formValues.invitation_id) { + return handleActionError("missing: invitation_id") + } + await declineFarmInvitation( + fdm, + formValues.invitation_id, + session.user.id, + ) + return dataWithSuccess(null, { + message: "Uitnodiging geweigerd.", + }) + } + if (formValues.intent === "update_role") { if (!formValues.username) { return handleActionError("missing: username") diff --git a/fdm-app/app/routes/farm._index.tsx b/fdm-app/app/routes/farm._index.tsx index 2a4c50613..d603dc2df 100644 --- a/fdm-app/app/routes/farm._index.tsx +++ b/fdm-app/app/routes/farm._index.tsx @@ -1,6 +1,12 @@ -import { getFarms } from "@svenvw/fdm-core" +import { + acceptFarmInvitation, + declineFarmInvitation, + getFarms, + listPendingInvitationsForUser, +} from "@svenvw/fdm-core" import { ArrowRight, + Bell, Check, House, Layers, @@ -9,13 +15,17 @@ import { Mountain, Plus, PlusCircle, + X, } from "lucide-react" import { + type ActionFunctionArgs, + Form, type LoaderFunctionArgs, type MetaFunction, NavLink, useLoaderData, } from "react-router" +import { dataWithError, dataWithSuccess } from "remix-toast" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" @@ -35,7 +45,9 @@ import { getCalendarSelection } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { extractFormValuesFromRequest } from "~/lib/form" import { getTimeBasedGreeting } from "~/lib/greetings" +import { AccessFormSchema } from "~/lib/schemas/access.schema" // Meta export const meta: MetaFunction = () => { @@ -77,18 +89,68 @@ export async function loader({ request }: LoaderFunctionArgs) { } }) + // Get pending farm invitations for this user + const pendingInvitations = await listPendingInvitationsForUser( + fdm, + session.user.id, + ) + // Return user information from loader return { farms: farms, farmOptions: farmOptions, calendar: calendar, username: session.userName, + pendingInvitations: pendingInvitations, } } catch (error) { throw handleLoaderError(error) } } +export async function action({ request }: ActionFunctionArgs) { + try { + const session = await getSession(request) + const formValues = await extractFormValuesFromRequest( + request, + AccessFormSchema, + ) + + if (formValues.intent === "accept_farm_invitation") { + if (!formValues.invitation_id) { + return dataWithError(null, "Ontbrekend uitnodigingsnummer") + } + await acceptFarmInvitation( + fdm, + formValues.invitation_id, + session.user.id, + ) + return dataWithSuccess(null, { + message: "Uitnodiging geaccepteerd! 🎉", + }) + } + + if (formValues.intent === "decline_farm_invitation") { + if (!formValues.invitation_id) { + return dataWithError(null, "Ontbrekend uitnodigingsnummer") + } + await declineFarmInvitation( + fdm, + formValues.invitation_id, + session.user.id, + ) + return dataWithSuccess(null, { + message: "Uitnodiging geweigerd.", + }) + } + + return dataWithError(null, "Onbekende actie") + } catch (error) { + console.error(error) + return dataWithError(null, "Er is iets misgegaan") + } +} + function SupportNote() { return (
@@ -266,6 +328,111 @@ export default function AppIndex() {
+ + {loaderData.pendingInvitations.length > 0 && ( +
+

+ Openstaande uitnodigingen +

+
+ {loaderData.pendingInvitations.map( + (invitation) => ( + + +
+
+ +
+
+ + Uitnodiging + + + Rol:{" "} + {invitation.role === + "owner" + ? "Eigenaar" + : invitation.role === + "advisor" + ? "Adviseur" + : "Onderzoeker"} + +
+
+
+ + {invitation.farm_name ?? invitation.farm_id} + {invitation.org_name && ( + + Voor organisatie: {invitation.org_name} + + )} + + +
+ + + +
+
+ + + +
+
+
+ ), + )} +
+
+ )} + @@ -389,6 +556,110 @@ export default function AppIndex() { + {/* Pending farm invitations */} + {loaderData.pendingInvitations.length > 0 && ( + <> + +
+ {loaderData.pendingInvitations.map( + (invitation) => ( + + +
+
+ +
+
+ + Uitnodiging + + + Rol:{" "} + {invitation.role === + "owner" + ? "Eigenaar" + : invitation.role === + "advisor" + ? "Adviseur" + : "Onderzoeker"} + +
+
+
+ + {invitation.farm_name ?? invitation.farm_id} + {invitation.org_name && ( + + Voor organisatie: {invitation.org_name} + + )} + + +
+ + + +
+
+ + + +
+
+
+ ), + )} +
+ + )} + { + // Auto-accept pending invitations when email becomes verified + if (user.emailVerified) { + await autoAcceptInvitationsForNewUser( + fdm, + user.email, + user.id, + ) + } }, }, }, diff --git a/fdm-core/src/db/migrations/0023_romantic_dagger.sql b/fdm-core/src/db/migrations/0023_romantic_dagger.sql new file mode 100644 index 000000000..81ca30664 --- /dev/null +++ b/fdm-core/src/db/migrations/0023_romantic_dagger.sql @@ -0,0 +1,15 @@ +CREATE TABLE "fdm-authz"."farm_invitation" ( + "invitation_id" text PRIMARY KEY NOT NULL, + "farm_id" text NOT NULL, + "target_email" text, + "target_principal_id" text, + "role" text NOT NULL, + "inviter_id" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "expires" timestamp with time zone NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "accepted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE UNIQUE INDEX "farm_invitation_unique_email_idx" ON "fdm-authz"."farm_invitation" USING btree ("farm_id","target_email") WHERE "fdm-authz"."farm_invitation"."status" = 'pending';--> statement-breakpoint +CREATE UNIQUE INDEX "farm_invitation_unique_principal_idx" ON "fdm-authz"."farm_invitation" USING btree ("farm_id","target_principal_id") WHERE "fdm-authz"."farm_invitation"."status" = 'pending'; \ No newline at end of file diff --git a/fdm-core/src/db/migrations/meta/0022_snapshot.json b/fdm-core/src/db/migrations/meta/0022_snapshot.json index aac2827eb..f3b2a11e9 100644 --- a/fdm-core/src/db/migrations/meta/0022_snapshot.json +++ b/fdm-core/src/db/migrations/meta/0022_snapshot.json @@ -1,6 +1,6 @@ { - "id": "68820e10-7b74-4632-9d86-500508549bb8", - "prevId": "1eb64507-01b4-41c6-8583-c114db99b60f", + "id": "8a96c296-24b8-4edc-bdbc-b140c973f74e", + "prevId": "68820e10-7b74-4632-9d86-500508549bb8", "version": "7", "dialect": "postgresql", "tables": { diff --git a/fdm-core/src/db/migrations/meta/0023_snapshot.json b/fdm-core/src/db/migrations/meta/0023_snapshot.json new file mode 100644 index 000000000..4b01db6ef --- /dev/null +++ b/fdm-core/src/db/migrations/meta/0023_snapshot.json @@ -0,0 +1,3886 @@ +{ + "id": "d0d8c439-9f2f-4e24-9a46-df2d2caddb17", + "prevId": "8a96c296-24b8-4edc-bdbc-b140c973f74e", + "version": "7", + "dialect": "postgresql", + "tables": { + "fdm.cultivation_catalogue_selecting": { + "name": "cultivation_catalogue_selecting", + "schema": "fdm", + "columns": { + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu_source": { + "name": "b_lu_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cultivation_catalogue_selecting_b_id_farm_farms_b_id_farm_fk": { + "name": "cultivation_catalogue_selecting_b_id_farm_farms_b_id_farm_fk", + "tableFrom": "cultivation_catalogue_selecting", + "tableTo": "farms", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_farm" + ], + "columnsTo": [ + "b_id_farm" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.cultivation_ending": { + "name": "cultivation_ending", + "schema": "fdm", + "columns": { + "b_lu": { + "name": "b_lu", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu_end": { + "name": "b_lu_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "m_cropresidue": { + "name": "m_cropresidue", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cultivation_ending_b_lu_cultivations_b_lu_fk": { + "name": "cultivation_ending_b_lu_cultivations_b_lu_fk", + "tableFrom": "cultivation_ending", + "tableTo": "cultivations", + "schemaTo": "fdm", + "columnsFrom": [ + "b_lu" + ], + "columnsTo": [ + "b_lu" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.cultivation_harvesting": { + "name": "cultivation_harvesting", + "schema": "fdm", + "columns": { + "b_id_harvesting": { + "name": "b_id_harvesting", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_id_harvestable": { + "name": "b_id_harvestable", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu": { + "name": "b_lu", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu_harvest_date": { + "name": "b_lu_harvest_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cultivation_harvesting_b_id_harvestable_harvestables_b_id_harvestable_fk": { + "name": "cultivation_harvesting_b_id_harvestable_harvestables_b_id_harvestable_fk", + "tableFrom": "cultivation_harvesting", + "tableTo": "harvestables", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_harvestable" + ], + "columnsTo": [ + "b_id_harvestable" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cultivation_harvesting_b_lu_cultivations_b_lu_fk": { + "name": "cultivation_harvesting_b_lu_cultivations_b_lu_fk", + "tableFrom": "cultivation_harvesting", + "tableTo": "cultivations", + "schemaTo": "fdm", + "columnsFrom": [ + "b_lu" + ], + "columnsTo": [ + "b_lu" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.cultivation_starting": { + "name": "cultivation_starting", + "schema": "fdm", + "columns": { + "b_id": { + "name": "b_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu": { + "name": "b_lu", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu_start": { + "name": "b_lu_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "b_sowing_amount": { + "name": "b_sowing_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_sowing_method": { + "name": "b_sowing_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cultivation_starting_b_id_fields_b_id_fk": { + "name": "cultivation_starting_b_id_fields_b_id_fk", + "tableFrom": "cultivation_starting", + "tableTo": "fields", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id" + ], + "columnsTo": [ + "b_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cultivation_starting_b_lu_cultivations_b_lu_fk": { + "name": "cultivation_starting_b_lu_cultivations_b_lu_fk", + "tableFrom": "cultivation_starting", + "tableTo": "cultivations", + "schemaTo": "fdm", + "columnsFrom": [ + "b_lu" + ], + "columnsTo": [ + "b_lu" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.cultivations": { + "name": "cultivations", + "schema": "fdm", + "columns": { + "b_lu": { + "name": "b_lu", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_lu_catalogue": { + "name": "b_lu_catalogue", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu_variety": { + "name": "b_lu_variety", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "b_lu_idx": { + "name": "b_lu_idx", + "columns": [ + { + "expression": "b_lu", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cultivations_b_lu_catalogue_cultivations_catalogue_b_lu_catalogue_fk": { + "name": "cultivations_b_lu_catalogue_cultivations_catalogue_b_lu_catalogue_fk", + "tableFrom": "cultivations", + "tableTo": "cultivations_catalogue", + "schemaTo": "fdm", + "columnsFrom": [ + "b_lu_catalogue" + ], + "columnsTo": [ + "b_lu_catalogue" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.cultivations_catalogue": { + "name": "cultivations_catalogue", + "schema": "fdm", + "columns": { + "b_lu_catalogue": { + "name": "b_lu_catalogue", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_lu_source": { + "name": "b_lu_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu_name": { + "name": "b_lu_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_lu_name_en": { + "name": "b_lu_name_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_lu_harvestable": { + "name": "b_lu_harvestable", + "type": "b_lu_harvestable", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": true + }, + "b_lu_harvestcat": { + "name": "b_lu_harvestcat", + "type": "b_lu_harvestcat", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false + }, + "b_lu_hcat3": { + "name": "b_lu_hcat3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_lu_hcat3_name": { + "name": "b_lu_hcat3_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_lu_croprotation": { + "name": "b_lu_croprotation", + "type": "b_lu_croprotation", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false + }, + "b_lu_yield": { + "name": "b_lu_yield", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_dm": { + "name": "b_lu_dm", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_hi": { + "name": "b_lu_hi", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_n_harvestable": { + "name": "b_lu_n_harvestable", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_n_residue": { + "name": "b_lu_n_residue", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_n_fixation": { + "name": "b_n_fixation", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_eom": { + "name": "b_lu_eom", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_eom_residues": { + "name": "b_lu_eom_residues", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_rest_oravib": { + "name": "b_lu_rest_oravib", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "b_lu_variety_options": { + "name": "b_lu_variety_options", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "b_lu_start_default": { + "name": "b_lu_start_default", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_date_harvest_default": { + "name": "b_date_harvest_default", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "b_lu_catalogue_idx": { + "name": "b_lu_catalogue_idx", + "columns": [ + { + "expression": "b_lu_catalogue", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "b_lu_start_default_format": { + "name": "b_lu_start_default_format", + "value": "b_lu_start_default IS NULL OR b_lu_start_default ~ '^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'" + }, + "b_date_harvest_default_format": { + "name": "b_date_harvest_default_format", + "value": "b_date_harvest_default IS NULL OR b_date_harvest_default ~ '^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'" + } + }, + "isRLSEnabled": false + }, + "fdm.derogation_applying": { + "name": "derogation_applying", + "schema": "fdm", + "columns": { + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_id_derogation": { + "name": "b_id_derogation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "derogation_one_per_farm_per": { + "name": "derogation_one_per_farm_per", + "columns": [ + { + "expression": "b_id_derogation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "derogation_applying_b_id_farm_farms_b_id_farm_fk": { + "name": "derogation_applying_b_id_farm_farms_b_id_farm_fk", + "tableFrom": "derogation_applying", + "tableTo": "farms", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_farm" + ], + "columnsTo": [ + "b_id_farm" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "derogation_applying_b_id_derogation_derogations_b_id_derogation_fk": { + "name": "derogation_applying_b_id_derogation_derogations_b_id_derogation_fk", + "tableFrom": "derogation_applying", + "tableTo": "derogations", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_derogation" + ], + "columnsTo": [ + "b_id_derogation" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.derogations": { + "name": "derogations", + "schema": "fdm", + "columns": { + "b_id_derogation": { + "name": "b_id_derogation", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_derogation_year": { + "name": "b_derogation_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.farms": { + "name": "farms", + "schema": "fdm", + "columns": { + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_name_farm": { + "name": "b_name_farm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_businessid_farm": { + "name": "b_businessid_farm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_address_farm": { + "name": "b_address_farm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_postalcode_farm": { + "name": "b_postalcode_farm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "b_id_farm_idx": { + "name": "b_id_farm_idx", + "columns": [ + { + "expression": "b_id_farm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.fertilizer_acquiring": { + "name": "fertilizer_acquiring", + "schema": "fdm", + "columns": { + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_id": { + "name": "p_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_acquiring_amount": { + "name": "p_acquiring_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_acquiring_date": { + "name": "p_acquiring_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "fertilizer_acquiring_b_id_farm_farms_b_id_farm_fk": { + "name": "fertilizer_acquiring_b_id_farm_farms_b_id_farm_fk", + "tableFrom": "fertilizer_acquiring", + "tableTo": "farms", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_farm" + ], + "columnsTo": [ + "b_id_farm" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fertilizer_acquiring_p_id_fertilizers_p_id_fk": { + "name": "fertilizer_acquiring_p_id_fertilizers_p_id_fk", + "tableFrom": "fertilizer_acquiring", + "tableTo": "fertilizers", + "schemaTo": "fdm", + "columnsFrom": [ + "p_id" + ], + "columnsTo": [ + "p_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.fertilizer_applying": { + "name": "fertilizer_applying", + "schema": "fdm", + "columns": { + "p_app_id": { + "name": "p_app_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_id": { + "name": "b_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_id": { + "name": "p_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_app_amount": { + "name": "p_app_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_app_method": { + "name": "p_app_method", + "type": "p_app_method", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false + }, + "p_app_date": { + "name": "p_app_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "p_app_idx": { + "name": "p_app_idx", + "columns": [ + { + "expression": "p_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fertilizer_applying_b_id_fields_b_id_fk": { + "name": "fertilizer_applying_b_id_fields_b_id_fk", + "tableFrom": "fertilizer_applying", + "tableTo": "fields", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id" + ], + "columnsTo": [ + "b_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fertilizer_applying_p_id_fertilizers_p_id_fk": { + "name": "fertilizer_applying_p_id_fertilizers_p_id_fk", + "tableFrom": "fertilizer_applying", + "tableTo": "fertilizers", + "schemaTo": "fdm", + "columnsFrom": [ + "p_id" + ], + "columnsTo": [ + "p_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.fertilizer_catalogue_enabling": { + "name": "fertilizer_catalogue_enabling", + "schema": "fdm", + "columns": { + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_source": { + "name": "p_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "fertilizer_catalogue_enabling_b_id_farm_farms_b_id_farm_fk": { + "name": "fertilizer_catalogue_enabling_b_id_farm_farms_b_id_farm_fk", + "tableFrom": "fertilizer_catalogue_enabling", + "tableTo": "farms", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_farm" + ], + "columnsTo": [ + "b_id_farm" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.fertilizer_picking": { + "name": "fertilizer_picking", + "schema": "fdm", + "columns": { + "p_id": { + "name": "p_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_id_catalogue": { + "name": "p_id_catalogue", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_picking_date": { + "name": "p_picking_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "fertilizer_picking_p_id_fertilizers_p_id_fk": { + "name": "fertilizer_picking_p_id_fertilizers_p_id_fk", + "tableFrom": "fertilizer_picking", + "tableTo": "fertilizers", + "schemaTo": "fdm", + "columnsFrom": [ + "p_id" + ], + "columnsTo": [ + "p_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fertilizer_picking_p_id_catalogue_fertilizers_catalogue_p_id_catalogue_fk": { + "name": "fertilizer_picking_p_id_catalogue_fertilizers_catalogue_p_id_catalogue_fk", + "tableFrom": "fertilizer_picking", + "tableTo": "fertilizers_catalogue", + "schemaTo": "fdm", + "columnsFrom": [ + "p_id_catalogue" + ], + "columnsTo": [ + "p_id_catalogue" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.fertilizers": { + "name": "fertilizers", + "schema": "fdm", + "columns": { + "p_id": { + "name": "p_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "p_id_idx": { + "name": "p_id_idx", + "columns": [ + { + "expression": "p_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.fertilizers_catalogue": { + "name": "fertilizers_catalogue", + "schema": "fdm", + "columns": { + "p_id_catalogue": { + "name": "p_id_catalogue", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "p_source": { + "name": "p_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_name_nl": { + "name": "p_name_nl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p_name_en": { + "name": "p_name_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "p_description": { + "name": "p_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "p_app_method_options": { + "name": "p_app_method_options", + "type": "p_app_method[]", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false + }, + "p_dm": { + "name": "p_dm", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_density": { + "name": "p_density", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_om": { + "name": "p_om", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_a": { + "name": "p_a", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_hc": { + "name": "p_hc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_eom": { + "name": "p_eom", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_eoc": { + "name": "p_eoc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_c_rt": { + "name": "p_c_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_c_of": { + "name": "p_c_of", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_c_if": { + "name": "p_c_if", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_c_fr": { + "name": "p_c_fr", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_cn_of": { + "name": "p_cn_of", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_n_rt": { + "name": "p_n_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_n_if": { + "name": "p_n_if", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_n_of": { + "name": "p_n_of", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_n_wc": { + "name": "p_n_wc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_no3_rt": { + "name": "p_no3_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_nh4_rt": { + "name": "p_nh4_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_p_rt": { + "name": "p_p_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_k_rt": { + "name": "p_k_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_mg_rt": { + "name": "p_mg_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_ca_rt": { + "name": "p_ca_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_ne": { + "name": "p_ne", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_s_rt": { + "name": "p_s_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_s_wc": { + "name": "p_s_wc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_cu_rt": { + "name": "p_cu_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_zn_rt": { + "name": "p_zn_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_na_rt": { + "name": "p_na_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_si_rt": { + "name": "p_si_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_b_rt": { + "name": "p_b_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_mn_rt": { + "name": "p_mn_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_ni_rt": { + "name": "p_ni_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_fe_rt": { + "name": "p_fe_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_mo_rt": { + "name": "p_mo_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_co_rt": { + "name": "p_co_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_as_rt": { + "name": "p_as_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_cd_rt": { + "name": "p_cd_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_cr_rt": { + "name": "p_cr_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_cr_vi": { + "name": "p_cr_vi", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_pb_rt": { + "name": "p_pb_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_hg_rt": { + "name": "p_hg_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_cl_rt": { + "name": "p_cl_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_ef_nh3": { + "name": "p_ef_nh3", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "p_type_manure": { + "name": "p_type_manure", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "p_type_mineral": { + "name": "p_type_mineral", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "p_type_compost": { + "name": "p_type_compost", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "p_type_rvo": { + "name": "p_type_rvo", + "type": "p_type_rvo", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "p_id_catalogue_idx": { + "name": "p_id_catalogue_idx", + "columns": [ + { + "expression": "p_id_catalogue", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.field_acquiring": { + "name": "field_acquiring", + "schema": "fdm", + "columns": { + "b_id": { + "name": "b_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_start": { + "name": "b_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "b_acquiring_method": { + "name": "b_acquiring_method", + "type": "b_acquiring_method", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "field_acquiring_b_id_fields_b_id_fk": { + "name": "field_acquiring_b_id_fields_b_id_fk", + "tableFrom": "field_acquiring", + "tableTo": "fields", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id" + ], + "columnsTo": [ + "b_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "field_acquiring_b_id_farm_farms_b_id_farm_fk": { + "name": "field_acquiring_b_id_farm_farms_b_id_farm_fk", + "tableFrom": "field_acquiring", + "tableTo": "farms", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_farm" + ], + "columnsTo": [ + "b_id_farm" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.field_discarding": { + "name": "field_discarding", + "schema": "fdm", + "columns": { + "b_id": { + "name": "b_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_end": { + "name": "b_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "field_discarding_b_id_fields_b_id_fk": { + "name": "field_discarding_b_id_fields_b_id_fk", + "tableFrom": "field_discarding", + "tableTo": "fields", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id" + ], + "columnsTo": [ + "b_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.fields": { + "name": "fields", + "schema": "fdm", + "columns": { + "b_id": { + "name": "b_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_name": { + "name": "b_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_geometry": { + "name": "b_geometry", + "type": "geometry(Polygon,4326)", + "primaryKey": false, + "notNull": false + }, + "b_id_source": { + "name": "b_id_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_bufferstrip": { + "name": "b_bufferstrip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "b_id_idx": { + "name": "b_id_idx", + "columns": [ + { + "expression": "b_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "b_geom_idx": { + "name": "b_geom_idx", + "columns": [ + { + "expression": "b_geometry", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.harvestable_analyses": { + "name": "harvestable_analyses", + "schema": "fdm", + "columns": { + "b_id_harvestable_analysis": { + "name": "b_id_harvestable_analysis", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_lu_yield": { + "name": "b_lu_yield", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_yield_fresh": { + "name": "b_lu_yield_fresh", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_yield_bruto": { + "name": "b_lu_yield_bruto", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_tarra": { + "name": "b_lu_tarra", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_dm": { + "name": "b_lu_dm", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_moist": { + "name": "b_lu_moist", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_uww": { + "name": "b_lu_uww", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_cp": { + "name": "b_lu_cp", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_n_harvestable": { + "name": "b_lu_n_harvestable", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_n_residue": { + "name": "b_lu_n_residue", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_p_harvestable": { + "name": "b_lu_p_harvestable", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_p_residue": { + "name": "b_lu_p_residue", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_k_harvestable": { + "name": "b_lu_k_harvestable", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_lu_k_residue": { + "name": "b_lu_k_residue", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "b_id_harvestable_analyses_idx": { + "name": "b_id_harvestable_analyses_idx", + "columns": [ + { + "expression": "b_id_harvestable_analysis", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.harvestable_sampling": { + "name": "harvestable_sampling", + "schema": "fdm", + "columns": { + "b_id_harvestable": { + "name": "b_id_harvestable", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_id_harvestable_analysis": { + "name": "b_id_harvestable_analysis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_sampling_date": { + "name": "b_sampling_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "harvestable_sampling_b_id_harvestable_harvestables_b_id_harvestable_fk": { + "name": "harvestable_sampling_b_id_harvestable_harvestables_b_id_harvestable_fk", + "tableFrom": "harvestable_sampling", + "tableTo": "harvestables", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_harvestable" + ], + "columnsTo": [ + "b_id_harvestable" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "harvestable_sampling_b_id_harvestable_analysis_harvestable_analyses_b_id_harvestable_analysis_fk": { + "name": "harvestable_sampling_b_id_harvestable_analysis_harvestable_analyses_b_id_harvestable_analysis_fk", + "tableFrom": "harvestable_sampling", + "tableTo": "harvestable_analyses", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_harvestable_analysis" + ], + "columnsTo": [ + "b_id_harvestable_analysis" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.harvestables": { + "name": "harvestables", + "schema": "fdm", + "columns": { + "b_id_harvestable": { + "name": "b_id_harvestable", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "b_id_harvestable_idx": { + "name": "b_id_harvestable_idx", + "columns": [ + { + "expression": "b_id_harvestable", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.intending_grazing": { + "name": "intending_grazing", + "schema": "fdm", + "columns": { + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_grazing_intention": { + "name": "b_grazing_intention", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "b_grazing_intention_year": { + "name": "b_grazing_intention_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "intending_grazing_b_id_farm_farms_b_id_farm_fk": { + "name": "intending_grazing_b_id_farm_farms_b_id_farm_fk", + "tableFrom": "intending_grazing", + "tableTo": "farms", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_farm" + ], + "columnsTo": [ + "b_id_farm" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intending_grazing_b_id_farm_b_grazing_intention_year_pk": { + "name": "intending_grazing_b_id_farm_b_grazing_intention_year_pk", + "columns": [ + "b_id_farm", + "b_grazing_intention_year" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.organic_certifications": { + "name": "organic_certifications", + "schema": "fdm", + "columns": { + "b_id_organic": { + "name": "b_id_organic", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_organic_traces": { + "name": "b_organic_traces", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_organic_skal": { + "name": "b_organic_skal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "b_organic_issued": { + "name": "b_organic_issued", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "b_organic_expires": { + "name": "b_organic_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.organic_certifications_holding": { + "name": "organic_certifications_holding", + "schema": "fdm", + "columns": { + "b_id_farm": { + "name": "b_id_farm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "b_id_organic": { + "name": "b_id_organic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organic_one_farm_per_cert": { + "name": "organic_one_farm_per_cert", + "columns": [ + { + "expression": "b_id_organic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organic_certifications_holding_b_id_farm_farms_b_id_farm_fk": { + "name": "organic_certifications_holding_b_id_farm_farms_b_id_farm_fk", + "tableFrom": "organic_certifications_holding", + "tableTo": "farms", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_farm" + ], + "columnsTo": [ + "b_id_farm" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organic_certifications_holding_b_id_organic_organic_certifications_b_id_organic_fk": { + "name": "organic_certifications_holding_b_id_organic_organic_certifications_b_id_organic_fk", + "tableFrom": "organic_certifications_holding", + "tableTo": "organic_certifications", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id_organic" + ], + "columnsTo": [ + "b_id_organic" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.soil_analysis": { + "name": "soil_analysis", + "schema": "fdm", + "columns": { + "a_id": { + "name": "a_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "a_date": { + "name": "a_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "a_source": { + "name": "a_source", + "type": "a_source", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false, + "default": "'other'" + }, + "a_al_ox": { + "name": "a_al_ox", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_c_of": { + "name": "a_c_of", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_ca_co": { + "name": "a_ca_co", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_ca_co_po": { + "name": "a_ca_co_po", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_caco3_if": { + "name": "a_caco3_if", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_cec_co": { + "name": "a_cec_co", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_clay_mi": { + "name": "a_clay_mi", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_cn_fr": { + "name": "a_cn_fr", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_com_fr": { + "name": "a_com_fr", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_cu_cc": { + "name": "a_cu_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_density_sa": { + "name": "a_density_sa", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_fe_ox": { + "name": "a_fe_ox", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_k_cc": { + "name": "a_k_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_k_co": { + "name": "a_k_co", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_k_co_po": { + "name": "a_k_co_po", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_mg_cc": { + "name": "a_mg_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_mg_co": { + "name": "a_mg_co", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_mg_co_po": { + "name": "a_mg_co_po", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_n_pmn": { + "name": "a_n_pmn", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_n_rt": { + "name": "a_n_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_nh4_cc": { + "name": "a_nh4_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_nmin_cc": { + "name": "a_nmin_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_no3_cc": { + "name": "a_no3_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_p_al": { + "name": "a_p_al", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_p_cc": { + "name": "a_p_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_p_ox": { + "name": "a_p_ox", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_p_rt": { + "name": "a_p_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_p_sg": { + "name": "a_p_sg", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_p_wa": { + "name": "a_p_wa", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_ph_cc": { + "name": "a_ph_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_s_rt": { + "name": "a_s_rt", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_sand_mi": { + "name": "a_sand_mi", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_silt_mi": { + "name": "a_silt_mi", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_som_loi": { + "name": "a_som_loi", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "a_zn_cc": { + "name": "a_zn_cc", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_gwl_class": { + "name": "b_gwl_class", + "type": "b_gwl_class", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false + }, + "b_soiltype_agr": { + "name": "b_soiltype_agr", + "type": "b_soiltype_agr", + "typeSchema": "fdm", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm.soil_sampling": { + "name": "soil_sampling", + "schema": "fdm", + "columns": { + "b_id_sampling": { + "name": "b_id_sampling", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "b_id": { + "name": "b_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "a_id": { + "name": "a_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "a_depth_upper": { + "name": "a_depth_upper", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "a_depth_lower": { + "name": "a_depth_lower", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "b_sampling_date": { + "name": "b_sampling_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "b_sampling_geometry": { + "name": "b_sampling_geometry", + "type": "geometry(MultiPoint,4326)", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "soil_sampling_b_id_fields_b_id_fk": { + "name": "soil_sampling_b_id_fields_b_id_fk", + "tableFrom": "soil_sampling", + "tableTo": "fields", + "schemaTo": "fdm", + "columnsFrom": [ + "b_id" + ], + "columnsTo": [ + "b_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "soil_sampling_a_id_soil_analysis_a_id_fk": { + "name": "soil_sampling_a_id_soil_analysis_a_id_fk", + "tableFrom": "soil_sampling", + "tableTo": "soil_analysis", + "schemaTo": "fdm", + "columnsFrom": [ + "a_id" + ], + "columnsTo": [ + "a_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.account": { + "name": "account", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "fdm-authn", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.invitation": { + "name": "invitation", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "schemaTo": "fdm-authn", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "schemaTo": "fdm-authn", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.member": { + "name": "member", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "schemaTo": "fdm-authn", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "schemaTo": "fdm-authn", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.organization": { + "name": "organization", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.rate_limit": { + "name": "rate_limit", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.session": { + "name": "session", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "fdm-authn", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.user": { + "name": "user", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "firstname": { + "name": "firstname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surname": { + "name": "surname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'nl-NL'" + }, + "farm_active": { + "name": "farm_active", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authn.verification": { + "name": "verification", + "schema": "fdm-authn", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authz.audit": { + "name": "audit", + "schema": "fdm-authz", + "columns": { + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_timestamp": { + "name": "audit_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "audit_origin": { + "name": "audit_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_resource": { + "name": "target_resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_resource_id": { + "name": "target_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granting_resource": { + "name": "granting_resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granting_resource_id": { + "name": "granting_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authz.farm_invitation": { + "name": "farm_invitation", + "schema": "fdm-authz", + "columns": { + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "farm_id": { + "name": "farm_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_email": { + "name": "target_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_principal_id": { + "name": "target_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "farm_invitation_unique_email_idx": { + "name": "farm_invitation_unique_email_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"fdm-authz\".\"farm_invitation\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "farm_invitation_unique_principal_idx": { + "name": "farm_invitation_unique_principal_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"fdm-authz\".\"farm_invitation\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-authz.role": { + "name": "role", + "schema": "fdm-authz", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted": { + "name": "deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_idx": { + "name": "role_idx", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-calculator.calculation_cache": { + "name": "calculation_cache", + "schema": "fdm-calculator", + "columns": { + "calculation_hash": { + "name": "calculation_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "calculation_function": { + "name": "calculation_function", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calculator_version": { + "name": "calculator_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "fdm-calculator.calculation_errors": { + "name": "calculation_errors", + "schema": "fdm-calculator", + "columns": { + "calculation_error_id": { + "name": "calculation_error_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "calculation_function": { + "name": "calculation_function", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calculator_version": { + "name": "calculator_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stack_trace": { + "name": "stack_trace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "fdm.b_acquiring_method": { + "name": "b_acquiring_method", + "schema": "fdm", + "values": [ + "nl_01", + "nl_02", + "nl_03", + "nl_04", + "nl_07", + "nl_09", + "nl_10", + "nl_11", + "nl_12", + "nl_13", + "nl_61", + "nl_63", + "unknown" + ] + }, + "fdm.p_app_method": { + "name": "p_app_method", + "schema": "fdm", + "values": [ + "slotted coulter", + "incorporation", + "incorporation 2 tracks", + "injection", + "shallow injection", + "spraying", + "broadcasting", + "spoke wheel", + "pocket placement", + "narrowband" + ] + }, + "fdm.b_gwl_class": { + "name": "b_gwl_class", + "schema": "fdm", + "values": [ + "I", + "Ia", + "Ic", + "II", + "IIa", + "IIb", + "IIc", + "III", + "IIIa", + "IIIb", + "IV", + "IVu", + "IVc", + "V", + "Va", + "Vao", + "Vad", + "Vb", + "Vbo", + "Vbd", + "sV", + "sVb", + "VI", + "VIo", + "VId", + "VII", + "VIIo", + "VIId", + "VIII", + "VIIIo", + "VIIId" + ] + }, + "fdm.b_lu_harvestcat": { + "name": "b_lu_harvestcat", + "schema": "fdm", + "values": [ + "HC010", + "HC020", + "HC031", + "HC040", + "HC041", + "HC042", + "HC050" + ] + }, + "fdm.b_lu_harvestable": { + "name": "b_lu_harvestable", + "schema": "fdm", + "values": [ + "none", + "once", + "multiple" + ] + }, + "fdm.b_lu_croprotation": { + "name": "b_lu_croprotation", + "schema": "fdm", + "values": [ + "other", + "clover", + "nature", + "potato", + "grass", + "rapeseed", + "starch", + "maize", + "cereal", + "sugarbeet", + "alfalfa", + "catchcrop" + ] + }, + "fdm.a_source": { + "name": "a_source", + "schema": "fdm", + "values": [ + "nl-rva-l122", + "nl-rva-l136", + "nl-rva-l264", + "nl-rva-l320", + "nl-rva-l335", + "nl-rva-l610", + "nl-rva-l648", + "nl-rva-l697", + "nl-other-nmi", + "other" + ] + }, + "fdm.b_soiltype_agr": { + "name": "b_soiltype_agr", + "schema": "fdm", + "values": [ + "moerige_klei", + "rivierklei", + "dekzand", + "zeeklei", + "dalgrond", + "veen", + "loess", + "duinzand", + "maasklei" + ] + }, + "fdm.p_type_rvo": { + "name": "p_type_rvo", + "schema": "fdm", + "values": [ + "10", + "11", + "12", + "13", + "14", + "17", + "18", + "19", + "23", + "30", + "31", + "32", + "33", + "35", + "39", + "40", + "41", + "42", + "43", + "46", + "50", + "56", + "60", + "61", + "75", + "76", + "80", + "81", + "90", + "91", + "92", + "25", + "26", + "27", + "95", + "96", + "97", + "98", + "99", + "100", + "101", + "102", + "103", + "104", + "105", + "106", + "107", + "108", + "109", + "110", + "111", + "112", + "113", + "114", + "115", + "116", + "117", + "120" + ] + } + }, + "schemas": { + "fdm": "fdm", + "fdm-authn": "fdm-authn", + "fdm-authz": "fdm-authz", + "fdm-calculator": "fdm-calculator" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/fdm-core/src/db/migrations/meta/_journal.json b/fdm-core/src/db/migrations/meta/_journal.json index 756793c6c..6f421fa6b 100644 --- a/fdm-core/src/db/migrations/meta/_journal.json +++ b/fdm-core/src/db/migrations/meta/_journal.json @@ -1,167 +1,174 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1731414293847, - "tag": "0000_v0", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1741267610502, - "tag": "0001_v0-15-0", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1743420907290, - "tag": "0002_v0-18-0", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1744205441260, - "tag": "0003_v0-20-0-1", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1745410821339, - "tag": "0004_v0-20-0-2", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1748353081475, - "tag": "0005_v0-20-0-3", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1748353926519, - "tag": "0006_v0-20-0-4", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1750146397071, - "tag": "0007_v0-21-0-1", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1750751079210, - "tag": "0008_v0-21-0-2", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1752056714510, - "tag": "0009_v0-22-0-1", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1753084974762, - "tag": "0010_v0-22-0-2", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1754396961710, - "tag": "0011_v0-22-1-1", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1754661913554, - "tag": "0012_v0-24-0-1", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1755074095394, - "tag": "0013_v0-24-0-2", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1760450273146, - "tag": "0014_v0-26-0-1", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1760621691069, - "tag": "0015_v0-26-0-2", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1761121611245, - "tag": "0016_v0-26-0-3", - "breakpoints": true - }, - { - "idx": 17, - "version": "7", - "when": 1761909360538, - "tag": "0017_v0-26-0-4", - "breakpoints": true - }, - { - "idx": 18, - "version": "7", - "when": 1763025310900, - "tag": "0018_v0-27-0-1", - "breakpoints": true - }, - { - "idx": 19, - "version": "7", - "when": 1763647193660, - "tag": "0019_v0-27-0-2", - "breakpoints": true - }, - { - "idx": 20, - "version": "7", - "when": 1767697779747, - "tag": "0020_v0-28-0-1", - "breakpoints": true - }, - { - "idx": 21, - "version": "7", - "when": 1768485087752, - "tag": "0021_v0-29-0-1", - "breakpoints": true - }, - { - "idx": 22, - "version": "7", - "when": 1768485087753, - "tag": "0022_v0-29-0-2", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1731414293847, + "tag": "0000_v0", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1741267610502, + "tag": "0001_v0-15-0", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1743420907290, + "tag": "0002_v0-18-0", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1744205441260, + "tag": "0003_v0-20-0-1", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1745410821339, + "tag": "0004_v0-20-0-2", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1748353081475, + "tag": "0005_v0-20-0-3", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1748353926519, + "tag": "0006_v0-20-0-4", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1750146397071, + "tag": "0007_v0-21-0-1", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1750751079210, + "tag": "0008_v0-21-0-2", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1752056714510, + "tag": "0009_v0-22-0-1", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1753084974762, + "tag": "0010_v0-22-0-2", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1754396961710, + "tag": "0011_v0-22-1-1", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1754661913554, + "tag": "0012_v0-24-0-1", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1755074095394, + "tag": "0013_v0-24-0-2", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1760450273146, + "tag": "0014_v0-26-0-1", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1760621691069, + "tag": "0015_v0-26-0-2", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1761121611245, + "tag": "0016_v0-26-0-3", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1761909360538, + "tag": "0017_v0-26-0-4", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1763025310900, + "tag": "0018_v0-27-0-1", + "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1763647193660, + "tag": "0019_v0-27-0-2", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1767697779747, + "tag": "0020_v0-28-0-1", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1768485087752, + "tag": "0021_v0-29-0-1", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1768485087753, + "tag": "0022_v0-29-0-2", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1771513245902, + "tag": "0023_romantic_dagger", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/fdm-core/src/db/schema-authz.ts b/fdm-core/src/db/schema-authz.ts index 02dedfeb4..c3b95ce73 100644 --- a/fdm-core/src/db/schema-authz.ts +++ b/fdm-core/src/db/schema-authz.ts @@ -1,4 +1,5 @@ // Authorization +import { sql } from "drizzle-orm" import { boolean, index, @@ -6,6 +7,7 @@ import { pgSchema, text, timestamp, + uniqueIndex, } from "drizzle-orm/pg-core" // Define postgres schema @@ -53,3 +55,31 @@ export const audit = fdmAuthZSchema.table("audit", { export type auditTypeSelect = typeof audit.$inferSelect export type auditTypeInsert = typeof audit.$inferInsert + +export const farmInvitation = fdmAuthZSchema.table( + "farm_invitation", + { + invitation_id: text().primaryKey(), + farm_id: text().notNull(), + target_email: text(), // For unregistered users (lowercased/trimmed) + target_principal_id: text(), // For registered users or organizations + role: text().notNull(), // 'owner', 'advisor', 'researcher' + inviter_id: text().notNull(), + status: text().notNull().default("pending"), // 'pending', 'accepted', 'declined', 'expired' + expires: timestamp({ withTimezone: true }).notNull(), + created: timestamp({ withTimezone: true }).notNull().defaultNow(), + accepted_at: timestamp({ withTimezone: true }), + }, + (table) => [ + // Prevent duplicate pending invitations for the same target/farm + uniqueIndex("farm_invitation_unique_email_idx") + .on(table.farm_id, table.target_email) + .where(sql`${table.status} = 'pending'`), + uniqueIndex("farm_invitation_unique_principal_idx") + .on(table.farm_id, table.target_principal_id) + .where(sql`${table.status} = 'pending'`), + ], +) + +export type farmInvitationTypeSelect = typeof farmInvitation.$inferSelect +export type farmInvitationTypeInsert = typeof farmInvitation.$inferInsert diff --git a/fdm-core/src/farm.test.ts b/fdm-core/src/farm.test.ts index 36e4eda38..07e6766fb 100644 --- a/fdm-core/src/farm.test.ts +++ b/fdm-core/src/farm.test.ts @@ -3,14 +3,20 @@ import { beforeAll, describe, expect, inject, it } from "vitest" import type { FdmAuth } from "./authentication" import { createFdmAuth } from "./authentication" import { listPrincipalsForResource } from "./authorization" +import * as authNSchema from "./db/schema-authn" +import * as authZSchema from "./db/schema-authz" import * as schema from "./db/schema" import { + acceptFarmInvitation, addFarm, + declineFarmInvitation, getFarm, getFarms, grantRoleToFarm, isAllowedToDeleteFarm, isAllowedToShareFarm, + listPendingInvitationsForFarm, + listPendingInvitationsForUser, listPrincipalsForFarm, removeFarm, revokePrincipalFromFarm, @@ -81,6 +87,12 @@ describe("Farm Functions", () => { }) target_id = target.user.id + // Mark target's email as verified so acceptFarmInvitation works + await fdm + .update(authNSchema.user) + .set({ emailVerified: true }) + .where(eq(authNSchema.user.id, target_id)) + // Create a test farm farmName = "Test Farm" farmBusinessId = "123456" @@ -243,7 +255,7 @@ describe("Farm Functions", () => { }) describe("grantRoleToFarm", () => { - it("should grant a role to a principal for a given farm", async () => { + it("should create an invitation for a principal for a given farm", async () => { await grantRoleToFarm( fdm, principal_id, @@ -252,13 +264,30 @@ describe("Farm Functions", () => { "advisor", ) + // Verify invitation was created (not a direct role grant) + const invitations = await fdm + .select() + .from(authZSchema.farmInvitation) + .where( + eq(authZSchema.farmInvitation.farm_id, b_id_farm), + ) + const invitation = invitations.find( + (i) => i.target_principal_id === target_id, + ) + expect(invitation).toBeDefined() + expect(invitation?.role).toBe("advisor") + expect(invitation?.status).toBe("pending") + + // Accept the invitation so subsequent tests (updateRole, revoke) work + await acceptFarmInvitation(fdm, invitation!.invitation_id, target_id) + + // Now the role should be granted const principals = await listPrincipalsForResource( fdm, "farm", b_id_farm, ) const advisor = principals.find((p) => p.principal_id === target_id) - expect(advisor).toEqual( expect.objectContaining({ principal_id: target_id, @@ -315,6 +344,40 @@ describe("Farm Functions", () => { ), ).rejects.toThrowError("Exception for grantRoleToFarm") }) + + it("should throw an error if target is already a member of the farm", async () => { + // target_id already has advisor role from the first test + await expect( + grantRoleToFarm( + fdm, + principal_id, + target_username, + b_id_farm, + "advisor", + ), + ).rejects.toThrowError("Exception for grantRoleToFarm") + }) + + it("should create an email-based invitation for an unregistered email", async () => { + const unregisteredEmail = "newuser_unregistered@example.com" + await grantRoleToFarm( + fdm, + principal_id, + unregisteredEmail, + b_id_farm, + "researcher", + ) + + const invitations = await fdm + .select() + .from(authZSchema.farmInvitation) + .where( + eq(authZSchema.farmInvitation.target_email, unregisteredEmail), + ) + expect(invitations.length).toBeGreaterThanOrEqual(1) + expect(invitations[0].status).toBe("pending") + expect(invitations[0].role).toBe("researcher") + }) }) describe("updateRoleOfPrincipalAtFarm", () => { @@ -709,6 +772,13 @@ describe("Farm Functions", () => { testFarmId, ) expect(principals).toEqual([]) + + // Verify farm invitations are deleted + const farmInvitations = await fdm + .select() + .from(authZSchema.farmInvitation) + .where(eq(authZSchema.farmInvitation.farm_id, testFarmId)) + expect(farmInvitations).toEqual([]) }) it("should throw an error if the principal does not have write access", async () => { @@ -760,4 +830,290 @@ describe("Farm Functions", () => { ) }) }) + + describe("acceptFarmInvitation", () => { + let invitationFarmId: string + let invitationId: string + + beforeAll(async () => { + // Create a fresh farm for invitation tests + invitationFarmId = await addFarm( + fdm, + principal_id, + "Invitation Test Farm", + "INV001", + "Invitation Lane", + "11111", + ) + }) + + it("should accept a pending invitation and grant the role", async () => { + await grantRoleToFarm( + fdm, + principal_id, + target_username, + invitationFarmId, + "researcher", + ) + + const rows = await fdm + .select() + .from(authZSchema.farmInvitation) + .where( + eq(authZSchema.farmInvitation.farm_id, invitationFarmId), + ) + invitationId = rows[0].invitation_id + + await acceptFarmInvitation(fdm, invitationId, target_id) + + const principals = await listPrincipalsForResource( + fdm, + "farm", + invitationFarmId, + ) + const grantee = principals.find((p) => p.principal_id === target_id) + expect(grantee).toBeDefined() + expect(grantee?.role).toBe("researcher") + }) + + it("should throw if invitation is already accepted", async () => { + await expect( + acceptFarmInvitation(fdm, invitationId, target_id), + ).rejects.toThrowError("Exception for acceptFarmInvitation") + }) + + it("should throw if invitation does not exist", async () => { + await expect( + acceptFarmInvitation(fdm, createId(), target_id), + ).rejects.toThrowError("Exception for acceptFarmInvitation") + }) + + it("should throw if the user is not the invitation target", async () => { + const otherFarmId = await addFarm( + fdm, + principal_id, + "Other Farm", + "OTH001", + "Other Lane", + "22222", + ) + await grantRoleToFarm( + fdm, + principal_id, + target_username, + otherFarmId, + "advisor", + ) + const rows = await fdm + .select() + .from(authZSchema.farmInvitation) + .where(eq(authZSchema.farmInvitation.farm_id, otherFarmId)) + const otherInvitationId = rows[0].invitation_id + + const wrongUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "wronguser@example.com", + name: "wronguser", + username: "wronguser", + password: "password", + } as any, + }) + + await expect( + acceptFarmInvitation(fdm, otherInvitationId, wrongUser.user.id), + ).rejects.toThrowError("Exception for acceptFarmInvitation") + }) + }) + + describe("declineFarmInvitation", () => { + let declineFarmId: string + let declineInvitationId: string + + beforeAll(async () => { + declineFarmId = await addFarm( + fdm, + principal_id, + "Decline Test Farm", + "DEC001", + "Decline Lane", + "33333", + ) + await grantRoleToFarm( + fdm, + principal_id, + target_username, + declineFarmId, + "advisor", + ) + const rows = await fdm + .select() + .from(authZSchema.farmInvitation) + .where(eq(authZSchema.farmInvitation.farm_id, declineFarmId)) + declineInvitationId = rows[0].invitation_id + }) + + it("should decline a pending invitation", async () => { + await declineFarmInvitation(fdm, declineInvitationId, target_id) + + const rows = await fdm + .select() + .from(authZSchema.farmInvitation) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + declineInvitationId, + ), + ) + expect(rows[0].status).toBe("declined") + }) + + it("should throw if invitation is already declined", async () => { + await expect( + declineFarmInvitation(fdm, declineInvitationId, target_id), + ).rejects.toThrowError("Exception for declineFarmInvitation") + }) + + it("should throw if the user is not the invitation target", async () => { + const anotherFarmId = await addFarm( + fdm, + principal_id, + "Another Decline Farm", + "DEC002", + "Another Decline Lane", + "44444", + ) + await grantRoleToFarm( + fdm, + principal_id, + target_username, + anotherFarmId, + "advisor", + ) + const rows = await fdm + .select() + .from(authZSchema.farmInvitation) + .where(eq(authZSchema.farmInvitation.farm_id, anotherFarmId)) + const anotherInvitationId = rows[0].invitation_id + + const otherUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "otherwronguser@example.com", + name: "otherwronguser", + username: "otherwronguser", + password: "password", + } as any, + }) + + await expect( + declineFarmInvitation(fdm, anotherInvitationId, otherUser.user.id), + ).rejects.toThrowError("Exception for declineFarmInvitation") + }) + }) + + describe("listPendingInvitationsForFarm", () => { + let listFarmId: string + + beforeAll(async () => { + listFarmId = await addFarm( + fdm, + principal_id, + "List Invitations Farm", + "LIST001", + "List Lane", + "55555", + ) + await grantRoleToFarm( + fdm, + principal_id, + target_username, + listFarmId, + "advisor", + ) + }) + + it("should return pending invitations for a farm", async () => { + const invitations = await listPendingInvitationsForFarm( + fdm, + principal_id, + listFarmId, + ) + expect(invitations.length).toBeGreaterThanOrEqual(1) + expect(invitations[0].status).toBe("pending") + expect(invitations[0].farm_id).toBe(listFarmId) + }) + + it("should throw if principal does not have share permission", async () => { + const otherId = createId() + await expect( + listPendingInvitationsForFarm(fdm, otherId, listFarmId), + ).rejects.toThrowError("Principal does not have permission to perform this action") + }) + }) + + describe("listPendingInvitationsForUser", () => { + let listUserFarmId: string + let listUserTarget: { user: { id: string } } + let listUserTargetId: string + + beforeAll(async () => { + listUserTarget = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "listuser@example.com", + name: "listuser", + username: "listuser", + password: "password", + } as any, + }) + listUserTargetId = listUserTarget.user.id + + listUserFarmId = await addFarm( + fdm, + principal_id, + "List User Invitations Farm", + "LSTU001", + "List User Lane", + "66666", + ) + await grantRoleToFarm( + fdm, + principal_id, + "listuser@example.com", + listUserFarmId, + "researcher", + ) + }) + + it("should return pending invitations for a user by email", async () => { + const invitations = await listPendingInvitationsForUser( + fdm, + listUserTargetId, + ) + expect(invitations.length).toBeGreaterThanOrEqual(1) + const inv = invitations.find((i) => i.farm_id === listUserFarmId) + expect(inv).toBeDefined() + expect(inv?.status).toBe("pending") + }) + + it("should return pending invitations for a user by principal_id", async () => { + // target_id has pending invitations from other tests + const invitations = await listPendingInvitationsForUser( + fdm, + target_id, + ) + // Target may have invitations from earlier tests (email-based or principal-based) + expect(Array.isArray(invitations)).toBe(true) + }) + + it("should return empty array if user does not exist or has no invitations", async () => { + const nonExistentUserId = createId() + const invitations = await listPendingInvitationsForUser( + fdm, + nonExistentUserId, + ) + expect(invitations).toEqual([]) + }) + }) }) diff --git a/fdm-core/src/farm.ts b/fdm-core/src/farm.ts index e924de5a5..7a9cfd892 100644 --- a/fdm-core/src/farm.ts +++ b/fdm-core/src/farm.ts @@ -1,4 +1,4 @@ -import { asc, eq, inArray } from "drizzle-orm" +import { and, asc, eq, gt, inArray, or } from "drizzle-orm" import { checkPermission, getRolesOfPrincipalForResource, @@ -9,6 +9,8 @@ import { updateRole, } from "./authorization" import type { PrincipalId, Role } from "./authorization.d" +import * as authNSchema from "./db/schema-authn" +import * as authZSchema from "./db/schema-authz" import * as schema from "./db/schema" import { handleError } from "./error" import type { FdmType } from "./fdm" @@ -328,22 +330,118 @@ export async function grantRoleToFarm( "grantRoleToFarm", ) - const targetDetails = await identifyPrincipal(tx, target) - if (!targetDetails) { - throw new Error("Target not found") + const normalizedTarget = target.toLowerCase().trim() + + // Check if target is a registered principal (user or organization) + const targetDetails = await identifyPrincipal(tx, normalizedTarget) + + let targetEmail: string | null = null + let targetPrincipalId: string | null = null + + if (targetDetails) { + // Registered user or organization + targetPrincipalId = targetDetails.id + + // Check if target is already a member of this farm + const existingMembers = await listPrincipalsForResource( + tx, + "farm", + b_id_farm, + ) + const isAlreadyMember = existingMembers.some( + (m) => m.principal_id === targetPrincipalId, + ) + if (isAlreadyMember) { + throw new Error("Target is already a member of this farm") + } + } else { + // Check if target is a valid email (unregistered user) + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(normalizedTarget)) { + throw new Error( + "Target not found and not a valid email address", + ) + } + targetEmail = normalizedTarget } - await grantRole(tx, "farm", role, b_id_farm, targetDetails.id) + const expires = new Date() + expires.setDate(expires.getDate() + 7) // 7 days expiry - // Check if at least 1 owner is still prestent on this farm - const owners = await listPrincipalsForResource( - tx, - "farm", - b_id_farm, - ) - const ownerCount = owners.filter((x) => x.role === "owner").length - if (ownerCount === 0) { - throw new Error("Farm should have at least 1 owner") + if (targetEmail) { + // Check for existing pending invitation for this email + farm + const existing = await tx + .select() + .from(authZSchema.farmInvitation) + .where( + and( + eq(authZSchema.farmInvitation.farm_id, b_id_farm), + eq( + authZSchema.farmInvitation.target_email, + targetEmail, + ), + eq(authZSchema.farmInvitation.status, "pending"), + ), + ) + .limit(1) + + if (existing.length > 0) { + await tx + .update(authZSchema.farmInvitation) + .set({ role, inviter_id: principal_id, expires }) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + existing[0].invitation_id, + ), + ) + } else { + await tx.insert(authZSchema.farmInvitation).values({ + invitation_id: createId(), + farm_id: b_id_farm, + target_email: targetEmail, + role, + inviter_id: principal_id, + expires, + }) + } + } else { + // Check for existing pending invitation for this principal + farm + const existing = await tx + .select() + .from(authZSchema.farmInvitation) + .where( + and( + eq(authZSchema.farmInvitation.farm_id, b_id_farm), + eq( + authZSchema.farmInvitation.target_principal_id, + targetPrincipalId!, + ), + eq(authZSchema.farmInvitation.status, "pending"), + ), + ) + .limit(1) + + if (existing.length > 0) { + await tx + .update(authZSchema.farmInvitation) + .set({ role, inviter_id: principal_id, expires }) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + existing[0].invitation_id, + ), + ) + } else { + await tx.insert(authZSchema.farmInvitation).values({ + invitation_id: createId(), + farm_id: b_id_farm, + target_principal_id: targetPrincipalId, + role, + inviter_id: principal_id, + expires, + }) + } } }) } catch (err) { @@ -476,7 +574,7 @@ export async function revokePrincipalFromFarm( * @param principal_id - The identifier of the principal requesting the list (must have 'read' permission). * @param b_id_farm - The identifier of the farm. * - * @returns A Promise that resolves to an array of Principal objects, each representing a principal associated with the farm. + * @returns A Promise that resolves to an array of Principal objects (including pending ones), each representing a principal associated with the farm. * * @throws {Error} If the acting principal does not have 'read' permission, or if any other error occurs during the operation. */ @@ -484,7 +582,13 @@ export async function listPrincipalsForFarm( fdm: FdmType, principal_id: string, b_id_farm: string, -): Promise { +): Promise< + (Principal & { + role: string + status: "active" | "pending" + invitation_id?: string + })[] +> { try { return await fdm.transaction(async (tx: FdmType) => { await checkPermission( @@ -501,21 +605,183 @@ export async function listPrincipalsForFarm( b_id_farm, ) - // Collect details of principals - const principalsDetails = await Promise.all( - principals.map(async (principal) => { - const details = await getPrincipal( - tx, - principal.principal_id, - ) + // Collect all principal IDs to fetch (active + pending with principal target) + const now = new Date() + const pendingInvitations = await tx + .select() + .from(authZSchema.farmInvitation) + .where( + and( + eq(authZSchema.farmInvitation.farm_id, b_id_farm), + eq(authZSchema.farmInvitation.status, "pending"), + gt(authZSchema.farmInvitation.expires, now), + ), + ) + + const activeIds = principals.map((p) => p.principal_id) + const pendingIdsWithPrincipal: string[] = ( + pendingInvitations as authZSchema.farmInvitationTypeSelect[] + ) + .map((i) => i.target_principal_id) + .filter((id): id is string => id !== null) + + const allPrincipalIds = [ + ...new Set([...activeIds, ...pendingIdsWithPrincipal]), + ] + + // Bulk fetch details for all principals + const principalsMap = new Map() + + if (allPrincipalIds.length > 0) { + // Fetch Users + const users = await tx + .select({ + id: authNSchema.user.id, + username: authNSchema.user.username, + displayUserName: authNSchema.user.displayUsername, + image: authNSchema.user.image, + isVerified: authNSchema.user.emailVerified, + firstname: authNSchema.user.firstname, + surname: authNSchema.user.surname, + email: authNSchema.user.email, + name: authNSchema.user.name, + }) + .from(authNSchema.user) + .where(inArray(authNSchema.user.id, allPrincipalIds)) + + for (const u of users) { + let initials = u.email + if (u.firstname && u.surname) { + initials = u.firstname.charAt(0).toUpperCase() + const surnameParts = u.surname.split(/\s+/) + let firstCap = "" + for (const part of surnameParts) { + if (part.length > 0) { + const char = part.charAt(0) + if ( + char === char.toUpperCase() && + char.match(/[a-zA-Z]/) + ) { + firstCap = char.toUpperCase() + break + } + } + } + initials += firstCap + } else if (u.firstname) { + initials = u.firstname[0] + } else if (u.name) { + initials = u.name[0] + } + + principalsMap.set(u.id, { + id: u.id, + username: u.username, + email: u.email, + initials: initials.toUpperCase(), + displayUserName: u.displayUserName, + image: u.image, + type: "user", + isVerified: u.isVerified, + }) + } + + // Fetch Organizations (for IDs not found in users) + const remainingIds = allPrincipalIds.filter( + (id) => !principalsMap.has(id), + ) + if (remainingIds.length > 0) { + const orgs = await tx + .select({ + id: authNSchema.organization.id, + name: authNSchema.organization.name, + slug: authNSchema.organization.slug, + logo: authNSchema.organization.logo, + metadata: authNSchema.organization.metadata, + }) + .from(authNSchema.organization) + .where( + inArray(authNSchema.organization.id, remainingIds), + ) + + for (const o of orgs) { + const metadata = o.metadata + ? JSON.parse(o.metadata) + : null + principalsMap.set(o.id, { + id: o.id, + username: o.slug, + email: null, + initials: o.name.charAt(0).toUpperCase(), + displayUserName: o.name, + image: o.logo, + type: "organization", + isVerified: metadata ? metadata.isVerified : false, + }) + } + } + } + + // Map active principals + const principalsDetails = principals.map((p) => { + const details = principalsMap.get(p.principal_id) + return { + ...details, + id: p.principal_id, + username: details?.username ?? "unknown", + initials: details?.initials ?? "?", + displayUserName: details?.displayUserName ?? null, + image: details?.image ?? null, + type: details?.type ?? "user", + isVerified: details?.isVerified ?? false, + email: details?.email ?? null, + role: p.role, + status: "active" as const, + } + }) + + // Map pending invitations + const pendingDetails = pendingInvitations.map( + (invitation: authZSchema.farmInvitationTypeSelect) => { + if (invitation.target_principal_id) { + const details = principalsMap.get( + invitation.target_principal_id, + ) + return { + ...details, + id: invitation.target_principal_id, + username: details?.username ?? "unknown", + initials: details?.initials ?? "?", + displayUserName: details?.displayUserName ?? null, + image: details?.image ?? null, + type: details?.type ?? "user", + isVerified: details?.isVerified ?? false, + email: details?.email ?? null, + role: invitation.role, + status: "pending" as const, + invitation_id: invitation.invitation_id, + } + } + + // Email-based invitation (unregistered user) + const email = invitation.target_email ?? "unknown" return { - ...details, - role: principal.role, + id: `pending-${invitation.invitation_id}`, + username: email, + email: email, + initials: email.charAt(0).toUpperCase(), + displayUserName: email, + image: null, + type: "user" as const, + isVerified: false, + role: invitation.role, + status: "pending" as const, + invitation_id: invitation.invitation_id, } - }), + }, ) - return principalsDetails + return [...principalsDetails, ...pendingDetails] }) } catch (err) { throw handleError(err, "Exception for listPrincipalsForFarm", { @@ -525,7 +791,437 @@ export async function listPrincipalsForFarm( } /** - * Checks if the specified principal is allowed to share a given farm. + * Accepts a pending farm invitation on behalf of the acting user. + * + * For user-targeted invitations: verifies email is verified and matches the invitation target. + * For organization-targeted invitations: verifies the acting user is an admin or owner of the organization. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param invitation_id - The unique identifier of the invitation to accept. + * @param user_id - The ID of the user accepting the invitation. + * + * @throws {Error} If the invitation is not found, already processed, expired, or the user is not authorized. + */ +export async function acceptFarmInvitation( + fdm: FdmType, + invitation_id: string, + user_id: string, +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + const invitations = await tx + .select() + .from(authZSchema.farmInvitation) + .where( + eq(authZSchema.farmInvitation.invitation_id, invitation_id), + ) + .limit(1) + + if (invitations.length === 0) { + throw new Error("Invitation not found") + } + const invitation = invitations[0] + + if (invitation.status !== "pending") { + throw new Error(`Invitation is already ${invitation.status}`) + } + if (invitation.expires < new Date()) { + await tx + .update(authZSchema.farmInvitation) + .set({ status: "expired" }) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + invitation_id, + ), + ) + throw new Error("Invitation has expired") + } + + let granteeId: string + + if (invitation.target_principal_id) { + const targetDetails = await getPrincipal( + tx, + invitation.target_principal_id, + ) + if (!targetDetails) { + throw new Error("Invitation target not found") + } + + if (targetDetails.type === "organization") { + // Verify user is admin or owner of the organization + const membership = await tx + .select() + .from(authNSchema.member) + .where( + and( + eq( + authNSchema.member.organizationId, + invitation.target_principal_id, + ), + eq(authNSchema.member.userId, user_id), + ), + ) + .limit(1) + + if ( + membership.length === 0 || + !["admin", "owner"].includes(membership[0].role) + ) { + throw new Error( + "Only admins or owners can accept farm invitations on behalf of an organization", + ) + } + } else { + // User target: verify it matches the accepting user + if (invitation.target_principal_id !== user_id) { + throw new Error("This invitation is not for you") + } + const userRecord = await tx + .select({ + emailVerified: authNSchema.user.emailVerified, + }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if ( + userRecord.length === 0 || + !userRecord[0].emailVerified + ) { + throw new Error( + "Email must be verified before accepting a farm invitation", + ) + } + } + + granteeId = invitation.target_principal_id + } else if (invitation.target_email) { + // Email-based invitation: match accepting user's email + const userRecord = await tx + .select({ + email: authNSchema.user.email, + emailVerified: authNSchema.user.emailVerified, + }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if (userRecord.length === 0) { + throw new Error("User not found") + } + if ( + userRecord[0].email.toLowerCase().trim() !== + invitation.target_email + ) { + throw new Error( + "This invitation is not for your email address", + ) + } + if (!userRecord[0].emailVerified) { + throw new Error( + "Email must be verified before accepting a farm invitation", + ) + } + granteeId = user_id + } else { + throw new Error("Invalid invitation: no target specified") + } + + // Grant the role + await grantRole( + tx, + "farm", + invitation.role as "owner" | "advisor" | "researcher", + invitation.farm_id, + granteeId, + ) + + // Mark invitation as accepted + await tx + .update(authZSchema.farmInvitation) + .set({ + status: "accepted", + accepted_at: new Date(), + target_principal_id: granteeId, + }) + .where( + eq(authZSchema.farmInvitation.invitation_id, invitation_id), + ) + }) + } catch (err) { + throw handleError(err, "Exception for acceptFarmInvitation", { + invitation_id, + user_id, + }) + } +} + +/** + * Declines a pending farm invitation on behalf of the acting user. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param invitation_id - The unique identifier of the invitation to decline. + * @param user_id - The ID of the user declining the invitation. + * + * @throws {Error} If the invitation is not found, already processed, or the user is not authorized. + */ +export async function declineFarmInvitation( + fdm: FdmType, + invitation_id: string, + user_id: string, +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + const invitations = await tx + .select() + .from(authZSchema.farmInvitation) + .where( + eq(authZSchema.farmInvitation.invitation_id, invitation_id), + ) + .limit(1) + + if (invitations.length === 0) { + throw new Error("Invitation not found") + } + const invitation = invitations[0] + + if (invitation.status !== "pending") { + throw new Error(`Invitation is already ${invitation.status}`) + } + + // Verify the user has the right to decline this invitation + if (invitation.target_principal_id) { + const targetDetails = await getPrincipal( + tx, + invitation.target_principal_id, + ) + if (targetDetails?.type === "organization") { + const membership = await tx + .select() + .from(authNSchema.member) + .where( + and( + eq( + authNSchema.member.organizationId, + invitation.target_principal_id, + ), + eq(authNSchema.member.userId, user_id), + ), + ) + .limit(1) + + if ( + membership.length === 0 || + !["admin", "owner"].includes(membership[0].role) + ) { + throw new Error( + "Only admins or owners can decline farm invitations on behalf of an organization", + ) + } + } else { + if (invitation.target_principal_id !== user_id) { + throw new Error("This invitation is not for you") + } + } + } else if (invitation.target_email) { + const userRecord = await tx + .select({ email: authNSchema.user.email }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if ( + userRecord.length === 0 || + userRecord[0].email.toLowerCase().trim() !== + invitation.target_email + ) { + throw new Error( + "This invitation is not for your email address", + ) + } + } + + await tx + .update(authZSchema.farmInvitation) + .set({ status: "declined" }) + .where( + eq(authZSchema.farmInvitation.invitation_id, invitation_id), + ) + }) + } catch (err) { + throw handleError(err, "Exception for declineFarmInvitation", { + invitation_id, + user_id, + }) + } +} + +/** + * Lists all pending (non-expired) invitations for a specific farm. + * + * Requires `share` permission on the farm. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param principal_id - The identifier of the principal requesting the list (must have 'share' permission). + * @param b_id_farm - The identifier of the farm. + * + * @returns A Promise that resolves to an array of pending farm invitation records. + */ +export async function listPendingInvitationsForFarm( + fdm: FdmType, + principal_id: string, + b_id_farm: string, +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + await checkPermission( + tx, + "farm", + "share", + b_id_farm, + principal_id, + "listPendingInvitationsForFarm", + ) + + const now = new Date() + return await tx + .select() + .from(authZSchema.farmInvitation) + .where( + and( + eq(authZSchema.farmInvitation.farm_id, b_id_farm), + eq(authZSchema.farmInvitation.status, "pending"), + gt(authZSchema.farmInvitation.expires, now), + ), + ) + }) + } catch (err) { + throw handleError(err, "Exception for listPendingInvitationsForFarm", { + b_id_farm, + }) + } +} + +/** + * Lists all pending (non-expired) farm invitations for a given user. + * + * Returns invitations where the target matches the user's principal ID, email address, + * or an organization for which the user is an admin or owner. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param user_id - The ID of the user to retrieve invitations for. + * + * @returns A Promise that resolves to an array of pending farm invitation records. + */ +export async function listPendingInvitationsForUser( + fdm: FdmType, + user_id: string, +): Promise< + (authZSchema.farmInvitationTypeSelect & { + farm_name: string | null + org_name: string | null + })[] +> { + try { + return await fdm.transaction(async (tx: FdmType) => { + // Get user's email + const userRecord = await tx + .select({ email: authNSchema.user.email }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if (userRecord.length === 0) { + return [] + } + const userEmail = userRecord[0].email.toLowerCase().trim() + + // Get organization IDs where user is admin or owner + const orgMemberships = await tx + .select({ organizationId: authNSchema.member.organizationId }) + .from(authNSchema.member) + .where( + and( + eq(authNSchema.member.userId, user_id), + inArray(authNSchema.member.role, ["admin", "owner"]), + ), + ) + const orgIds = orgMemberships.map( + (m: { organizationId: string }) => m.organizationId, + ) + + const now = new Date() + + // Build conditions: by email, by principal_id, or by managed org + const conditions = [ + and( + eq(authZSchema.farmInvitation.target_email, userEmail), + eq(authZSchema.farmInvitation.status, "pending"), + gt(authZSchema.farmInvitation.expires, now), + ), + and( + eq(authZSchema.farmInvitation.target_principal_id, user_id), + eq(authZSchema.farmInvitation.status, "pending"), + gt(authZSchema.farmInvitation.expires, now), + ), + ] + + if (orgIds.length > 0) { + conditions.push( + and( + inArray( + authZSchema.farmInvitation.target_principal_id, + orgIds, + ), + eq(authZSchema.farmInvitation.status, "pending"), + gt(authZSchema.farmInvitation.expires, now), + ), + ) + } + + return await tx + .select({ + invitation_id: authZSchema.farmInvitation.invitation_id, + farm_id: authZSchema.farmInvitation.farm_id, + farm_name: schema.farms.b_name_farm, + org_name: authNSchema.organization.name, + target_email: authZSchema.farmInvitation.target_email, + target_principal_id: + authZSchema.farmInvitation.target_principal_id, + role: authZSchema.farmInvitation.role, + inviter_id: authZSchema.farmInvitation.inviter_id, + status: authZSchema.farmInvitation.status, + expires: authZSchema.farmInvitation.expires, + created: authZSchema.farmInvitation.created, + accepted_at: authZSchema.farmInvitation.accepted_at, + }) + .from(authZSchema.farmInvitation) + .leftJoin( + schema.farms, + eq( + authZSchema.farmInvitation.farm_id, + schema.farms.b_id_farm, + ), + ) + .leftJoin( + authNSchema.organization, + eq( + authZSchema.farmInvitation.target_principal_id, + authNSchema.organization.id, + ), + ) + .where(or(...conditions)) + }) + } catch (err) { + throw handleError(err, "Exception for listPendingInvitationsForUser", { + user_id, + }) + } +} + +/** * * This function verifies if the acting principal has 'share' permission on the farm. * @@ -805,6 +1501,11 @@ export async function removeFarm( ) } + // Step 4b: Delete all invitations for this farm + await tx + .delete(authZSchema.farmInvitation) + .where(eq(authZSchema.farmInvitation.farm_id, b_id_farm)) + // Step 5: Finally, delete the farm itself await tx .delete(schema.farms) diff --git a/fdm-core/src/global-setup.ts b/fdm-core/src/global-setup.ts index 60766277a..0a821a10e 100644 --- a/fdm-core/src/global-setup.ts +++ b/fdm-core/src/global-setup.ts @@ -86,6 +86,7 @@ export async function teardown() { await fdm.delete(authZSchema.role).execute() await fdm.delete(authZSchema.audit).execute() + await fdm.delete(authZSchema.farmInvitation).execute() }) } catch (error) { console.error("Error cleaning up database tables:", error) diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index 6d884c0a3..e9c00311c 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -65,11 +65,15 @@ export { } from "./derogation" export { addFarm, + acceptFarmInvitation, + declineFarmInvitation, getFarm, getFarms, grantRoleToFarm, isAllowedToDeleteFarm, isAllowedToShareFarm, + listPendingInvitationsForFarm, + listPendingInvitationsForUser, listPrincipalsForFarm, removeFarm, revokePrincipalFromFarm, @@ -144,7 +148,10 @@ export { removeOrganicCertification, } from "./organic" export type { OrganicCertification } from "./organic.d" -export { lookupPrincipal } from "./principal" +export { autoAcceptInvitationsForNewUser } from "./invitation" +export { + lookupPrincipal, +} from "./principal" export { addSoilAnalysis, getCurrentSoilData, diff --git a/fdm-core/src/invitation.test.ts b/fdm-core/src/invitation.test.ts new file mode 100644 index 000000000..8f3f61ff0 --- /dev/null +++ b/fdm-core/src/invitation.test.ts @@ -0,0 +1,229 @@ +import { and, eq } from "drizzle-orm" +import { beforeAll, describe, expect, inject, it } from "vitest" +import type { FdmAuth } from "./authentication" +import { createFdmAuth } from "./authentication" +import { listPrincipalsForResource } from "./authorization" +import * as authNSchema from "./db/schema-authn" +import * as authZSchema from "./db/schema-authz" +import { addFarm, grantRoleToFarm } from "./farm" +import { createFdmServer } from "./fdm-server" +import type { FdmServerType } from "./fdm-server.d" +import { autoAcceptInvitationsForNewUser } from "./invitation" + +describe("autoAcceptInvitationsForNewUser", () => { + let fdm: FdmServerType + let fdmAuth: FdmAuth + let ownerPrincipalId: string + let farmId: string + + beforeAll(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) + + const googleAuth = { + clientId: "mock_google_client_id", + clientSecret: "mock_google_client_secret", + } + const microsoftAuth = { + clientId: "mock_ms_client_id", + clientSecret: "mock_ms_client_secret", + } + fdmAuth = createFdmAuth(fdm, googleAuth, microsoftAuth, undefined, true) + + // Create an owner to create farms + const owner = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "invowner@example.com", + name: "invowner", + username: "invowner", + password: "password", + } as any, + }) + ownerPrincipalId = owner.user.id + + // Create a farm + farmId = await addFarm( + fdm, + ownerPrincipalId, + "Auto Accept Test Farm", + "AUTO001", + "Auto Lane", + "77777", + ) + }) + + it("should auto-accept a pending email-based invitation when user verifies email", async () => { + const targetEmail = "inviteduser_auto@example.com" + + // Create email-based invitation + await grantRoleToFarm( + fdm, + ownerPrincipalId, + targetEmail, + farmId, + "advisor", + ) + + // Create user account + const newUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: targetEmail, + name: "inviteduser_auto", + username: "inviteduser_auto", + password: "password", + } as any, + }) + const newUserId = newUser.user.id + + // Auto-accept invitations (simulating email verification) + await autoAcceptInvitationsForNewUser(fdm, targetEmail, newUserId) + + // Role should now be granted + const principals = await listPrincipalsForResource( + fdm, + "farm", + farmId, + ) + const grantee = principals.find((p) => p.principal_id === newUserId) + expect(grantee).toBeDefined() + expect(grantee?.role).toBe("advisor") + + // Invitation should be marked as accepted + const invitations = await fdm + .select() + .from(authZSchema.farmInvitation) + .where( + and( + eq(authZSchema.farmInvitation.target_email, targetEmail), + eq(authZSchema.farmInvitation.farm_id, farmId), + ), + ) + expect(invitations[0].status).toBe("accepted") + expect(invitations[0].target_principal_id).toBe(newUserId) + }) + + it("should skip expired invitations", async () => { + const expiredEmail = "expireduser_auto@example.com" + const anotherFarmId = await addFarm( + fdm, + ownerPrincipalId, + "Expired Invite Farm", + "EXP001", + "Expired Lane", + "88888", + ) + + // Create email-based invitation + await grantRoleToFarm( + fdm, + ownerPrincipalId, + expiredEmail, + anotherFarmId, + "researcher", + ) + + // Manually expire the invitation + await fdm + .update(authZSchema.farmInvitation) + .set({ expires: new Date("2000-01-01") }) + .where(eq(authZSchema.farmInvitation.target_email, expiredEmail)) + + const expiredUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: expiredEmail, + name: "expireduser_auto", + username: "expireduser_auto", + password: "password", + } as any, + }) + + await autoAcceptInvitationsForNewUser(fdm, expiredEmail, expiredUser.user.id) + + // Role should NOT be granted + const principals = await listPrincipalsForResource( + fdm, + "farm", + anotherFarmId, + ) + const grantee = principals.find( + (p) => p.principal_id === expiredUser.user.id, + ) + expect(grantee).toBeUndefined() + + // Invitation should be marked as expired + const invitations = await fdm + .select() + .from(authZSchema.farmInvitation) + .where(eq(authZSchema.farmInvitation.target_email, expiredEmail)) + expect(invitations[0].status).toBe("expired") + }) + + it("should do nothing if there are no pending invitations for the email", async () => { + const noInviteEmail = "noinvite@example.com" + const noInviteUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: noInviteEmail, + name: "noinvite", + username: "noinvite", + password: "password", + } as any, + }) + + // Should not throw, just do nothing + await expect( + autoAcceptInvitationsForNewUser(fdm, noInviteEmail, noInviteUser.user.id), + ).resolves.toBeUndefined() + }) + + it("should handle email case-insensitively", async () => { + const mixedCaseEmail = "MixedCase_auto@Example.COM" + const normalizedEmail = mixedCaseEmail.toLowerCase().trim() + const yetAnotherFarmId = await addFarm( + fdm, + ownerPrincipalId, + "Case Test Farm", + "CASE001", + "Case Lane", + "99999", + ) + + // Create invitation with already-lowercased email (grantRoleToFarm normalizes) + await grantRoleToFarm( + fdm, + ownerPrincipalId, + mixedCaseEmail, + yetAnotherFarmId, + "advisor", + ) + + const caseUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: normalizedEmail, + name: "caseuser", + username: "caseuser_auto", + password: "password", + } as any, + }) + + // Auto-accept with the normalized email + await autoAcceptInvitationsForNewUser(fdm, normalizedEmail, caseUser.user.id) + + const principals = await listPrincipalsForResource( + fdm, + "farm", + yetAnotherFarmId, + ) + const grantee = principals.find((p) => p.principal_id === caseUser.user.id) + expect(grantee).toBeDefined() + expect(grantee?.role).toBe("advisor") + }) +}) diff --git a/fdm-core/src/invitation.ts b/fdm-core/src/invitation.ts new file mode 100644 index 000000000..b08b680b4 --- /dev/null +++ b/fdm-core/src/invitation.ts @@ -0,0 +1,85 @@ +import { and, eq } from "drizzle-orm" +import * as authZSchema from "./db/schema-authz" +import { handleError } from "./error" +import type { FdmType } from "./fdm" +import { grantRole } from "./authorization" + +/** + * Automatically accepts all pending farm invitations for a newly verified user. + * + * This function looks up pending invitations matching the user's email address + * and grants the corresponding roles. It MUST only be called when `emailVerified` + * is confirmed to be true. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param email - The verified email address of the user. + * @param user_id - The ID of the newly verified user. + */ +export async function autoAcceptInvitationsForNewUser( + fdm: FdmType, + email: string, + user_id: string, +): Promise { + try { + const normalizedEmail = email.toLowerCase().trim() + + await fdm.transaction(async (tx: FdmType) => { + // Find all pending invitations for this email + const pendingInvitations = await tx + .select() + .from(authZSchema.farmInvitation) + .where( + and( + eq(authZSchema.farmInvitation.target_email, normalizedEmail), + eq(authZSchema.farmInvitation.status, "pending"), + ), + ) + + const now = new Date() + for (const invitation of pendingInvitations) { + // Skip expired invitations + if (invitation.expires < now) { + await tx + .update(authZSchema.farmInvitation) + .set({ status: "expired" }) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + invitation.invitation_id, + ), + ) + continue + } + + // Grant the role to the user + await grantRole( + tx, + "farm", + invitation.role as "owner" | "advisor" | "researcher", + invitation.farm_id, + user_id, + ) + + // Mark invitation as accepted + await tx + .update(authZSchema.farmInvitation) + .set({ + status: "accepted", + accepted_at: now, + target_principal_id: user_id, + }) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + invitation.invitation_id, + ), + ) + } + }) + } catch (err) { + throw handleError(err, "Exception for autoAcceptInvitationsForNewUser", { + email, + user_id, + }) + } +} diff --git a/fdm-core/src/principal.d.ts b/fdm-core/src/principal.d.ts index a789388ea..952fa6d18 100644 --- a/fdm-core/src/principal.d.ts +++ b/fdm-core/src/principal.d.ts @@ -2,6 +2,7 @@ * @typedef Principal * @property {string} id - The unique identifier of the principal. * @property {string} username - The username or slug of the principal. + * @property {string | null} email - The email address of the principal (can be null for organizations). * @property {string} initials - The initials of the principal. * @property {string | null} displayUserName - The display name of the principal (can be null). * @property {string | null} image - The image URL of the principal (can be null). @@ -11,6 +12,7 @@ export type Principal = { id: string username: string + email: string | null initials: string displayUserName: string | null image: string | null diff --git a/fdm-core/src/principal.ts b/fdm-core/src/principal.ts index c4835b4aa..b2ccd2a97 100644 --- a/fdm-core/src/principal.ts +++ b/fdm-core/src/principal.ts @@ -85,7 +85,9 @@ export async function getPrincipal( } return { + id: principal_id, username: user[0].username, + email: user[0].email, initials: initials.toUpperCase(), displayUserName: user[0].displayUserName, image: user[0].image, @@ -112,7 +114,9 @@ export async function getPrincipal( const metadata = JSON.parse(organization[0].metadata) return { + id: principal_id, username: organization[0].slug, + email: null, initials: organization[0].name.charAt(0).toUpperCase(), displayUserName: organization[0].name, image: organization[0].logo, From 286c44a61e2b71b4d868c4d54eca8b4b4eb58b34 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:12:01 +0100 Subject: [PATCH 02/25] fix: enable autocomplete for users not in the list --- .../blocks/access/invitation-form.tsx | 7 +++- .../app/components/custom/autocomplete.tsx | 30 +++++++++---- ...arm.create.$b_id_farm.$calendar.access.tsx | 42 +++++++++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/fdm-app/app/components/blocks/access/invitation-form.tsx b/fdm-app/app/components/blocks/access/invitation-form.tsx index e98ab9c01..6159be01a 100644 --- a/fdm-app/app/components/blocks/access/invitation-form.tsx +++ b/fdm-app/app/components/blocks/access/invitation-form.tsx @@ -64,8 +64,13 @@ export const InvitationForm = ({ principals }: InvitationFormProps) => { shouldTouch: true, }) }} - emptyMessage="Geen gebruikers gevonden" + emptyMessage={(value) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ? `Nodig ${value} uit voor toegang` + : "Geen gebruikers gevonden" + } placeholder="Zoek naar een gebruiker of organisatie" + allowValuesOutsideList={true} form={form} // Pass the form instance name="username" // Name for remix-hook-form registration /> diff --git a/fdm-app/app/components/custom/autocomplete.tsx b/fdm-app/app/components/custom/autocomplete.tsx index cf4e90334..8147eaa5f 100644 --- a/fdm-app/app/components/custom/autocomplete.tsx +++ b/fdm-app/app/components/custom/autocomplete.tsx @@ -30,12 +30,14 @@ type Props = { searchParamName?: string // Query parameter name for search term (default: 'identifier') excludeValues?: T[] // Optional array of values to filter out iconMap?: IconMap // Optional map of icon identifiers to components - emptyMessage?: string + emptyMessage?: string | ((inputValue: string) => string) placeholder?: string // biome-ignore lint/suspicious/noExplicitAny: Using any temporarily due to potential type conflicts with remix-hook-form form?: any name?: string // Name for remix-hook-form registration className?: string + /** When true, values typed directly (not from dropdown) are accepted as-is (e.g. email addresses) */ + allowValuesOutsideList?: boolean } export function AutoComplete({ @@ -45,11 +47,12 @@ export function AutoComplete({ searchParamName = "identifier", // Default search param name excludeValues = [], iconMap = { user: User, organization: Users }, // Default icon map - emptyMessage = "No items.", + emptyMessage = "No items." as string | ((inputValue: string) => string), placeholder = "Search...", form, name, className, + allowValuesOutsideList = false, }: Props) { const fetcher = useFetcher[]>() const [open, setOpen] = useState(false) @@ -152,17 +155,24 @@ export function AutoComplete({ setOpen(false) } - // Keep input if it matches a valid item, otherwise clear if no selection + // Keep input if it matches a valid item, otherwise use typed value as-is (if allowFreeform) const handleInputBlur = () => { // Timeout to allow click selection to register first setTimeout(() => { if (!open) { - // If input doesn't match the selected label, and no value is selected, clear input - if (inputValue !== selectedLabel && !selectedValue) { - setInputValue("") + if (inputValue && !selectedValue) { + if (allowValuesOutsideList) { + // Accept typed value as-is (e.g. email address) + onSelectedValueChange(inputValue as T) + if (form && name) { + form.setValue(name, inputValue) + } + } else { + // Only dropdown selections allowed — clear the input + setInputValue("") + } } - // If input matches selected label, keep it. - // If input doesn't match, but a value IS selected, revert input to selected label + // If input doesn't match selected label, revert input to selected label else if (inputValue !== selectedLabel && selectedValue) { setInputValue(selectedLabel) } @@ -249,7 +259,9 @@ export function AutoComplete({ ) : null} {!isLoading && !items.length && inputValue ? ( // Show empty only if not loading and user typed something - {emptyMessage ?? "No items."} + {typeof emptyMessage === "function" + ? emptyMessage(inputValue) + : emptyMessage} ) : null} diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx index bc2cbfa68..f451a3282 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx @@ -3,6 +3,7 @@ import { grantRoleToFarm, isAllowedToShareFarm, listPrincipalsForFarm, + lookupPrincipal, revokePrincipalFromFarm, updateRoleOfPrincipalAtFarm, } from "@svenvw/fdm-core" @@ -22,6 +23,10 @@ import { Separator } from "~/components/ui/separator" import { getSession } from "~/lib/auth.server" import { getCalendar } from "~/lib/calendar" import { clientConfig } from "~/lib/config" +import { + renderFarmInvitationEmail, + sendEmail, +} from "~/lib/email.server" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" @@ -172,6 +177,43 @@ export async function action({ request, params }: ActionFunctionArgs) { b_id_farm, formValues.role, ) + + // Send invitation email + try { + const farm = await getFarm(fdm, principalId, b_id_farm) + const inviterName = session.userName + const normalizedTarget = formValues.username.toLowerCase().trim() + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedTarget) + + const matchedPrincipals = await lookupPrincipal(fdm, normalizedTarget) + const targetPrincipal = matchedPrincipals.find( + (p) => + p.username.toLowerCase() === normalizedTarget || + (isEmail && p.email?.toLowerCase() === normalizedTarget), + ) + + const targetEmail = isEmail + ? normalizedTarget + : targetPrincipal?.type === "user" + ? targetPrincipal.email + : null + + if (targetEmail) { + const isUnregistered = !targetPrincipal + const email = await renderFarmInvitationEmail( + targetEmail, + inviterName, + farm.b_name_farm ?? b_id_farm, + b_id_farm, + formValues.role, + isUnregistered, + ) + await sendEmail(email) + } + } catch (emailError) { + console.error("Error sending farm invitation email:", emailError) + } + return dataWithSuccess(null, { message: `${formValues.username} is uitgenodigd!`, }) From 214d2e4a57655ec6722adf8b5849a907b1c753d5 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:22:58 +0100 Subject: [PATCH 03/25] fix: replace regex check for email with validator package --- fdm-app/app/components/blocks/access/invitation-form.tsx | 3 ++- fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx | 7 ++++--- .../app/routes/farm.create.$b_id_farm.$calendar.access.tsx | 7 ++++--- fdm-core/package.json | 4 +++- fdm-core/src/farm.ts | 4 ++-- pnpm-lock.yaml | 6 ++++++ 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/fdm-app/app/components/blocks/access/invitation-form.tsx b/fdm-app/app/components/blocks/access/invitation-form.tsx index 6159be01a..a0b44ef60 100644 --- a/fdm-app/app/components/blocks/access/invitation-form.tsx +++ b/fdm-app/app/components/blocks/access/invitation-form.tsx @@ -3,6 +3,7 @@ import { User, Users } from "lucide-react" import { useState } from "react" import { Form } from "react-router-dom" import { RemixFormProvider, useRemixForm } from "remix-hook-form" +import isEmail from "validator/lib/isEmail" import type { z } from "zod" import { AutoComplete } from "~/components/custom/autocomplete" import { Button } from "~/components/ui/button" @@ -65,7 +66,7 @@ export const InvitationForm = ({ principals }: InvitationFormProps) => { }) }} emptyMessage={(value) => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + isEmail(value) ? `Nodig ${value} uit voor toegang` : "Geen gebruikers gevonden" } diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx index d5438087b..2408482e8 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx @@ -9,6 +9,7 @@ import { revokePrincipalFromFarm, updateRoleOfPrincipalAtFarm, } from "@svenvw/fdm-core" +import isEmail from "validator/lib/isEmail" import { type ActionFunctionArgs, data, @@ -139,17 +140,17 @@ export async function action({ request, params }: ActionFunctionArgs) { const farm = await getFarm(fdm, session.user.id, b_id_farm) const inviterName = session.userName const normalizedTarget = formValues.username.toLowerCase().trim() - const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedTarget) + const isEmailTarget = isEmail(normalizedTarget) // Try to find the principal to get their email if they are registered const matchedPrincipals = await lookupPrincipal(fdm, normalizedTarget) const targetPrincipal = matchedPrincipals.find( (p) => p.username.toLowerCase() === normalizedTarget || - (isEmail && p.email?.toLowerCase() === normalizedTarget), + (isEmailTarget && p.email?.toLowerCase() === normalizedTarget), ) - const targetEmail = isEmail + const targetEmail = isEmailTarget ? normalizedTarget : targetPrincipal?.type === "user" ? targetPrincipal.email diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx index f451a3282..ae9829be4 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx @@ -7,6 +7,7 @@ import { revokePrincipalFromFarm, updateRoleOfPrincipalAtFarm, } from "@svenvw/fdm-core" +import isEmail from "validator/lib/isEmail" import { type ActionFunctionArgs, data, @@ -183,16 +184,16 @@ export async function action({ request, params }: ActionFunctionArgs) { const farm = await getFarm(fdm, principalId, b_id_farm) const inviterName = session.userName const normalizedTarget = formValues.username.toLowerCase().trim() - const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedTarget) + const isEmailTarget = isEmail(normalizedTarget) const matchedPrincipals = await lookupPrincipal(fdm, normalizedTarget) const targetPrincipal = matchedPrincipals.find( (p) => p.username.toLowerCase() === normalizedTarget || - (isEmail && p.email?.toLowerCase() === normalizedTarget), + (isEmailTarget && p.email?.toLowerCase() === normalizedTarget), ) - const targetEmail = isEmail + const targetEmail = isEmailTarget ? normalizedTarget : targetPrincipal?.type === "user" ? targetPrincipal.email diff --git a/fdm-core/package.json b/fdm-core/package.json index 4004fcbac..7e5b6a5d5 100644 --- a/fdm-core/package.json +++ b/fdm-core/package.json @@ -50,6 +50,7 @@ "@rollup/plugin-node-resolve": "catalog:", "@svenvw/fdm-data": "workspace:^0.19.1", "@types/node": "catalog:", + "@types/validator": "^13.15.10", "@vitest/coverage-v8": "catalog:", "drizzle-kit": "catalog:", "fs-extra": "^11.3.3", @@ -72,7 +73,8 @@ "nanoid": "^5.1.6", "postgres": "^3.4.8", "safe-stable-stringify": "^2.5.0", - "unique-username-generator": "^1.5.1" + "unique-username-generator": "^1.5.1", + "validator": "^13.15.26" }, "packageManager": "pnpm@10.29.3", "publishConfig": { diff --git a/fdm-core/src/farm.ts b/fdm-core/src/farm.ts index 7a9cfd892..b3744c945 100644 --- a/fdm-core/src/farm.ts +++ b/fdm-core/src/farm.ts @@ -1,4 +1,5 @@ import { and, asc, eq, gt, inArray, or } from "drizzle-orm" +import isEmail from "validator/lib/isEmail" import { checkPermission, getRolesOfPrincipalForResource, @@ -356,8 +357,7 @@ export async function grantRoleToFarm( } } else { // Check if target is a valid email (unregistered user) - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(normalizedTarget)) { + if (!isEmail(normalizedTarget)) { throw new Error( "Target not found and not a valid email address", ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04a2b5510..f43c75db5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -423,6 +423,9 @@ importers: unique-username-generator: specifier: ^1.5.1 version: 1.5.1 + validator: + specifier: ^13.15.26 + version: 13.15.26 devDependencies: '@dotenvx/dotenvx': specifier: 'catalog:' @@ -436,6 +439,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.2.3 + '@types/validator': + specifier: ^13.15.10 + version: 13.15.10 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) From da9b3868de939b836f73ca574324c3756b084a7c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:35:01 +0100 Subject: [PATCH 04/25] refactor: implement feedback from code review --- .changeset/farm-invitation-app.md | 2 +- .../blocks/email/farm-invitation.tsx | 2 - .../app/components/custom/autocomplete.tsx | 13 ++- fdm-app/app/lib/email.server.ts | 2 - .../farm.$b_id_farm.settings.access.tsx | 3 +- ...arm.create.$b_id_farm.$calendar.access.tsx | 1 - fdm-core/src/authentication.ts | 36 +++++--- fdm-core/src/farm.ts | 85 +++++++++++++------ fdm-core/src/invitation.test.ts | 5 +- fdm-core/src/invitation.ts | 1 - fdm-core/src/principal.ts | 13 +-- 11 files changed, 102 insertions(+), 61 deletions(-) diff --git a/.changeset/farm-invitation-app.md b/.changeset/farm-invitation-app.md index e98948b48..75e067031 100644 --- a/.changeset/farm-invitation-app.md +++ b/.changeset/farm-invitation-app.md @@ -2,7 +2,7 @@ "@svenvw/fdm-app": minor --- -Add the possibilty for users to accept or reject an invitation to a farm, instead of having it automatically. This makes it also possible to invite non-registered users to get access to a farm after signing up. +Add the possibility for users to accept or reject an invitation to a farm, instead of having it automatically. This makes it also possible to invite non-registered users to get access to a farm after signing up. - Overview page shows pending farm invitations with accept/decline actions - Invitation email sent when a user is invited to a farm diff --git a/fdm-app/app/components/blocks/email/farm-invitation.tsx b/fdm-app/app/components/blocks/email/farm-invitation.tsx index 042e01083..d665c1b9a 100644 --- a/fdm-app/app/components/blocks/email/farm-invitation.tsx +++ b/fdm-app/app/components/blocks/email/farm-invitation.tsx @@ -17,7 +17,6 @@ interface FarmInvitationEmailProps { farmName: string inviterName: string targetEmail: string - invitationId: string role: string appName: string appBaseUrl?: string @@ -36,7 +35,6 @@ export const FarmInvitationEmail = ({ farmName, inviterName, targetEmail, - invitationId, role, appName, appBaseUrl = "", diff --git a/fdm-app/app/components/custom/autocomplete.tsx b/fdm-app/app/components/custom/autocomplete.tsx index 8147eaa5f..7e2495ea2 100644 --- a/fdm-app/app/components/custom/autocomplete.tsx +++ b/fdm-app/app/components/custom/autocomplete.tsx @@ -47,7 +47,7 @@ export function AutoComplete({ searchParamName = "identifier", // Default search param name excludeValues = [], iconMap = { user: User, organization: Users }, // Default icon map - emptyMessage = "No items." as string | ((inputValue: string) => string), + emptyMessage = "No items.", placeholder = "Search...", form, name, @@ -56,6 +56,7 @@ export function AutoComplete({ }: Props) { const fetcher = useFetcher[]>() const [open, setOpen] = useState(false) + const openRef = useRef(open) const [inputValue, setInputValue] = useState("") // Internal input state const [items, setItems] = useState[]>([]) const [isLoading, setIsLoading] = useState(false) @@ -159,7 +160,7 @@ export function AutoComplete({ const handleInputBlur = () => { // Timeout to allow click selection to register first setTimeout(() => { - if (!open) { + if (!openRef.current) { if (inputValue && !selectedValue) { if (allowValuesOutsideList) { // Accept typed value as-is (e.g. email address) @@ -182,7 +183,13 @@ export function AutoComplete({ return (
- + { + setOpen(value) + openRef.current = value + }} + > { @@ -84,7 +83,6 @@ export async function renderFarmInvitationEmail( farmName, inviterName, targetEmail, - invitationId, role, appName: serverConfig.name, appBaseUrl: serverConfig.url, diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx index 2408482e8..ab53c5502 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx @@ -129,7 +129,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } await grantRoleToFarm( fdm, - session.user.id, + session.principal_id, formValues.username, b_id_farm, formValues.role, @@ -162,7 +162,6 @@ export async function action({ request, params }: ActionFunctionArgs) { targetEmail, inviterName, farm.b_name_farm ?? b_id_farm, - b_id_farm, formValues.role, isUnregistered, ) diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx index ae9829be4..340bc1a29 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx @@ -205,7 +205,6 @@ export async function action({ request, params }: ActionFunctionArgs) { targetEmail, inviterName, farm.b_name_farm ?? b_id_farm, - b_id_farm, formValues.role, isUnregistered, ) diff --git a/fdm-core/src/authentication.ts b/fdm-core/src/authentication.ts index 82ff5a5d1..62734211a 100644 --- a/fdm-core/src/authentication.ts +++ b/fdm-core/src/authentication.ts @@ -228,11 +228,19 @@ export function createFdmAuth( // Auto-accept pending invitations if email is already verified (e.g. social login) if (user.emailVerified) { - await autoAcceptInvitationsForNewUser( - fdm, - user.email, - user.id, - ) + try { + await autoAcceptInvitationsForNewUser( + fdm, + user.email, + user.id, + ) + } catch (err) { + console.warn( + "autoAcceptInvitationsForNewUser failed for user", + user.id, + err, + ) + } } }, }, @@ -240,11 +248,19 @@ export function createFdmAuth( after: async (user) => { // Auto-accept pending invitations when email becomes verified if (user.emailVerified) { - await autoAcceptInvitationsForNewUser( - fdm, - user.email, - user.id, - ) + try { + await autoAcceptInvitationsForNewUser( + fdm, + user.email, + user.id, + ) + } catch (err) { + console.warn( + "autoAcceptInvitationsForNewUser failed for user", + user.id, + err, + ) + } } }, }, diff --git a/fdm-core/src/farm.ts b/fdm-core/src/farm.ts index b3744c945..a8a4f3dce 100644 --- a/fdm-core/src/farm.ts +++ b/fdm-core/src/farm.ts @@ -315,7 +315,7 @@ export async function updateFarm( */ export async function grantRoleToFarm( fdm: FdmType, - principal_id: PrincipalId, + principal_id: string, target: string, b_id_farm: schema.farmsTypeInsert["b_id_farm"], role: "owner" | "advisor" | "researcher", @@ -809,23 +809,43 @@ export async function acceptFarmInvitation( ): Promise { try { return await fdm.transaction(async (tx: FdmType) => { - const invitations = await tx - .select() - .from(authZSchema.farmInvitation) + // Atomically claim the invitation: only succeeds if still pending and not expired + const claimed = await tx + .update(authZSchema.farmInvitation) + .set({ status: "accepted", accepted_at: new Date() }) .where( - eq(authZSchema.farmInvitation.invitation_id, invitation_id), + and( + eq( + authZSchema.farmInvitation.invitation_id, + invitation_id, + ), + eq(authZSchema.farmInvitation.status, "pending"), + gt(authZSchema.farmInvitation.expires, new Date()), + ), ) - .limit(1) + .returning() - if (invitations.length === 0) { - throw new Error("Invitation not found") - } - const invitation = invitations[0] + if (claimed.length === 0) { + // Determine the precise reason for failure + const existing = await tx + .select() + .from(authZSchema.farmInvitation) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + invitation_id, + ), + ) + .limit(1) - if (invitation.status !== "pending") { - throw new Error(`Invitation is already ${invitation.status}`) - } - if (invitation.expires < new Date()) { + if (existing.length === 0) { + throw new Error("Invitation not found") + } + const inv = existing[0] + if (inv.status !== "pending") { + throw new Error(`Invitation is already ${inv.status}`) + } + // Must be expired await tx .update(authZSchema.farmInvitation) .set({ status: "expired" }) @@ -838,6 +858,7 @@ export async function acceptFarmInvitation( throw new Error("Invitation has expired") } + const invitation = claimed[0] let granteeId: string if (invitation.target_principal_id) { @@ -938,17 +959,18 @@ export async function acceptFarmInvitation( granteeId, ) - // Mark invitation as accepted - await tx - .update(authZSchema.farmInvitation) - .set({ - status: "accepted", - accepted_at: new Date(), - target_principal_id: granteeId, - }) - .where( - eq(authZSchema.farmInvitation.invitation_id, invitation_id), - ) + // Update target_principal_id if this was an email-based invitation + if (!invitation.target_principal_id) { + await tx + .update(authZSchema.farmInvitation) + .set({ target_principal_id: granteeId }) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + invitation_id, + ), + ) + } }) } catch (err) { throw handleError(err, "Exception for acceptFarmInvitation", { @@ -991,6 +1013,19 @@ export async function declineFarmInvitation( throw new Error(`Invitation is already ${invitation.status}`) } + if (invitation.expires < new Date()) { + await tx + .update(authZSchema.farmInvitation) + .set({ status: "expired" }) + .where( + eq( + authZSchema.farmInvitation.invitation_id, + invitation_id, + ), + ) + throw new Error("Invitation has expired") + } + // Verify the user has the right to decline this invitation if (invitation.target_principal_id) { const targetDetails = await getPrincipal( diff --git a/fdm-core/src/invitation.test.ts b/fdm-core/src/invitation.test.ts index 8f3f61ff0..b5070a508 100644 --- a/fdm-core/src/invitation.test.ts +++ b/fdm-core/src/invitation.test.ts @@ -3,7 +3,6 @@ import { beforeAll, describe, expect, inject, it } from "vitest" import type { FdmAuth } from "./authentication" import { createFdmAuth } from "./authentication" import { listPrincipalsForResource } from "./authorization" -import * as authNSchema from "./db/schema-authn" import * as authZSchema from "./db/schema-authz" import { addFarm, grantRoleToFarm } from "./farm" import { createFdmServer } from "./fdm-server" @@ -214,8 +213,8 @@ describe("autoAcceptInvitationsForNewUser", () => { } as any, }) - // Auto-accept with the normalized email - await autoAcceptInvitationsForNewUser(fdm, normalizedEmail, caseUser.user.id) + // Auto-accept with the mixed-case email to exercise normalization + await autoAcceptInvitationsForNewUser(fdm, mixedCaseEmail, caseUser.user.id) const principals = await listPrincipalsForResource( fdm, diff --git a/fdm-core/src/invitation.ts b/fdm-core/src/invitation.ts index b08b680b4..7c01757c6 100644 --- a/fdm-core/src/invitation.ts +++ b/fdm-core/src/invitation.ts @@ -78,7 +78,6 @@ export async function autoAcceptInvitationsForNewUser( }) } catch (err) { throw handleError(err, "Exception for autoAcceptInvitationsForNewUser", { - email, user_id, }) } diff --git a/fdm-core/src/principal.ts b/fdm-core/src/principal.ts index b2ccd2a97..8aed6387f 100644 --- a/fdm-core/src/principal.ts +++ b/fdm-core/src/principal.ts @@ -159,12 +159,7 @@ export async function getPrincipal( export async function identifyPrincipal( fdm: FdmType, identifier: string, -): Promise< - | ({ - id: string - } & Principal) - | undefined -> { +): Promise { try { return await fdm.transaction(async (tx: FdmType) => { // Check if principal is an user @@ -194,12 +189,8 @@ export async function identifyPrincipal( // Get the type of the principal const principalDetails = await getPrincipal(tx, principal_id[0].id) - // console.log(principalDetails) - return { - id: principal_id[0].id, - ...principalDetails, - } + return principalDetails }) } catch (err) { throw handleError(err, "Exception for identifyPrincipal", { From 4a0ae7e2be64f9bc536bfa9d48ec3b843284d4fc Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:14:11 +0100 Subject: [PATCH 05/25] refactor: make invitations generic instead of specific for farms --- .changeset/farm-invitation-system.md | 17 +- .../farm.$b_id_farm.settings.access.tsx | 8 +- fdm-app/app/routes/farm._index.tsx | 12 +- .../db/migrations/0023_misty_skullbuster.sql | 16 + .../db/migrations/0023_romantic_dagger.sql | 15 - .../src/db/migrations/meta/0023_snapshot.json | 44 +- fdm-core/src/db/migrations/meta/_journal.json | 4 +- fdm-core/src/db/schema-authz.ts | 21 +- fdm-core/src/farm.test.ts | 71 +- fdm-core/src/farm.ts | 633 +++--------------- fdm-core/src/global-setup.ts | 2 +- fdm-core/src/index.ts | 11 +- fdm-core/src/invitation.test.ts | 14 +- fdm-core/src/invitation.ts | 570 +++++++++++++++- 14 files changed, 782 insertions(+), 656 deletions(-) create mode 100644 fdm-core/src/db/migrations/0023_misty_skullbuster.sql delete mode 100644 fdm-core/src/db/migrations/0023_romantic_dagger.sql diff --git a/.changeset/farm-invitation-system.md b/.changeset/farm-invitation-system.md index c2148de42..5a6650dd9 100644 --- a/.changeset/farm-invitation-system.md +++ b/.changeset/farm-invitation-system.md @@ -2,11 +2,16 @@ "@svenvw/fdm-core": minor --- -Instead of directly granting roles, `grantRoleToFarm` now creates a pending invitation (7-day expiry) that must be accepted by the target principal. +Instead of directly granting roles, `grantRoleToFarm` now creates a pending invitation (7-day expiry) that must be accepted by the target principal. The invitation system has been refactored to be resource-agnostic, so any resource type (farm, field, etc.) can be shared via invitations. -**New functions:** -- `acceptFarmInvitation` — accepts a pending invitation and grants the role -- `declineFarmInvitation` — declines a pending invitation -- `listPendingInvitationsForFarm` — lists active invitations for a farm (requires share permission) -- `listPendingInvitationsForUser` — lists pending invitations for the current user, including farm name and org name +**New generic functions (work for any resource):** +- `createInvitation` — creates a pending invitation for a resource +- `acceptInvitation` — accepts a pending invitation and grants the role +- `declineInvitation` — declines a pending invitation +- `listPendingInvitationsForPrincipal` — lists pending invitations for a principal across all resources - `autoAcceptInvitationsForNewUser` — auto-accepts email-based invitations on email verification + +**Farm-specific functions:** +- `listPendingInvitationsForFarm` — lists active invitations for a farm (requires share permission) +- `listPendingInvitationsForUser` — lists pending farm invitations for the current user, enriched with farm name and org name + diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx index ab53c5502..1d1b3bd2a 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx @@ -1,6 +1,6 @@ import { - acceptFarmInvitation, - declineFarmInvitation, + acceptInvitation, + declineInvitation, getFarm, grantRoleToFarm, isAllowedToShareFarm, @@ -180,7 +180,7 @@ export async function action({ request, params }: ActionFunctionArgs) { if (!formValues.invitation_id) { return handleActionError("missing: invitation_id") } - await acceptFarmInvitation( + await acceptInvitation( fdm, formValues.invitation_id, session.user.id, @@ -194,7 +194,7 @@ export async function action({ request, params }: ActionFunctionArgs) { if (!formValues.invitation_id) { return handleActionError("missing: invitation_id") } - await declineFarmInvitation( + await declineInvitation( fdm, formValues.invitation_id, session.user.id, diff --git a/fdm-app/app/routes/farm._index.tsx b/fdm-app/app/routes/farm._index.tsx index d603dc2df..ab2274b34 100644 --- a/fdm-app/app/routes/farm._index.tsx +++ b/fdm-app/app/routes/farm._index.tsx @@ -1,6 +1,6 @@ import { - acceptFarmInvitation, - declineFarmInvitation, + acceptInvitation, + declineInvitation, getFarms, listPendingInvitationsForUser, } from "@svenvw/fdm-core" @@ -120,7 +120,7 @@ export async function action({ request }: ActionFunctionArgs) { if (!formValues.invitation_id) { return dataWithError(null, "Ontbrekend uitnodigingsnummer") } - await acceptFarmInvitation( + await acceptInvitation( fdm, formValues.invitation_id, session.user.id, @@ -134,7 +134,7 @@ export async function action({ request }: ActionFunctionArgs) { if (!formValues.invitation_id) { return dataWithError(null, "Ontbrekend uitnodigingsnummer") } - await declineFarmInvitation( + await declineInvitation( fdm, formValues.invitation_id, session.user.id, @@ -366,7 +366,7 @@ export default function AppIndex() {
- {invitation.farm_name ?? invitation.farm_id} + {invitation.farm_name ?? invitation.resource_id} {invitation.org_name && ( Voor organisatie: {invitation.org_name} @@ -593,7 +593,7 @@ export default function AppIndex() { - {invitation.farm_name ?? invitation.farm_id} + {invitation.farm_name ?? invitation.resource_id} {invitation.org_name && ( Voor organisatie: {invitation.org_name} diff --git a/fdm-core/src/db/migrations/0023_misty_skullbuster.sql b/fdm-core/src/db/migrations/0023_misty_skullbuster.sql new file mode 100644 index 000000000..d94a59108 --- /dev/null +++ b/fdm-core/src/db/migrations/0023_misty_skullbuster.sql @@ -0,0 +1,16 @@ +CREATE TABLE "fdm-authz"."invitation" ( + "invitation_id" text PRIMARY KEY NOT NULL, + "resource" text NOT NULL, + "resource_id" text NOT NULL, + "target_email" text, + "target_principal_id" text, + "role" text NOT NULL, + "inviter_id" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "expires" timestamp with time zone NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "accepted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE UNIQUE INDEX "invitation_unique_email_idx" ON "fdm-authz"."invitation" USING btree ("resource","resource_id","target_email") WHERE "fdm-authz"."invitation"."status" = 'pending';--> statement-breakpoint +CREATE UNIQUE INDEX "invitation_unique_principal_idx" ON "fdm-authz"."invitation" USING btree ("resource","resource_id","target_principal_id") WHERE "fdm-authz"."invitation"."status" = 'pending'; \ No newline at end of file diff --git a/fdm-core/src/db/migrations/0023_romantic_dagger.sql b/fdm-core/src/db/migrations/0023_romantic_dagger.sql deleted file mode 100644 index 81ca30664..000000000 --- a/fdm-core/src/db/migrations/0023_romantic_dagger.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "fdm-authz"."farm_invitation" ( - "invitation_id" text PRIMARY KEY NOT NULL, - "farm_id" text NOT NULL, - "target_email" text, - "target_principal_id" text, - "role" text NOT NULL, - "inviter_id" text NOT NULL, - "status" text DEFAULT 'pending' NOT NULL, - "expires" timestamp with time zone NOT NULL, - "created" timestamp with time zone DEFAULT now() NOT NULL, - "accepted_at" timestamp with time zone -); ---> statement-breakpoint -CREATE UNIQUE INDEX "farm_invitation_unique_email_idx" ON "fdm-authz"."farm_invitation" USING btree ("farm_id","target_email") WHERE "fdm-authz"."farm_invitation"."status" = 'pending';--> statement-breakpoint -CREATE UNIQUE INDEX "farm_invitation_unique_principal_idx" ON "fdm-authz"."farm_invitation" USING btree ("farm_id","target_principal_id") WHERE "fdm-authz"."farm_invitation"."status" = 'pending'; \ No newline at end of file diff --git a/fdm-core/src/db/migrations/meta/0023_snapshot.json b/fdm-core/src/db/migrations/meta/0023_snapshot.json index 4b01db6ef..9cf7f83d3 100644 --- a/fdm-core/src/db/migrations/meta/0023_snapshot.json +++ b/fdm-core/src/db/migrations/meta/0023_snapshot.json @@ -1,5 +1,5 @@ { - "id": "d0d8c439-9f2f-4e24-9a46-df2d2caddb17", + "id": "8edcbd6c-ae47-4531-817e-34748171ba25", "prevId": "8a96c296-24b8-4edc-bdbc-b140c973f74e", "version": "7", "dialect": "postgresql", @@ -3336,8 +3336,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "fdm-authz.farm_invitation": { - "name": "farm_invitation", + "fdm-authz.invitation": { + "name": "invitation", "schema": "fdm-authz", "columns": { "invitation_id": { @@ -3346,8 +3346,14 @@ "primaryKey": true, "notNull": true }, - "farm_id": { - "name": "farm_id", + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", "type": "text", "primaryKey": false, "notNull": true @@ -3404,11 +3410,17 @@ } }, "indexes": { - "farm_invitation_unique_email_idx": { - "name": "farm_invitation_unique_email_idx", + "invitation_unique_email_idx": { + "name": "invitation_unique_email_idx", "columns": [ { - "expression": "farm_id", + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", "isExpression": false, "asc": true, "nulls": "last" @@ -3421,16 +3433,22 @@ } ], "isUnique": true, - "where": "\"fdm-authz\".\"farm_invitation\".\"status\" = 'pending'", + "where": "\"fdm-authz\".\"invitation\".\"status\" = 'pending'", "concurrently": false, "method": "btree", "with": {} }, - "farm_invitation_unique_principal_idx": { - "name": "farm_invitation_unique_principal_idx", + "invitation_unique_principal_idx": { + "name": "invitation_unique_principal_idx", "columns": [ { - "expression": "farm_id", + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", "isExpression": false, "asc": true, "nulls": "last" @@ -3443,7 +3461,7 @@ } ], "isUnique": true, - "where": "\"fdm-authz\".\"farm_invitation\".\"status\" = 'pending'", + "where": "\"fdm-authz\".\"invitation\".\"status\" = 'pending'", "concurrently": false, "method": "btree", "with": {} diff --git a/fdm-core/src/db/migrations/meta/_journal.json b/fdm-core/src/db/migrations/meta/_journal.json index 6f421fa6b..68ab414bf 100644 --- a/fdm-core/src/db/migrations/meta/_journal.json +++ b/fdm-core/src/db/migrations/meta/_journal.json @@ -166,8 +166,8 @@ { "idx": 23, "version": "7", - "when": 1771513245902, - "tag": "0023_romantic_dagger", + "when": 1771578248864, + "tag": "0023_misty_skullbuster", "breakpoints": true } ] diff --git a/fdm-core/src/db/schema-authz.ts b/fdm-core/src/db/schema-authz.ts index c3b95ce73..d6074f133 100644 --- a/fdm-core/src/db/schema-authz.ts +++ b/fdm-core/src/db/schema-authz.ts @@ -56,11 +56,12 @@ export const audit = fdmAuthZSchema.table("audit", { export type auditTypeSelect = typeof audit.$inferSelect export type auditTypeInsert = typeof audit.$inferInsert -export const farmInvitation = fdmAuthZSchema.table( - "farm_invitation", +export const invitation = fdmAuthZSchema.table( + "invitation", { invitation_id: text().primaryKey(), - farm_id: text().notNull(), + resource: text().notNull(), // e.g. 'farm', 'field' + resource_id: text().notNull(), // e.g. the farm/field ID target_email: text(), // For unregistered users (lowercased/trimmed) target_principal_id: text(), // For registered users or organizations role: text().notNull(), // 'owner', 'advisor', 'researcher' @@ -71,15 +72,15 @@ export const farmInvitation = fdmAuthZSchema.table( accepted_at: timestamp({ withTimezone: true }), }, (table) => [ - // Prevent duplicate pending invitations for the same target/farm - uniqueIndex("farm_invitation_unique_email_idx") - .on(table.farm_id, table.target_email) + // Prevent duplicate pending invitations for the same target/resource + uniqueIndex("invitation_unique_email_idx") + .on(table.resource, table.resource_id, table.target_email) .where(sql`${table.status} = 'pending'`), - uniqueIndex("farm_invitation_unique_principal_idx") - .on(table.farm_id, table.target_principal_id) + uniqueIndex("invitation_unique_principal_idx") + .on(table.resource, table.resource_id, table.target_principal_id) .where(sql`${table.status} = 'pending'`), ], ) -export type farmInvitationTypeSelect = typeof farmInvitation.$inferSelect -export type farmInvitationTypeInsert = typeof farmInvitation.$inferInsert +export type invitationTypeSelect = typeof invitation.$inferSelect +export type invitationTypeInsert = typeof invitation.$inferInsert diff --git a/fdm-core/src/farm.test.ts b/fdm-core/src/farm.test.ts index 07e6766fb..0e196f226 100644 --- a/fdm-core/src/farm.test.ts +++ b/fdm-core/src/farm.test.ts @@ -7,9 +7,7 @@ import * as authNSchema from "./db/schema-authn" import * as authZSchema from "./db/schema-authz" import * as schema from "./db/schema" import { - acceptFarmInvitation, addFarm, - declineFarmInvitation, getFarm, getFarms, grantRoleToFarm, @@ -27,6 +25,7 @@ import type { FdmType } from "./fdm" import { createFdmServer } from "./fdm-server" import type { FdmServerType } from "./fdm-server.d" import { addFertilizer, addFertilizerToCatalogue } from "./fertilizer" +import { acceptInvitation, declineInvitation } from "./invitation" import { addField, getFields } from "./field" import { createId } from "./id" import { getPrincipal } from "./principal" @@ -87,7 +86,7 @@ describe("Farm Functions", () => { }) target_id = target.user.id - // Mark target's email as verified so acceptFarmInvitation works + // Mark target's email as verified so acceptInvitation works await fdm .update(authNSchema.user) .set({ emailVerified: true }) @@ -267,9 +266,9 @@ describe("Farm Functions", () => { // Verify invitation was created (not a direct role grant) const invitations = await fdm .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( - eq(authZSchema.farmInvitation.farm_id, b_id_farm), + eq(authZSchema.invitation.resource_id, b_id_farm), ) const invitation = invitations.find( (i) => i.target_principal_id === target_id, @@ -279,7 +278,7 @@ describe("Farm Functions", () => { expect(invitation?.status).toBe("pending") // Accept the invitation so subsequent tests (updateRole, revoke) work - await acceptFarmInvitation(fdm, invitation!.invitation_id, target_id) + await acceptInvitation(fdm, invitation!.invitation_id, target_id) // Now the role should be granted const principals = await listPrincipalsForResource( @@ -370,9 +369,9 @@ describe("Farm Functions", () => { const invitations = await fdm .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( - eq(authZSchema.farmInvitation.target_email, unregisteredEmail), + eq(authZSchema.invitation.target_email, unregisteredEmail), ) expect(invitations.length).toBeGreaterThanOrEqual(1) expect(invitations[0].status).toBe("pending") @@ -776,8 +775,8 @@ describe("Farm Functions", () => { // Verify farm invitations are deleted const farmInvitations = await fdm .select() - .from(authZSchema.farmInvitation) - .where(eq(authZSchema.farmInvitation.farm_id, testFarmId)) + .from(authZSchema.invitation) + .where(eq(authZSchema.invitation.resource_id, testFarmId)) expect(farmInvitations).toEqual([]) }) @@ -831,7 +830,7 @@ describe("Farm Functions", () => { }) }) - describe("acceptFarmInvitation", () => { + describe("acceptInvitation", () => { let invitationFarmId: string let invitationId: string @@ -858,13 +857,13 @@ describe("Farm Functions", () => { const rows = await fdm .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( - eq(authZSchema.farmInvitation.farm_id, invitationFarmId), + eq(authZSchema.invitation.resource_id, invitationFarmId), ) invitationId = rows[0].invitation_id - await acceptFarmInvitation(fdm, invitationId, target_id) + await acceptInvitation(fdm, invitationId, target_id) const principals = await listPrincipalsForResource( fdm, @@ -878,14 +877,14 @@ describe("Farm Functions", () => { it("should throw if invitation is already accepted", async () => { await expect( - acceptFarmInvitation(fdm, invitationId, target_id), - ).rejects.toThrowError("Exception for acceptFarmInvitation") + acceptInvitation(fdm, invitationId, target_id), + ).rejects.toThrowError("Exception for acceptInvitation") }) it("should throw if invitation does not exist", async () => { await expect( - acceptFarmInvitation(fdm, createId(), target_id), - ).rejects.toThrowError("Exception for acceptFarmInvitation") + acceptInvitation(fdm, createId(), target_id), + ).rejects.toThrowError("Exception for acceptInvitation") }) it("should throw if the user is not the invitation target", async () => { @@ -906,8 +905,8 @@ describe("Farm Functions", () => { ) const rows = await fdm .select() - .from(authZSchema.farmInvitation) - .where(eq(authZSchema.farmInvitation.farm_id, otherFarmId)) + .from(authZSchema.invitation) + .where(eq(authZSchema.invitation.resource_id, otherFarmId)) const otherInvitationId = rows[0].invitation_id const wrongUser = await fdmAuth.api.signUpEmail({ @@ -921,12 +920,12 @@ describe("Farm Functions", () => { }) await expect( - acceptFarmInvitation(fdm, otherInvitationId, wrongUser.user.id), - ).rejects.toThrowError("Exception for acceptFarmInvitation") + acceptInvitation(fdm, otherInvitationId, wrongUser.user.id), + ).rejects.toThrowError("Exception for acceptInvitation") }) }) - describe("declineFarmInvitation", () => { + describe("declineInvitation", () => { let declineFarmId: string let declineInvitationId: string @@ -948,20 +947,20 @@ describe("Farm Functions", () => { ) const rows = await fdm .select() - .from(authZSchema.farmInvitation) - .where(eq(authZSchema.farmInvitation.farm_id, declineFarmId)) + .from(authZSchema.invitation) + .where(eq(authZSchema.invitation.resource_id, declineFarmId)) declineInvitationId = rows[0].invitation_id }) it("should decline a pending invitation", async () => { - await declineFarmInvitation(fdm, declineInvitationId, target_id) + await declineInvitation(fdm, declineInvitationId, target_id) const rows = await fdm .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( eq( - authZSchema.farmInvitation.invitation_id, + authZSchema.invitation.invitation_id, declineInvitationId, ), ) @@ -970,8 +969,8 @@ describe("Farm Functions", () => { it("should throw if invitation is already declined", async () => { await expect( - declineFarmInvitation(fdm, declineInvitationId, target_id), - ).rejects.toThrowError("Exception for declineFarmInvitation") + declineInvitation(fdm, declineInvitationId, target_id), + ).rejects.toThrowError("Exception for declineInvitation") }) it("should throw if the user is not the invitation target", async () => { @@ -992,8 +991,8 @@ describe("Farm Functions", () => { ) const rows = await fdm .select() - .from(authZSchema.farmInvitation) - .where(eq(authZSchema.farmInvitation.farm_id, anotherFarmId)) + .from(authZSchema.invitation) + .where(eq(authZSchema.invitation.resource_id, anotherFarmId)) const anotherInvitationId = rows[0].invitation_id const otherUser = await fdmAuth.api.signUpEmail({ @@ -1007,8 +1006,8 @@ describe("Farm Functions", () => { }) await expect( - declineFarmInvitation(fdm, anotherInvitationId, otherUser.user.id), - ).rejects.toThrowError("Exception for declineFarmInvitation") + declineInvitation(fdm, anotherInvitationId, otherUser.user.id), + ).rejects.toThrowError("Exception for declineInvitation") }) }) @@ -1041,7 +1040,7 @@ describe("Farm Functions", () => { ) expect(invitations.length).toBeGreaterThanOrEqual(1) expect(invitations[0].status).toBe("pending") - expect(invitations[0].farm_id).toBe(listFarmId) + expect(invitations[0].resource_id).toBe(listFarmId) }) it("should throw if principal does not have share permission", async () => { @@ -1092,7 +1091,7 @@ describe("Farm Functions", () => { listUserTargetId, ) expect(invitations.length).toBeGreaterThanOrEqual(1) - const inv = invitations.find((i) => i.farm_id === listUserFarmId) + const inv = invitations.find((i) => i.resource_id === listUserFarmId) expect(inv).toBeDefined() expect(inv?.status).toBe("pending") }) diff --git a/fdm-core/src/farm.ts b/fdm-core/src/farm.ts index a8a4f3dce..b308b1b56 100644 --- a/fdm-core/src/farm.ts +++ b/fdm-core/src/farm.ts @@ -1,5 +1,4 @@ -import { and, asc, eq, gt, inArray, or } from "drizzle-orm" -import isEmail from "validator/lib/isEmail" +import { and, asc, eq, gt, inArray } from "drizzle-orm" import { checkPermission, getRolesOfPrincipalForResource, @@ -17,7 +16,11 @@ import { handleError } from "./error" import type { FdmType } from "./fdm" import { removeField } from "./field" import { createId } from "./id" -import { getPrincipal, identifyPrincipal } from "./principal" +import { + createInvitation, + listPendingInvitationsForPrincipal, +} from "./invitation" +import { identifyPrincipal } from "./principal" import type { Principal } from "./principal.d" /** @@ -301,17 +304,18 @@ export async function updateFarm( } /** - * Grants a specified role to a principal for a given farm. + * Grants a specified role to a principal for a given farm via an invitation. * - * This function checks if the acting principal has 'share' permission on the farm, then grants the specified role to the grantee. + * Checks if the acting principal has 'share' permission on the farm, then creates + * an invitation for the target. Delegates to {@link createInvitation}. * - * @param fdm - The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. + * @param fdm - The FDM instance providing the connection to the database. * @param principal_id - The identifier of the principal performing the grant (must have 'share' permission). - * @param target - The username, email or slug of the principal whose role is being updated. + * @param target - The username, email, or slug of the invitee. * @param b_id_farm - The identifier of the farm. * @param role - The role to be granted ('owner', 'advisor', or 'researcher'). * - * @throws {Error} If the acting principal does not have 'share' permission, or if any other error occurs during the operation. + * @throws {Error} If the acting principal does not have 'share' permission, or if any other error occurs. */ export async function grantRoleToFarm( fdm: FdmType, @@ -321,129 +325,22 @@ export async function grantRoleToFarm( role: "owner" | "advisor" | "researcher", ): Promise { try { - return await fdm.transaction(async (tx: FdmType) => { - await checkPermission( - tx, - "farm", - "share", - b_id_farm, - principal_id, - "grantRoleToFarm", - ) - - const normalizedTarget = target.toLowerCase().trim() - - // Check if target is a registered principal (user or organization) - const targetDetails = await identifyPrincipal(tx, normalizedTarget) - - let targetEmail: string | null = null - let targetPrincipalId: string | null = null - - if (targetDetails) { - // Registered user or organization - targetPrincipalId = targetDetails.id - - // Check if target is already a member of this farm - const existingMembers = await listPrincipalsForResource( - tx, - "farm", - b_id_farm, - ) - const isAlreadyMember = existingMembers.some( - (m) => m.principal_id === targetPrincipalId, - ) - if (isAlreadyMember) { - throw new Error("Target is already a member of this farm") - } - } else { - // Check if target is a valid email (unregistered user) - if (!isEmail(normalizedTarget)) { - throw new Error( - "Target not found and not a valid email address", - ) - } - targetEmail = normalizedTarget - } - - const expires = new Date() - expires.setDate(expires.getDate() + 7) // 7 days expiry - - if (targetEmail) { - // Check for existing pending invitation for this email + farm - const existing = await tx - .select() - .from(authZSchema.farmInvitation) - .where( - and( - eq(authZSchema.farmInvitation.farm_id, b_id_farm), - eq( - authZSchema.farmInvitation.target_email, - targetEmail, - ), - eq(authZSchema.farmInvitation.status, "pending"), - ), - ) - .limit(1) - - if (existing.length > 0) { - await tx - .update(authZSchema.farmInvitation) - .set({ role, inviter_id: principal_id, expires }) - .where( - eq( - authZSchema.farmInvitation.invitation_id, - existing[0].invitation_id, - ), - ) - } else { - await tx.insert(authZSchema.farmInvitation).values({ - invitation_id: createId(), - farm_id: b_id_farm, - target_email: targetEmail, - role, - inviter_id: principal_id, - expires, - }) - } - } else { - // Check for existing pending invitation for this principal + farm - const existing = await tx - .select() - .from(authZSchema.farmInvitation) - .where( - and( - eq(authZSchema.farmInvitation.farm_id, b_id_farm), - eq( - authZSchema.farmInvitation.target_principal_id, - targetPrincipalId!, - ), - eq(authZSchema.farmInvitation.status, "pending"), - ), - ) - .limit(1) - - if (existing.length > 0) { - await tx - .update(authZSchema.farmInvitation) - .set({ role, inviter_id: principal_id, expires }) - .where( - eq( - authZSchema.farmInvitation.invitation_id, - existing[0].invitation_id, - ), - ) - } else { - await tx.insert(authZSchema.farmInvitation).values({ - invitation_id: createId(), - farm_id: b_id_farm, - target_principal_id: targetPrincipalId, - role, - inviter_id: principal_id, - expires, - }) - } - } - }) + await checkPermission( + fdm, + "farm", + "share", + b_id_farm, + principal_id, + "grantRoleToFarm", + ) + return await createInvitation( + fdm, + "farm", + b_id_farm, + principal_id, + target, + role, + ) } catch (err) { throw handleError(err, "Exception for grantRoleToFarm", { b_id_farm, @@ -609,18 +506,19 @@ export async function listPrincipalsForFarm( const now = new Date() const pendingInvitations = await tx .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( and( - eq(authZSchema.farmInvitation.farm_id, b_id_farm), - eq(authZSchema.farmInvitation.status, "pending"), - gt(authZSchema.farmInvitation.expires, now), + eq(authZSchema.invitation.resource, "farm"), + eq(authZSchema.invitation.resource_id, b_id_farm), + eq(authZSchema.invitation.status, "pending"), + gt(authZSchema.invitation.expires, now), ), ) const activeIds = principals.map((p) => p.principal_id) const pendingIdsWithPrincipal: string[] = ( - pendingInvitations as authZSchema.farmInvitationTypeSelect[] + pendingInvitations as authZSchema.invitationTypeSelect[] ) .map((i) => i.target_principal_id) .filter((id): id is string => id !== null) @@ -742,7 +640,7 @@ export async function listPrincipalsForFarm( // Map pending invitations const pendingDetails = pendingInvitations.map( - (invitation: authZSchema.farmInvitationTypeSelect) => { + (invitation: authZSchema.invitationTypeSelect) => { if (invitation.target_principal_id) { const details = principalsMap.get( invitation.target_principal_id, @@ -790,309 +688,6 @@ export async function listPrincipalsForFarm( } } -/** - * Accepts a pending farm invitation on behalf of the acting user. - * - * For user-targeted invitations: verifies email is verified and matches the invitation target. - * For organization-targeted invitations: verifies the acting user is an admin or owner of the organization. - * - * @param fdm - The FDM instance providing the connection to the database. - * @param invitation_id - The unique identifier of the invitation to accept. - * @param user_id - The ID of the user accepting the invitation. - * - * @throws {Error} If the invitation is not found, already processed, expired, or the user is not authorized. - */ -export async function acceptFarmInvitation( - fdm: FdmType, - invitation_id: string, - user_id: string, -): Promise { - try { - return await fdm.transaction(async (tx: FdmType) => { - // Atomically claim the invitation: only succeeds if still pending and not expired - const claimed = await tx - .update(authZSchema.farmInvitation) - .set({ status: "accepted", accepted_at: new Date() }) - .where( - and( - eq( - authZSchema.farmInvitation.invitation_id, - invitation_id, - ), - eq(authZSchema.farmInvitation.status, "pending"), - gt(authZSchema.farmInvitation.expires, new Date()), - ), - ) - .returning() - - if (claimed.length === 0) { - // Determine the precise reason for failure - const existing = await tx - .select() - .from(authZSchema.farmInvitation) - .where( - eq( - authZSchema.farmInvitation.invitation_id, - invitation_id, - ), - ) - .limit(1) - - if (existing.length === 0) { - throw new Error("Invitation not found") - } - const inv = existing[0] - if (inv.status !== "pending") { - throw new Error(`Invitation is already ${inv.status}`) - } - // Must be expired - await tx - .update(authZSchema.farmInvitation) - .set({ status: "expired" }) - .where( - eq( - authZSchema.farmInvitation.invitation_id, - invitation_id, - ), - ) - throw new Error("Invitation has expired") - } - - const invitation = claimed[0] - let granteeId: string - - if (invitation.target_principal_id) { - const targetDetails = await getPrincipal( - tx, - invitation.target_principal_id, - ) - if (!targetDetails) { - throw new Error("Invitation target not found") - } - - if (targetDetails.type === "organization") { - // Verify user is admin or owner of the organization - const membership = await tx - .select() - .from(authNSchema.member) - .where( - and( - eq( - authNSchema.member.organizationId, - invitation.target_principal_id, - ), - eq(authNSchema.member.userId, user_id), - ), - ) - .limit(1) - - if ( - membership.length === 0 || - !["admin", "owner"].includes(membership[0].role) - ) { - throw new Error( - "Only admins or owners can accept farm invitations on behalf of an organization", - ) - } - } else { - // User target: verify it matches the accepting user - if (invitation.target_principal_id !== user_id) { - throw new Error("This invitation is not for you") - } - const userRecord = await tx - .select({ - emailVerified: authNSchema.user.emailVerified, - }) - .from(authNSchema.user) - .where(eq(authNSchema.user.id, user_id)) - .limit(1) - - if ( - userRecord.length === 0 || - !userRecord[0].emailVerified - ) { - throw new Error( - "Email must be verified before accepting a farm invitation", - ) - } - } - - granteeId = invitation.target_principal_id - } else if (invitation.target_email) { - // Email-based invitation: match accepting user's email - const userRecord = await tx - .select({ - email: authNSchema.user.email, - emailVerified: authNSchema.user.emailVerified, - }) - .from(authNSchema.user) - .where(eq(authNSchema.user.id, user_id)) - .limit(1) - - if (userRecord.length === 0) { - throw new Error("User not found") - } - if ( - userRecord[0].email.toLowerCase().trim() !== - invitation.target_email - ) { - throw new Error( - "This invitation is not for your email address", - ) - } - if (!userRecord[0].emailVerified) { - throw new Error( - "Email must be verified before accepting a farm invitation", - ) - } - granteeId = user_id - } else { - throw new Error("Invalid invitation: no target specified") - } - - // Grant the role - await grantRole( - tx, - "farm", - invitation.role as "owner" | "advisor" | "researcher", - invitation.farm_id, - granteeId, - ) - - // Update target_principal_id if this was an email-based invitation - if (!invitation.target_principal_id) { - await tx - .update(authZSchema.farmInvitation) - .set({ target_principal_id: granteeId }) - .where( - eq( - authZSchema.farmInvitation.invitation_id, - invitation_id, - ), - ) - } - }) - } catch (err) { - throw handleError(err, "Exception for acceptFarmInvitation", { - invitation_id, - user_id, - }) - } -} - -/** - * Declines a pending farm invitation on behalf of the acting user. - * - * @param fdm - The FDM instance providing the connection to the database. - * @param invitation_id - The unique identifier of the invitation to decline. - * @param user_id - The ID of the user declining the invitation. - * - * @throws {Error} If the invitation is not found, already processed, or the user is not authorized. - */ -export async function declineFarmInvitation( - fdm: FdmType, - invitation_id: string, - user_id: string, -): Promise { - try { - return await fdm.transaction(async (tx: FdmType) => { - const invitations = await tx - .select() - .from(authZSchema.farmInvitation) - .where( - eq(authZSchema.farmInvitation.invitation_id, invitation_id), - ) - .limit(1) - - if (invitations.length === 0) { - throw new Error("Invitation not found") - } - const invitation = invitations[0] - - if (invitation.status !== "pending") { - throw new Error(`Invitation is already ${invitation.status}`) - } - - if (invitation.expires < new Date()) { - await tx - .update(authZSchema.farmInvitation) - .set({ status: "expired" }) - .where( - eq( - authZSchema.farmInvitation.invitation_id, - invitation_id, - ), - ) - throw new Error("Invitation has expired") - } - - // Verify the user has the right to decline this invitation - if (invitation.target_principal_id) { - const targetDetails = await getPrincipal( - tx, - invitation.target_principal_id, - ) - if (targetDetails?.type === "organization") { - const membership = await tx - .select() - .from(authNSchema.member) - .where( - and( - eq( - authNSchema.member.organizationId, - invitation.target_principal_id, - ), - eq(authNSchema.member.userId, user_id), - ), - ) - .limit(1) - - if ( - membership.length === 0 || - !["admin", "owner"].includes(membership[0].role) - ) { - throw new Error( - "Only admins or owners can decline farm invitations on behalf of an organization", - ) - } - } else { - if (invitation.target_principal_id !== user_id) { - throw new Error("This invitation is not for you") - } - } - } else if (invitation.target_email) { - const userRecord = await tx - .select({ email: authNSchema.user.email }) - .from(authNSchema.user) - .where(eq(authNSchema.user.id, user_id)) - .limit(1) - - if ( - userRecord.length === 0 || - userRecord[0].email.toLowerCase().trim() !== - invitation.target_email - ) { - throw new Error( - "This invitation is not for your email address", - ) - } - } - - await tx - .update(authZSchema.farmInvitation) - .set({ status: "declined" }) - .where( - eq(authZSchema.farmInvitation.invitation_id, invitation_id), - ) - }) - } catch (err) { - throw handleError(err, "Exception for declineFarmInvitation", { - invitation_id, - user_id, - }) - } -} - /** * Lists all pending (non-expired) invitations for a specific farm. * @@ -1102,13 +697,13 @@ export async function declineFarmInvitation( * @param principal_id - The identifier of the principal requesting the list (must have 'share' permission). * @param b_id_farm - The identifier of the farm. * - * @returns A Promise that resolves to an array of pending farm invitation records. + * @returns A Promise that resolves to an array of pending invitation records for this farm. */ export async function listPendingInvitationsForFarm( fdm: FdmType, principal_id: string, b_id_farm: string, -): Promise { +): Promise { try { return await fdm.transaction(async (tx: FdmType) => { await checkPermission( @@ -1123,12 +718,13 @@ export async function listPendingInvitationsForFarm( const now = new Date() return await tx .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( and( - eq(authZSchema.farmInvitation.farm_id, b_id_farm), - eq(authZSchema.farmInvitation.status, "pending"), - gt(authZSchema.farmInvitation.expires, now), + eq(authZSchema.invitation.resource, "farm"), + eq(authZSchema.invitation.resource_id, b_id_farm), + eq(authZSchema.invitation.status, "pending"), + gt(authZSchema.invitation.expires, now), ), ) }) @@ -1140,114 +736,93 @@ export async function listPendingInvitationsForFarm( } /** - * Lists all pending (non-expired) farm invitations for a given user. + * Lists all pending (non-expired) farm invitations for a given user, enriched with farm and organization names. * - * Returns invitations where the target matches the user's principal ID, email address, - * or an organization for which the user is an admin or owner. + * Delegates to {@link listPendingInvitationsForPrincipal} and enriches farm-resource rows with names. * * @param fdm - The FDM instance providing the connection to the database. * @param user_id - The ID of the user to retrieve invitations for. * - * @returns A Promise that resolves to an array of pending farm invitation records. + * @returns A Promise that resolves to an array of pending invitation records enriched with farm_name and org_name. */ export async function listPendingInvitationsForUser( fdm: FdmType, user_id: string, ): Promise< - (authZSchema.farmInvitationTypeSelect & { + (authZSchema.invitationTypeSelect & { farm_name: string | null org_name: string | null })[] > { try { return await fdm.transaction(async (tx: FdmType) => { - // Get user's email - const userRecord = await tx - .select({ email: authNSchema.user.email }) - .from(authNSchema.user) - .where(eq(authNSchema.user.id, user_id)) - .limit(1) + const pending = await listPendingInvitationsForPrincipal( + tx, + user_id, + ) - if (userRecord.length === 0) { + if (pending.length === 0) { return [] } - const userEmail = userRecord[0].email.toLowerCase().trim() - // Get organization IDs where user is admin or owner - const orgMemberships = await tx - .select({ organizationId: authNSchema.member.organizationId }) - .from(authNSchema.member) - .where( - and( - eq(authNSchema.member.userId, user_id), - inArray(authNSchema.member.role, ["admin", "owner"]), - ), - ) - const orgIds = orgMemberships.map( - (m: { organizationId: string }) => m.organizationId, - ) + // Enrich with farm names for farm-resource invitations + const farmIds = [ + ...new Set( + pending + .filter((i) => i.resource === "farm") + .map((i) => i.resource_id), + ), + ] - const now = new Date() + const farmNames = new Map() + if (farmIds.length > 0) { + const farms = await tx + .select({ + b_id_farm: schema.farms.b_id_farm, + b_name_farm: schema.farms.b_name_farm, + }) + .from(schema.farms) + .where(inArray(schema.farms.b_id_farm, farmIds)) - // Build conditions: by email, by principal_id, or by managed org - const conditions = [ - and( - eq(authZSchema.farmInvitation.target_email, userEmail), - eq(authZSchema.farmInvitation.status, "pending"), - gt(authZSchema.farmInvitation.expires, now), - ), - and( - eq(authZSchema.farmInvitation.target_principal_id, user_id), - eq(authZSchema.farmInvitation.status, "pending"), - gt(authZSchema.farmInvitation.expires, now), + for (const f of farms) { + farmNames.set(f.b_id_farm, f.b_name_farm) + } + } + + // Collect org IDs from principal-targeted invitations + const orgTargetIds = [ + ...new Set( + pending + .filter((i) => i.target_principal_id !== null) + .map((i) => i.target_principal_id as string), ), ] - if (orgIds.length > 0) { - conditions.push( - and( - inArray( - authZSchema.farmInvitation.target_principal_id, - orgIds, - ), - eq(authZSchema.farmInvitation.status, "pending"), - gt(authZSchema.farmInvitation.expires, now), - ), - ) + const orgNames = new Map() + if (orgTargetIds.length > 0) { + const orgs = await tx + .select({ + id: authNSchema.organization.id, + name: authNSchema.organization.name, + }) + .from(authNSchema.organization) + .where(inArray(authNSchema.organization.id, orgTargetIds)) + + for (const o of orgs) { + orgNames.set(o.id, o.name) + } } - return await tx - .select({ - invitation_id: authZSchema.farmInvitation.invitation_id, - farm_id: authZSchema.farmInvitation.farm_id, - farm_name: schema.farms.b_name_farm, - org_name: authNSchema.organization.name, - target_email: authZSchema.farmInvitation.target_email, - target_principal_id: - authZSchema.farmInvitation.target_principal_id, - role: authZSchema.farmInvitation.role, - inviter_id: authZSchema.farmInvitation.inviter_id, - status: authZSchema.farmInvitation.status, - expires: authZSchema.farmInvitation.expires, - created: authZSchema.farmInvitation.created, - accepted_at: authZSchema.farmInvitation.accepted_at, - }) - .from(authZSchema.farmInvitation) - .leftJoin( - schema.farms, - eq( - authZSchema.farmInvitation.farm_id, - schema.farms.b_id_farm, - ), - ) - .leftJoin( - authNSchema.organization, - eq( - authZSchema.farmInvitation.target_principal_id, - authNSchema.organization.id, - ), - ) - .where(or(...conditions)) + return pending.map((i) => ({ + ...i, + farm_name: + i.resource === "farm" + ? (farmNames.get(i.resource_id) ?? null) + : null, + org_name: i.target_principal_id + ? (orgNames.get(i.target_principal_id) ?? null) + : null, + })) }) } catch (err) { throw handleError(err, "Exception for listPendingInvitationsForUser", { @@ -1538,8 +1113,8 @@ export async function removeFarm( // Step 4b: Delete all invitations for this farm await tx - .delete(authZSchema.farmInvitation) - .where(eq(authZSchema.farmInvitation.farm_id, b_id_farm)) + .delete(authZSchema.invitation) + .where(eq(authZSchema.invitation.resource_id, b_id_farm)) // Step 5: Finally, delete the farm itself await tx diff --git a/fdm-core/src/global-setup.ts b/fdm-core/src/global-setup.ts index 0a821a10e..837dc2c4a 100644 --- a/fdm-core/src/global-setup.ts +++ b/fdm-core/src/global-setup.ts @@ -86,7 +86,7 @@ export async function teardown() { await fdm.delete(authZSchema.role).execute() await fdm.delete(authZSchema.audit).execute() - await fdm.delete(authZSchema.farmInvitation).execute() + await fdm.delete(authZSchema.invitation).execute() }) } catch (error) { console.error("Error cleaning up database tables:", error) diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index e9c00311c..565299691 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -65,8 +65,6 @@ export { } from "./derogation" export { addFarm, - acceptFarmInvitation, - declineFarmInvitation, getFarm, getFarms, grantRoleToFarm, @@ -148,7 +146,14 @@ export { removeOrganicCertification, } from "./organic" export type { OrganicCertification } from "./organic.d" -export { autoAcceptInvitationsForNewUser } from "./invitation" +export { + autoAcceptInvitationsForNewUser, + createInvitation, + acceptInvitation, + declineInvitation, + listPendingInvitationsForPrincipal, +} from "./invitation" +export type { invitationTypeSelect, invitationTypeInsert } from "./db/schema-authz" export { lookupPrincipal, } from "./principal" diff --git a/fdm-core/src/invitation.test.ts b/fdm-core/src/invitation.test.ts index b5070a508..c6247f6d3 100644 --- a/fdm-core/src/invitation.test.ts +++ b/fdm-core/src/invitation.test.ts @@ -96,11 +96,11 @@ describe("autoAcceptInvitationsForNewUser", () => { // Invitation should be marked as accepted const invitations = await fdm .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( and( - eq(authZSchema.farmInvitation.target_email, targetEmail), - eq(authZSchema.farmInvitation.farm_id, farmId), + eq(authZSchema.invitation.target_email, targetEmail), + eq(authZSchema.invitation.resource_id, farmId), ), ) expect(invitations[0].status).toBe("accepted") @@ -129,9 +129,9 @@ describe("autoAcceptInvitationsForNewUser", () => { // Manually expire the invitation await fdm - .update(authZSchema.farmInvitation) + .update(authZSchema.invitation) .set({ expires: new Date("2000-01-01") }) - .where(eq(authZSchema.farmInvitation.target_email, expiredEmail)) + .where(eq(authZSchema.invitation.target_email, expiredEmail)) const expiredUser = await fdmAuth.api.signUpEmail({ headers: undefined, @@ -159,8 +159,8 @@ describe("autoAcceptInvitationsForNewUser", () => { // Invitation should be marked as expired const invitations = await fdm .select() - .from(authZSchema.farmInvitation) - .where(eq(authZSchema.farmInvitation.target_email, expiredEmail)) + .from(authZSchema.invitation) + .where(eq(authZSchema.invitation.target_email, expiredEmail)) expect(invitations[0].status).toBe("expired") }) diff --git a/fdm-core/src/invitation.ts b/fdm-core/src/invitation.ts index 7c01757c6..8a68ceaa0 100644 --- a/fdm-core/src/invitation.ts +++ b/fdm-core/src/invitation.ts @@ -1,15 +1,541 @@ -import { and, eq } from "drizzle-orm" +import { and, eq, gt, inArray, or } from "drizzle-orm" +import isEmail from "validator/lib/isEmail" +import { + grantRole, + listPrincipalsForResource, +} from "./authorization" +import type { Resource, Role } from "./authorization.d" +import * as authNSchema from "./db/schema-authn" import * as authZSchema from "./db/schema-authz" import { handleError } from "./error" import type { FdmType } from "./fdm" -import { grantRole } from "./authorization" +import { createId } from "./id" +import { identifyPrincipal } from "./principal" /** - * Automatically accepts all pending farm invitations for a newly verified user. + * Creates an invitation for a principal or email address to access a resource. * - * This function looks up pending invitations matching the user's email address - * and grants the corresponding roles. It MUST only be called when `emailVerified` - * is confirmed to be true. + * If the target is a registered principal, a principal-based invitation is created. + * If the target is a valid email address, an email-based invitation is created for + * unregistered users; access will be auto-granted upon registration and email verification. + * + * The inviter must have the necessary permission (enforced by the caller). + * + * @param fdm - The FDM instance providing the connection to the database. + * @param resource - The resource type (e.g. 'farm', 'field'). + * @param resource_id - The identifier of the resource. + * @param inviter_id - The ID of the principal creating the invitation. + * @param target - The username, email, or slug of the invitee. + * @param role - The role to grant upon acceptance. + * @param expires - Optional expiry date; defaults to 7 days from now. + * + * @throws {Error} If the target is already a member, or target is invalid. + */ +export async function createInvitation( + fdm: FdmType, + resource: Resource, + resource_id: string, + inviter_id: string, + target: string, + role: Role, + expires?: Date, +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + const normalizedTarget = target.toLowerCase().trim() + + // Check if target is a registered principal (user or organization) + const targetDetails = await identifyPrincipal(tx, normalizedTarget) + + let targetEmail: string | null = null + let targetPrincipalId: string | null = null + + if (targetDetails) { + targetPrincipalId = targetDetails.id + + // Check if target is already a member of this resource + const existingMembers = await listPrincipalsForResource( + tx, + resource, + resource_id, + ) + const isAlreadyMember = existingMembers.some( + (m) => m.principal_id === targetPrincipalId, + ) + if (isAlreadyMember) { + throw new Error( + "Target is already a member of this resource", + ) + } + } else { + if (!isEmail(normalizedTarget)) { + throw new Error( + "Target not found and not a valid email address", + ) + } + targetEmail = normalizedTarget + } + + const expiresDate = expires ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + + if (targetEmail) { + const existing = await tx + .select() + .from(authZSchema.invitation) + .where( + and( + eq(authZSchema.invitation.resource, resource), + eq(authZSchema.invitation.resource_id, resource_id), + eq(authZSchema.invitation.target_email, targetEmail), + eq(authZSchema.invitation.status, "pending"), + ), + ) + .limit(1) + + if (existing.length > 0) { + await tx + .update(authZSchema.invitation) + .set({ role, inviter_id, expires: expiresDate }) + .where( + eq( + authZSchema.invitation.invitation_id, + existing[0].invitation_id, + ), + ) + } else { + await tx.insert(authZSchema.invitation).values({ + invitation_id: createId(), + resource, + resource_id, + target_email: targetEmail, + role, + inviter_id, + expires: expiresDate, + }) + } + } else { + const existing = await tx + .select() + .from(authZSchema.invitation) + .where( + and( + eq(authZSchema.invitation.resource, resource), + eq(authZSchema.invitation.resource_id, resource_id), + eq( + authZSchema.invitation.target_principal_id, + targetPrincipalId!, + ), + eq(authZSchema.invitation.status, "pending"), + ), + ) + .limit(1) + + if (existing.length > 0) { + await tx + .update(authZSchema.invitation) + .set({ role, inviter_id, expires: expiresDate }) + .where( + eq( + authZSchema.invitation.invitation_id, + existing[0].invitation_id, + ), + ) + } else { + await tx.insert(authZSchema.invitation).values({ + invitation_id: createId(), + resource, + resource_id, + target_principal_id: targetPrincipalId, + role, + inviter_id, + expires: expiresDate, + }) + } + } + }) + } catch (err) { + throw handleError(err, "Exception for createInvitation", { + resource, + resource_id, + target, + role, + }) + } +} + +/** + * Accepts a pending invitation on behalf of the acting user. + * + * Uses an atomic conditional UPDATE to prevent TOCTOU races. + * For user-targeted invitations: verifies email is verified and matches the invitation target. + * For organization-targeted invitations: verifies the acting user is an admin or owner. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param invitation_id - The unique identifier of the invitation to accept. + * @param user_id - The ID of the user accepting the invitation. + * + * @throws {Error} If the invitation is not found, already processed, expired, or the user is not authorized. + */ +export async function acceptInvitation( + fdm: FdmType, + invitation_id: string, + user_id: string, +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + // Atomically claim the invitation: only succeeds if still pending and not expired + const claimed = await tx + .update(authZSchema.invitation) + .set({ status: "accepted", accepted_at: new Date() }) + .where( + and( + eq(authZSchema.invitation.invitation_id, invitation_id), + eq(authZSchema.invitation.status, "pending"), + gt(authZSchema.invitation.expires, new Date()), + ), + ) + .returning() + + if (claimed.length === 0) { + const existing = await tx + .select() + .from(authZSchema.invitation) + .where( + eq(authZSchema.invitation.invitation_id, invitation_id), + ) + .limit(1) + + if (existing.length === 0) { + throw new Error("Invitation not found") + } + const inv = existing[0] + if (inv.status !== "pending") { + throw new Error(`Invitation is already ${inv.status}`) + } + await tx + .update(authZSchema.invitation) + .set({ status: "expired" }) + .where( + eq( + authZSchema.invitation.invitation_id, + invitation_id, + ), + ) + throw new Error("Invitation has expired") + } + + const invitation = claimed[0] + let granteeId: string + + if (invitation.target_principal_id) { + if (invitation.target_principal_id) { + const orgMembership = await tx + .select() + .from(authNSchema.member) + .where( + and( + eq( + authNSchema.member.organizationId, + invitation.target_principal_id, + ), + eq(authNSchema.member.userId, user_id), + ), + ) + .limit(1) + + if (orgMembership.length > 0) { + // Organization target: verify user is admin or owner + if ( + !["admin", "owner"].includes( + orgMembership[0].role, + ) + ) { + throw new Error( + "Only admins or owners can accept invitations on behalf of an organization", + ) + } + } else { + // User target: verify it matches the accepting user + if (invitation.target_principal_id !== user_id) { + throw new Error("This invitation is not for you") + } + const userRecord = await tx + .select({ + emailVerified: authNSchema.user.emailVerified, + }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if ( + userRecord.length === 0 || + !userRecord[0].emailVerified + ) { + throw new Error( + "Email must be verified before accepting an invitation", + ) + } + } + } + + granteeId = invitation.target_principal_id + } else if (invitation.target_email) { + const userRecord = await tx + .select({ + email: authNSchema.user.email, + emailVerified: authNSchema.user.emailVerified, + }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if (userRecord.length === 0) { + throw new Error("User not found") + } + if ( + userRecord[0].email.toLowerCase().trim() !== + invitation.target_email + ) { + throw new Error( + "This invitation is not for your email address", + ) + } + if (!userRecord[0].emailVerified) { + throw new Error( + "Email must be verified before accepting an invitation", + ) + } + granteeId = user_id + } else { + throw new Error("Invalid invitation: no target specified") + } + + await grantRole( + tx, + invitation.resource as Resource, + invitation.role as Role, + invitation.resource_id, + granteeId, + ) + + // Update target_principal_id if this was an email-based invitation + if (!invitation.target_principal_id) { + await tx + .update(authZSchema.invitation) + .set({ target_principal_id: granteeId }) + .where( + eq( + authZSchema.invitation.invitation_id, + invitation_id, + ), + ) + } + }) + } catch (err) { + throw handleError(err, "Exception for acceptInvitation", { + invitation_id, + user_id, + }) + } +} + +/** + * Declines a pending invitation on behalf of the acting user. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param invitation_id - The unique identifier of the invitation to decline. + * @param user_id - The ID of the user declining the invitation. + * + * @throws {Error} If the invitation is not found, already processed, expired, or the user is not authorized. + */ +export async function declineInvitation( + fdm: FdmType, + invitation_id: string, + user_id: string, +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + const invitations = await tx + .select() + .from(authZSchema.invitation) + .where( + eq(authZSchema.invitation.invitation_id, invitation_id), + ) + .limit(1) + + if (invitations.length === 0) { + throw new Error("Invitation not found") + } + const invitation = invitations[0] + + if (invitation.status !== "pending") { + throw new Error(`Invitation is already ${invitation.status}`) + } + + if (invitation.expires < new Date()) { + await tx + .update(authZSchema.invitation) + .set({ status: "expired" }) + .where( + eq( + authZSchema.invitation.invitation_id, + invitation_id, + ), + ) + throw new Error("Invitation has expired") + } + + if (invitation.target_principal_id) { + const orgMembership = await tx + .select() + .from(authNSchema.member) + .where( + and( + eq( + authNSchema.member.organizationId, + invitation.target_principal_id, + ), + eq(authNSchema.member.userId, user_id), + ), + ) + .limit(1) + + if (orgMembership.length > 0) { + if ( + !["admin", "owner"].includes(orgMembership[0].role) + ) { + throw new Error( + "Only admins or owners can decline invitations on behalf of an organization", + ) + } + } else { + if (invitation.target_principal_id !== user_id) { + throw new Error("This invitation is not for you") + } + } + } else if (invitation.target_email) { + const userRecord = await tx + .select({ email: authNSchema.user.email }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if ( + userRecord.length === 0 || + userRecord[0].email.toLowerCase().trim() !== + invitation.target_email + ) { + throw new Error( + "This invitation is not for your email address", + ) + } + } + + await tx + .update(authZSchema.invitation) + .set({ status: "declined" }) + .where( + eq(authZSchema.invitation.invitation_id, invitation_id), + ) + }) + } catch (err) { + throw handleError(err, "Exception for declineInvitation", { + invitation_id, + user_id, + }) + } +} + +/** + * Lists all pending (non-expired) invitations for a given user across all resources. + * + * Returns invitations where the target matches the user's principal ID, email address, + * or an organization for which the user is an admin or owner. + * + * @param fdm - The FDM instance providing the connection to the database. + * @param user_id - The ID of the user to retrieve invitations for. + * + * @returns A Promise that resolves to an array of pending invitation records. + */ +export async function listPendingInvitationsForPrincipal( + fdm: FdmType, + user_id: string, +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + const userRecord = await tx + .select({ email: authNSchema.user.email }) + .from(authNSchema.user) + .where(eq(authNSchema.user.id, user_id)) + .limit(1) + + if (userRecord.length === 0) { + return [] + } + const userEmail = userRecord[0].email.toLowerCase().trim() + + const orgMemberships = await tx + .select({ organizationId: authNSchema.member.organizationId }) + .from(authNSchema.member) + .where( + and( + eq(authNSchema.member.userId, user_id), + inArray(authNSchema.member.role, ["admin", "owner"]), + ), + ) + const orgIds = orgMemberships.map( + (m: { organizationId: string }) => m.organizationId, + ) + + const now = new Date() + + const conditions = [ + and( + eq(authZSchema.invitation.target_email, userEmail), + eq(authZSchema.invitation.status, "pending"), + gt(authZSchema.invitation.expires, now), + ), + and( + eq(authZSchema.invitation.target_principal_id, user_id), + eq(authZSchema.invitation.status, "pending"), + gt(authZSchema.invitation.expires, now), + ), + ] + + if (orgIds.length > 0) { + conditions.push( + and( + inArray( + authZSchema.invitation.target_principal_id, + orgIds, + ), + eq(authZSchema.invitation.status, "pending"), + gt(authZSchema.invitation.expires, now), + ), + ) + } + + return await tx + .select() + .from(authZSchema.invitation) + .where(or(...conditions)) + }) + } catch (err) { + throw handleError( + err, + "Exception for listPendingInvitationsForPrincipal", + { + user_id, + }, + ) + } +} + +/** + * Automatically accepts all pending invitations for a newly verified user. + * + * Looks up pending invitations matching the user's email address across all resources + * and grants the corresponding roles. MUST only be called when `emailVerified` is confirmed. * * @param fdm - The FDM instance providing the connection to the database. * @param email - The verified email address of the user. @@ -24,45 +550,41 @@ export async function autoAcceptInvitationsForNewUser( const normalizedEmail = email.toLowerCase().trim() await fdm.transaction(async (tx: FdmType) => { - // Find all pending invitations for this email const pendingInvitations = await tx .select() - .from(authZSchema.farmInvitation) + .from(authZSchema.invitation) .where( and( - eq(authZSchema.farmInvitation.target_email, normalizedEmail), - eq(authZSchema.farmInvitation.status, "pending"), + eq(authZSchema.invitation.target_email, normalizedEmail), + eq(authZSchema.invitation.status, "pending"), ), ) const now = new Date() - for (const invitation of pendingInvitations) { - // Skip expired invitations - if (invitation.expires < now) { + for (const inv of pendingInvitations) { + if (inv.expires < now) { await tx - .update(authZSchema.farmInvitation) + .update(authZSchema.invitation) .set({ status: "expired" }) .where( eq( - authZSchema.farmInvitation.invitation_id, - invitation.invitation_id, + authZSchema.invitation.invitation_id, + inv.invitation_id, ), ) continue } - // Grant the role to the user await grantRole( tx, - "farm", - invitation.role as "owner" | "advisor" | "researcher", - invitation.farm_id, + inv.resource as Resource, + inv.role as Role, + inv.resource_id, user_id, ) - // Mark invitation as accepted await tx - .update(authZSchema.farmInvitation) + .update(authZSchema.invitation) .set({ status: "accepted", accepted_at: now, @@ -70,8 +592,8 @@ export async function autoAcceptInvitationsForNewUser( }) .where( eq( - authZSchema.farmInvitation.invitation_id, - invitation.invitation_id, + authZSchema.invitation.invitation_id, + inv.invitation_id, ), ) } From 70f85aee2792d004e001bf4e71718042b548036c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:22:17 +0100 Subject: [PATCH 06/25] docs: Add invitations to the page about Authorization --- .changeset/tall-buckets-occur.md | 5 ++ .../docs/core-concepts/01-database-schema.md | 25 ++++++ .../docs/core-concepts/10-authorization.md | 81 +++++++++++++++---- 3 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 .changeset/tall-buckets-occur.md diff --git a/.changeset/tall-buckets-occur.md b/.changeset/tall-buckets-occur.md new file mode 100644 index 000000000..8a43bceba --- /dev/null +++ b/.changeset/tall-buckets-occur.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-docs": minor +--- + +Add invitations to the page about Authorization diff --git a/fdm-docs/docs/core-concepts/01-database-schema.md b/fdm-docs/docs/core-concepts/01-database-schema.md index a9f9238df..d7b2f40ad 100644 --- a/fdm-docs/docs/core-concepts/01-database-schema.md +++ b/fdm-docs/docs/core-concepts/01-database-schema.md @@ -742,6 +742,31 @@ This schema manages roles, permissions, and auditing for authorization purposes. * Composite index on (`resource`, `resource_id`, `principal_id`, `role`, `deleted`). +#### **`invitation`** + +**Purpose**: Stores pending and historical invitations to access a resource. A role is only granted after the recipient explicitly accepts the invitation. + +| Column | Type | Constraints | Description | +|---|---|---|---| +| **invitation_id** | `text` | Primary Key | Unique identifier for the invitation. | +| **resource** | `text` | Not Null | Resource type being shared (e.g. `farm`). | +| **resource_id** | `text` | Not Null | Identifier of the specific resource instance. | +| **inviter_id** | `text` | Not Null | Principal who created the invitation. | +| **target_email** | `text` | | Email address for invitations to unregistered users. | +| **target_principal_id** | `text` | | Principal ID for invitations to existing users or organizations. | +| **role** | `text` | Not Null | Role to grant on acceptance (`owner`, `advisor`, `researcher`). | +| **status** | `text` | Not Null | Current state: `pending`, `accepted`, `declined`, or `expired`. | +| **expires** | `timestamp with time zone` | Not Null | Expiry cutoff (default: 7 days after creation). | +| **created** | `timestamp with time zone` | Not Null | Timestamp when the invitation was created. | +| **accepted_at** | `timestamp with time zone` | | Timestamp when the invitation was accepted. | + +At least one of `target_email` or `target_principal_id` must be set. + +**Indexes:** + +* Unique partial index on (`resource`, `resource_id`, `target_email`) where `status = 'pending'` — prevents duplicate pending email invitations. +* Unique partial index on (`resource`, `resource_id`, `target_principal_id`) where `status = 'pending'` — prevents duplicate pending principal invitations. + #### **`audit`** **Purpose**: Logs authorization checks (audit trail) to record who attempted what action on which resource. diff --git a/fdm-docs/docs/core-concepts/10-authorization.md b/fdm-docs/docs/core-concepts/10-authorization.md index 23321caf6..2fb8b7d63 100644 --- a/fdm-docs/docs/core-concepts/10-authorization.md +++ b/fdm-docs/docs/core-concepts/10-authorization.md @@ -8,25 +8,76 @@ Authorization is the process of determining what actions a user is allowed to pe FDM's permission model is based on a combination of **resources**, **roles**, and **actions**. -* **Resources:** These are the main entities in the FDM, such as `farm`, `field`, `cultivation`, etc. -* **Roles:** These are collections of permissions that can be assigned to a user for a specific resource. FDM defines the following roles: -* `owner`: Full control over the resource. -* `advisor`: Can view and edit the resource. -* `researcher`: Can only view the resource. -* **Actions:** These are the operations that can be performed on a resource, such as `read`, `write`, `list`, and `share`. +- **Resources:** The main entities in FDM — `farm`, `field`, `cultivation`, `fertilizer_application`, `soil_analysis`, `harvesting`, `organization`, and `user`. +- **Roles:** Collections of permissions assigned to a principal for a specific resource: + - `owner` — Full control (read, write, list, share). + - `advisor` — Can view and edit (read, write, list), but cannot share. + - `researcher` — Read-only access. +- **Actions:** `read`, `write`, `list`, and `share`. + +### Role–Action Matrix + +| Resource | owner | advisor | researcher | +|---|---|---|---| +| farm | read, write, list, **share** | read, write, list | read | +| field | read, write, list, **share** | read, write, list | read | +| cultivation | read, write, list, **share** | read, write, list | read | +| harvesting | read, write, list, **share** | read, write, list | read | +| soil\_analysis | read, write, list, **share** | read, write, list | read | +| fertilizer\_application | read, write, list, **share** | read, write, list | read | ## How Access Control is Handled -Access control is handled by the `fdm-authz` schema, which contains two main tables: +Access control is handled by the `fdm-authz` schema, which contains three main tables: + +- **`role`** — Stores active role assignments. Each row links a `principal_id` to a `resource` and `resource_id` with a specific role. +- **`invitation`** — Stores pending (and historical) invitations to access a resource (see [Invitations](#invitations) below). +- **`audit`** — An audit trail of all authorization checks, recording who attempted what action on which resource and whether it was allowed or denied. + +### Resource Hierarchy + +Permissions are inherited through the resource hierarchy. A role granted on a parent resource also covers all child resources: + +``` +farm + └── field + ├── cultivation + ├── harvesting + ├── fertilizer_application + └── soil_analysis +``` + +When `checkPermission` is called, it constructs the full chain from the target resource up to `farm` and checks whether the principal holds a qualifying role on **any** resource in that chain. This means an `advisor` on a `farm` automatically has `write` access to all fields and cultivations within that farm. + +## Invitations + +Rather than granting roles directly, FDM uses an **invitation system** to share access. This allows the recipient to explicitly accept or decline before any role is active. + +### How It Works + +1. **Create** — An actor with `share` permission calls a function like `grantRoleToFarm`, which internally calls `createInvitation`. A pending invitation record is created in the `invitation` table with a 7-day expiry. +2. **Notify** — The inviter can send an email to the recipient. In `fdm-app`, the `renderFarmInvitationEmail` and `sendEmail` helpers are available for this purpose. +3. **Accept or decline** — The recipient calls `acceptInvitation` or `declineInvitation`. On acceptance, `grantRole` is called and the role becomes active. + +### Email vs. Principal Targets + +Invitations support two target types: + +- **Principal-targeted** — The target is an existing user or organization (looked up by username or email). The `target_principal_id` column is set. +- **Email-targeted** — The target is an email address that has no account yet. The `target_email` column is set. When the user later signs up and verifies their email, `autoAcceptInvitationsForNewUser` is called automatically to claim any pending invitations. + +### The `invitation` Table -* **`role`**: This table stores the roles that have been assigned to users for specific resources. Each row in this table represents a single role assignment, linking a `principal_id` (user) to a `resource` and `resource_id`. -* **`audit`**: This table provides an audit trail of all authorization checks. It records who attempted to perform what action on which resource, and whether the action was allowed or denied. +The full column reference for the `invitation` table is documented in the [Database Schema](./01-database-schema.md#fdm-authz-schema-authorization). -When a user attempts to perform an action, the `checkPermission` function is called. This function does the following: +### Invitation API -1. **Determines the required roles:** It first determines which roles are required to perform the requested action on the given resource. -2. **Constructs the resource hierarchy:** It then constructs the resource hierarchy for the target resource. For example, if the target resource is a `cultivation`, the hierarchy would be `farm` -> `field` -> `cultivation`. -3. **Checks for permissions:** It then checks to see if the user has been granted any of the required roles on any of the resources in the hierarchy. -4. **Audits the check:** Finally, it records the result of the check in the `audit` table. +| Function | Description | +|---|---| +| `createInvitation(fdm, resource, resource_id, inviter_id, target, role)` | Creates a pending invitation | +| `acceptInvitation(fdm, invitation_id, user_id)` | Accepts and activates the role | +| `declineInvitation(fdm, invitation_id, user_id)` | Declines the invitation | +| `listPendingInvitationsForPrincipal(fdm, principal_id)` | Lists all pending invitations for a user | +| `autoAcceptInvitationsForNewUser(fdm, email, user_id)` | Auto-accepts email-targeted invitations after email verification | -This system provides a flexible and secure way to control access to your data, while also providing a complete audit trail of all access control decisions. +For farm-specific helpers that add permission checks, see `grantRoleToFarm`, `listPendingInvitationsForFarm`, and `listPendingInvitationsForUser` in the farm API. From 3530577bbc9e58081a677658e2e109e5724eb48e Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:48:57 +0100 Subject: [PATCH 07/25] tests: increase coverage --- fdm-core/src/farm.test.ts | 56 +++++ fdm-core/src/invitation.test.ts | 361 +++++++++++++++++++++++++++++++- 2 files changed, 416 insertions(+), 1 deletion(-) diff --git a/fdm-core/src/farm.test.ts b/fdm-core/src/farm.test.ts index 0e196f226..6f1e8404c 100644 --- a/fdm-core/src/farm.test.ts +++ b/fdm-core/src/farm.test.ts @@ -597,6 +597,26 @@ describe("Farm Functions", () => { "Principal does not have permission to perform this action", ) }) + + it("should include organization as a pending principal when org is invited", async () => { + // No fdm-core API for org creation — use raw insert (same pattern as authorization.test.ts) + const orgId = createId() + const orgSlug = `listprincipals-org-${orgId.toLowerCase()}` + await fdm.insert(authNSchema.organization).values({ + id: orgId, + name: "ListPrincipals Org", + slug: orgSlug, + createdAt: new Date(), + }) + + const orgFarmId = await addFarm(fdm, principal_id, "Org Principals Farm", "ORGPRI", "Org Principals Lane", "20001") + await grantRoleToFarm(fdm, principal_id, orgSlug, orgFarmId, "advisor") + + const principals = await listPrincipalsForFarm(fdm, principal_id, orgFarmId) + const orgPrincipal = principals.find((p) => p.id === orgId) + expect(orgPrincipal).toBeDefined() + expect(orgPrincipal?.type).toBe("organization") + }) }) describe("isAllowedToShareFarm", () => { @@ -1114,5 +1134,41 @@ describe("Farm Functions", () => { ) expect(invitations).toEqual([]) }) + + it("should include org_name when invitation targets an organization", async () => { + const orgAdminUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "listuser_orgadmin@example.com", + name: "listuser_orgadmin", + username: "listuser_orgadmin", + password: "password", + } as any, + }) + + const orgId = createId() + const orgSlug = `pending-inv-org-${orgId.toLowerCase()}` + await fdm.insert(authNSchema.organization).values({ + id: orgId, + name: "Pending Invitations Org", + slug: orgSlug, + createdAt: new Date(), + }) + await fdm.insert(authNSchema.member).values({ + id: createId(), + organizationId: orgId, + userId: orgAdminUser.user.id, + role: "owner", + createdAt: new Date(), + }) + + const orgInvFarmId = await addFarm(fdm, principal_id, "List User Org Inv Farm", "LSUORG", "List User Org Lane", "20002") + await grantRoleToFarm(fdm, principal_id, orgSlug, orgInvFarmId, "advisor") + + const invitations = await listPendingInvitationsForUser(fdm, orgAdminUser.user.id) + const orgInv = invitations.find((i) => i.resource_id === orgInvFarmId) + expect(orgInv).toBeDefined() + expect(orgInv?.org_name).toBe("Pending Invitations Org") + }) }) }) diff --git a/fdm-core/src/invitation.test.ts b/fdm-core/src/invitation.test.ts index c6247f6d3..884a47bc2 100644 --- a/fdm-core/src/invitation.test.ts +++ b/fdm-core/src/invitation.test.ts @@ -3,11 +3,18 @@ import { beforeAll, describe, expect, inject, it } from "vitest" import type { FdmAuth } from "./authentication" import { createFdmAuth } from "./authentication" import { listPrincipalsForResource } from "./authorization" +import * as authNSchema from "./db/schema-authn" import * as authZSchema from "./db/schema-authz" import { addFarm, grantRoleToFarm } from "./farm" import { createFdmServer } from "./fdm-server" import type { FdmServerType } from "./fdm-server.d" -import { autoAcceptInvitationsForNewUser } from "./invitation" +import { createId } from "./id" +import { + acceptInvitation, + autoAcceptInvitationsForNewUser, + declineInvitation, + listPendingInvitationsForPrincipal, +} from "./invitation" describe("autoAcceptInvitationsForNewUser", () => { let fdm: FdmServerType @@ -226,3 +233,355 @@ describe("autoAcceptInvitationsForNewUser", () => { expect(grantee?.role).toBe("advisor") }) }) + +describe("acceptInvitation", () => { + let fdm: FdmServerType + let fdmAuth: FdmAuth + let ownerPrincipalId: string + let farmId: string + + beforeAll(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) + fdmAuth = createFdmAuth( + fdm, + { clientId: "mock_google_client_id", clientSecret: "mock_google_client_secret" }, + { clientId: "mock_ms_client_id", clientSecret: "mock_ms_client_secret" }, + undefined, + true, + ) + + const owner = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "accept_owner@example.com", + name: "accept_owner", + username: "accept_owner", + password: "password", + } as any, + }) + ownerPrincipalId = owner.user.id + + farmId = await addFarm(fdm, ownerPrincipalId, "Accept Test Farm", "ACC001", "Accept Lane", "10001") + }) + + it("should accept an email-targeted invitation and grant the role", async () => { + const email = "accept_email_target@example.com" + await grantRoleToFarm(fdm, ownerPrincipalId, email, farmId, "advisor") + + const target = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email, name: "accept_email_target", username: "accept_email_target", password: "password" } as any, + }) + // Mark email as verified + await fdm.update(authNSchema.user).set({ emailVerified: true }).where(eq(authNSchema.user.id, target.user.id)) + + // Get invitation_id via fdm-core function + const pending = await listPendingInvitationsForPrincipal(fdm, target.user.id) + const invitation = pending.find((i) => i.resource_id === farmId) + expect(invitation).toBeDefined() + + await acceptInvitation(fdm, invitation!.invitation_id, target.user.id) + + const principals = await listPrincipalsForResource(fdm, "farm", farmId) + expect(principals.find((p) => p.principal_id === target.user.id)?.role).toBe("advisor") + }) + + it("should throw if invitation does not exist", async () => { + const user = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email: "accept_notfound@example.com", name: "accept_notfound", username: "accept_notfound", password: "password" } as any, + }) + await expect( + acceptInvitation(fdm, createId(), user.user.id), + ).rejects.toThrowError("Exception for acceptInvitation") + }) + + it("should throw if invitation is already accepted", async () => { + const email = "accept_double@example.com" + await grantRoleToFarm(fdm, ownerPrincipalId, email, farmId, "researcher") + + const target = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email, name: "accept_double", username: "accept_double", password: "password" } as any, + }) + await fdm.update(authNSchema.user).set({ emailVerified: true }).where(eq(authNSchema.user.id, target.user.id)) + + const pending = await listPendingInvitationsForPrincipal(fdm, target.user.id) + const invitation = pending.find((i) => i.resource_id === farmId) + await acceptInvitation(fdm, invitation!.invitation_id, target.user.id) + + await expect( + acceptInvitation(fdm, invitation!.invitation_id, target.user.id), + ).rejects.toThrowError("Exception for acceptInvitation") + }) + + it("should throw if user email does not match email-targeted invitation", async () => { + const email = "accept_mismatch_target@example.com" + await grantRoleToFarm(fdm, ownerPrincipalId, email, farmId, "advisor") + + // Register the actual target so we can look up their pending invitation + const targetUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email, name: "accept_mismatch_target", username: "accept_mismatch_target", password: "password" } as any, + }) + const wrongUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email: "accept_mismatch_wrong@example.com", name: "accept_mismatch_wrong", username: "accept_mismatch_wrong", password: "password" } as any, + }) + + const pending = await listPendingInvitationsForPrincipal(fdm, targetUser.user.id) + const invitation = pending.find((i) => i.resource_id === farmId) + expect(invitation).toBeDefined() + + await expect( + acceptInvitation(fdm, invitation!.invitation_id, wrongUser.user.id), + ).rejects.toThrowError("Exception for acceptInvitation") + }) + + it("should throw if invitation is expired", async () => { + const email = "accept_expired@example.com" + const expiredFarmId = await addFarm(fdm, ownerPrincipalId, "Accept Expired Farm", "ACCEXP", "Accept Expired Lane", "10002") + await grantRoleToFarm(fdm, ownerPrincipalId, email, expiredFarmId, "researcher") + + // Expire the invitation + await fdm.update(authZSchema.invitation) + .set({ expires: new Date("2000-01-01") }) + .where(eq(authZSchema.invitation.target_email, email)) + + const target = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email, name: "accept_expired", username: "accept_expired", password: "password" } as any, + }) + await fdm.update(authNSchema.user).set({ emailVerified: true }).where(eq(authNSchema.user.id, target.user.id)) + + // Get invitation_id - must use direct query since listPendingInvitationsForPrincipal filters out expired + const rows = await fdm.select().from(authZSchema.invitation) + .where(and(eq(authZSchema.invitation.target_email, email), eq(authZSchema.invitation.resource_id, expiredFarmId))) + const invitation_id = rows[0].invitation_id + + await expect( + acceptInvitation(fdm, invitation_id, target.user.id), + ).rejects.toThrowError("Exception for acceptInvitation") + }) +}) + +describe("declineInvitation", () => { + let fdm: FdmServerType + let fdmAuth: FdmAuth + let ownerPrincipalId: string + let farmId: string + + beforeAll(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) + fdmAuth = createFdmAuth( + fdm, + { clientId: "mock_google_client_id", clientSecret: "mock_google_client_secret" }, + { clientId: "mock_ms_client_id", clientSecret: "mock_ms_client_secret" }, + undefined, + true, + ) + + const owner = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "decline_owner@example.com", + name: "decline_owner", + username: "decline_owner", + password: "password", + } as any, + }) + ownerPrincipalId = owner.user.id + + farmId = await addFarm(fdm, ownerPrincipalId, "Decline Test Farm", "DEC001B", "Decline Lane", "10003") + }) + + it("should decline an email-targeted invitation", async () => { + const email = "decline_email_target@example.com" + await grantRoleToFarm(fdm, ownerPrincipalId, email, farmId, "advisor") + + const target = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email, name: "decline_email_target", username: "decline_email_target", password: "password" } as any, + }) + + const pending = await listPendingInvitationsForPrincipal(fdm, target.user.id) + const invitation = pending.find((i) => i.resource_id === farmId) + expect(invitation).toBeDefined() + + await declineInvitation(fdm, invitation!.invitation_id, target.user.id) + + // Invitation should now be declined — listPendingInvitationsForPrincipal no longer returns it + const afterDecline = await listPendingInvitationsForPrincipal(fdm, target.user.id) + expect(afterDecline.find((i) => i.invitation_id === invitation!.invitation_id)).toBeUndefined() + }) + + it("should throw if invitation does not exist", async () => { + const user = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email: "decline_notfound@example.com", name: "decline_notfound", username: "decline_notfound", password: "password" } as any, + }) + await expect( + declineInvitation(fdm, createId(), user.user.id), + ).rejects.toThrowError("Exception for declineInvitation") + }) + + it("should throw if invitation is already declined", async () => { + const email = "decline_double@example.com" + await grantRoleToFarm(fdm, ownerPrincipalId, email, farmId, "researcher") + + const target = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email, name: "decline_double", username: "decline_double", password: "password" } as any, + }) + + const pending = await listPendingInvitationsForPrincipal(fdm, target.user.id) + const invitation = pending.find((i) => i.resource_id === farmId) + await declineInvitation(fdm, invitation!.invitation_id, target.user.id) + + await expect( + declineInvitation(fdm, invitation!.invitation_id, target.user.id), + ).rejects.toThrowError("Exception for declineInvitation") + }) + + it("should throw if invitation is expired", async () => { + const email = "decline_expired@example.com" + const expiredFarmId = await addFarm(fdm, ownerPrincipalId, "Decline Expired Farm", "DECEXP", "Decline Expired Lane", "10004") + await grantRoleToFarm(fdm, ownerPrincipalId, email, expiredFarmId, "researcher") + + // Expire the invitation — must use raw query, no fdm-core API for this + await fdm.update(authZSchema.invitation) + .set({ expires: new Date("2000-01-01") }) + .where(eq(authZSchema.invitation.target_email, email)) + + const target = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email, name: "decline_expired", username: "decline_expired", password: "password" } as any, + }) + + // Must use raw query: listPendingInvitationsForPrincipal filters out expired + const rows = await fdm.select().from(authZSchema.invitation) + .where(and(eq(authZSchema.invitation.target_email, email), eq(authZSchema.invitation.resource_id, expiredFarmId))) + const invitation_id = rows[0].invitation_id + + await expect( + declineInvitation(fdm, invitation_id, target.user.id), + ).rejects.toThrowError("Exception for declineInvitation") + }) + + it("should throw if a different user tries to decline a principal-targeted invitation", async () => { + const targetUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email: "decline_target_principal@example.com", name: "decline_target_principal", username: "decline_target_principal", password: "password" } as any, + }) + const wrongUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email: "decline_wrong_principal@example.com", name: "decline_wrong_principal", username: "decline_wrong_principal", password: "password" } as any, + }) + + const principalFarmId = await addFarm(fdm, ownerPrincipalId, "Decline Principal Farm", "DECPRI", "Decline Principal Lane", "10005") + await grantRoleToFarm(fdm, ownerPrincipalId, "decline_target_principal", principalFarmId, "advisor") + + const pending = await listPendingInvitationsForPrincipal(fdm, targetUser.user.id) + const invitation = pending.find((i) => i.resource_id === principalFarmId) + expect(invitation).toBeDefined() + + await expect( + declineInvitation(fdm, invitation!.invitation_id, wrongUser.user.id), + ).rejects.toThrowError("Exception for declineInvitation") + }) +}) + +describe("listPendingInvitationsForPrincipal", () => { + let fdm: FdmServerType + let fdmAuth: FdmAuth + let ownerPrincipalId: string + + beforeAll(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) + fdmAuth = createFdmAuth( + fdm, + { clientId: "mock_google_client_id", clientSecret: "mock_google_client_secret" }, + { clientId: "mock_ms_client_id", clientSecret: "mock_ms_client_secret" }, + undefined, + true, + ) + + const owner = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "list_inv_owner@example.com", + name: "list_inv_owner", + username: "list_inv_owner", + password: "password", + } as any, + }) + ownerPrincipalId = owner.user.id + }) + + it("should return empty array for a non-existent user", async () => { + const invitations = await listPendingInvitationsForPrincipal(fdm, createId()) + expect(invitations).toEqual([]) + }) + + it("should return email-targeted invitations for a user by email", async () => { + const targetUser = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email: "list_inv_emailuser@example.com", name: "list_inv_emailuser", username: "list_inv_emailuser", password: "password" } as any, + }) + + const emailFarmId = await addFarm(fdm, ownerPrincipalId, "List Inv Email Farm", "LSIEMA", "List Inv Email Lane", "10007") + await grantRoleToFarm(fdm, ownerPrincipalId, "list_inv_emailuser@example.com", emailFarmId, "researcher") + + const invitations = await listPendingInvitationsForPrincipal(fdm, targetUser.user.id) + const emailInv = invitations.find((i) => i.resource_id === emailFarmId) + expect(emailInv).toBeDefined() + expect(emailInv?.status).toBe("pending") + }) + + it("should include org-targeted invitations for a user who is an org admin", async () => { + const orgAdmin = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { email: "list_inv_orgadmin@example.com", name: "list_inv_orgadmin", username: "list_inv_orgadmin", password: "password" } as any, + }) + + // No fdm-core API for org creation — use raw insert (same pattern as authorization.test.ts) + const orgId = createId() + const orgSlug = `list-inv-org-${orgId.toLowerCase()}` + await fdm.insert(authNSchema.organization).values({ + id: orgId, + name: "List Inv Org", + slug: orgSlug, + createdAt: new Date(), + }) + await fdm.insert(authNSchema.member).values({ + id: createId(), + organizationId: orgId, + userId: orgAdmin.user.id, + role: "admin", + createdAt: new Date(), + }) + + const orgFarmId = await addFarm(fdm, ownerPrincipalId, "List Inv Org Farm", "LSIORG", "List Inv Org Lane", "10006") + await grantRoleToFarm(fdm, ownerPrincipalId, orgSlug, orgFarmId, "advisor") + + const invitations = await listPendingInvitationsForPrincipal(fdm, orgAdmin.user.id) + const orgInv = invitations.find((i) => i.target_principal_id === orgId) + expect(orgInv).toBeDefined() + expect(orgInv?.status).toBe("pending") + }) +}) From 8cb6751566b8bcdd11683d70d0081a119a156301 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:53:12 +0100 Subject: [PATCH 08/25] refactor: improve invite email --- .../app/components/blocks/email/farm-invitation.tsx | 12 ++++++------ fdm-app/app/lib/email.server.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/fdm-app/app/components/blocks/email/farm-invitation.tsx b/fdm-app/app/components/blocks/email/farm-invitation.tsx index d665c1b9a..628ecaeda 100644 --- a/fdm-app/app/components/blocks/email/farm-invitation.tsx +++ b/fdm-app/app/components/blocks/email/farm-invitation.tsx @@ -73,7 +73,7 @@ export const FarmInvitationEmail = ({ /> - Uitnodiging voor bedrijfstoegang + Uitnodiging voor {farmName} in {appName} Hallo {targetEmail}, @@ -87,8 +87,7 @@ export const FarmInvitationEmail = ({ <> Maak een account aan om de uitnodiging te - accepteren. Na registratie en verificatie - van je e-mailadres wordt je toegang + accepteren. Na registratie wordt je toegang automatisch verleend.
@@ -103,8 +102,8 @@ export const FarmInvitationEmail = ({ ) : ( <> - Log in en accepteer of weiger de uitnodiging - via je dashboard. + Log in en accepteer of weiger de + uitnodiging.
+ +
+ + + +
+ + + ) +} diff --git a/fdm-app/app/routes/farm._index.tsx b/fdm-app/app/routes/farm._index.tsx index c95e93773..05034c0b1 100644 --- a/fdm-app/app/routes/farm._index.tsx +++ b/fdm-app/app/routes/farm._index.tsx @@ -27,6 +27,7 @@ import { } from "react-router" import { dataWithError, dataWithSuccess } from "remix-toast" import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { PendingInvitationCard } from "~/components/blocks/farm/pending-invitation" import { Header } from "~/components/blocks/header/base" import { HeaderFarm } from "~/components/blocks/header/farm" import { Badge } from "~/components/ui/badge" @@ -344,106 +345,10 @@ export default function AppIndex() {
{loaderData.pendingInvitations.map( (invitation) => ( - - -
-
- -
-
- - {invitation.farm_name ?? - invitation.resource_id} - - - Rol:{" "} - {getRoleLabel(invitation.role)} - -
-
-
- - Je hebt een uitnodiging - ontvangen voor toegang - tot bedrijf{" "} - {invitation.farm_name ?? - invitation.resource_id}{" "} - als{" "} - {getRoleLabel(invitation.role)} - . - {invitation.org_name && ( - - Deze uitnodiging - ontvang je - namens - organisatie:{" "} - { - invitation.org_name - } - - )} - Je kunt deze uitnodiging - accepteren of weigeren. - - -
- - - -
-
- - - -
-
-
+ ), )}
@@ -574,103 +479,10 @@ export default function AppIndex() {
{loaderData.pendingInvitations.map( (invitation) => ( - - -
-
- -
-
- - {invitation.farm_name ?? - invitation.resource_id} - - - Rol:{" "} - {getRoleLabel(invitation.role)} - -
-
-
- - Je hebt een uitnodiging - ontvangen voor toegang tot - bedrijf{" "} - {invitation.farm_name ?? - invitation.resource_id}{" "} - als{" "} - {getRoleLabel(invitation.role)} - . - {invitation.org_name && ( - - Deze uitnodiging - ontvang je namens - organisatie:{" "} - { - invitation.org_name - } - - )}{" "} - Je kunt deze uitnodiging - accepteren of weigeren. - - -
- - - -
-
- - - -
-
-
+ invitation={invitation} + /> ), )}
From bdb10c0043aa44c252ff06290aa9a0933225ece4 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:59:30 +0100 Subject: [PATCH 23/25] fix: handle inactive email recipients by revoking permissions --- .../app/routes/farm.$b_id_farm.settings.access.tsx | 14 ++++++++++++++ .../farm.create.$b_id_farm.$calendar.access.tsx | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx index 4599707de..7e1e3be4d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx @@ -29,6 +29,7 @@ import { AccessFormSchema } from "~/lib/schemas/access.schema" import { renderFarmInvitationEmail, sendEmail, + isInactiveRecipientError, } from "~/lib/email.server" // Meta @@ -169,6 +170,19 @@ export async function action({ request, params }: ActionFunctionArgs) { } } catch (emailError) { console.error("Error sending farm invitation email:", emailError) + if (isInactiveRecipientError(emailError)) { + // Revoke permission if email fails due to inactive recipient + await revokePrincipalFromFarm( + fdm, + session.principal_id, + formValues.username, + b_id_farm, + ) + return dataWithError( + null, + `We kunnen geen e-mails naar ${formValues.username} sturen omdat het als inactief is gemarkeerd. Neem contact op met de ondersteuning voor hulp.`, + ) + } } return dataWithSuccess(null, { diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx index 871071be3..6df23a2ca 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx @@ -27,6 +27,7 @@ import { clientConfig } from "~/lib/config" import { renderFarmInvitationEmail, sendEmail, + isInactiveRecipientError, } from "~/lib/email.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" @@ -212,6 +213,19 @@ export async function action({ request, params }: ActionFunctionArgs) { } } catch (emailError) { console.error("Error sending farm invitation email:", emailError) + if (isInactiveRecipientError(emailError)) { + // Revoke permission if email fails due to inactive recipient + await revokePrincipalFromFarm( + fdm, + principalId, + formValues.username, + b_id_farm, + ) + return dataWithError( + null, + `We kunnen geen e-mails naar ${formValues.username} sturen omdat het als inactief is gemarkeerd. Neem contact op met de ondersteuning voor hulp.`, + ) + } } return dataWithSuccess(null, { From baa1088e62222bcd458e931a6ad51b189bad76d4 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:40:06 +0100 Subject: [PATCH 24/25] fix: Guard revokePrincipalFromFarm against email-only invite targets to preserve error context --- .../farm.$b_id_farm.settings.access.tsx | 20 +++++++++++------- ...arm.create.$b_id_farm.$calendar.access.tsx | 21 ++++++++++++------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx index 7e1e3be4d..390f8de00 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx @@ -137,6 +137,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ) // Send invitation email + let targetPrincipal: any = null; try { const farm = await getFarm(fdm, session.principal_id, b_id_farm) const inviterName = session.userName @@ -145,7 +146,7 @@ export async function action({ request, params }: ActionFunctionArgs) { // Try to find the principal to get their email if they are registered const matchedPrincipals = await lookupPrincipal(fdm, normalizedTarget) - const targetPrincipal = matchedPrincipals.find( + targetPrincipal = matchedPrincipals.find( (p) => p.username.toLowerCase() === normalizedTarget || (isEmailTarget && p.email?.toLowerCase() === normalizedTarget), @@ -171,13 +172,16 @@ export async function action({ request, params }: ActionFunctionArgs) { } catch (emailError) { console.error("Error sending farm invitation email:", emailError) if (isInactiveRecipientError(emailError)) { - // Revoke permission if email fails due to inactive recipient - await revokePrincipalFromFarm( - fdm, - session.principal_id, - formValues.username, - b_id_farm, - ) + // Only revoke if we resolved a registered principal; + // otherwise (email-only invite), keep the pending invitation. + if (targetPrincipal && targetPrincipal.type === "user") { + await revokePrincipalFromFarm( + fdm, + session.principal_id, + formValues.username, + b_id_farm, + ) + } return dataWithError( null, `We kunnen geen e-mails naar ${formValues.username} sturen omdat het als inactief is gemarkeerd. Neem contact op met de ondersteuning voor hulp.`, diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx index 6df23a2ca..636560013 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx @@ -180,6 +180,8 @@ export async function action({ request, params }: ActionFunctionArgs) { formValues.role, ) + let targetPrincipal: any = null; + // Send invitation email try { const farm = await getFarm(fdm, principalId, b_id_farm) @@ -188,7 +190,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const isEmailTarget = isEmail(normalizedTarget) const matchedPrincipals = await lookupPrincipal(fdm, normalizedTarget) - const targetPrincipal = matchedPrincipals.find( + targetPrincipal = matchedPrincipals.find( (p) => p.username.toLowerCase() === normalizedTarget || (isEmailTarget && p.email?.toLowerCase() === normalizedTarget), @@ -214,13 +216,16 @@ export async function action({ request, params }: ActionFunctionArgs) { } catch (emailError) { console.error("Error sending farm invitation email:", emailError) if (isInactiveRecipientError(emailError)) { - // Revoke permission if email fails due to inactive recipient - await revokePrincipalFromFarm( - fdm, - principalId, - formValues.username, - b_id_farm, - ) + // Only revoke if we resolved a registered principal; + // otherwise (email-only invite), keep the pending invitation. + if (targetPrincipal && targetPrincipal.type === "user") { + await revokePrincipalFromFarm( + fdm, + principalId, + formValues.username, + b_id_farm, + ) + } return dataWithError( null, `We kunnen geen e-mails naar ${formValues.username} sturen omdat het als inactief is gemarkeerd. Neem contact op met de ondersteuning voor hulp.`, From c370904a6873bb1e6735c50568cc6abe343abe13 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:10:09 +0100 Subject: [PATCH 25/25] refactor: improve text when no user is know and enable to send invitation from dropdown --- .../blocks/access/invitation-form.tsx | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/components/blocks/access/invitation-form.tsx b/fdm-app/app/components/blocks/access/invitation-form.tsx index a0b44ef60..e177c8516 100644 --- a/fdm-app/app/components/blocks/access/invitation-form.tsx +++ b/fdm-app/app/components/blocks/access/invitation-form.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { User, Users } from "lucide-react" import { useState } from "react" -import { Form } from "react-router-dom" +import { Form, useSubmit } from "react-router-dom" import { RemixFormProvider, useRemixForm } from "remix-hook-form" import isEmail from "validator/lib/isEmail" import type { z } from "zod" @@ -32,6 +32,7 @@ type InvitationFormProps = { } export const InvitationForm = ({ principals }: InvitationFormProps) => { + const submit = useSubmit() const [selectedValue, setSelectedValue] = useState("") const form = useRemixForm>({ mode: "onTouched", @@ -40,6 +41,18 @@ export const InvitationForm = ({ principals }: InvitationFormProps) => { role: "advisor", // Set default role intent: "invite_user", }, + submitHandlers: { + onValid: (data) => { + submit( + { + username: data.username, + role: data.role, + intent: "invite_user", + }, + { method: "post" }, + ) + }, + }, }) // Define icon map for AutoComplete @@ -47,7 +60,7 @@ export const InvitationForm = ({ principals }: InvitationFormProps) => { return ( -
+
{ }) }} emptyMessage={(value) => - isEmail(value) - ? `Nodig ${value} uit voor toegang` - : "Geen gebruikers gevonden" + isEmail(value) ? ( + + ) : ( + "Geen gebruikers gevonden. Je kunt ook een e-mailadres invoeren om een uitnodiging te sturen." + ) } placeholder="Zoek naar een gebruiker of organisatie" allowValuesOutsideList={true}