Skip to content

Implement farm invitation system with pending workflow#470

Merged
SvenVw merged 27 commits into
developmentfrom
FDM460
Feb 23, 2026
Merged

Implement farm invitation system with pending workflow#470
SvenVw merged 27 commits into
developmentfrom
FDM460

Conversation

@SvenVw
Copy link
Copy Markdown
Collaborator

@SvenVw SvenVw commented Feb 19, 2026

Summary by CodeRabbit

  • New Features

    • Invitation-based farm access: invite users (including unregistered emails); invitees can accept or decline. Pending invitations shown in dashboard and farm settings with per-invitation Accept/Decline actions and notification badges. Autocomplete accepts free-form emails when inviting.
  • Emails

    • NL-localized invitation email templates with tailored CTA for unregistered vs registered recipients; emails sent on invite.
  • Bug Fixes / Reliability

    • Invitations expire, include rate limits and per-target caps; auto-accept on email verification.
  • Tests

    • Comprehensive invitation test coverage added.

Closes #460

…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.
@SvenVw SvenVw self-assigned this Feb 19, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 19, 2026

🦋 Changeset detected

Latest commit: c370904

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@svenvw/fdm-app Minor
@svenvw/fdm-core Minor
@svenvw/fdm-docs Minor
@svenvw/fdm-calculator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Replaces direct farm role grants with a resource‑agnostic invitation workflow (email or principal target) with expiry, rate limits, auto‑accept on email verification, email notifications, and UI/actions to list, accept, and decline pending invitations.

Changes

