diff --git a/.changeset/farm-invitation-app.md b/.changeset/farm-invitation-app.md new file mode 100644 index 000000000..75e067031 --- /dev/null +++ b/.changeset/farm-invitation-app.md @@ -0,0 +1,9 @@ +--- +"@svenvw/fdm-app": minor +--- + +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 +- 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..5a6650dd9 --- /dev/null +++ b/.changeset/farm-invitation-system.md @@ -0,0 +1,17 @@ +--- +"@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. The invitation system has been refactored to be resource-agnostic, so any resource type (farm, field, etc.) can be shared via invitations. + +**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/.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-app/app/components/blocks/access/invitation-form.tsx b/fdm-app/app/components/blocks/access/invitation-form.tsx index e98ab9c01..e177c8516 100644 --- a/fdm-app/app/components/blocks/access/invitation-form.tsx +++ b/fdm-app/app/components/blocks/access/invitation-form.tsx @@ -1,8 +1,9 @@ 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" import { AutoComplete } from "~/components/custom/autocomplete" import { Button } from "~/components/ui/button" @@ -31,6 +32,7 @@ type InvitationFormProps = { } export const InvitationForm = ({ principals }: InvitationFormProps) => { + const submit = useSubmit() const [selectedValue, setSelectedValue] = useState("") const form = useRemixForm>({ mode: "onTouched", @@ -39,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 @@ -46,7 +60,7 @@ export const InvitationForm = ({ principals }: InvitationFormProps) => { return ( -
+
{ shouldTouch: true, }) }} - emptyMessage="Geen gebruikers gevonden" + emptyMessage={(value) => + 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} form={form} // Pass the form instance name="username" // Name for remix-hook-form registration /> 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..b03931ae9 --- /dev/null +++ b/fdm-app/app/components/blocks/email/farm-invitation.tsx @@ -0,0 +1,133 @@ +import { + Body, + Button, + Container, + Font, + 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 + 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, + role, + appName, + appBaseUrl, + logoFileName = "/fdm-high-resolution-logo-transparent.png", + isUnregistered = false, +}: FarmInvitationEmailProps) => { + const logoPath = `${appBaseUrl}${logoFileName}` + const roleLabel = roleLabels[role] ?? role + + return ( + + + + + + {`${inviterName} heeft je uitgenodigd voor toegang tot bedrijf ${farmName} in ${appName}.`} + + + + +
+ {`${appName} +
+ + Uitnodiging voor {farmName} in {appName} + + + 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 wordt je toegang + automatisch verleend. + +
+ +
+ + ) : ( + <> + + Log in en accepteer of weiger de + uitnodiging. + +
+ +
+
+ + of open {appName} om je uitnodigingen te + bekijken. + +
+ + )} + + 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/components/blocks/email/invitation.tsx b/fdm-app/app/components/blocks/email/invitation.tsx index dd48299dc..94aaf535c 100644 --- a/fdm-app/app/components/blocks/email/invitation.tsx +++ b/fdm-app/app/components/blocks/email/invitation.tsx @@ -2,6 +2,7 @@ import { Body, Button, Container, + Font, Head, Heading, Html, @@ -34,20 +35,15 @@ export const InvitationEmail = ({ }: InvitationEmailProps) => { const logoPath = `${appBaseUrl}${logoFileName}` - const fontFamily = `"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif` - return ( - - {`${inviterName} heeft je uitgenodigd om lid te worden van ${organizationName} in ${appName}.`} diff --git a/fdm-app/app/components/blocks/email/magic-link.tsx b/fdm-app/app/components/blocks/email/magic-link.tsx index 2876d7e94..2a178b057 100644 --- a/fdm-app/app/components/blocks/email/magic-link.tsx +++ b/fdm-app/app/components/blocks/email/magic-link.tsx @@ -2,6 +2,7 @@ import { Body, Button, Container, + Font, Head, Heading, Html, @@ -33,15 +34,12 @@ export const MagicLinkEmail = ({ {`Aanmelden bij ${appName}`} - - {`Link om aan te melden bij ${appName}`} diff --git a/fdm-app/app/components/blocks/email/welcome.tsx b/fdm-app/app/components/blocks/email/welcome.tsx index f1f74e886..d3405bf71 100644 --- a/fdm-app/app/components/blocks/email/welcome.tsx +++ b/fdm-app/app/components/blocks/email/welcome.tsx @@ -2,6 +2,7 @@ import { Body, Button, Container, + Font, Head, Heading, Hr, @@ -30,23 +31,17 @@ export function WelcomeEmail({ }: WelcomeEmailProps) { const previewText = `Welkom bij ${appName}! Krijg inzicht in je bedrijfsdata.` const logoPath = `${appBaseUrl}${logoFileName}` - - const fontFamily = `"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif` - const absoluteUrl = url.startsWith("http") ? url : `https://${url}` return ( - - {previewText} diff --git a/fdm-app/app/components/blocks/farm/pending-invitation.tsx b/fdm-app/app/components/blocks/farm/pending-invitation.tsx new file mode 100644 index 000000000..645e1c033 --- /dev/null +++ b/fdm-app/app/components/blocks/farm/pending-invitation.tsx @@ -0,0 +1,102 @@ +import { Bell, Check, X } from "lucide-react" +import { Form } from "react-router" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card" + +type PendingInvitation = { + invitation_id: string + resource_id: string + role: string + farm_name: string | null + org_name: string | null +} + +type Props = { + invitation: PendingInvitation +} + +function getRoleLabel(role: string): string { + if (role === "owner") return "Eigenaar" + if (role === "advisor") return "Adviseur" + if (role === "researcher") return "Onderzoeker" + return "Lid" +} + +export function PendingInvitationCard({ invitation }: Props) { + const farmLabel = invitation.farm_name ?? invitation.resource_id + + return ( + + +
+
+ +
+
+ {farmLabel} + + Rol: {getRoleLabel(invitation.role)} + +
+
+
+ + Je hebt een uitnodiging ontvangen voor toegang tot bedrijf{" "} + {farmLabel} als {getRoleLabel(invitation.role)}. + {invitation.org_name && ( + + Deze uitnodiging ontvang je namens organisatie:{" "} + {invitation.org_name} + + )}{" "} + Je kunt deze uitnodiging accepteren of weigeren. + + + + + + + +
+ + + +
+
+
+ ) +} diff --git a/fdm-app/app/components/custom/autocomplete.tsx b/fdm-app/app/components/custom/autocomplete.tsx index cf4e90334..e2f769dad 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({ @@ -50,9 +52,11 @@ export function AutoComplete({ form, name, className, + allowValuesOutsideList = false, }: 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) @@ -60,13 +64,12 @@ export function AutoComplete({ const prevInputValue = useRef(null) const inputRef = useRef(null) // Ref for the input element - // Derive display label for the currently selected value + // Derive display label for the currently selected value. + // Falls back to selectedValue for free-form entries (when allowValuesOutsideList is true). const selectedLabel = useMemo(() => { - // Find the label from fetched items or potentially passed initial state if needed - // For now, assume we fetch it or it's cleared if not found const selectedItem = items.find((item) => item.value === selectedValue) - return selectedItem?.label ?? "" - }, [selectedValue, items]) + return selectedItem?.label ?? (allowValuesOutsideList ? selectedValue : "") + }, [selectedValue, items, allowValuesOutsideList]) // Effect to fetch data when input value changes (debounced) useEffect(() => { @@ -150,40 +153,61 @@ export function AutoComplete({ } } setOpen(false) + openRef.current = 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). + // Runs synchronously (no setTimeout) so the form value is committed before the submit button + // click fires. Dropdown item clicks are protected by onMouseDown e.preventDefault() on + // each CommandItem, which prevents blur from firing during dropdown selection. 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 input matches selected label, keep it. - // If input doesn't match, but a value IS selected, revert input to selected label - else if (inputValue !== selectedLabel && selectedValue) { - setInputValue(selectedLabel) + 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("") } - }, 100) // Small delay + } + // If input doesn't match selected label, revert input to selected label + else if (inputValue !== selectedLabel && selectedValue) { + setInputValue(selectedLabel) + } } return (
- + { + setOpen(value) + openRef.current = value + }} + > setOpen(e.key !== "Escape")} - onMouseDown={() => - setOpen((open) => !!inputValue || !open) - } - onFocus={() => setOpen(true)} + onKeyDown={(e) => { + const next = e.key !== "Escape" + setOpen(next) + openRef.current = next + }} + onMouseDown={() => { + const next = !!inputValue || !open + setOpen(next) + openRef.current = next + }} + onFocus={() => { + setOpen(true) + openRef.current = true + }} onBlur={handleInputBlur} > ({ ) : 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/lib/email.server.ts b/fdm-app/app/lib/email.server.ts index 47f40d990..91990b078 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,42 @@ export async function renderInvitationEmail( return email } +export async function renderFarmInvitationEmail( + targetEmail: string, + inviterName: string, + farmName: string, + role: string, + isUnregistered: boolean, +): Promise { + const emailHtml = await render( + FarmInvitationEmail({ + farmName, + inviterName, + targetEmail, + 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: isUnregistered + ? "invitation-farm-new-user" + : "invitation-farm", + } + + return email +} + export async function renderMagicLinkEmail( emailAddress: string, magicLinkUrl: string, diff --git a/fdm-app/app/lib/error.ts b/fdm-app/app/lib/error.ts index c7bb2541a..6b9b7a5d3 100644 --- a/fdm-app/app/lib/error.ts +++ b/fdm-app/app/lib/error.ts @@ -148,7 +148,35 @@ export function handleLoaderError(error: unknown) { ) } +/** + * Recursively checks whether an error or any error in its cause chain + * contains the given substring in its message. + */ +function containsErrorMessage(error: unknown, message: string): boolean { + if (!(error instanceof Error)) return false + if (error.message.includes(message)) return true + return containsErrorMessage(error.cause, message) +} + export function handleActionError(error: unknown) { + // Spam prevention: inviter exceeded hourly limit + if (containsErrorMessage(error, "Rate limit exceeded")) { + console.warn("Invitation rate limit hit:", error) + return dataWithWarning( + null, + "Je hebt te veel uitnodigingen verstuurd in het afgelopen uur. Wacht even en probeer het later opnieuw.", + ) + } + + // Spam prevention: target already has too many pending invitations + if (containsErrorMessage(error, "too many pending invitations")) { + console.warn("Invitation pending cap hit:", error) + return dataWithWarning( + null, + "Deze persoon heeft al te veel openstaande uitnodigingen. Probeer het later opnieuw.", + ) + } + // Handle 'data' thrown errors if ( typeof error === "object" && 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..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 @@ -1,11 +1,15 @@ import { + acceptInvitation, + declineInvitation, getFarm, grantRoleToFarm, isAllowedToShareFarm, listPrincipalsForFarm, + lookupPrincipal, revokePrincipalFromFarm, updateRoleOfPrincipalAtFarm, } from "@svenvw/fdm-core" +import isEmail from "validator/lib/isEmail" import { type ActionFunctionArgs, data, @@ -22,6 +26,11 @@ 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, + isInactiveRecipientError, +} from "~/lib/email.server" // Meta export const meta: MetaFunction = () => { @@ -121,17 +130,98 @@ export async function action({ request, params }: ActionFunctionArgs) { } await grantRoleToFarm( fdm, - session.user.id, + session.principal_id, formValues.username, b_id_farm, formValues.role, ) + // Send invitation email + let targetPrincipal: any = null; + try { + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + const inviterName = session.userName + const normalizedTarget = formValues.username.toLowerCase().trim() + const isEmailTarget = isEmail(normalizedTarget) + + // Try to find the principal to get their email if they are registered + const matchedPrincipals = await lookupPrincipal(fdm, normalizedTarget) + targetPrincipal = matchedPrincipals.find( + (p) => + p.username.toLowerCase() === normalizedTarget || + (isEmailTarget && p.email?.toLowerCase() === normalizedTarget), + ) + + const targetEmail = isEmailTarget + ? 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, + formValues.role, + isUnregistered, + ) + await sendEmail(email) + } + } catch (emailError) { + console.error("Error sending farm invitation email:", emailError) + if (isInactiveRecipientError(emailError)) { + // 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.`, + ) + } + } + return dataWithSuccess(null, { message: `${formValues.username} is uitgenodigd! 🎉`, }) } + if (formValues.intent === "accept_farm_invitation") { + if (!formValues.invitation_id) { + return handleActionError("missing: invitation_id") + } + await acceptInvitation( + 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 declineInvitation( + 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") @@ -167,8 +257,6 @@ export async function action({ request, params }: ActionFunctionArgs) { } throw new Error("invalid intent") } catch (error) { - console.error(error) - return dataWithError(null, "Er is iets misgegaan") - // throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm._index.tsx b/fdm-app/app/routes/farm._index.tsx index 2a4c50613..05034c0b1 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 { + acceptInvitation, + declineInvitation, + getFarms, + listPendingInvitationsForUser, +} from "@svenvw/fdm-core" import { ArrowRight, + Bell, Check, House, Layers, @@ -9,14 +15,19 @@ 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 { 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" @@ -35,7 +46,16 @@ 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" + +function getRoleLabel(role: string): string { + if (role === "owner") return "Eigenaar" + if (role === "advisor") return "Adviseur" + if (role === "researcher") return "Onderzoeker" + return "Lid" +} // Meta export const meta: MetaFunction = () => { @@ -77,18 +97,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 uitnodigings id") + } + await acceptInvitation( + 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 uitnodigings id") + } + await declineInvitation( + 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 +336,25 @@ export default function AppIndex() {
+ + {loaderData.pendingInvitations.length > 0 && ( +
+

+ Openstaande uitnodigingen +

+
+ {loaderData.pendingInvitations.map( + (invitation) => ( + + ), + )} +
+
+ )} +
@@ -311,16 +400,7 @@ export default function AppIndex() { variant="secondary" className="text-[10px] uppercase tracking-wider" > - {role === - "owner" - ? "Eigenaar" - : role === - "advisor" - ? "Adviseur" - : role === - "researcher" - ? "Onderzoeker" - : "Lid"} + {getRoleLabel(role)} ), )} @@ -389,6 +469,26 @@ export default function AppIndex() { + {/* Pending farm invitations */} + {loaderData.pendingInvitations.length > 0 && ( + <> + +
+ {loaderData.pendingInvitations.map( + (invitation) => ( + + ), + )} +
+ + )} + + p.username.toLowerCase() === normalizedTarget || + (isEmailTarget && p.email?.toLowerCase() === normalizedTarget), + ) + + const targetEmail = isEmailTarget + ? 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, + formValues.role, + isUnregistered, + ) + await sendEmail(email) + } + } catch (emailError) { + console.error("Error sending farm invitation email:", emailError) + if (isInactiveRecipientError(emailError)) { + // 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.`, + ) + } + } + return dataWithSuccess(null, { message: `${formValues.username} is uitgenodigd!`, }) @@ -217,8 +278,6 @@ export async function action({ request, params }: ActionFunctionArgs) { throw new Error("Invalid intent") } catch (error) { - console.error(error) - return dataWithError(null, "Er is iets misgegaan") - // throw handleActionError(error) + return handleActionError(error) } } 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/authentication.ts b/fdm-core/src/authentication.ts index 2386b4a5c..62734211a 100644 --- a/fdm-core/src/authentication.ts +++ b/fdm-core/src/authentication.ts @@ -7,6 +7,7 @@ import { generateFromEmail } from "unique-username-generator" import * as authNSchema from "./db/schema-authn" import { handleError } from "./error" import type { FdmType } from "./fdm" +import { autoAcceptInvitationsForNewUser } from "./invitation" export type BetterAuth = FdmAuth @@ -224,6 +225,43 @@ export function createFdmAuth( }) .where(eq(authNSchema.user.id, user.id)) } + + // Auto-accept pending invitations if email is already verified (e.g. social login) + if (user.emailVerified) { + try { + await autoAcceptInvitationsForNewUser( + fdm, + user.email, + user.id, + ) + } catch (err) { + console.warn( + "autoAcceptInvitationsForNewUser failed for user", + user.id, + err, + ) + } + } + }, + }, + update: { + after: async (user) => { + // Auto-accept pending invitations when email becomes verified + if (user.emailVerified) { + 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/db/migrations/0023_powerful_rocket_raccoon.sql b/fdm-core/src/db/migrations/0023_powerful_rocket_raccoon.sql new file mode 100644 index 000000000..885638d4a --- /dev/null +++ b/fdm-core/src/db/migrations/0023_powerful_rocket_raccoon.sql @@ -0,0 +1,18 @@ +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, + CONSTRAINT "invitation_target_check" CHECK ("fdm-authz"."invitation"."target_email" IS NOT NULL OR "fdm-authz"."invitation"."target_principal_id" IS NOT NULL) +); +--> 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';--> statement-breakpoint +CREATE INDEX "invitation_pending_target_email_idx" ON "fdm-authz"."invitation" USING btree ("target_email") WHERE "fdm-authz"."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..c1b8aa24b --- /dev/null +++ b/fdm-core/src/db/migrations/meta/0023_snapshot.json @@ -0,0 +1,3782 @@ +{ + "id": "0440bcd2-2895-4237-9ebd-c7864e702108", + "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.invitation": { + "name": "invitation", + "schema": "fdm-authz", + "columns": { + "invitation_id": { + "name": "invitation_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 + }, + "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": { + "invitation_unique_email_idx": { + "name": "invitation_unique_email_idx", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"fdm-authz\".\"invitation\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_unique_principal_idx": { + "name": "invitation_unique_principal_idx", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"fdm-authz\".\"invitation\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_target_email_idx": { + "name": "invitation_pending_target_email_idx", + "columns": [ + { + "expression": "target_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"fdm-authz\".\"invitation\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "invitation_target_check": { + "name": "invitation_target_check", + "value": "\"fdm-authz\".\"invitation\".\"target_email\" IS NOT NULL OR \"fdm-authz\".\"invitation\".\"target_principal_id\" IS NOT NULL" + } + }, + "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": {} + } +} diff --git a/fdm-core/src/db/migrations/meta/_journal.json b/fdm-core/src/db/migrations/meta/_journal.json index 756793c6c..e0cce8eac 100644 --- a/fdm-core/src/db/migrations/meta/_journal.json +++ b/fdm-core/src/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1768485087753, "tag": "0022_v0-29-0-2", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1771594531138, + "tag": "0023_powerful_rocket_raccoon", + "breakpoints": true } ] } diff --git a/fdm-core/src/db/schema-authz.ts b/fdm-core/src/db/schema-authz.ts index 02dedfeb4..63d67cc8d 100644 --- a/fdm-core/src/db/schema-authz.ts +++ b/fdm-core/src/db/schema-authz.ts @@ -1,11 +1,14 @@ // Authorization +import { sql } from "drizzle-orm" import { boolean, + check, index, integer, pgSchema, text, timestamp, + uniqueIndex, } from "drizzle-orm/pg-core" // Define postgres schema @@ -53,3 +56,40 @@ export const audit = fdmAuthZSchema.table("audit", { export type auditTypeSelect = typeof audit.$inferSelect export type auditTypeInsert = typeof audit.$inferInsert + +export const invitation = fdmAuthZSchema.table( + "invitation", + { + invitation_id: text().primaryKey(), + 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' + 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/resource + uniqueIndex("invitation_unique_email_idx") + .on(table.resource, table.resource_id, table.target_email) + .where(sql`${table.status} = 'pending'`), + uniqueIndex("invitation_unique_principal_idx") + .on(table.resource, table.resource_id, table.target_principal_id) + .where(sql`${table.status} = 'pending'`), + check( + "invitation_target_check", + sql`${table.target_email} IS NOT NULL OR ${table.target_principal_id} IS NOT NULL`, + ), + // Partial index for fast lookup by target_email when status is pending + index("invitation_pending_target_email_idx") + .on(table.target_email) + .where(sql`${table.status} = 'pending'`), + ], +) + +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 36e4eda38..6f1e8404c 100644 --- a/fdm-core/src/farm.test.ts +++ b/fdm-core/src/farm.test.ts @@ -3,6 +3,8 @@ 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 { addFarm, @@ -11,6 +13,8 @@ import { grantRoleToFarm, isAllowedToDeleteFarm, isAllowedToShareFarm, + listPendingInvitationsForFarm, + listPendingInvitationsForUser, listPrincipalsForFarm, removeFarm, revokePrincipalFromFarm, @@ -21,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" @@ -81,6 +86,12 @@ describe("Farm Functions", () => { }) target_id = target.user.id + // Mark target's email as verified so acceptInvitation 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 +254,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 +263,30 @@ describe("Farm Functions", () => { "advisor", ) + // Verify invitation was created (not a direct role grant) + const invitations = await fdm + .select() + .from(authZSchema.invitation) + .where( + eq(authZSchema.invitation.resource_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 acceptInvitation(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 +343,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.invitation) + .where( + eq(authZSchema.invitation.target_email, unregisteredEmail), + ) + expect(invitations.length).toBeGreaterThanOrEqual(1) + expect(invitations[0].status).toBe("pending") + expect(invitations[0].role).toBe("researcher") + }) }) describe("updateRoleOfPrincipalAtFarm", () => { @@ -535,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", () => { @@ -709,6 +791,13 @@ describe("Farm Functions", () => { testFarmId, ) expect(principals).toEqual([]) + + // Verify farm invitations are deleted + const farmInvitations = await fdm + .select() + .from(authZSchema.invitation) + .where(eq(authZSchema.invitation.resource_id, testFarmId)) + expect(farmInvitations).toEqual([]) }) it("should throw an error if the principal does not have write access", async () => { @@ -760,4 +849,326 @@ describe("Farm Functions", () => { ) }) }) + + describe("acceptInvitation", () => { + 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.invitation) + .where( + eq(authZSchema.invitation.resource_id, invitationFarmId), + ) + invitationId = rows[0].invitation_id + + await acceptInvitation(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( + acceptInvitation(fdm, invitationId, target_id), + ).rejects.toThrowError("Exception for acceptInvitation") + }) + + it("should throw if invitation does not exist", async () => { + await expect( + acceptInvitation(fdm, createId(), target_id), + ).rejects.toThrowError("Exception for acceptInvitation") + }) + + 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.invitation) + .where(eq(authZSchema.invitation.resource_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( + acceptInvitation(fdm, otherInvitationId, wrongUser.user.id), + ).rejects.toThrowError("Exception for acceptInvitation") + }) + }) + + describe("declineInvitation", () => { + 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.invitation) + .where(eq(authZSchema.invitation.resource_id, declineFarmId)) + declineInvitationId = rows[0].invitation_id + }) + + it("should decline a pending invitation", async () => { + await declineInvitation(fdm, declineInvitationId, target_id) + + const rows = await fdm + .select() + .from(authZSchema.invitation) + .where( + eq( + authZSchema.invitation.invitation_id, + declineInvitationId, + ), + ) + expect(rows[0].status).toBe("declined") + }) + + it("should throw if invitation is already declined", async () => { + await expect( + declineInvitation(fdm, declineInvitationId, target_id), + ).rejects.toThrowError("Exception for declineInvitation") + }) + + 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.invitation) + .where(eq(authZSchema.invitation.resource_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( + declineInvitation(fdm, anotherInvitationId, otherUser.user.id), + ).rejects.toThrowError("Exception for declineInvitation") + }) + }) + + 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].resource_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.resource_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([]) + }) + + 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/farm.ts b/fdm-core/src/farm.ts index e924de5a5..7d4407b89 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 } from "drizzle-orm" import { checkPermission, getRolesOfPrincipalForResource, @@ -9,12 +9,18 @@ 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" 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" /** @@ -298,21 +304,22 @@ 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, - principal_id: PrincipalId, + principal_id: string, target: string, b_id_farm: schema.farmsTypeInsert["b_id_farm"], role: "owner" | "advisor" | "researcher", @@ -327,24 +334,14 @@ export async function grantRoleToFarm( principal_id, "grantRoleToFarm", ) - - const targetDetails = await identifyPrincipal(tx, target) - if (!targetDetails) { - throw new Error("Target not found") - } - - await grantRole(tx, "farm", role, b_id_farm, targetDetails.id) - - // Check if at least 1 owner is still prestent on this farm - const owners = await listPrincipalsForResource( + return await createInvitation( tx, "farm", b_id_farm, + principal_id, + target, + role, ) - const ownerCount = owners.filter((x) => x.role === "owner").length - if (ownerCount === 0) { - throw new Error("Farm should have at least 1 owner") - } }) } catch (err) { throw handleError(err, "Exception for grantRoleToFarm", { @@ -476,7 +473,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 +481,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 +504,197 @@ 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.invitation) + .where( + and( + 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.invitationTypeSelect[] + ) + .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?.charAt(0) ?? "U" + 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.invitationTypeSelect) => { + 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 + // Deduplicate by principal_id, preferring "active" over "pending" + const deduped = new Map< + string, + (typeof principalsDetails)[number] + >() + for (const entry of principalsDetails) { + deduped.set(entry.id, entry) + } + for (const entry of pendingDetails) { + if (!deduped.has(entry.id)) { + deduped.set(entry.id, entry) + } + } + return Array.from(deduped.values()) }) } catch (err) { throw handleError(err, "Exception for listPrincipalsForFarm", { @@ -525,7 +704,149 @@ export async function listPrincipalsForFarm( } /** - * Checks if the specified principal is allowed to share a given farm. + * 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 invitation records for this farm. + */ +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.invitation) + .where( + and( + eq(authZSchema.invitation.resource, "farm"), + eq(authZSchema.invitation.resource_id, b_id_farm), + eq(authZSchema.invitation.status, "pending"), + gt(authZSchema.invitation.expires, now), + ), + ) + }) + } catch (err) { + throw handleError(err, "Exception for listPendingInvitationsForFarm", { + b_id_farm, + }) + } +} + +/** + * Lists all pending (non-expired) farm invitations for a given user, enriched with farm and organization names. + * + * 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 invitation records enriched with farm_name and org_name. + */ +export async function listPendingInvitationsForUser( + fdm: FdmType, + user_id: string, +): Promise< + (authZSchema.invitationTypeSelect & { + farm_name: string | null + org_name: string | null + })[] +> { + try { + return await fdm.transaction(async (tx: FdmType) => { + const pending = await listPendingInvitationsForPrincipal( + tx, + user_id, + ) + + if (pending.length === 0) { + return [] + } + + // Enrich with farm names for farm-resource invitations + const farmIds = [ + ...new Set( + pending + .filter((i) => i.resource === "farm") + .map((i) => i.resource_id), + ), + ] + + 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)) + + 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), + ), + ] + + 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 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", { + user_id, + }) + } +} + +/** * * This function verifies if the acting principal has 'share' permission on the farm. * @@ -805,6 +1126,16 @@ export async function removeFarm( ) } + // Step 4b: Delete all invitations for this farm + await tx + .delete(authZSchema.invitation) + .where( + and( + eq(authZSchema.invitation.resource, "farm"), + eq(authZSchema.invitation.resource_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..837dc2c4a 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.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 6d884c0a3..e4292dad2 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -70,6 +70,8 @@ export { grantRoleToFarm, isAllowedToDeleteFarm, isAllowedToShareFarm, + listPendingInvitationsForFarm, + listPendingInvitationsForUser, listPrincipalsForFarm, removeFarm, revokePrincipalFromFarm, @@ -144,6 +146,19 @@ export { removeOrganicCertification, } from "./organic" export type { OrganicCertification } from "./organic.d" +export { + autoAcceptInvitationsForNewUser, + createInvitation, + acceptInvitation, + declineInvitation, + listPendingInvitationsForPrincipal, + MAX_INVITATIONS_PER_INVITER_PER_HOUR, + MAX_INVITATIONS_PENDING_PER_TARGET, +} from "./invitation" +export type { + invitationTypeSelect, + invitationTypeInsert, +} from "./db/schema-authz" export { lookupPrincipal } from "./principal" export { addSoilAnalysis, diff --git a/fdm-core/src/invitation.test.ts b/fdm-core/src/invitation.test.ts new file mode 100644 index 000000000..a98f7b123 --- /dev/null +++ b/fdm-core/src/invitation.test.ts @@ -0,0 +1,980 @@ +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 { createId } from "./id" +import { + MAX_INVITATIONS_PER_INVITER_PER_HOUR, + MAX_INVITATIONS_PENDING_PER_TARGET, + acceptInvitation, + autoAcceptInvitationsForNewUser, + declineInvitation, + listPendingInvitationsForPrincipal, +} 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.invitation) + .where( + and( + eq(authZSchema.invitation.target_email, targetEmail), + eq(authZSchema.invitation.resource_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.invitation) + .set({ expires: new Date("2000-01-01") }) + .where(eq(authZSchema.invitation.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.invitation) + .where(eq(authZSchema.invitation.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 mixed-case email to exercise normalization + await autoAcceptInvitationsForNewUser( + fdm, + mixedCaseEmail, + 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") + }) +}) + +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, + }) + + // Mark email as verified + 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) + 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, + }) + + // Mark email as verified + 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 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") + }) +}) + +describe("createInvitation spam prevention", () => { + let fdm: FdmServerType + let fdmAuth: FdmAuth + let rateLimitOwnerPrincipalId: string + let rateLimitFarmId: string + let pendingCapOwnerPrincipalId: 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) + + const rateLimitOwner = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "spam_ratelimit_owner@example.com", + name: "spam_ratelimit_owner", + username: "spam_ratelimit_owner", + password: "password", + } as any, + }) + rateLimitOwnerPrincipalId = rateLimitOwner.user.id + rateLimitFarmId = await addFarm( + fdm, + rateLimitOwnerPrincipalId, + "Rate Limit Farm", + "RLF001", + "Rate Limit Lane", + "55555", + ) + + const pendingCapOwner = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "spam_pendingcap_owner@example.com", + name: "spam_pendingcap_owner", + username: "spam_pendingcap_owner", + password: "password", + } as any, + }) + pendingCapOwnerPrincipalId = pendingCapOwner.user.id + }) + + it("should reject when inviter exceeds hourly rate limit", async () => { + // Send exactly MAX invitations to different email addresses (they don't exist as users) + for (let i = 0; i < MAX_INVITATIONS_PER_INVITER_PER_HOUR; i++) { + await grantRoleToFarm( + fdm, + rateLimitOwnerPrincipalId, + `rl_target_${i}@example.com`, + rateLimitFarmId, + "advisor", + ) + } + + // The next one should be rejected by the rate limiter + await expect( + grantRoleToFarm( + fdm, + rateLimitOwnerPrincipalId, + "rl_overflow@example.com", + rateLimitFarmId, + "advisor", + ), + ).rejects.toThrowError("Exception for grantRoleToFarm") + }) + + it("should reject when target already has too many pending invitations", async () => { + const targetEmail = "pending_flood@example.com" + + // Create MAX farms and invite the same target to each + for (let i = 0; i < MAX_INVITATIONS_PENDING_PER_TARGET; i++) { + const farmId = await addFarm( + fdm, + pendingCapOwnerPrincipalId, + `Flood Farm ${i}`, + `FLD${String(i).padStart(3, "0")}`, + "Flood Street", + "66666", + ) + await grantRoleToFarm( + fdm, + pendingCapOwnerPrincipalId, + targetEmail, + farmId, + "advisor", + ) + } + + // The next invitation to the same target should be rejected + const overflowFarmId = await addFarm( + fdm, + pendingCapOwnerPrincipalId, + "Flood Farm Overflow", + "FLDX01", + "Flood Street", + "66666", + ) + await expect( + grantRoleToFarm( + fdm, + pendingCapOwnerPrincipalId, + targetEmail, + overflowFarmId, + "advisor", + ), + ).rejects.toThrowError("Exception for grantRoleToFarm") + }) +}) diff --git a/fdm-core/src/invitation.ts b/fdm-core/src/invitation.ts new file mode 100644 index 000000000..0a76cf32b --- /dev/null +++ b/fdm-core/src/invitation.ts @@ -0,0 +1,681 @@ +import { and, count, eq, gt, inArray, or } from "drizzle-orm" +import isEmail from "validator/lib/isEmail.js" +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 { createId } from "./id" +import { identifyPrincipal } from "./principal" + +/** + * Maximum number of invitations an inviter can send within a rolling one-hour window. + * Enforced in {@link createInvitation} to prevent spam campaigns. + */ +export const MAX_INVITATIONS_PER_INVITER_PER_HOUR = 20 + +/** + * Maximum number of globally pending invitations a single target (email or principal) + * may have at once across all resources. + * Enforced in {@link createInvitation} to prevent inbox flooding. + */ +export const MAX_INVITATIONS_PENDING_PER_TARGET = 10 + +/** + * Creates an invitation for a principal or email address to access a resource. + * + * 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 + } + + // Rate limit: prevent an inviter from sending too many invitations per hour + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) + const [rateRow] = await tx + .select({ value: count() }) + .from(authZSchema.invitation) + .where( + and( + eq(authZSchema.invitation.inviter_id, inviter_id), + gt(authZSchema.invitation.created, oneHourAgo), + ), + ) + if (rateRow.value >= MAX_INVITATIONS_PER_INVITER_PER_HOUR) { + throw new Error( + "Rate limit exceeded: too many invitations sent in the last hour", + ) + } + + 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 { + // Pending cap: prevent flooding a target's inbox across resources + const [pendingRow] = await tx + .select({ value: count() }) + .from(authZSchema.invitation) + .where( + and( + eq( + authZSchema.invitation.target_email, + targetEmail, + ), + eq(authZSchema.invitation.status, "pending"), + ), + ) + if ( + pendingRow.value >= MAX_INVITATIONS_PENDING_PER_TARGET + ) { + throw new Error( + "Target has too many pending invitations. Please try again later.", + ) + } + + 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 { + // Pending cap: prevent flooding a target's inbox across resources + const [pendingRow] = await tx + .select({ value: count() }) + .from(authZSchema.invitation) + .where( + and( + eq( + authZSchema.invitation.target_principal_id, + targetPrincipalId!, + ), + eq(authZSchema.invitation.status, "pending"), + ), + ) + if ( + pendingRow.value >= MAX_INVITATIONS_PENDING_PER_TARGET + ) { + throw new Error( + "Target has too many pending invitations. Please try again later.", + ) + } + + 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, + 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) { + 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, + emailVerified: authNSchema.user.emailVerified, + }) + .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", + ) + } + if (!userRecord[0].emailVerified) { + throw new Error( + "Email must be verified before declining an invitation", + ) + } + } + + 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. + * @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) => { + const pendingInvitations = await tx + .select() + .from(authZSchema.invitation) + .where( + and( + eq( + authZSchema.invitation.target_email, + normalizedEmail, + ), + eq(authZSchema.invitation.status, "pending"), + ), + ) + + const now = new Date() + for (const inv of pendingInvitations) { + if (inv.expires < now) { + await tx + .update(authZSchema.invitation) + .set({ status: "expired" }) + .where( + eq( + authZSchema.invitation.invitation_id, + inv.invitation_id, + ), + ) + continue + } + + try { + await tx.transaction(async (savepointTx: FdmType) => { + await grantRole( + savepointTx, + inv.resource as Resource, + inv.role as Role, + inv.resource_id, + user_id, + ) + + await savepointTx + .update(authZSchema.invitation) + .set({ + status: "accepted", + accepted_at: now, + target_principal_id: user_id, + }) + .where( + eq( + authZSchema.invitation.invitation_id, + inv.invitation_id, + ), + ) + }) + } catch (e) { + console.warn( + `Failed to auto-accept invitation ${inv.invitation_id} for user ${user_id}:`, + e, + ) + } + } + }) + } catch (err) { + throw handleError( + err, + "Exception for autoAcceptInvitationsForNewUser", + { + 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..999673ab3 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, @@ -128,25 +132,23 @@ export async function getPrincipal( } /** - * Identifies a principal (either a user or an organization) based on a username or email. + * Identifies a principal (either a user or an organization) based on a username, email, or org slug. * * This function searches for a principal, first by checking the user table for a matching username or email, - * and then, if not found, by checking the organization table for a matching slug. If a principal is found, - * its details are retrieved and returned. + * and then, if not found, by checking the organization table for a matching slug. * * @param fdm - The FDM instance providing the connection to the database. * @param identifier - The username, email, or organization slug to search for. - * @returns A promise that resolves to an array of LookupPrincipal objects. If a principal is found, the array contains a single object with the principal's details. If no principal is found, the array is empty. + * @returns A promise that resolves to the matching {@link Principal}, or `undefined` if not found. * * @throws {Error} - Throws an error if any database operation fails. * The error includes a message and context information about the failed operation. * * @example * ```typescript - * // Example usage: - * const principalDetails = await identifyPrincipal(fdm, "john.doe@example.com"); - * if (principalDetails.length > 0) { - * console.log("Principal Details:", principalDetails[0]); + * const principal = await identifyPrincipal(fdm, "john.doe@example.com"); + * if (principal) { + * console.log("Principal Details:", principal); * } else { * console.log("Principal not found."); * } @@ -155,12 +157,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 @@ -190,12 +187,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", { 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..dac25f28f 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: + +```text +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, expires?)` | Creates a pending invitation; `expires` defaults to 7 days from now | +| `acceptInvitation(fdm, invitation_id, user_id)` | Accepts and activates the role | +| `declineInvitation(fdm, invitation_id, user_id)` | Declines the invitation | +| `listPendingInvitationsForPrincipal(fdm, user_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. 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))