Cohort / File(s) Summary
DB schema & teardown
fdm-core/src/db/schema-authz.ts, fdm-core/src/global-setup.ts
Adds invitation table with columns for resource, resource_id, target_email/target_principal_id, role, inviter_id, status, expires, accepted_at; partial unique indexes to prevent duplicate pending invites; teardown now deletes invitations.
Invitation core & API exports
fdm-core/src/invitation.ts, fdm-core/src/index.ts
New generic invitation system: createInvitation, acceptInvitation, declineInvitation, listPendingInvitationsForPrincipal, autoAcceptInvitationsForNewUser, plus constants and limits; exported from core index.
Invitation tests
fdm-core/src/invitation.test.ts, fdm-core/src/farm.test.ts
Comprehensive tests for create/accept/decline/auto-accept flows, rate limits, listing, and edge cases; farm tests updated for invitation-based flows.
Farm core changes
fdm-core/src/farm.ts, fdm-core/src/index.ts
grantRoleToFarm now creates invitations (7‑day expiry) instead of immediate grant; listPrincipalsForFarm includes pending invitations; added listPendingInvitationsForFarm and listPendingInvitationsForUser; cleanup on removeFarm.
Auth integration
fdm-core/src/authentication.ts
Calls autoAcceptInvitationsForNewUser on user create/update when emailVerified is true (wrapped in try/catch).
Principal shape & helpers
fdm-core/src/principal.d.ts, fdm-core/src/principal.ts
Principal now includes id and nullable email; identifyPrincipal returns `Principal
App email templates
fdm-app/app/components/blocks/email/farm-invitation.tsx, fdm-app/app/components/blocks/email/*
Adds FarmInvitationEmail (NL localized) and switches other email templates to use Font component for font loading.
App routes & UI
fdm-app/app/routes/farm._index.tsx, fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx, fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx
Loaders/actions expose pending invitations; server actions to accept/decline; invite flow renders and sends invitation emails; errors logged/handled and inactive-recipient cases revoke invitations.
UI components
fdm-app/app/components/blocks/farm/pending-invitation.tsx, fdm-app/app/components/blocks/access/invitation-form.tsx
New PendingInvitationCard component rendering Accept/Decline forms; invitation form and autocomplete updated to allow freeform email entries and dynamic empty messages.
Autocomplete component
fdm-app/app/components/custom/autocomplete.tsx
Adds allowValuesOutsideList?: boolean and `emptyMessage?: string
Docs, changesets & manifests
fdm-docs/docs/*, .changeset/*, fdm-core/package.json
Docs updated to describe invitation workflow; changesets added; validator + @types/validator added to dependencies for email detection.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI
    participant Route
    participant Core
    participant DB
    participant Email
    User->>UI: Submit invite (email or username)
    UI->>Route: POST invite
    Route->>Core: grantRoleToFarm(farmId, target, role)
    Core->>DB: createInvitation(resource, resource_id, inviter, target, role) (status: pending, expires +7d)
    DB-->>Core: invitation created
    Route->>Core: lookupPrincipal(target) (optional)
    Core->>DB: query principal by email/username
    DB-->>Core: principal data or none
    Route->>Email: renderFarmInvitationEmail(...)
    Email-->>Route: html/content
    Route->>Email: sendEmail(targetEmail, content)
    Email-->>Route: sent
    Route-->>UI: success
Loading
sequenceDiagram
    participant Invitee
    participant Email
    participant UI
    participant Route
    participant Core
    participant DB
    Invitee->>Email: Click Accept link (or visits dashboard)
    Invitee->>UI: POST accept_farm_invitation(invitation_id)
    UI->>Route: accept action
    Route->>Core: acceptInvitation(invitation_id, user_id)
    Core->>DB: fetch invitation
    DB-->>Core: pending invitation
    Core->>Core: validate target / emailVerified / org-admin
    Core->>DB: grantRole(...) and update invitation status -> accepted
    DB-->>Core: role recorded, invitation updated
    Core-->>Route: success
    Route-->>UI: success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • BoraIneviNMI
  • gerardhros

Poem

🐇
I hopped along the invite trail,
Left a nibble, left a mail,
Seven days to say "Hello",
Or politely tell me "No",
I nibble bugs and fetch the mail.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Implement farm invitation system with pending workflow' accurately and specifically summarizes the main change: a new farm invitation system with pending invitation workflow.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #460: schema changes with invitation table, core logic for grantRoleToFarm refactoring, acceptInvitation/declineInvitation flows, email notifications, dashboard UI, and security measures including share permission checks and email verification.
Out of Scope Changes check ✅ Passed All changes align with the objectives stated in #460. Minor changes include email Font component refactoring, validator dependency addition, and documentation updates—all supporting or ancillary to the main invitation system implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch FDM460

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 19, 2026

Codecov Report

❌ Patch coverage is 84.07407% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.87%. Comparing base (a3899d4) to head (c370904).
⚠️ Report is 28 commits behind head on development.

Files with missing lines Patch % Lines
fdm-core/src/farm.ts 84.25% 17 Missing ⚠️
fdm-core/src/invitation.ts 88.59% 17 Missing ⚠️
fdm-core/src/authentication.ts 11.11% 8 Missing ⚠️
fdm-core/src/db/schema-authz.ts 50.00% 1 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff               @@
##           development     #470      +/-   ##
===============================================
- Coverage        88.10%   87.87%   -0.24%     
===============================================
  Files               93       94       +1     
  Lines             4675     4930     +255     
  Branches          1498     1582      +84     
===============================================
+ Hits              4119     4332     +213     
- Misses             556      598      +42     
Flag Coverage Δ
fdm-calculator 88.86% <ø> (ø)
fdm-core 86.31% <84.07%> (-0.38%) ⬇️
fdm-data 92.12% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread fdm-core/src/farm.ts Fixed
@coderabbitai coderabbitai Bot changed the title @coderabbitai Implement farm invitation system with pending workflow Feb 19, 2026
@coderabbitai coderabbitai Bot added branch:development Issue only affecting development, not the main branch (yet) enhancement New feature or request fdm-app fdm-core labels Feb 19, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
fdm-app/app/components/custom/autocomplete.tsx (1)

158-181: ⚠️ Potential issue | 🟡 Minor

Stale open closure may prevent allowValuesOutsideList from committing typed values on blur

open is read inside the setTimeout callback from the render-closure captured at the time handleInputBlur was called. When the input blurs (e.g., Tab or clicking outside), open is still true at that render. Even if Radix fires onOpenChange(false) in the same event, React state updates are asynchronous — the closure never sees the new value. The !open guard evaluates to false, so the allowValuesOutsideList branch is skipped, leaving form.username empty and causing a validation failure on submit.

Fix by tracking open through a ref so the timeout always reads the latest value:

🐛 Proposed fix
+    const openRef = useRef(false)
+    // Keep ref in sync with open state
+    useEffect(() => {
+        openRef.current = open
+    }, [open])
 
     const handleInputBlur = () => {
         setTimeout(() => {
-            if (!open) {
+            if (!openRef.current) {
                 if (inputValue && !selectedValue) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/custom/autocomplete.tsx` around lines 158 - 181, The
blur handler's setTimeout reads the stale render-closure value of open so the
!open guard can be wrong; change it to read a mutable ref that always reflects
the latest open state: create a ref (e.g., openRef = useRef(open)), update
openRef.current inside the component's onOpenChange handler, and then in
handleInputBlur's timeout check !openRef.current instead of !open; keep the
existing logic that uses allowValuesOutsideList, selectedValue,
onSelectedValueChange, form/name, setInputValue and selectedLabel but read open
via the ref so typed values are committed correctly on blur.
fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx (1)

129-135: ⚠️ Potential issue | 🟠 Major

Use session.principal_id instead of session.user.id for grantRoleToFarm.

This file passes session.user.id to grantRoleToFarm (line 131), whereas farm.create.$b_id_farm.$calendar.access.tsx passes session.principal_id (line 175). The function parameter is the acting principal, which is used for the share permission check. If the session principal differs from the user ID (e.g., user acting on behalf of an organization), using session.user.id may incorrectly deny or bypass the authorization check. Use session.principal_id consistently with the create route.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm`.$b_id_farm.settings.access.tsx around lines 129 -
135, The call to grantRoleToFarm is passing session.user.id but the function
expects the acting principal for the `share` permission check; replace
session.user.id with session.principal_id in the grantRoleToFarm call so the
authorization uses the session principal (matching how
farm.create.$b_id_farm.$calendar.access.tsx passes session.principal_id),
ensuring correct permission enforcement around the `share` check.
fdm-core/src/farm.ts (1)

317-321: ⚠️ Potential issue | 🟡 Minor

principal_id: PrincipalId is passed as inviter_id (expects string)

PrincipalId can be string | string[] (per the codebase convention — read operations use PrincipalId; write/insert operations use string). inviter_id in the farmInvitation schema expects a plain string. If a caller passes an array, the insert silently stores an array reference instead of an ID.

Per the project convention (noted in learnings), functions that insert records should declare principal_id: string rather than PrincipalId. Changing the parameter type here would also propagate the correct constraint to callers.

🛡️ Proposed fix
 export async function grantRoleToFarm(
     fdm: FdmType,
-    principal_id: PrincipalId,
+    principal_id: string,
     target: string,

Based on learnings: "functions that insert/create data intentionally use principal_id: string instead of PrincipalId to enforce that only a single principal ID can be used for creation operations."

Also applies to: 391-391, 404-404, 428-428, 441-441

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 317 - 321, Change the parameter type for
principal_id from PrincipalId to string for functions that perform inserts so
inviter_id is stored as a plain string; update the function signature containing
"principal_id: PrincipalId, target: string, b_id_farm: ..., role: ..." to
"principal_id: string" and any other insert-related functions (the other
occurrences noted at the same file around the blocks referenced at 391, 404,
428, 441) to enforce a single-string principal for farmInvitation/farm creation;
ensure callers are adjusted if needed so they pass a string ID rather than a
PrincipalId array or union type.
fdm-core/src/farm.test.ts (1)

504-520: ⚠️ Potential issue | 🔴 Critical

Test regression: grantRoleToFarm now creates pending invitations instead of active roles, breaking this error test

Both branches of grantRoleToFarm (lines 315–445) create a pending farmInvitation rather than granting an active role. When revokePrincipalFromFarm is then called with target_username, the target has no active role to revoke. Since revokePrincipal uses a soft-delete (UPDATE deleted = now), it silently succeeds even when no matching role exists (0 rows affected, no exception). The owner count check then passes because principal_id remains an owner, and the function returns normally—causing the rejects.toThrowError assertion to fail.

Additionally, fdmMock.revokePrincipal is mocked but never called: revokePrincipalFromFarm imports revokePrincipal directly from ./authorization.ts (line 547), not from the fdm object, so the mock is ineffective regardless.

Fix by accepting the invitation before revoking (to restore an active role for the target), or remove the mock and let the actual behavior be tested.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.test.ts` around lines 504 - 520, The test fails because
grantRoleToFarm now creates a pending farmInvitation (not an active role), so
revokePrincipalFromFarm finds no active role and the fdmMock.revokePrincipal
mock is never invoked because revokePrincipal is imported directly from
./authorization.ts; fix by accepting the invitation before calling
revokePrincipalFromFarm (e.g., call the function that transitions farmInvitation
to an active role or simulate acceptance) so the target has an active role to
revoke, or alternatively remove the ineffective fdmMock and instead mock/spy the
revokePrincipal export from ./authorization.ts (or import the module and
jest.mock that module) so the revokePrincipal call is observed; ensure you
reference grantRoleToFarm, revokePrincipalFromFarm, revokePrincipal,
farmInvitation and fdmMock when making the change.
🧹 Nitpick comments (11)
fdm-core/src/principal.ts (2)

197-197: Remove commented-out debug log

-            // console.log(principalDetails)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/principal.ts` at line 197, Remove the leftover commented-out
debug log "// console.log(principalDetails)" from principal.ts; delete that line
(and any similar commented console.log references in the same function or
nearby) so the production code contains no stray debug prints, keeping the
function that uses principalDetails unchanged.

159-208: identifyPrincipal return type is now redundant — simplify to Promise<Principal | undefined>

Principal already declares id: string (added in this PR), so { id: string } & Principal is equivalent to just Principal. The explicit id: principal_id[0].id in the returned object literal (line 200) is also redundant because ...principalDetails will spread the same value.

♻️ Proposed simplification
 export async function identifyPrincipal(
     fdm: FdmType,
     identifier: string,
-): Promise<
-    | ({
-          id: string
-      } & Principal)
-    | undefined
-> {
+): Promise<Principal | undefined> {
-            return {
-                id: principal_id[0].id,
-                ...principalDetails,
-            }
+            return principalDetails
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/principal.ts` around lines 159 - 208, The identifyPrincipal
function currently declares a redundant return type of Promise<{ id: string } &
Principal | undefined> and constructs a redundant return object; update the
signature to Promise<Principal | undefined>, and in the transaction return the
principalDetails directly (or return principalDetails as-is) since Principal
already contains id; ensure getPrincipal(tx, ...) still returns a Principal so
no additional merging (the explicit id: principal_id[0].id and the object spread
can be removed).
fdm-core/src/db/schema-authz.ts (1)

59-82: No DB-level constraint enforces that at least one of target_email / target_principal_id is set

Application code in grantRoleToFarm prevents both fields being NULL, but without a DB check constraint a direct insert could produce an orphaned invitation row with no target. Consider adding a constraint for defense-in-depth.

♻️ Proposed addition

Add check to your imports from drizzle-orm/pg-core, then add to the constraints array:

+import { ..., check } from "drizzle-orm/pg-core"
 
 export const farmInvitation = fdmAuthZSchema.table(
     "farm_invitation",
     { ... },
     (table) => [
         uniqueIndex("farm_invitation_unique_email_idx")
             .on(table.farm_id, table.target_email)
             .where(sql`${table.status} = 'pending'`),
         uniqueIndex("farm_invitation_unique_principal_idx")
             .on(table.farm_id, table.target_principal_id)
             .where(sql`${table.status} = 'pending'`),
+        check(
+            "farm_invitation_target_not_both_null",
+            sql`${table.target_email} IS NOT NULL OR ${table.target_principal_id} IS NOT NULL`,
+        ),
     ],
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/db/schema-authz.ts` around lines 59 - 82, The farmInvitation
table lacks a DB-level check that ensures at least one of target_email or
target_principal_id is set; import check from drizzle-orm/pg-core and add a
table constraint to the farmInvitation definition (in the constraints array
passed as the third argument) that enforces (target_email IS NOT NULL OR
target_principal_id IS NOT NULL), referencing the farmInvitation table and the
target_email / target_principal_id column symbols so the DB prevents orphaned
invitations.
fdm-app/app/components/custom/autocomplete.tsx (1)

50-50: Unnecessary as cast on emptyMessage default

"No items." is a string, which is directly assignable to string | ((inputValue: string) => string). The explicit cast is redundant.

-    emptyMessage = "No items." as string | ((inputValue: string) => string),
+    emptyMessage = "No items.",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/custom/autocomplete.tsx` at line 50, The default value
for the emptyMessage prop is using an unnecessary type assertion; replace the
explicit cast ("No items." as string | ((inputValue: string) => string)) with
the plain string literal "No items." and rely on the declared prop/type of
emptyMessage to accept string | ((inputValue: string) => string); update the
default in the autocomplete component where emptyMessage is defined so the value
is simply "No items." without the as cast.
fdm-app/app/components/blocks/email/farm-invitation.tsx (2)

16-27: invitationId prop is declared but never used in the component.

The invitationId is included in FarmInvitationEmailProps and destructured (line 39), but it's never referenced in the rendered output. Either remove it from the interface and destructuring, or use it (e.g., as a reference ID in the email or as a query param in the CTA link).

If it should be removed
 interface FarmInvitationEmailProps {
     farmName: string
     inviterName: string
     targetEmail: string
-    invitationId: string
     role: string
     appName: string
     appBaseUrl?: string
     logoFileName?: string
     /** If true, renders a "create account" CTA for unregistered users */
     isUnregistered?: boolean
 }
 export const FarmInvitationEmail = ({
     farmName,
     inviterName,
     targetEmail,
-    invitationId,
     role,
     appName,

Also applies to: 35-45

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/blocks/email/farm-invitation.tsx` around lines 16 -
27, The interface FarmInvitationEmailProps and the component destructuring
include invitationId but it is never used; either remove invitationId from
FarmInvitationEmailProps and from the component's destructuring, or incorporate
it into the rendered output (for example append invitationId as a query param to
the CTA link URL or render it as a reference ID in the email body). Locate the
FarmInvitationEmailProps declaration and the component props destructure (where
invitationId is currently pulled) and implement one of these changes
consistently across the component (update the CTA link builder or remove the
prop from the interface and destructure).

80-82: Greeting uses targetEmail — consider using the recipient's name when available.

"Hallo {targetEmail}" is functional, but for registered users whose name is known to the caller, passing and displaying a friendlier name would improve the email experience. Low priority.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/blocks/email/farm-invitation.tsx` around lines 80 -
82, The greeting currently renders the recipient's email via targetEmail; update
the FarmInvitation component to prefer a recipient name when available by
adding/using a prop like recipientName (or displayName) and change the greeting
Text to render recipientName || targetEmail so it falls back to the email;
ensure any callers that build this component pass the name when available and
update prop typing for the component (e.g., props on FarmInvitation) to include
the new optional recipientName.
fdm-app/app/routes/farm._index.tsx (1)

331-434: ~100 lines of invitation card rendering duplicated within the same file.

The invitation card UI (icon, role label, farm/org info, accept/decline forms) is identical between the "no farms" branch (lines 331–434) and the "has farms" branch (lines 559–661). Extract a shared PendingInvitationCard component (or a PendingInvitationsSection) to eliminate the duplication and keep both views in sync.

Additionally, the role label mapping (owner→Eigenaar, advisor→Adviseur, etc.) now appears in at least four places across the codebase (here twice, in the email component, and in the farm card badges). Consider extracting a shared getRoleLabel(role: string): string utility.

Also applies to: 559-661

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm._index.tsx` around lines 331 - 434, Extract the
duplicated invitation card markup into a new React component named
PendingInvitationCard (props: invitation) and replace both in-file repeated
blocks (the branch at the current render and the other branch starting around
the later block) with <PendingInvitationCard invitation={invitation} /> inside
the .map; move the accept/decline Form logic, hidden inputs (intent and
invitation_id), and buttons into that component and preserve Card, CardHeader,
CardContent, CardFooter structure and keys (use invitation.invitation_id). Also
create a small utility getRoleLabel(role: string): string that maps
"owner"→"Eigenaar", "advisor"→"Adviseur", default→"Onderzoeker", import and use
it in PendingInvitationCard (and update the other usages elsewhere such as the
mail component and farm card badges) so role label logic is centralized.
fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx (1)

180-215: Duplicated email-invitation logic across two route actions.

This entire block (normalize target → lookup principal → derive email → render & send) is nearly identical to farm.$b_id_farm.settings.access.tsx lines 137–172. Consider extracting a shared helper (e.g., sendFarmInvitationEmail(fdm, session, formValues, b_id_farm)) in ~/lib/email.server.ts or a similar utility module to keep both routes in sync and reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.access.tsx around lines
180 - 215, Extract the duplicated invitation flow into a shared helper (e.g.,
sendFarmInvitationEmail) in ~/lib/email.server.ts: implement a function
sendFarmInvitationEmail(fdm, session, formValues, b_id_farm) that performs
normalization of formValues.username, determines isEmail, calls lookupPrincipal,
picks targetPrincipal, derives targetEmail, calls renderFarmInvitationEmail with
inviterName and isUnregistered, and calls sendEmail (returning success/failure
or throwing on error); then replace the duplicated blocks in
farm.create.$b_id_farm.$calendar.access.tsx and
farm.$b_id_farm.settings.access.tsx with a single call to this helper and import
it, preserving existing error handling at the call sites.
fdm-core/src/farm.test.ts (1)

1100-1108: Strengthen the assertion for the "by principal_id" test case

The test only verifies Array.isArray(invitations), which provides no meaningful signal. At a minimum, assert the known state: if target_id has pending invitations from earlier tests (e.g., from the unaccepted principal-targeted invitation created in the grantRoleToFarm describe block), the array should be non-empty.

♻️ Suggested improvement
 it("should return pending invitations for a user by principal_id", async () => {
     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)
+    expect(Array.isArray(invitations)).toBe(true)
+    // target_id has at least one pending invitation from the unaccepted grantRoleToFarm call above
+    expect(invitations.length).toBeGreaterThanOrEqual(1)
+    expect(invitations.every(inv => inv.status === "pending")).toBe(true)
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.test.ts` around lines 1100 - 1108, The test currently only
checks Array.isArray(invitations) which is weak; update the "should return
pending invitations for a user by principal_id" test that calls
listPendingInvitationsForUser(fdm, target_id) to also assert that the returned
invitations array is non-empty (e.g.,
expect(invitations.length).toBeGreaterThan(0)) since target_id is expected to
have pending invitations from earlier tests—keep the array-type check if you
want both assertions.
fdm-core/src/farm.ts (2)

652-682: Initials calculation duplicates getPrincipal and has a null-unsafe init

This block is a verbatim copy of the logic in getPrincipal (fdm-core/src/principal.ts lines 32–131). Duplication here risks the two diverging silently.

Additionally, let initials = u.email (line 653) is typed as string | null. If email is null and none of the firstname/name guards trigger, initials.toUpperCase() at line 681 throws at runtime. The same latent bug exists in getPrincipal; extracting a shared helper fixes both at once.

♻️ Suggested refactor: extract a shared helper in principal.ts
+// In principal.ts — add and export:
+export function computeInitials(user: {
+    email: string | null
+    firstname?: string | null
+    surname?: string | null
+    name?: string | null
+}): string {
+    let initials: string | null = user.email
+    if (user.firstname && user.surname) {
+        initials = user.firstname.charAt(0).toUpperCase()
+        const surnameParts = user.surname.split(/\s+/)
+        for (const part of surnameParts) {
+            if (part.length > 0) {
+                const char = part.charAt(0)
+                if (char === char.toUpperCase() && char.match(/[a-zA-Z]/)) {
+                    initials += char.toUpperCase()
+                    break
+                }
+            }
+        }
+    } else if (user.firstname) {
+        initials = user.firstname[0]
+    } else if (user.name) {
+        initials = user.name[0]
+    }
+    return (initials ?? "?").toUpperCase()
+}

Then replace the duplicated block in both getPrincipal and listPrincipalsForFarm with computeInitials(u).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 652 - 682, The initials logic in
listPrincipalsForFarm duplicates getPrincipal and uses u.email (string | null)
causing initials.toUpperCase() to throw; extract a shared helper
computeInitials(principal: PrincipalLike) in principal.ts that returns a
non-null string (use safe fallbacks like empty string and normalize casing),
replace the duplicated initials computation in both getPrincipal and
listPrincipalsForFarm to call computeInitials(u), and ensure principalsMap.set
still stores initials: computeInitials(u). This removes duplication and fixes
the null-unsafe email case.

359-365: Consider a library for email validation instead of regex

While the current regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ functions correctly for basic email structure checks, using a dedicated library provides better RFC compliance and reduces maintenance burden. Consider isemail, email-validator, or an RFC 5322 parser like email-addresses for more robust validation.

The regex works safely in practice and doesn't exhibit the ReDoS behavior originally claimed. However, as a general best practice for email validation on user input, a library is preferable to custom regex.

📚 Library alternatives for TypeScript/Node.js
  • isemail (Hapi) – RFC-compliant with built-in TypeScript support
  • email-validator – Simple package with included type definitions
  • email-addresses – RFC 5322 parser-based approach (recommended for strict compliance)
  • @sparser/email-address-parser – WASM-based RFC 5322/6532 parser with TS declarations

Note: Avoid the Segment is-email package in versions < 1.0.1 (has known ReDoS CVE-2021-36716).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 359 - 365, The current inline regex
(emailRegex) used to validate normalizedTarget before assigning targetEmail
should be replaced with a well‑maintained email validation library (e.g.,
isemail or email-validator) for RFC compliance; update the code that currently
references emailRegex.test(normalizedTarget) to call the library's validation
function (for example isEmail(normalizedTarget) or
validator.validate(normalizedTarget)), keep the same error behavior (throwing
"Target not found and not a valid email address" when invalid), and add the
proper import and TypeScript types for the chosen validator so normalizedTarget
-> validation -> targetEmail assignment is robust and maintainable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.changeset/farm-invitation-app.md:
- Line 5: Fix the typo in the release note sentence "Add the possibilty for
users to accept or reject an invitation to a farm..." by replacing "possibilty"
with "possibility" so the sentence reads "Add the possibility for users to
accept or reject an invitation to a farm, instead of having it automatically."

In @.changeset/farm-invitation-system.md:
- Around line 1-12: The changeset currently declares a "minor" bump for
"@svenvw/fdm-core" but the change to grantRoleToFarm (now creating pending
invitations) is breaking; update the changeset header from "minor" to "major" so
the package version is bumped to a major release and consumers are properly
warned about the breaking API change (ensure the header string "major" replaces
"minor" and keep the rest of the changeset content intact).

In `@fdm-core/src/authentication.ts`:
- Around line 228-236: The call to autoAcceptInvitationsForNewUser inside the
after-hook can throw and block user creation; wrap the invocation in a try/catch
so failures are logged but do not propagate. Specifically, inside the after hook
where you check user.emailVerified and call autoAcceptInvitationsForNewUser(fdm,
user.email, user.id), catch any error, log it via the existing logger (or
process logger) with context (e.g., "autoAcceptInvitationsForNewUser failed for
user.id/user.email") and swallow the error so the hook returns successfully and
does not prevent user creation.
- Around line 239-249: The update hook currently calls
autoAcceptInvitationsForNewUser(user.email, user.id) directly which can throw
and runs on every profile edit; wrap that call in a try/catch to prevent
exceptions from bubbling out of the after hook, log a short debug/info message
before attempting auto-accept (including user.id or user.email) and log the
caught error (without rethrowing), and keep the existing guard that only runs
when user.emailVerified is truthy so work is only attempted when verified.

In `@fdm-core/src/farm.ts`:
- Around line 990-992: The declineFarmInvitation path currently only checks if
invitation.status !== "pending" (in declineFarmInvitation) and can wrongly allow
declining an already-expired invite; update declineFarmInvitation to mirror
acceptFarmInvitation by checking the invitation expiry before changing state —
if the invitation is expired, set invitation.status = "expired" (persist the
change) and throw an error instead of allowing a "declined" transition; ensure
you use the same expiry-detection logic used by acceptFarmInvitation and
persist/log the status change so audit records remain consistent.
- Around line 820-838: In acceptFarmInvitation, replace the separate SELECT +
application-level status check with a single atomic conditional UPDATE on
authZSchema.farmInvitation that sets status = 'accepted' (and
target_principal_id if required) WHERE invitation_id = invitation_id AND status
= 'pending' (and expires >= NOW()); check the UPDATE's affectedRows and throw
"Invitation not found" / "Invitation has expired" if zero rows were affected,
then call grantRole only after the successful conditional UPDATE, and remove the
later unconditional UPDATE that sets status = "accepted" (leave only any
target_principal_id update if not covered by the conditional UPDATE) to prevent
the TOCTOU race.

In `@fdm-core/src/invitation.test.ts`:
- Around line 186-228: The test currently passes the already-normalized
normalizedEmail into autoAcceptInvitationsForNewUser so it never verifies that
autoAcceptInvitationsForNewUser normalizes input; change the call to
autoAcceptInvitationsForNewUser(fdm, mixedCaseEmail, caseUser.user.id) so the
function receives the mixed-case address and exercises its normalization path
(leave grantRoleToFarm call and other assertions unchanged, referencing the
mixedCaseEmail variable and the autoAcceptInvitationsForNewUser function).
- Line 6: Remove the unused import causing TS6133: delete the import of
authNSchema from invitation.test.ts (the line importing "authNSchema" from
"./db/schema-authn") so the test file no longer imports an unused symbol;
alternatively, if the schema is actually needed, reference authNSchema in the
test (e.g., use a value from it) to avoid the unused-import error—prefer
removing the import if it's truly unused.

In `@fdm-core/src/invitation.ts`:
- Around line 79-84: The current catch block passes the raw email into
handleError (in the autoAcceptInvitationsForNewUser flow), which can leak PII to
logs; instead remove or obfuscate the email before adding it to the error
context—e.g., omit the email field or replace it with a hashed/masked value
(derived from email) and keep user_id and other safe metadata; update the call
site that invokes handleError(err, "Exception for
autoAcceptInvitationsForNewUser", { email, user_id }) to pass only non-PII or
masked_email and user_id.

---

Outside diff comments:
In `@fdm-app/app/components/custom/autocomplete.tsx`:
- Around line 158-181: The blur handler's setTimeout reads the stale
render-closure value of open so the !open guard can be wrong; change it to read
a mutable ref that always reflects the latest open state: create a ref (e.g.,
openRef = useRef(open)), update openRef.current inside the component's
onOpenChange handler, and then in handleInputBlur's timeout check
!openRef.current instead of !open; keep the existing logic that uses
allowValuesOutsideList, selectedValue, onSelectedValueChange, form/name,
setInputValue and selectedLabel but read open via the ref so typed values are
committed correctly on blur.

In `@fdm-app/app/routes/farm`.$b_id_farm.settings.access.tsx:
- Around line 129-135: The call to grantRoleToFarm is passing session.user.id
but the function expects the acting principal for the `share` permission check;
replace session.user.id with session.principal_id in the grantRoleToFarm call so
the authorization uses the session principal (matching how
farm.create.$b_id_farm.$calendar.access.tsx passes session.principal_id),
ensuring correct permission enforcement around the `share` check.

In `@fdm-core/src/farm.test.ts`:
- Around line 504-520: The test fails because grantRoleToFarm now creates a
pending farmInvitation (not an active role), so revokePrincipalFromFarm finds no
active role and the fdmMock.revokePrincipal mock is never invoked because
revokePrincipal is imported directly from ./authorization.ts; fix by accepting
the invitation before calling revokePrincipalFromFarm (e.g., call the function
that transitions farmInvitation to an active role or simulate acceptance) so the
target has an active role to revoke, or alternatively remove the ineffective
fdmMock and instead mock/spy the revokePrincipal export from ./authorization.ts
(or import the module and jest.mock that module) so the revokePrincipal call is
observed; ensure you reference grantRoleToFarm, revokePrincipalFromFarm,
revokePrincipal, farmInvitation and fdmMock when making the change.

In `@fdm-core/src/farm.ts`:
- Around line 317-321: Change the parameter type for principal_id from
PrincipalId to string for functions that perform inserts so inviter_id is stored
as a plain string; update the function signature containing "principal_id:
PrincipalId, target: string, b_id_farm: ..., role: ..." to "principal_id:
string" and any other insert-related functions (the other occurrences noted at
the same file around the blocks referenced at 391, 404, 428, 441) to enforce a
single-string principal for farmInvitation/farm creation; ensure callers are
adjusted if needed so they pass a string ID rather than a PrincipalId array or
union type.

---

Nitpick comments:
In `@fdm-app/app/components/blocks/email/farm-invitation.tsx`:
- Around line 16-27: The interface FarmInvitationEmailProps and the component
destructuring include invitationId but it is never used; either remove
invitationId from FarmInvitationEmailProps and from the component's
destructuring, or incorporate it into the rendered output (for example append
invitationId as a query param to the CTA link URL or render it as a reference ID
in the email body). Locate the FarmInvitationEmailProps declaration and the
component props destructure (where invitationId is currently pulled) and
implement one of these changes consistently across the component (update the CTA
link builder or remove the prop from the interface and destructure).
- Around line 80-82: The greeting currently renders the recipient's email via
targetEmail; update the FarmInvitation component to prefer a recipient name when
available by adding/using a prop like recipientName (or displayName) and change
the greeting Text to render recipientName || targetEmail so it falls back to the
email; ensure any callers that build this component pass the name when available
and update prop typing for the component (e.g., props on FarmInvitation) to
include the new optional recipientName.

In `@fdm-app/app/components/custom/autocomplete.tsx`:
- Line 50: The default value for the emptyMessage prop is using an unnecessary
type assertion; replace the explicit cast ("No items." as string | ((inputValue:
string) => string)) with the plain string literal "No items." and rely on the
declared prop/type of emptyMessage to accept string | ((inputValue: string) =>
string); update the default in the autocomplete component where emptyMessage is
defined so the value is simply "No items." without the as cast.

In `@fdm-app/app/routes/farm._index.tsx`:
- Around line 331-434: Extract the duplicated invitation card markup into a new
React component named PendingInvitationCard (props: invitation) and replace both
in-file repeated blocks (the branch at the current render and the other branch
starting around the later block) with <PendingInvitationCard
invitation={invitation} /> inside the .map; move the accept/decline Form logic,
hidden inputs (intent and invitation_id), and buttons into that component and
preserve Card, CardHeader, CardContent, CardFooter structure and keys (use
invitation.invitation_id). Also create a small utility getRoleLabel(role:
string): string that maps "owner"→"Eigenaar", "advisor"→"Adviseur",
default→"Onderzoeker", import and use it in PendingInvitationCard (and update
the other usages elsewhere such as the mail component and farm card badges) so
role label logic is centralized.

In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.access.tsx:
- Around line 180-215: Extract the duplicated invitation flow into a shared
helper (e.g., sendFarmInvitationEmail) in ~/lib/email.server.ts: implement a
function sendFarmInvitationEmail(fdm, session, formValues, b_id_farm) that
performs normalization of formValues.username, determines isEmail, calls
lookupPrincipal, picks targetPrincipal, derives targetEmail, calls
renderFarmInvitationEmail with inviterName and isUnregistered, and calls
sendEmail (returning success/failure or throwing on error); then replace the
duplicated blocks in farm.create.$b_id_farm.$calendar.access.tsx and
farm.$b_id_farm.settings.access.tsx with a single call to this helper and import
it, preserving existing error handling at the call sites.

In `@fdm-core/src/db/schema-authz.ts`:
- Around line 59-82: The farmInvitation table lacks a DB-level check that
ensures at least one of target_email or target_principal_id is set; import check
from drizzle-orm/pg-core and add a table constraint to the farmInvitation
definition (in the constraints array passed as the third argument) that enforces
(target_email IS NOT NULL OR target_principal_id IS NOT NULL), referencing the
farmInvitation table and the target_email / target_principal_id column symbols
so the DB prevents orphaned invitations.

In `@fdm-core/src/farm.test.ts`:
- Around line 1100-1108: The test currently only checks
Array.isArray(invitations) which is weak; update the "should return pending
invitations for a user by principal_id" test that calls
listPendingInvitationsForUser(fdm, target_id) to also assert that the returned
invitations array is non-empty (e.g.,
expect(invitations.length).toBeGreaterThan(0)) since target_id is expected to
have pending invitations from earlier tests—keep the array-type check if you
want both assertions.

In `@fdm-core/src/farm.ts`:
- Around line 652-682: The initials logic in listPrincipalsForFarm duplicates
getPrincipal and uses u.email (string | null) causing initials.toUpperCase() to
throw; extract a shared helper computeInitials(principal: PrincipalLike) in
principal.ts that returns a non-null string (use safe fallbacks like empty
string and normalize casing), replace the duplicated initials computation in
both getPrincipal and listPrincipalsForFarm to call computeInitials(u), and
ensure principalsMap.set still stores initials: computeInitials(u). This removes
duplication and fixes the null-unsafe email case.
- Around line 359-365: The current inline regex (emailRegex) used to validate
normalizedTarget before assigning targetEmail should be replaced with a
well‑maintained email validation library (e.g., isemail or email-validator) for
RFC compliance; update the code that currently references
emailRegex.test(normalizedTarget) to call the library's validation function (for
example isEmail(normalizedTarget) or validator.validate(normalizedTarget)), keep
the same error behavior (throwing "Target not found and not a valid email
address" when invalid), and add the proper import and TypeScript types for the
chosen validator so normalizedTarget -> validation -> targetEmail assignment is
robust and maintainable.

In `@fdm-core/src/principal.ts`:
- Line 197: Remove the leftover commented-out debug log "//
console.log(principalDetails)" from principal.ts; delete that line (and any
similar commented console.log references in the same function or nearby) so the
production code contains no stray debug prints, keeping the function that uses
principalDetails unchanged.
- Around line 159-208: The identifyPrincipal function currently declares a
redundant return type of Promise<{ id: string } & Principal | undefined> and
constructs a redundant return object; update the signature to Promise<Principal
| undefined>, and in the transaction return the principalDetails directly (or
return principalDetails as-is) since Principal already contains id; ensure
getPrincipal(tx, ...) still returns a Principal so no additional merging (the
explicit id: principal_id[0].id and the object spread can be removed).

Comment thread .changeset/farm-invitation-app.md Outdated
Comment thread .changeset/farm-invitation-system.md
Comment thread fdm-core/src/authentication.ts
Comment thread fdm-core/src/authentication.ts
Comment thread fdm-core/src/farm.ts Outdated
Comment thread fdm-core/src/farm.ts Outdated
Comment thread fdm-core/src/invitation.test.ts
Comment thread fdm-core/src/invitation.test.ts
Comment thread fdm-core/src/invitation.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
fdm-app/app/components/custom/autocomplete.tsx (2)

68-73: ⚠️ Potential issue | 🟠 Major

handleInputBlur clears the displayed input after focus+blur for committed free-form values when allowValuesOutsideList=true.

When a free-form value (e.g., an email address) is committed by the first blur, selectedValue = "foo@bar.com" but selectedLabel = "" (the email is not in items). On a second focus + immediate blur without typing:

  • inputValue = "foo@bar.com" (truthy), !selectedValue = false → the first if on line 164 fails.
  • else if (inputValue !== selectedLabel && selectedValue)"foo@bar.com" !== "" && "foo@bar.com"true.
  • setInputValue(selectedLabel) = setInputValue("")the input goes blank.

selectedValue and the form state still hold "foo@bar.com", so the form would submit the correct value, but the displayed input is now empty, creating a misleading UI state. The useEffect at lines 123–134 does not restore the input because selectedValue && selectedLabel = truthy && "" = false.

The cleanest fix is to update selectedLabel to fall back to selectedValue when allowValuesOutsideList=true and the value is not found in items. This makes the else if comparison self-consistent ("foo@bar.com" !== "foo@bar.com"false) and also corrects the useEffect restore path for pre-populated free-form values:

🐛 Proposed fix — make selectedLabel fall back to selectedValue for free-form entries
 const selectedLabel = useMemo(() => {
     const selectedItem = items.find((item) => item.value === selectedValue)
-    return selectedItem?.label ?? ""
+    if (selectedItem) return selectedItem.label
+    // For free-form entries the value IS the display label
+    if (allowValuesOutsideList && selectedValue) return selectedValue as string
+    return ""
-}, [selectedValue, items])
+}, [selectedValue, items, allowValuesOutsideList])

Also applies to: 160-182

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/custom/autocomplete.tsx` around lines 68 - 73, The
computed selectedLabel should fall back to the committed free-form value when
the component allows values outside the list; update the useMemo that computes
selectedLabel (the block referencing selectedValue and items) to return
selectedItem?.label ?? (allowValuesOutsideList ? selectedValue ?? "" : "") so
that when allowValuesOutsideList is true and the current selectedValue isn't in
items the displayed label equals the selectedValue; this will also make the
handleInputBlur comparisons and the useEffect restore logic behave consistently
for pre-populated free-form values.

68-73: ⚠️ Potential issue | 🟠 Major

handleInputBlur's else if branch clears the displayed value after focus+blur on a committed free-form entry (allowValuesOutsideList=true).

When a free-form value (e.g. an email address "foo@bar.com") is committed by the first blur, selectedValue = "foo@bar.com" but selectedLabel = "" because the address is not in items. On a subsequent focus + immediate blur without typing anything:

  • First if (inputValue && !selectedValue) — fails (selectedValue is non-empty).
  • else if ("foo@bar.com" !== "" && "foo@bar.com")truesetInputValue("").

The input goes blank while selectedValue and the form state still hold "foo@bar.com". The useEffect at lines 123–134 does not restore the display because selectedValue && selectedLabel = truthy && "" = false.

The cleanest fix is to have selectedLabel fall back to selectedValue for free-form entries, making every downstream comparison self-consistent:

🐛 Proposed fix — fall back to selectedValue in selectedLabel for free-form entries
 const selectedLabel = useMemo(() => {
     const selectedItem = items.find((item) => item.value === selectedValue)
-    return selectedItem?.label ?? ""
-}, [selectedValue, items])
+    if (selectedItem) return selectedItem.label
+    // For free-form values (allowValuesOutsideList) the value itself is the display label
+    if (allowValuesOutsideList && selectedValue) return selectedValue as string
+    return ""
+}, [selectedValue, items, allowValuesOutsideList])

This also corrects pre-populated free-form values on initial render, which currently show an empty input until a fetch fires.

Also applies to: 159-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/custom/autocomplete.tsx` around lines 68 - 73,
selectedLabel falls back to empty string when selectedValue is a free-form
entry, causing handleInputBlur's else-if to clear the input; change the useMemo
that defines selectedLabel to return selectedItem?.label ?? selectedValue (i.e.,
use selectedValue as a fallback when no matching item exists) so free-form
committed values display consistently (affects selectedLabel, selectedValue, and
downstream logic in handleInputBlur and the related useEffect).
fdm-core/src/principal.ts (1)

134-162: ⚠️ Potential issue | 🟡 Minor

Stale JSDoc for identifyPrincipal after return-type change.

The @returns comment still says "resolves to an array of LookupPrincipal objects … the array contains a single object … the array is empty" and the @example calls .length and [0] on the result — none of which is valid now that the return type is Promise<Principal | undefined>. This will mislead consumers of the public API.

📝 Suggested JSDoc update
- * `@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 a {`@link` Principal} object if a matching principal is found, or `undefined` if none is found.

Update the @example accordingly:

- * const principalDetails = await identifyPrincipal(fdm, "john.doe@example.com");
- * if (principalDetails.length > 0) {
- *   console.log("Principal Details:", principalDetails[0]);
- * } else {
- *   console.log("Principal not found.");
- * }
+ * const principalDetails = await identifyPrincipal(fdm, "john.doe@example.com");
+ * if (principalDetails) {
+ *   console.log("Principal Details:", principalDetails);
+ * } else {
+ *   console.log("Principal not found.");
+ * }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/principal.ts` around lines 134 - 162, Update the stale JSDoc for
identifyPrincipal to match its actual return type Promise<Principal |
undefined>: change the `@returns` description to state it resolves to a Principal
or undefined (not an array), adjust the example to await identifyPrincipal and
check for truthiness (e.g., const principal = await identifyPrincipal(...); if
(principal) { /* use principal */ } else { /* not found */ }), and remove any
array-specific references such as .length or [0]; keep the thrown-error and
parameter docs as-is.
🧹 Nitpick comments (4)
fdm-core/src/db/schema-authz.ts (1)

69-69: status as plain text() has no DB-level constraint on valid values.

Invalid status strings ("revoked", "cancelled", etc.) can be written directly to the database. Drizzle supports schema-scoped enums via pgSchema.enum(), which would also produce a proper PostgreSQL ENUM type and prevent invalid values at the DB level.

♻️ Proposed refactor using a schema-scoped enum
+export const invitationStatusEnum = fdmAuthZSchema.enum("invitation_status", [
+    "pending",
+    "accepted",
+    "declined",
+    "expired",
+])
+
 export const invitation = fdmAuthZSchema.table(
     "invitation",
     {
         ...
-        status: text().notNull().default("pending"),
+        status: invitationStatusEnum().notNull().default("pending"),
         ...
     },

Alternatively, a CHECK constraint (using the same check() import) keeps it as text while still enforcing valid values at the DB level:

check(
    "invitation_status_check",
    sql`${table.status} IN ('pending', 'accepted', 'declined', 'expired')`,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/db/schema-authz.ts` at line 69, Change the plain text status
column to a DB-enforced enum or add a CHECK constraint: replace the current
status: text().notNull().default("pending") definition with a schema-scoped enum
created via pgSchema.enum("invitation_status",
["pending","accepted","declined","expired"]) and then use that enum for the
column (e.g., status: pgEnum("invitation_status").notNull().default("pending")),
or keep text() but add a check() constraint (e.g.,
check("invitation_status_check", sql`${table.status} IN
('pending','accepted','declined','expired')`)); update any imports to include
pgSchema/pgEnum or check/sql as needed and ensure the enum name and column
identifier match (status) across the schema.
fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx (1)

182-215: Email invitation logic duplicated across two route files.

The block at lines 182–215 is nearly identical to the same block in fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx (lines 138–172). Any bug fix or enhancement must be applied in both places; extract to a shared helper (e.g., sendFarmInvitationEmailIfPossible) in ~/lib/email.server or a dedicated utility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.access.tsx around lines
182 - 215, Extract the duplicated invitation-email block into a shared async
helper (e.g., sendFarmInvitationEmailIfPossible) in ~/lib/email.server that
takes the same inputs it needs (fdm, principalId, b_id_farm, formValues,
session) and encapsulates the logic currently in the try block: call
getFarm(b_id_farm), normalize formValues.username, detect isEmail via isEmail(),
lookupPrincipal(), resolve targetPrincipal and targetEmail,
renderFarmInvitationEmail(targetEmail, inviterName, farm.b_name_farm ??
b_id_farm, formValues.role, isUnregistered) and call sendEmail(email); preserve
the try/catch and logging behavior (console.error) inside the helper and then
replace both in-file blocks with a single await
sendFarmInvitationEmailIfPossible(...) call so fixes are applied in one place.
fdm-core/src/farm.ts (1)

1114-1118: Tighten invitation cleanup to the farm resource.

This delete is keyed only by resource_id. For clarity and future-proofing, consider also filtering on resource = "farm" so other resources with the same ID aren’t affected.

♻️ Suggested tweak
-            await tx
-                .delete(authZSchema.invitation)
-                .where(eq(authZSchema.invitation.resource_id, b_id_farm))
+            await tx
+                .delete(authZSchema.invitation)
+                .where(
+                    and(
+                        eq(authZSchema.invitation.resource, "farm"),
+                        eq(authZSchema.invitation.resource_id, b_id_farm),
+                    ),
+                )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 1114 - 1118, The invitation delete
currently only filters by resource_id using
tx.delete(authZSchema.invitation).where(eq(authZSchema.invitation.resource_id,
b_id_farm)); tighten it to also filter by the resource type by adding a second
condition (e.g., eq(authZSchema.invitation.resource, "farm")) combined with the
existing predicate so only invitations for the farm resource with that ID are
removed; update the where clause used in the tx.delete call to include both
predicates (AND).
fdm-app/app/routes/farm._index.tsx (1)

332-433: Deduplicate pending-invitation card markup.

The invitation card UI is duplicated in both the “no farms” and “has farms” branches, which risks divergence. Consider extracting a shared PendingInvitations component.

Also applies to: 559-661

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm._index.tsx` around lines 332 - 433, The
pending-invitation card markup (rendering loaderData.pendingInvitations into
Card elements using invitation.invitation_id, CardHeader/CardContent/CardFooter
and the accept_farm_invitation / decline_farm_invitation Forms) is duplicated
across the “no farms” and “has farms” branches; extract this into a reusable
PendingInvitations component that accepts the invitations array and renders each
Card (preserving keys, role mapping, org_name display, hidden inputs for intent
and invitation_id, and Button variants) and replace both duplicated blocks with
<PendingInvitations invitations={loaderData.pendingInvitations} /> to keep
behavior identical and avoid future drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/components/blocks/email/farm-invitation.tsx`:
- Around line 40-44: The default appBaseUrl is empty which makes logoPath =
`${appBaseUrl}${logoFileName}` produce a relative URL (e.g.,
`/fdm-high-resolution-logo-transparent.png`) that email clients cannot resolve;
update FarmInvitationEmailProps to require appBaseUrl (remove the
default/optional) or enforce a guard in the FarmInvitationEmail component:
validate/apply a fallback absolute URL before computing logoPath, and update any
callers (e.g., renderFarmInvitationEmail) or docs to always supply a full
absolute appBaseUrl so logoPath becomes an absolute URL.
- Around line 51-54: Remove the external Google Fonts <link> tag and the
adjacent <style> block from the farm-invitation.tsx email component (the <link
href="https://fonts.googleapis.com..."> element and any embedded <style> for
that font); email clients usually strip external Head content and this leaks
recipient info to Google, so rely on the existing fontFamily fallback stack
already used in the component's JSX/inline styles instead of loading web fonts.

In `@fdm-app/app/components/custom/autocomplete.tsx`:
- Line 59: The openRef created as const openRef = useRef(open) never reflects
programmatic opens via PopoverAnchor and so the guard in handleInputBlur is
ineffective; either remove openRef entirely and rely on the existing
e.preventDefault() guard on CommandItem.onMouseDown, or keep it and explicitly
update openRef.current whenever you call setOpen — update every site that
directly calls setOpen (handleSelectItem, onKeyDown, onMouseDown, onFocus) to
also set openRef.current to the same boolean, and ensure any Escape-key path
that calls setOpen(false) also sets openRef.current = false so blur logic
behaves correctly with both PopoverTrigger and PopoverAnchor usage.
- Line 59: openRef created via useRef(open) falls out of sync because setOpen is
called directly in several handlers; update openRef.current whenever you call
setOpen and also sync it inside the onOpenChange handler (and/or a useEffect
watching open) so the ref always matches the controlled open prop. Concretely:
wherever setOpen(true/false) is invoked (functions/handlers named onKeyDown,
onMouseDown, onFocus, handleSelectItem), immediately set openRef.current =
true/false to mirror it, and ensure onOpenChange (or a small useEffect that
watches open) also assigns openRef.current = open so Radix-driven updates stay
synced.

In `@fdm-app/app/routes/farm`.$b_id_farm.settings.access.tsx:
- Around line 138-172: The getFarm call inside the email-sending block is using
session.user.id which breaks org-principal flows; update the call to
getFarm(fdm, session.principal_id, b_id_farm) (same principal id used by
grantRoleToFarm) so permissions are correct and the invitation email will be
sent for organization principals; keep the rest of the logic unchanged and
ensure no other references to session.user.id are used for principal-scoped
operations in this block.

In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.access.tsx:
- Around line 183-215: The getFarm call uses the wrong identity so permission
checks fail when acting as an org principal; replace the incorrect
session.user.id (or any usage of session.user) passed into getFarm with the
existing principalId variable (session.principal_id) used by grantRoleToFarm so
getFarm(fdm, principalId, b_id_farm) is called; locate the getFarm invocation in
the try block around the email-sending flow and update the second argument to
principalId to ensure permission checks succeed and the invitation email is
sent.

In `@fdm-core/src/db/schema-authz.ts`:
- Around line 59-83: The invitation table currently allows both target_email and
target_principal_id to be NULL; add a DB-level CHECK constraint in the
fdmAuthZSchema.table callback (alongside the existing uniqueIndex entries for
invitation_unique_email_idx and invitation_unique_principal_idx) using
drizzle-orm's check() from drizzle-orm/pg-core that enforces (target_email IS
NOT NULL OR target_principal_id IS NOT NULL) so at least one target is set for
each invitation record.

In `@fdm-core/src/invitation.ts`:
- Around line 474-489: The decline path that checks invitation.target_email
currently only verifies the user's email string; update the same block in
invitation.ts (the else if handling invitation.target_email) to also select and
check authNSchema.user.email_verified from the tx query (alongside email),
require email_verified === true before allowing decline, and throw the same
verification-related error used in the accept flow when email is unverified;
keep the existing email lowercase/trim comparison and error flow otherwise.

In `@fdm-docs/docs/core-concepts/10-authorization.md`:
- Around line 41-48: The fenced code block showing the resource hierarchy
currently lacks a language identifier; update the triple-backtick fence around
the ASCII tree so it reads ```text (i.e., add the "text" language identifier to
the fenced block) to satisfy markdownlint MD040 and ensure the block is treated
as plain text.

---

Outside diff comments:
In `@fdm-app/app/components/custom/autocomplete.tsx`:
- Around line 68-73: The computed selectedLabel should fall back to the
committed free-form value when the component allows values outside the list;
update the useMemo that computes selectedLabel (the block referencing
selectedValue and items) to return selectedItem?.label ??
(allowValuesOutsideList ? selectedValue ?? "" : "") so that when
allowValuesOutsideList is true and the current selectedValue isn't in items the
displayed label equals the selectedValue; this will also make the
handleInputBlur comparisons and the useEffect restore logic behave consistently
for pre-populated free-form values.
- Around line 68-73: selectedLabel falls back to empty string when selectedValue
is a free-form entry, causing handleInputBlur's else-if to clear the input;
change the useMemo that defines selectedLabel to return selectedItem?.label ??
selectedValue (i.e., use selectedValue as a fallback when no matching item
exists) so free-form committed values display consistently (affects
selectedLabel, selectedValue, and downstream logic in handleInputBlur and the
related useEffect).

In `@fdm-core/src/principal.ts`:
- Around line 134-162: Update the stale JSDoc for identifyPrincipal to match its
actual return type Promise<Principal | undefined>: change the `@returns`
description to state it resolves to a Principal or undefined (not an array),
adjust the example to await identifyPrincipal and check for truthiness (e.g.,
const principal = await identifyPrincipal(...); if (principal) { /* use
principal */ } else { /* not found */ }), and remove any array-specific
references such as .length or [0]; keep the thrown-error and parameter docs
as-is.

---

Duplicate comments:
In `@fdm-core/src/authentication.ts`:
- Around line 229-244: No change required: the create hook now correctly guards
autoAcceptInvitationsForNewUser with a try/catch for user.emailVerified,
preventing throws from breaking signup; leave the block as-is
(autoAcceptInvitationsForNewUser(fdm, user.email, user.id) wrapped in try/catch
and console.warn) and no further modifications are necessary.
- Around line 247-264: Add a short-circuit so autoAcceptInvitationsForNewUser is
only invoked once per user: add a persistent flag on the user record (e.g.,
invitations_processed boolean or invitationsAcceptedAt timestamp) and check that
flag in the update.after hook before calling autoAcceptInvitationsForNewUser;
after a successful call set that flag on the user (update the User record) so
subsequent profile edits skip the transaction. Reference the update.after hook
where autoAcceptInvitationsForNewUser(fdm, user.email, user.id) is called and
ensure the flag is written only after successful completion to avoid
re-processing.

---

Nitpick comments:
In `@fdm-app/app/routes/farm._index.tsx`:
- Around line 332-433: The pending-invitation card markup (rendering
loaderData.pendingInvitations into Card elements using invitation.invitation_id,
CardHeader/CardContent/CardFooter and the accept_farm_invitation /
decline_farm_invitation Forms) is duplicated across the “no farms” and “has
farms” branches; extract this into a reusable PendingInvitations component that
accepts the invitations array and renders each Card (preserving keys, role
mapping, org_name display, hidden inputs for intent and invitation_id, and
Button variants) and replace both duplicated blocks with <PendingInvitations
invitations={loaderData.pendingInvitations} /> to keep behavior identical and
avoid future drift.

In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.access.tsx:
- Around line 182-215: Extract the duplicated invitation-email block into a
shared async helper (e.g., sendFarmInvitationEmailIfPossible) in
~/lib/email.server that takes the same inputs it needs (fdm, principalId,
b_id_farm, formValues, session) and encapsulates the logic currently in the try
block: call getFarm(b_id_farm), normalize formValues.username, detect isEmail
via isEmail(), lookupPrincipal(), resolve targetPrincipal and targetEmail,
renderFarmInvitationEmail(targetEmail, inviterName, farm.b_name_farm ??
b_id_farm, formValues.role, isUnregistered) and call sendEmail(email); preserve
the try/catch and logging behavior (console.error) inside the helper and then
replace both in-file blocks with a single await
sendFarmInvitationEmailIfPossible(...) call so fixes are applied in one place.

In `@fdm-core/src/db/schema-authz.ts`:
- Line 69: Change the plain text status column to a DB-enforced enum or add a
CHECK constraint: replace the current status:
text().notNull().default("pending") definition with a schema-scoped enum created
via pgSchema.enum("invitation_status",
["pending","accepted","declined","expired"]) and then use that enum for the
column (e.g., status: pgEnum("invitation_status").notNull().default("pending")),
or keep text() but add a check() constraint (e.g.,
check("invitation_status_check", sql`${table.status} IN
('pending','accepted','declined','expired')`)); update any imports to include
pgSchema/pgEnum or check/sql as needed and ensure the enum name and column
identifier match (status) across the schema.

In `@fdm-core/src/farm.ts`:
- Around line 1114-1118: The invitation delete currently only filters by
resource_id using
tx.delete(authZSchema.invitation).where(eq(authZSchema.invitation.resource_id,
b_id_farm)); tighten it to also filter by the resource type by adding a second
condition (e.g., eq(authZSchema.invitation.resource, "farm")) combined with the
existing predicate so only invitations for the farm resource with that ID are
removed; update the where clause used in the tx.delete call to include both
predicates (AND).

Comment thread fdm-app/app/components/blocks/email/farm-invitation.tsx Outdated
Comment thread fdm-app/app/components/blocks/email/farm-invitation.tsx Outdated
Comment thread fdm-app/app/components/custom/autocomplete.tsx
Comment thread fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx
Comment thread fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx
Comment thread fdm-core/src/db/schema-authz.ts
Comment thread fdm-core/src/invitation.ts
Comment thread fdm-docs/docs/core-concepts/10-authorization.md Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (7)
fdm-docs/docs/core-concepts/10-authorization.md (1)

80-80: Minor: doc parameter name principal_id doesn't match the actual function signature user_id.

The listPendingInvitationsForPrincipal implementation in invitation.ts accepts user_id: string (only users, not arbitrary principals), but the API table exposes principal_id. The description "Lists all pending invitations for a user" already signals the correct intent — aligning the parameter name would prevent confusion.

📘 Proposed fix
-| `listPendingInvitationsForPrincipal(fdm, principal_id)` | Lists all pending invitations for a user |
+| `listPendingInvitationsForPrincipal(fdm, user_id)` | Lists all pending invitations for a user |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-docs/docs/core-concepts/10-authorization.md` at line 80, The docs use the
parameter name `principal_id` for listPendingInvitationsForPrincipal but the
actual implementation in invitation.ts accepts `user_id: string`; update the API
table entry (the parameter column for listPendingInvitationsForPrincipal) to
`user_id` to match the function signature and intent (only users), and scan for
any other occurrences in the same doc to replace `principal_id` with `user_id`
to keep names consistent with the invitation.ts function.
fdm-core/src/invitation.ts (1)

300-301: Remove the redundant inner if check.

Line 301 duplicates the outer guard on line 300 — both test invitation.target_principal_id. The inner block should be dedented one level.

♻️ Proposed fix
 if (invitation.target_principal_id) {
-    if (invitation.target_principal_id) {
-        const orgMembership = await tx
-            .select()
-            .from(authNSchema.member)
-            ...
-        ...
-    }
-
+    const orgMembership = await tx
+        .select()
+        .from(authNSchema.member)
+        ...
+    ...
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/invitation.ts` around lines 300 - 301, The code contains a
duplicated guard: an outer if checking invitation.target_principal_id and an
inner identical if (invitation.target_principal_id); remove the redundant inner
if and dedent its body so the logic inside runs under the single outer guard
(locate the block around invitation.target_principal_id in the invitation
handling code and delete the inner conditional).
fdm-core/src/db/schema-authz.ts (1)

60-91: LGTM — invitation table schema is well-formed; the past CHECK-constraint concern is addressed.

The partial unique indexes correctly scope duplicate-prevention to status = 'pending', and the invitation_target_check constraint enforces the at-least-one-target invariant at the DB level.

One optional improvement: queries in autoAcceptInvitationsForNewUser filter solely by target_email and status = 'pending'. The leading columns of invitation_unique_email_idx are (resource, resource_id, target_email), so the planner has to scan the full partial index for a by-email lookup. Consider a separate partial index on (target_email) WHERE status = 'pending' for that hot path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/db/schema-authz.ts` around lines 60 - 91, Queries in
autoAcceptInvitationsForNewUser scan the partial unique index
invitation_unique_email_idx whose leading columns are (resource, resource_id,
target_email), causing inefficient lookups by target_email alone; add a new
partial index (e.g. invitation_pending_target_email_idx) on table invitation for
column target_email WHERE status = 'pending' (non-unique) to serve that hot path
and speed up queries filtering by target_email and status; update the schema
definition where invitation is declared to include this new index alongside
invitation_unique_email_idx.
fdm-core/src/farm.ts (3)

550-573: Initials computation is duplicated from getPrincipal.

The surname-capital-letter logic (lines 550–573) is a near-exact copy of getPrincipal (principal.ts lines 57–84). If the algorithm changes (e.g., handling diacritics), both locations must be updated in lockstep.

Consider extracting a shared helper like computeInitials(firstname, surname, email, name) and reusing it in both getPrincipal and listPrincipalsForFarm.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 550 - 573, The initials computation in
listPrincipalsForFarm duplicates the surname-capital-letter logic from
getPrincipal; extract that logic into a shared helper (e.g.,
computeInitials(firstname, surname, email, name)) and replace the inlined code
in both getPrincipal and listPrincipalsForFarm to call this helper; ensure the
helper returns the same fallback order (email -> firstname+surname initials ->
firstname[0] -> name[0]) and preserve the existing surname-splitting and
uppercase-letter detection behavior so both locations remain consistent.

748-832: org_name enrichment assumes target_principal_id is always an organization — verify intent.

Lines 792–813 collect all target_principal_id values and look them up exclusively in the organization table. If a pending invitation targets a user principal (not an org), the lookup simply yields null for org_name, which is benign. However, the variable names (orgTargetIds, orgNames) suggest the code expects only organization principals, while the data can contain user principals too.

If the intention is to show the organization name only when the invitation target is an organization, this works correctly — but consider adding a brief comment clarifying the intent, since the variable naming is slightly misleading.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 748 - 832, The code in
listPendingInvitationsForUser collects target_principal_id into orgTargetIds and
looks them up only in authNSchema.organization (orgNames) which is ambiguous if
invitations can target user principals; either clarify intent with a comment
above the orgTargetIds/orgNames block stating that target_principal_id may be a
user and org_name will be null for non-org targets, or if the intent is to
resolve only organization principals, change the filter to only include
invitations whose target principal type equals "organization" (or the
appropriate enum) before building orgTargetIds and querying
authNSchema.organization; update variable names (orgTargetIds/orgNames) or add
the comment accordingly to remove ambiguity.

320-351: grantRoleToFarm no longer runs inside a transaction.

checkPermission and createInvitation are two independent top-level calls against fdm. There is a small TOCTOU window where the caller's share permission could be revoked between the check (line 328) and the invitation creation (line 336). The practical risk is low because permission revocation is rare and the worst-case outcome is a single extra invitation, but it's worth noting.

If createInvitation already opens its own transaction, wrapping both calls in one would require passing the transaction handle through. This is acceptable as-is.

#!/bin/bash
# Check if createInvitation opens its own transaction
ast-grep --pattern 'async function createInvitation($$$) {
  $$$
  fdm.transaction($$$)
  $$$
}'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 320 - 351, The function grantRoleToFarm
has a TOCTOU risk because checkPermission and createInvitation run outside a
shared transaction; wrap both calls in a single fdm.transaction so the
permission check and invitation creation are atomic. Modify grantRoleToFarm to
call await fdm.transaction(async (trx) => { await checkPermission(fdm, "farm",
"share", b_id_farm, principal_id, "grantRoleToFarm", trx); return await
createInvitation(fdm, "farm", b_id_farm, principal_id, target, role, trx); });
and update checkPermission and createInvitation to accept an optional
transaction parameter (trx) and use it for DB operations when provided; preserve
the existing catch/handleError behavior.
fdm-app/app/routes/farm._index.tsx (1)

332-456: Extract the invitation card into a shared component to eliminate ~120 lines of duplication.

The invitation card rendering (icon, title, role label, description text, accept/decline forms) is duplicated nearly verbatim between the "no farms" view (lines 340–451) and the "has farms" view (lines 591–698). Extract it into a reusable InvitationCard component within this file or a separate component file.

♻️ Sketch of extracted component
+function InvitationCard({
+    invitation,
+}: {
+    invitation: {
+        invitation_id: string
+        farm_name: string | null
+        resource_id: string
+        role: string
+        org_name: string | null
+    }
+}) {
+    const roleLabel =
+        invitation.role === "owner"
+            ? "Eigenaar"
+            : invitation.role === "advisor"
+              ? "Adviseur"
+              : "Onderzoeker"
+
+    return (
+        <Card className="flex flex-col text-left">
+            <CardHeader className="pb-2">
+                <div className="flex items-center gap-3">
+                    <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 text-amber-600">
+                        <Bell className="h-5 w-5" />
+                    </div>
+                    <div>
+                        <CardTitle className="text-base">
+                            {invitation.farm_name ?? invitation.resource_id}
+                        </CardTitle>
+                        <CardDescription className="text-xs">
+                            Rol: {roleLabel}
+                        </CardDescription>
+                    </div>
+                </div>
+            </CardHeader>
+            <CardContent className="grow py-2 text-sm text-muted-foreground">
+                Je hebt een uitnodiging ontvangen voor toegang tot bedrijf{" "}
+                {invitation.farm_name ?? invitation.resource_id} als {roleLabel}.
+                {invitation.org_name && (
+                    <span className="block text-xs text-muted-foreground">
+                        Deze uitnodiging ontvang je namens organisatie: {invitation.org_name}
+                    </span>
+                )}
+                Je kunt deze uitnodiging accepteren of weigeren.
+            </CardContent>
+            <CardFooter className="flex gap-2 pt-2">
+                <Form method="post" className="flex-1">
+                    <input type="hidden" name="intent" value="accept_farm_invitation" />
+                    <input type="hidden" name="invitation_id" value={invitation.invitation_id} />
+                    <Button type="submit" size="sm" className="w-full">
+                        <Check className="mr-1 h-3 w-3" />
+                        Accepteren
+                    </Button>
+                </Form>
+                <Form method="post" className="flex-1">
+                    <input type="hidden" name="intent" value="decline_farm_invitation" />
+                    <input type="hidden" name="invitation_id" value={invitation.invitation_id} />
+                    <Button type="submit" size="sm" variant="outline" className="w-full">
+                        <X className="mr-1 h-3 w-3" />
+                        Weigeren
+                    </Button>
+                </Form>
+            </CardFooter>
+        </Card>
+    )
+}

Also applies to: 581-703

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/routes/farm._index.tsx` around lines 332 - 456, The invitation
card markup for loaderData.pendingInvitations is duplicated; extract it to a
reusable InvitationCard component that accepts an invitation prop and renders
the same structure (uses Card, CardHeader, CardTitle, CardDescription,
CardContent, CardFooter, Bell, Check, X, and two Forms submitting intent +
invitation_id). Replace both inline map renderings with
loaderData.pendingInvitations.map(inv => <InvitationCard invitation={inv} />)
(or equivalent), ensure the accept/decline forms keep their hidden inputs
name="intent"/value ("accept_farm_invitation"/"decline_farm_invitation") and
name="invitation_id"/value={invitation.invitation_id}, and export/import the
component in this file (or a new file) so behavior and styling remain identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/routes/farm._index.tsx`:
- Around line 358-364: The role label rendering for invitation.role currently
falls back to "Onderzoeker" and should match the farm card badge behavior (which
includes a fallback "Lid"); update the ternary that computes the label for
invitation.role so it maps "owner" => "Eigenaar", "advisor" => "Adviseur",
"researcher" => "Onderzoeker" and uses "Lid" as the default for any unknown
role; ideally extract this logic into a shared helper (e.g. getRoleLabel) and
use it both where invitation.role is rendered and in the farm card badge
rendering to keep labels consistent.

In `@fdm-core/src/farm.ts`:
- Around line 478-489: listPrincipalsForFarm currently returns duplicate
principals when a user has both an active role and a pending invitation; modify
listPrincipalsForFarm to deduplicate by principal_id: build a map keyed by
principal_id and for each entry prefer an "active" record over a "pending" one
(keep role from the chosen record, include invitation_id only for pending, and
set the status field to "active" or "pending"), then return the map values; this
ensures the returned (Principal & {role,status,invitation_id?})[] contains one
entry per principal and the UI will no longer see duplicates.

In `@fdm-core/src/invitation.ts`:
- Around line 630-666: Wrap the per-invitation grant+accept sequence in a
try/catch inside the pendingInvitations loop so a thrown error from
grantRole(tx, ...) (which uses tx.transaction/savepoints) does not abort the
outer fdm.transaction; specifically, around the call to grantRole(...) and the
following tx.update(...).set({ status: "accepted", ... }) for
authZSchema.invitation, catch the error, log or record the failure (e.g., set
status to "failed" or leave as "pending" per policy) and continue the loop; keep
the existing early expiry handling (the expires check and the update to status
"expired") unchanged.

---

Nitpick comments:
In `@fdm-app/app/routes/farm._index.tsx`:
- Around line 332-456: The invitation card markup for
loaderData.pendingInvitations is duplicated; extract it to a reusable
InvitationCard component that accepts an invitation prop and renders the same
structure (uses Card, CardHeader, CardTitle, CardDescription, CardContent,
CardFooter, Bell, Check, X, and two Forms submitting intent + invitation_id).
Replace both inline map renderings with loaderData.pendingInvitations.map(inv =>
<InvitationCard invitation={inv} />) (or equivalent), ensure the accept/decline
forms keep their hidden inputs name="intent"/value
("accept_farm_invitation"/"decline_farm_invitation") and
name="invitation_id"/value={invitation.invitation_id}, and export/import the
component in this file (or a new file) so behavior and styling remain identical.

In `@fdm-core/src/db/schema-authz.ts`:
- Around line 60-91: Queries in autoAcceptInvitationsForNewUser scan the partial
unique index invitation_unique_email_idx whose leading columns are (resource,
resource_id, target_email), causing inefficient lookups by target_email alone;
add a new partial index (e.g. invitation_pending_target_email_idx) on table
invitation for column target_email WHERE status = 'pending' (non-unique) to
serve that hot path and speed up queries filtering by target_email and status;
update the schema definition where invitation is declared to include this new
index alongside invitation_unique_email_idx.

In `@fdm-core/src/farm.ts`:
- Around line 550-573: The initials computation in listPrincipalsForFarm
duplicates the surname-capital-letter logic from getPrincipal; extract that
logic into a shared helper (e.g., computeInitials(firstname, surname, email,
name)) and replace the inlined code in both getPrincipal and
listPrincipalsForFarm to call this helper; ensure the helper returns the same
fallback order (email -> firstname+surname initials -> firstname[0] -> name[0])
and preserve the existing surname-splitting and uppercase-letter detection
behavior so both locations remain consistent.
- Around line 748-832: The code in listPendingInvitationsForUser collects
target_principal_id into orgTargetIds and looks them up only in
authNSchema.organization (orgNames) which is ambiguous if invitations can target
user principals; either clarify intent with a comment above the
orgTargetIds/orgNames block stating that target_principal_id may be a user and
org_name will be null for non-org targets, or if the intent is to resolve only
organization principals, change the filter to only include invitations whose
target principal type equals "organization" (or the appropriate enum) before
building orgTargetIds and querying authNSchema.organization; update variable
names (orgTargetIds/orgNames) or add the comment accordingly to remove
ambiguity.
- Around line 320-351: The function grantRoleToFarm has a TOCTOU risk because
checkPermission and createInvitation run outside a shared transaction; wrap both
calls in a single fdm.transaction so the permission check and invitation
creation are atomic. Modify grantRoleToFarm to call await fdm.transaction(async
(trx) => { await checkPermission(fdm, "farm", "share", b_id_farm, principal_id,
"grantRoleToFarm", trx); return await createInvitation(fdm, "farm", b_id_farm,
principal_id, target, role, trx); }); and update checkPermission and
createInvitation to accept an optional transaction parameter (trx) and use it
for DB operations when provided; preserve the existing catch/handleError
behavior.

In `@fdm-core/src/invitation.ts`:
- Around line 300-301: The code contains a duplicated guard: an outer if
checking invitation.target_principal_id and an inner identical if
(invitation.target_principal_id); remove the redundant inner if and dedent its
body so the logic inside runs under the single outer guard (locate the block
around invitation.target_principal_id in the invitation handling code and delete
the inner conditional).

In `@fdm-docs/docs/core-concepts/10-authorization.md`:
- Line 80: The docs use the parameter name `principal_id` for
listPendingInvitationsForPrincipal but the actual implementation in
invitation.ts accepts `user_id: string`; update the API table entry (the
parameter column for listPendingInvitationsForPrincipal) to `user_id` to match
the function signature and intent (only users), and scan for any other
occurrences in the same doc to replace `principal_id` with `user_id` to keep
names consistent with the invitation.ts function.

Comment thread fdm-app/app/routes/farm._index.tsx Outdated
Comment thread fdm-core/src/farm.ts
Comment thread fdm-core/src/invitation.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
fdm-app/app/components/custom/autocomplete.tsx (1)

59-59: openRef is written at every setOpen call site but never read — safe to remove.

The handleInputBlur rewrite (lines 163–180) dropped the old !openRef.current guard and now relies solely on e.preventDefault() on CommandItem.onMouseDown. All five openRef.current = … assignments remain but no code path ever reads openRef.current. The ref is pure maintenance overhead.

♻️ Proposed cleanup
-    const openRef = useRef(open)
     ...
     const handleSelectItem = (itemValue: string) => {
         ...
         setOpen(false)
-        openRef.current = false
     }
     ...
     <Popover
         open={open}
-        onOpenChange={(value) => {
-            setOpen(value)
-            openRef.current = value
-        }}
+        onOpenChange={setOpen}
     >
         ...
         <CommandPrimitive.Input
             ...
-            onKeyDown={(e) => {
-                const next = e.key !== "Escape"
-                setOpen(next)
-                openRef.current = next
-            }}
+            onKeyDown={(e) => setOpen(e.key !== "Escape")}
-            onMouseDown={() => {
-                const next = !!inputValue || !open
-                setOpen(next)
-                openRef.current = next
-            }}
+            onMouseDown={() => setOpen(!!inputValue || !open)}
-            onFocus={() => {
-                setOpen(true)
-                openRef.current = true
-            }}
+            onFocus={() => setOpen(true)}

Also applies to: 156-157, 186-189, 197-201, 202-206, 207-210

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-app/app/components/custom/autocomplete.tsx` at line 59, Remove the unused
openRef ref and all assignments to it: delete the declaration const openRef =
useRef(open) and remove every occurrence that sets openRef.current (the five
assignments near the setOpen call sites). Update related code paths so they rely
on the existing setOpen state and the new handleInputBlur behavior (do not
reintroduce reads of openRef in handleInputBlur or CommandItem.onMouseDown);
ensure no other code references openRef after the cleanup (search for openRef,
openRef.current, and the specific assignment sites around setOpen,
handleInputBlur, and CommandItem.onMouseDown and delete them).
fdm-core/src/farm.ts (1)

682-692: as any cast in deduplication hides a type mismatch.

The map is typed as Map<string, (typeof principalsDetails)[number]>, but pendingDetails entries don't match that type (they always carry invitation_id while principalsDetails entries do not). The as any cast suppresses the error instead of aligning the types. Both arrays should be typed to the declared return element type before merging.

♻️ Proposed fix
+type PrincipalWithRole = Principal & {
+    role: string
+    status: "active" | "pending"
+    invitation_id?: string
+}

-const deduped = new Map<string, (typeof principalsDetails)[number]>()
+const deduped = new Map<string, PrincipalWithRole>()
 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 as any)
+        deduped.set(entry.id, entry)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fdm-core/src/farm.ts` around lines 682 - 692, The deduplication uses
Map<string, (typeof principalsDetails)[number]> but merges entries from
principalsDetails and pendingDetails which have different shapes (pendingDetails
entries include invitation_id), and the existing as any cast hides this
mismatch; fix by defining a single element type (e.g., PrincipalDetailUnion or a
common minimal interface used as the Map value) that both principalsDetails and
pendingDetails are converted/mapped to before inserting into the Map, or
explicitly map pendingDetails entries to the same shape as principalsDetails
(dropping or optionalizing invitation_id) so you can remove the as any and then
dedupe via the deduped Map (keep references to deduped, principalsDetails,
pendingDetails to locate code).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/routes/farm._index.tsx`:
- Around line 155-158: The catch block in farm._index.tsx currently replaces all
errors with a generic message; update the catch in the action handler that calls
acceptInvitation/declineInvitation so it returns the original error message
instead (e.g., use error?.message || String(error) as the message passed to
dataWithError) and still log the full error to console; refer to the catch
surrounding the acceptInvitation/declineInvitation calls to locate and change
the return from dataWithError(null, "Er is iets misgegaan") to include the
actual error text.
- Around line 339-451: The pending-invitation Card JSX is duplicated in two
places (the loaderData.pendingInvitations.map blocks); extract that markup into
a reusable component (e.g., PendingInvitationCard) that accepts a single prop
invitation and encapsulates the Card, CardHeader/Content/Footer, the two Forms
with hidden inputs ("intent" and "invitation_id"), and the icons (Bell, Check,
X) and Dutch copy; then replace both duplicated map blocks with
loaderData.pendingInvitations.map(inv => <PendingInvitationCard
key={inv.invitation_id} invitation={inv} />) so layout, labels and form behavior
remain identical and updates are centralized.

In `@fdm-core/src/farm.ts`:
- Around line 550-579: The initials fallback currently sets initials = u.email
(full email) causing full address to be used; update the fallback logic in the
loop that builds principalsMap (the block using variables initials, u.firstname,
u.surname, u.name and calling principalsMap.set) so that when no
firstname/surname/name exist you set initials to the first character of u.email
(e.g., u.email.charAt(0) or equivalent) (and still call .toUpperCase()
downstream) — this mirrors the behavior of the previous getPrincipal helper and
ensures initials are a single letter.
- Around line 328-343: The permission check and invitation creation in
grantRoleToFarm are not atomic: checkPermission(...) and createInvitation(...)
run separately allowing a TOCTOU race; wrap both operations in a single
fdm.transaction so the share permission check and the call to
createInvitation("farm", b_id_farm, principal_id, target, role) execute in the
same transaction context (pass the transaction/context into checkPermission and
createInvitation if they accept it or call their transactional variants),
preserving the original atomic behavior.

In `@fdm-core/src/invitation.ts`:
- Around line 229-236: The error context for createInvitation is leaking PII by
passing the raw target (email) into handleError; remove target from the context
and instead include a non-PII identifier such as target_user_id (if present) or
a redacted indicator (e.g., "target_redacted": true) when calling handleError in
the createInvitation catch block so logs/error-tracking never contain the
plaintext email; update the call that currently references resource,
resource_id, target, role to use resource, resource_id, role and target_user_id
(or a redacted flag) instead.

In `@fdm-docs/docs/core-concepts/10-authorization.md`:
- Line 77: The API table entry for createInvitation omits the optional expires
parameter; update the table row for `createInvitation(fdm, resource,
resource_id, inviter_id, target, role)` to reflect the actual signature
`createInvitation(fdm, resource, resource_id, inviter_id, target, role,
expires?)` and note that `expires` is optional and defaults to 7 days so callers
know they can override the expiry when calling `createInvitation`.

---

Duplicate comments:
In `@fdm-app/app/routes/farm._index.tsx`:
- Around line 52-57: getRoleLabel currently maps known roles and returns "Lid"
as a fallback; ensure all call sites use this helper (e.g., where active farm
roles and pending invitation roles are rendered) by replacing any inline
role-to-label logic with calls to getRoleLabel(role) so both active roles and
pending invitations consistently display the same labels.

In `@fdm-docs/docs/core-concepts/10-authorization.md`:
- Around line 41-48: The markdown code block in 10-authorization.md now includes
the language identifier ```text which resolves the MD040 lint warning; no code
changes required—confirm the code block around the farm/field tree uses ```text
and leave it as-is to keep the markdownlint warning cleared.

---

Nitpick comments:
In `@fdm-app/app/components/custom/autocomplete.tsx`:
- Line 59: Remove the unused openRef ref and all assignments to it: delete the
declaration const openRef = useRef(open) and remove every occurrence that sets
openRef.current (the five assignments near the setOpen call sites). Update
related code paths so they rely on the existing setOpen state and the new
handleInputBlur behavior (do not reintroduce reads of openRef in handleInputBlur
or CommandItem.onMouseDown); ensure no other code references openRef after the
cleanup (search for openRef, openRef.current, and the specific assignment sites
around setOpen, handleInputBlur, and CommandItem.onMouseDown and delete them).

In `@fdm-core/src/farm.ts`:
- Around line 682-692: The deduplication uses Map<string, (typeof
principalsDetails)[number]> but merges entries from principalsDetails and
pendingDetails which have different shapes (pendingDetails entries include
invitation_id), and the existing as any cast hides this mismatch; fix by
defining a single element type (e.g., PrincipalDetailUnion or a common minimal
interface used as the Map value) that both principalsDetails and pendingDetails
are converted/mapped to before inserting into the Map, or explicitly map
pendingDetails entries to the same shape as principalsDetails (dropping or
optionalizing invitation_id) so you can remove the as any and then dedupe via
the deduped Map (keep references to deduped, principalsDetails, pendingDetails
to locate code).

Comment thread fdm-app/app/routes/farm._index.tsx
Comment thread fdm-app/app/routes/farm._index.tsx
Comment thread fdm-core/src/farm.ts Outdated
Comment thread fdm-core/src/farm.ts
Comment thread fdm-core/src/invitation.ts
Comment thread fdm-docs/docs/core-concepts/10-authorization.md Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.access.tsx:
- Around line 183-224: The email error handler currently calls
revokePrincipalFromFarm unconditionally and will throw "Target not found" for
email-only invitees; change the catch block to only call revokePrincipalFromFarm
when a registered principal was resolved (use the earlier
matchedPrincipals/targetPrincipal or check that targetPrincipal?.type === "user"
/ has an id) and skip revocation for email-only targets so the original
isInactiveRecipientError is preserved and pending invitations remain; ensure the
original emailError is returned via dataWithError when skipping revocation.

---

Duplicate comments:
In `@fdm-app/app/routes/farm._index.tsx`:
- Around line 155-159: The catch block currently swallows detailed invitation
errors and always returns a generic message; update the catch to inspect the
caught error (e.g., check error.message, error.code, or a custom InvitationError
predicate) and if it is an invitation-related error return dataWithError(null,
error.message) (or a mapped, user-friendly message), otherwise fall back to the
generic "Er is iets misgegaan"; ensure you reference the same catch handling and
the dataWithError helper so actionable invitation errors are surfaced to users.

In `@fdm-app/app/routes/farm`.$b_id_farm.settings.access.tsx:
- Around line 139-185: The catch block may call revokePrincipalFromFarm for an
invite that was sent to an email-only target (no registered principal), causing
a "Target not found" error; update the error-handling in the try/catch around
sendEmail in the farm.$b_id_farm.settings.access route to only call
revokePrincipalFromFarm when a principal was actually resolved (i.e., check that
targetPrincipal is truthy and not email-only) — use the existing local symbols
targetPrincipal and isEmailTarget to guard the revokePrincipalFromFarm(fdm,
session.principal_id, formValues.username, b_id_farm) call and otherwise skip
revocation (or return the same user-facing error) so we don’t attempt to revoke
a non-existent principal.

Comment thread fdm-app/app/routes/farm.create.$b_id_farm.$calendar.access.tsx
@SvenVw SvenVw requested a review from gerardhros February 20, 2026 15:15
Comment thread fdm-app/app/components/blocks/farm/pending-invitation.tsx
Comment thread fdm-app/app/lib/error.ts
Comment thread fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx
Comment thread fdm-app/app/routes/farm.$b_id_farm.settings.access.tsx
* Enforced in {@link createInvitation} to prevent inbox flooding.
*/
export const MAX_INVITATIONS_PENDING_PER_TARGET = 10

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for now fine. on organization level this might be changed later.

Copy link
Copy Markdown
Collaborator

@gerardhros gerardhros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice improvement

@gerardhros
Copy link
Copy Markdown
Collaborator

check the automatic checks

@SvenVw SvenVw merged commit ac230a8 into development Feb 23, 2026
8 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

branch:development Issue only affecting development, not the main branch (yet) enhancement New feature or request fdm-app fdm-core fdm-docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Farm invitations and access sharing

3 participants