Skip to content

feat: Allow inviting people to projects / orgs who are not signed up on Swetrix#484

Merged
Blaumaus merged 9 commits intomainfrom
feature/invitation-to-outsiders
Mar 9, 2026
Merged

feat: Allow inviting people to projects / orgs who are not signed up on Swetrix#484
Blaumaus merged 9 commits intomainfrom
feature/invitation-to-outsiders

Conversation

@Blaumaus
Copy link
Member

@Blaumaus Blaumaus commented Mar 9, 2026

Changes

If applicable, please describe what changes were made in this pull request.

Community Edition support

  • Your feature is implemented for the Swetrix Community Edition
  • This PR only updates the Cloud (Enterprise) Edition code (e.g. Paddle webhooks, blog, payouts, etc.)

Database migrations

  • Clickhouse / MySQL migrations added for this PR
  • No table schemas changed in this PR

Documentation

  • You have updated the documentation according to your PR
  • This PR did not change any publicly documented endpoints

Summary by CodeRabbit

  • New Features

    • Invitation-based signup for unregistered users (create account & accept project/org invites).
    • Ability to send invitations to emails for projects and organisations; recipients can claim or register via invitation.
    • Invitations auto-redeem on signup and mark users as invited (affects onboarding and subscription checks).
    • New email templates and localized strings for invitation flows.
    • Personal project creation now enforces subscription requirement where applicable.
  • Documentation

    • Updated invitation/invite-user docs to cover unregistered invite flows and billing notes.

@Blaumaus Blaumaus self-assigned this Mar 9, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds a pending-invitations system: new entity/service/module and DB migration, email templates, backend endpoints and redemption logic, integration into project/org invite flows and auth, frontend invitation signup route/page, and a user flag registeredViaInvitation to track redeemed invites.

Changes

Cohort / File(s) Summary
Pending Invitation Core Infrastructure
backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts, backend/apps/cloud/src/pending-invitation/pending-invitation.service.ts, backend/apps/cloud/src/pending-invitation/pending-invitation.module.ts
New PendingInvitation entity and enum (PROJECT_SHARE, ORGANISATION_MEMBER); service with CRUD/query methods; module wiring TypeORM and exporting the service.
Database Migration & User Model
backend/migrations/mysql/2026_03_09_pending_invitations.sql, backend/apps/cloud/src/user/entities/user.entity.ts
Adds pending_invitation table and index; adds registeredViaInvitation boolean column to User entity (default false).
Auth: Controller, Service, DTOs, Module
backend/apps/cloud/src/auth/auth.controller.ts, backend/apps/cloud/src/auth/auth.service.ts, backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts, backend/apps/cloud/src/auth/dtos/index.ts, backend/apps/cloud/src/auth/auth.module.ts, backend/apps/cloud/src/app.module.ts
New endpoints GET /invitation/:id, POST /invitation/:id/claim, POST /register/invitation; RegisterInvitation DTO; redeemPendingInvitations(user) implemented and invoked during signup flows; DI of PendingInvitationService and OrganisationService wired into auth module and app module.
Project & Organisation Invite Flows
backend/apps/cloud/src/project/project.controller.ts, backend/apps/cloud/src/project/project.module.ts, backend/apps/cloud/src/organisation/organisation.controller.ts, backend/apps/cloud/src/organisation/organisation.module.ts
Invite flows updated to create pending invitations for unregistered emails and send unregistered-invite emails; project creation adds a subscription guard for personal projects; modules import PendingInvitationModule.
Mailer & Templates
backend/apps/cloud/src/mailer/letter.ts, backend/apps/cloud/src/mailer/mailer.service.ts, backend/apps/cloud/src/common/templates/en/project-invitation-unregistered.html, backend/apps/cloud/src/common/templates/en/organisation-invitation-unregistered.html
New template identifiers and mailer meta entries; HTML email templates for unregistered project and organisation invitations with signup links.
Frontend: API & Routes
web/app/api/api.server.ts, web/app/routes/signup_.invitation.$id.tsx, web/app/pages/Auth/Signup/InvitationSignup.tsx, web/app/utils/routes.ts
Server API functions for invitation details, claim, and register-via-invitation; new route and page for invitation signup with loader/action handling validation, registration, and redirects; route path added to routes util.
Frontend: Auth/UX Integration
web/app/lib/models/User.ts, web/app/routes/login.tsx, web/app/routes/dashboard.tsx, web/app/routes/subscribe.tsx, web/app/pages/Dashboard/Dashboard.tsx
User model gains registeredViaInvitation; post-auth redirect and loader conditions updated to exempt invitation-registered users from subscription redirects; login response type extended; dashboard project creation subscription guard added.
Frontend: UI & Utilities
web/app/ui/Input.tsx, web/app/ui/Text.tsx, web/app/pages/Dashboard/AddProject.tsx, web/app/utils/auth.ts, web/app/utils/validator.ts, web/app/routes/projects.settings.$id.tsx
Input gains readOnly prop; muted text color tweak; dark-mode class adjustments; MAX_PASSWORD_CHARS increased to 72 and validator/use updated; minor API path fix for share deletion.
Localization & Docs
web/public/locales/{en,de,fr,pl,uk}.json, docs/content/docs/sitesettings/how-to-invite-users-to-your-website.mdx
Adds invitation-related translation keys and subscriptionRequired messages; documentation updated to describe invite flows for registered vs unregistered users and billing notes.
Small Validation Adjustments
backend/apps/cloud/src/auth/dtos/register.dto.ts, backend/apps/cloud/src/auth/dtos/request-change-email.dto.ts, backend/apps/cloud/src/auth/dtos/reset-password.dto.ts, backend/apps/community/src/.../auth/dtos/*.ts
Increased password MaxLength from 50 to 72 across several DTOs in cloud and community apps to align client/server validation.

Sequence Diagram(s)

sequenceDiagram
    actor Inviter as Inviter (User)
    participant Ctrl as Project/Organisation Controller
    participant PIS as PendingInvitation Service
    participant DB as Database
    participant Mailer as Mailer Service
    participant Email as Email Provider

    Inviter->>Ctrl: invite(email)
    Ctrl->>PIS: create pending invitation
    PIS->>DB: insert pending_invitation
    Ctrl->>Mailer: send unregistered-invite(email, url)
    Mailer->>Email: send template
    Email-->>Inviter: deliver invite email

    actor Invitee as Invitee (New User)
    participant Frontend as Frontend
    participant AuthAPI as Auth Controller
    participant AuthService as Auth Service

    Invitee->>Frontend: click signup link (id)
    Frontend->>AuthAPI: GET /invitation/:id
    AuthAPI->>DB: read pending_invitation
    AuthAPI-->>Frontend: return invitation metadata
    Invitee->>Frontend: submit registration (email,password)
    Frontend->>AuthAPI: POST /register/invitation
    AuthAPI->>AuthService: create user
    AuthService->>PIS: findByEmail & redeem invitations
    AuthService->>DB: create ProjectShare / Org member as applicable
    AuthService->>PIS: delete pending invitations
    AuthAPI-->>Frontend: return JWT + user (registeredViaInvitation=true)
    Frontend->>Invitee: redirect to dashboard
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A hop, a nibble, an emailed line,
"Join our burrow" — click the sign.
No account? No worry — a link and a cheer,
Sign up, accept, your access is near,
Tiny paws, big welcomes, invitations clear! 🎉

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is incomplete and generic. The 'Changes' section contains only a template placeholder with no actual description of what was implemented. Fill in the 'Changes' section with a detailed summary of the implementation, including the new pending invitation feature, email templates, and API endpoints added.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically describes the main change: enabling invitations to projects and organizations for unregistered users, which is the core feature throughout the changeset.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/invitation-to-outsiders

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.

Copy link

@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: 19

🧹 Nitpick comments (5)
backend/migrations/mysql/2026_03_09_pending_invitations.sql (1)

2-15: Missing FOREIGN KEY constraints - diverges from recent migration patterns.

Per recent migrations (2025_12_07_ai_chat.sql, 2025_12_08_feature_flags.sql, 2025_12_14_pinned_projects.sql), the established pattern includes FOREIGN KEY constraints with ON DELETE CASCADE for projectId and userId references.

Without FK constraints:

  • Orphaned rows will remain if referenced projects/organisations/users are deleted
  • No referential integrity enforcement

If intentional (e.g., invitations should persist after inviter deletion), please add a comment explaining this design decision. Otherwise, consider adding FK constraints after fixing the column length mismatch.

Example FK constraints (after fixing column lengths)
  FOREIGN KEY (`projectId`) REFERENCES `project` (`id`) ON DELETE CASCADE,
  FOREIGN KEY (`organisationId`) REFERENCES `organisation` (`id`) ON DELETE CASCADE,
  FOREIGN KEY (`inviterId`) REFERENCES `user` (`id`) ON DELETE CASCADE

Note: ON DELETE CASCADE may not be appropriate for all cases. If invitations should persist after project deletion but be cleaned up when the inviter is deleted, use ON DELETE SET NULL for projectId/organisationId (requires nullable columns) and ON DELETE CASCADE for inviterId.

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

In `@backend/migrations/mysql/2026_03_09_pending_invitations.sql` around lines 2 -
15, The pending_issue is missing FOREIGN KEY constraints on pending_invitation
which breaks referential integrity; update the pending_invitation table
definition (table name: pending_invitation) to match referenced id column types
(fix column lengths for projectId, organisationId, inviterId to the same
type/length as project.id/organisation.id/user.id—likely varchar(36)), then add
FOREIGN KEY clauses referencing project(id), organisation(id) and user(id) with
appropriate ON DELETE behavior (e.g., ON DELETE CASCADE for inviterId, and
either ON DELETE CASCADE or ON DELETE SET NULL for projectId/organisationId—make
those columns NULLABLE if using SET NULL); alternatively, if persistence after
deletion is intentional, add an inline comment in the migration explaining that
design choice.
backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts (1)

27-31: Consider adding constraint: projectId XOR organisationId based on type.

For PROJECT_SHARE invitations, projectId should be non-null and organisationId should be null (and vice versa for ORGANISATION_MEMBER). Currently, both are nullable with no database-level constraint enforcing this relationship.

While application logic may enforce this, a CHECK constraint would provide defense-in-depth against data inconsistencies.

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

In `@backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts`
around lines 27 - 31, Add a DB-level CHECK constraint to the PendingInvitation
entity to enforce that projectId and organisationId are mutually exclusive based
on the invitation type: update the entity (class PendingInvitation) to include a
CHECK (via TypeORM's `@Check` decorator or equivalent) that requires (type =
'PROJECT_SHARE' AND projectId IS NOT NULL AND organisationId IS NULL) OR (type =
'ORGANISATION_MEMBER' AND organisationId IS NOT NULL AND projectId IS NULL); if
your ORM/version doesn't support `@Check` on the entity, create a migration that
adds the equivalent SQL CHECK constraint referencing the projectId and
organisationId columns to enforce the XOR invariant.
backend/apps/cloud/src/auth/auth.controller.ts (2)

576-578: Note: Rate limit is shared between regular and invitation registration.

Both /register and /register/invitation use the same rate limit key ('register'), meaning 5 total attempts per IP across both endpoints in 24 hours. This prevents bypassing limits but could block legitimate invitation users if their IP has exhausted attempts via regular registration.

Consider using a distinct key like 'register-invitation' if invitation-based registration should have independent limits.

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

In `@backend/apps/cloud/src/auth/auth.controller.ts` around lines 576 - 578, The
rate limit call uses the shared key 'register' which makes /register and
/register/invitation share the same allowance; update the call in the invitation
registration flow to use a distinct key (e.g., 'register-invitation') so it has
its own quota. Locate the invocation of checkRateLimit (function name) where ip
is computed via getIPFromHeaders and change the second argument from 'register'
to a separate string like 'register-invitation'; ensure any tests or
documentation that reference the rate-limit key are updated accordingly.

154-167: Consider adding a null check for updatedUser.

While findUserById should succeed for a just-created user, the code directly accesses updatedUser.sharedProjects without a null check. If the database query fails for any reason, this would throw a runtime error.

🛡️ Defensive fix
     const updatedUser =
       redeemedCount > 0
         ? await this.userService.findUserById(newUser.id)
         : newUser
 
-    if (redeemedCount > 0) {
+    if (redeemedCount > 0 && updatedUser) {
       const [sharedProjects, organisationMemberships] = await Promise.all([
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/auth/auth.controller.ts` around lines 154 - 167, The
code assumes updatedUser from userService.findUserById is non-null before
assigning sharedProjects and organisationMemberships; add a null check after the
await (in auth.controller.ts around the updatedUser assignment) and handle the
null case (either set updatedUser = newUser as a safe fallback or throw/log and
return an error response) before calling
this.authService.getSharedProjectsForUser and
this.userService.getOrganisationsForUser and before assigning
updatedUser.sharedProjects/updatedUser.organisationMemberships to avoid runtime
exceptions.
web/app/routes/signup_.invitation.$id.tsx (1)

119-121: Basic email validation could allow invalid formats.

The check !email.includes('@') permits invalid emails like "@" or "a@b". While the backend likely performs stricter validation, improving frontend validation provides better UX with immediate feedback.

♻️ More robust email check
-  if (!email || !email.includes('@')) {
+  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+  if (!email || !emailRegex.test(email)) {
     fieldErrors.email = 'Please enter a valid email address'
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/routes/signup_.invitation`.$id.tsx around lines 119 - 121, The
current email check using `!email || !email.includes('@')` (in the signup
invitation handler where `email` is validated and `fieldErrors.email` is set) is
too permissive; replace it with a stronger validation (e.g., test `email`
against a simple robust regex such as one that requires non-space local part, an
'@', and a domain with a dot like /^[^\s@]+@[^\s@]+\.[^\s@]+$/ or use a
validation helper/library) and update the conditional that sets
`fieldErrors.email` so only truly invalid formats trigger the error message.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/apps/cloud/src/auth/auth.controller.ts`:
- Around line 663-672: The code calls userService.findUserById and then
unconditionally assigns updatedUser.sharedProjects and
updatedUser.organisationMemberships, but findUserById may return null; guard
against a null updatedUser by checking its existence before assigning (e.g., if
(!updatedUser) { handle/error/throw }): locate the updatedUser variable and the
call to userService.findUserById, and ensure you either throw or return an
appropriate error/response when updatedUser is null before using
authService.getSharedProjectsForUser and userService.getOrganisationsForUser
results or move those calls after the null check so you only assign properties
on a non-null updatedUser.

In `@backend/apps/cloud/src/auth/auth.service.ts`:
- Around line 208-225: The duplicate-share check fails because
projectService.findOne only loads relations: ['share'] but not the nested
share.user; update the findOne call used here (where pending.projectId is
loaded) to include the nested relation for users (e.g., relations: ['share',
'share.user']) so that the check using project.share?.some(s => s.user?.id ===
user.id) can correctly detect existing ProjectShare entries; ensure ProjectShare
and methods like this.projectService.findOne and this.projectService.createShare
remain unchanged except for adding the nested relation.

In `@backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts`:
- Around line 28-35: The ApiProperty for the password declares maxLength: 72 but
the validator uses `@MaxLength`(50), causing a mismatch; update the `@MaxLength`
decorator on the password property (and its validation message) to 72 to match
ApiProperty (or alternatively change ApiProperty.maxLength to 50) so the Swagger
contract and runtime validation align, ensuring you modify the `@MaxLength`(...)
value and its message string where the decorator is applied to the password
property in register-invitation DTO.

In
`@backend/apps/cloud/src/common/templates/en/organisation-invitation-unregistered.html`:
- Around line 11-14: The fallback plain-text URL in the
organisation-invitation-unregistered.html template should be made clickable:
replace the raw {{url}} text with an anchor using the same {{url}} for href
(e.g., <a href="{{url}}">...</a>), and include safe attributes like
target="_blank" and rel="noopener noreferrer" so the fallback link remains
usable when the main CTA is stripped; update the block containing the "If you
are having trouble..." copy to use that anchor around {{url}}.

In `@backend/apps/cloud/src/organisation/organisation.controller.ts`:
- Around line 218-254: The current flow creates a PendingInvitation via
pendingInvitationService.create then does mailerService.sendEmail inside the
same try/catch, which can leave a dangling DB record if send fails and also can
mis-report success; change it to first create the pendingInvitation, then wrap
only the mail send in a try/catch: call mailerService.sendEmail(...) and on
failure call pendingInvitationService.delete(pendingInvitation.id) (or
pendingInvitationService.remove/purge equivalent) to rollback the created
invitation, log the error with reason and email/orgId, then throw the
BadRequestException; only after a successful send call
organisationService.findOne(...) and return that result. Ensure you reference
pendingInvitation.id when deleting and keep logging using this.logger.error with
{ orgId: organisation?.id, email: inviteDTO.email, reason }.

In `@backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts`:
- Around line 33-34: The role column in PendingInvitation (role: string in
pending-invitation.entity.ts) is untyped and allows invalid values; update the
code to either (A) change the entity to use a DB enum column mapped to the
appropriate union of enums (or separate columns) so only valid Role /
OrganisationRole values can be persisted, or (B) add explicit validation inside
auth.service.ts (redeemPendingInvitations) before casting: check pending.role
against Object.values(Role) for project invitations and
Object.values(OrganisationRole) for organisation invitations, log/warn and skip
or throw on invalid values, then safely cast to Role or OrganisationRole for
assignment to ProjectShare.role / OrganisationMember.role; reference Role
(project-share.entity) and OrganisationRole (organisation-member.entity) when
implementing validation or changing the entity mapping.

In `@backend/apps/cloud/src/pending-invitation/pending-invitation.service.ts`:
- Around line 14-16: Add DB-level UNIQUE constraints in the migration for
pending_invitation: UNIQUE(email, projectId, type) and UNIQUE(email,
organisationId, type) to prevent race-condition duplicates, and update the
PendingInvitationService.create method (the async create(data:
Partial<PendingInvitation>): Promise<PendingInvitation> that currently calls
this.repository.save(data)) to handle constraint violations: catch DB errors
from repository.save, detect duplicate-key errors (e.g., MySQL ER_DUP_ENTRY or
Postgres 23505), and convert them into a graceful response (return the existing
pending invitation, noop, or throw a dedicated DuplicatePendingInvitationError)
instead of allowing duplicate inserts; alternatively implement an atomic upsert
via the ORM/query builder (INSERT ... ON DUPLICATE KEY UPDATE / ON CONFLICT DO
NOTHING) to avoid the race entirely.

In `@backend/apps/cloud/src/project/project.controller.ts`:
- Around line 1060-1094: The code creates a pending invite via
pendingInvitationService.create and then calls mailerService.sendEmail, but on
send failure it returns BadRequestException(reason) (leaking internal details)
and leaves the pending invite in DB; update the try/catch around the send flow
so that if mailerService.sendEmail throws you delete the created invite (use
pendingInvitation.id via pendingInvitationService.delete or remove method) and
log the internal error locally (console.error or processLogger.error) but throw
a generic BadRequestException('Could not send invitation') (or similar fixed
message) instead of BadRequestException(reason); also defensively check
pendingInvitation exists before attempting to delete and continue to return
processProjectUser(updatedProject) on success by keeping projectService.findOne
and processProjectUser usage as-is.

In `@backend/migrations/mysql/2026_03_09_pending_invitations.sql`:
- Around line 2-15: The pending_invitation table's FK target lengths don't match
referenced tables: change column definitions in pending_invitation — set
projectId to varchar(12), organisationId to varchar(36), and inviterId to
varchar(36) (preserve NULL/default behavior for projectId/organisationId and NOT
NULL for inviterId as appropriate), then add FOREIGN KEY constraints referencing
project(id), organisation(id), and user(id) respectively and keep the existing
indexes (IDX_pending_invitation_project / IDX_pending_invitation_organisation /
email index) to enable referential integrity and prevent invalid IDs from being
stored.

In `@docs/content/docs/sitesettings/how-to-invite-users-to-your-website.mdx`:
- Line 43: The sentence "If an invited user wants to create their own personal
projects later on, they will need to start a free trial and subscribe to a
plan." overstates requirements; update that sentence (the invited-user personal
project line) to state that starting a free trial is sufficient because the
backend only blocks PlanCode.none — e.g., change wording to indicate a free
trial (or any non-none plan) allows creating personal projects rather than
requiring an immediate subscription.

In `@web/app/api/api.server.ts`:
- Around line 506-540: The invitation registration path (registerViaInvitation)
currently forces persistent cookies by calling createAuthCookies(..., true);
change it to respect the caller's session policy like registerUser does — either
accept a remember/rememberSession boolean on the data payload and pass that
through to createAuthCookies, or explicitly use false if invitations must always
be session-scoped; update the registerViaInvitation signature and the call-site
to use that boolean (referencing registerViaInvitation, createAuthCookies and
registerUser to mirror behavior).

In `@web/app/pages/Dashboard/AddProject.tsx`:
- Around line 38-42: Tailwind dark/group-hover variants are reversed in the
className strings inside AddProject.tsx: replace occurrences of
"group-hover:dark:text-gray-300" with "dark:group-hover:text-gray-300" (both in
the icon's class list that contains "dark:text-gray-200
group-hover:dark:text-gray-300" and in the span with className 'mt-2 block
text-sm font-semibold text-gray-900 dark:text-gray-50
group-hover:dark:text-gray-300') so the selectors compile as .dark .group:hover
… and work with root-level dark mode.

In `@web/app/pages/Dashboard/Dashboard.tsx`:
- Around line 248-255: The subscription gate in Dashboard.tsx currently blocks
users with planCode === 'none' even if they were invited; update the if
condition that checks isSelfhosted, user?.planCode === 'none', and
!newProjectOrganisationId to also allow users where user.registeredViaInvitation
is true (i.e. add && !user?.registeredViaInvitation or otherwise exclude invited
users from the check), so invited signups are exempt and won’t trigger
toast.error(t('project.settings.subscriptionRequired')) or return.

In `@web/app/routes/signup_.invitation`.$id.tsx:
- Around line 69-91: claimInvitation failures are currently ignored which lets
users be redirected to dashboard even if claiming the invite failed; update the
block that calls claimInvitation to await and capture the result or catch thrown
errors from claimInvitation (referencing claimInvitation and authResult in the
existing branch), and if it fails log the error and surface it to the user
before redirecting (for example set a flash message or redirect to an
error/onboarding flow instead of immediately redirecting to '/dashboard'),
making sure to preserve cookies via createHeadersWithCookies when redirecting
and avoid proceeding to redirect('/dashboard') on claim failure.
- Around line 139-144: The code uses a non-null assertion on id when calling
registerViaInvitation (registerViaInvitation(request, { pendingInvitationId:
id!, ... })), which can throw at runtime; instead validate params.id (the id
variable derived from params) before calling registerViaInvitation and handle
the missing case explicitly (e.g., return a 400/throw a controlled error).
Update the signup_.invitation.$id route handler to check that id is a defined
non-empty string and only pass it to registerViaInvitation if valid, otherwise
short-circuit with an appropriate response or error; ensure any callers or error
messages reference pendingInvitationId and registerViaInvitation for clarity.

In `@web/app/routes/subscribe.tsx`:
- Around line 58-61: The conditional in subscribe.tsx is using
user.registeredViaInvitation as a permanent entitlement check; change it so the
redirect depends on current access state (e.g., check user.planCode and actual
memberships/access such as user.sharedProjects.length, user.orgs.length, or an
explicit user.hasActiveAccess flag) instead of the signup-origin flag, or
alternatively clear user.registeredViaInvitation at the end of the invitation
onboarding (e.g., in the invite completion handler/completeOnboarding function)
so it no longer causes future redirects; update the conditional that references
user.registeredViaInvitation accordingly and ensure any onboarding flow updates
the user record to remove registeredViaInvitation once finished.

In `@web/public/locales/de.json`:
- Around line 729-736: The German translations reuse the single {{type}} token
across phrases with different grammatical cases, causing awkward forms like
"Konto erstellen & Projekt beitreten"; update the locale to provide separate,
case-appropriate strings instead of a shared {{type}} token by replacing usages
of "invitedToJoin", "invitedByAs" and "createAndJoin" to reference specific keys
(e.g., "project" and "organisation") or new keyed variants (e.g.,
"projectAccusative"/"organisationAccusative") and adjust the templates to
interpolate the correct key for each context so each phrase uses the
grammatically correct form.

In `@web/public/locales/en.json`:
- Around line 729-736: The page metadata in organisation.invite.$id.tsx
currently uses the project-specific titles.invitation ("Shared project
invitation"); update the metadata generation in the route (the exported
loader/metadata or default export that sets page title — look for where
titles.invitation is referenced) to use the neutral auth.invitation keys or
compute the title from the invitation.type (project vs organisation) so the
title matches auth.invitation entries; replace the hardcoded titles.invitation
usage with a dynamic lookup that selects auth.invitation.* or a single neutral
key like auth.invitation.invitedToJoin depending on the invitation payload.

In `@web/public/locales/uk.json`:
- Around line 729-736: The Ukrainian invitation strings use a shared {{type}}
token which causes incorrect case/inflection for different entities; update the
keys so each grammatical form is explicit: replace the combined "invitedToJoin"
/ "invitedByAs" usages that rely on {{type}} with separate keys such as
"invitedToJoinProject", "invitedToJoinOrganisation", "invitedByAsProject",
"invitedByAsOrganisation" (or other clear names), and update "createAndJoin"
similarly (e.g., "createAndJoinProject" / "createAndJoinOrganisation"), keeping
or removing the generic "project" and "organisation" tokens; then update any
code that references the old keys to use the new entity-specific keys so the
correct Ukrainian inflections are used.

---

Nitpick comments:
In `@backend/apps/cloud/src/auth/auth.controller.ts`:
- Around line 576-578: The rate limit call uses the shared key 'register' which
makes /register and /register/invitation share the same allowance; update the
call in the invitation registration flow to use a distinct key (e.g.,
'register-invitation') so it has its own quota. Locate the invocation of
checkRateLimit (function name) where ip is computed via getIPFromHeaders and
change the second argument from 'register' to a separate string like
'register-invitation'; ensure any tests or documentation that reference the
rate-limit key are updated accordingly.
- Around line 154-167: The code assumes updatedUser from
userService.findUserById is non-null before assigning sharedProjects and
organisationMemberships; add a null check after the await (in auth.controller.ts
around the updatedUser assignment) and handle the null case (either set
updatedUser = newUser as a safe fallback or throw/log and return an error
response) before calling this.authService.getSharedProjectsForUser and
this.userService.getOrganisationsForUser and before assigning
updatedUser.sharedProjects/updatedUser.organisationMemberships to avoid runtime
exceptions.

In `@backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts`:
- Around line 27-31: Add a DB-level CHECK constraint to the PendingInvitation
entity to enforce that projectId and organisationId are mutually exclusive based
on the invitation type: update the entity (class PendingInvitation) to include a
CHECK (via TypeORM's `@Check` decorator or equivalent) that requires (type =
'PROJECT_SHARE' AND projectId IS NOT NULL AND organisationId IS NULL) OR (type =
'ORGANISATION_MEMBER' AND organisationId IS NOT NULL AND projectId IS NULL); if
your ORM/version doesn't support `@Check` on the entity, create a migration that
adds the equivalent SQL CHECK constraint referencing the projectId and
organisationId columns to enforce the XOR invariant.

In `@backend/migrations/mysql/2026_03_09_pending_invitations.sql`:
- Around line 2-15: The pending_issue is missing FOREIGN KEY constraints on
pending_invitation which breaks referential integrity; update the
pending_invitation table definition (table name: pending_invitation) to match
referenced id column types (fix column lengths for projectId, organisationId,
inviterId to the same type/length as project.id/organisation.id/user.id—likely
varchar(36)), then add FOREIGN KEY clauses referencing project(id),
organisation(id) and user(id) with appropriate ON DELETE behavior (e.g., ON
DELETE CASCADE for inviterId, and either ON DELETE CASCADE or ON DELETE SET NULL
for projectId/organisationId—make those columns NULLABLE if using SET NULL);
alternatively, if persistence after deletion is intentional, add an inline
comment in the migration explaining that design choice.

In `@web/app/routes/signup_.invitation`.$id.tsx:
- Around line 119-121: The current email check using `!email ||
!email.includes('@')` (in the signup invitation handler where `email` is
validated and `fieldErrors.email` is set) is too permissive; replace it with a
stronger validation (e.g., test `email` against a simple robust regex such as
one that requires non-space local part, an '@', and a domain with a dot like
/^[^\s@]+@[^\s@]+\.[^\s@]+$/ or use a validation helper/library) and update the
conditional that sets `fieldErrors.email` so only truly invalid formats trigger
the error message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 924e5b1f-8bc4-4858-910f-69cc5fde08df

📥 Commits

Reviewing files that changed from the base of the PR and between 02872e5 and 417f45f.

📒 Files selected for processing (38)
  • backend/apps/cloud/src/app.module.ts
  • backend/apps/cloud/src/auth/auth.controller.ts
  • backend/apps/cloud/src/auth/auth.module.ts
  • backend/apps/cloud/src/auth/auth.service.ts
  • backend/apps/cloud/src/auth/dtos/index.ts
  • backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts
  • backend/apps/cloud/src/common/templates/en/organisation-invitation-unregistered.html
  • backend/apps/cloud/src/common/templates/en/project-invitation-unregistered.html
  • backend/apps/cloud/src/mailer/letter.ts
  • backend/apps/cloud/src/mailer/mailer.service.ts
  • backend/apps/cloud/src/organisation/organisation.controller.ts
  • backend/apps/cloud/src/organisation/organisation.module.ts
  • backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts
  • backend/apps/cloud/src/pending-invitation/pending-invitation.module.ts
  • backend/apps/cloud/src/pending-invitation/pending-invitation.service.ts
  • backend/apps/cloud/src/project/project.controller.ts
  • backend/apps/cloud/src/project/project.module.ts
  • backend/apps/cloud/src/user/entities/user.entity.ts
  • backend/migrations/mysql/2026_03_09_pending_invitations.sql
  • docs/content/docs/sitesettings/how-to-invite-users-to-your-website.mdx
  • web/app/api/api.server.ts
  • web/app/lib/models/User.ts
  • web/app/pages/Auth/Signup/InvitationSignup.tsx
  • web/app/pages/Dashboard/AddProject.tsx
  • web/app/pages/Dashboard/Dashboard.tsx
  • web/app/routes/dashboard.tsx
  • web/app/routes/login.tsx
  • web/app/routes/signup_.invitation.$id.tsx
  • web/app/routes/subscribe.tsx
  • web/app/ui/Input.tsx
  • web/app/ui/Text.tsx
  • web/app/utils/auth.ts
  • web/app/utils/routes.ts
  • web/public/locales/de.json
  • web/public/locales/en.json
  • web/public/locales/fr.json
  • web/public/locales/pl.json
  • web/public/locales/uk.json

Comment on lines +663 to +672
const updatedUser = await this.userService.findUserById(newUser.id)

const [sharedProjects, organisationMemberships] = await Promise.all([
this.authService.getSharedProjectsForUser(newUser.id),
this.userService.getOrganisationsForUser(newUser.id),
])

updatedUser.sharedProjects = sharedProjects
updatedUser.organisationMemberships = organisationMemberships

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential null reference: updatedUser could be null.

findUserById may return null (e.g., in case of a database issue), but lines 670-671 unconditionally assign properties to updatedUser. This would throw a runtime error.

🛡️ Proposed fix
     const updatedUser = await this.userService.findUserById(newUser.id)
 
+    if (!updatedUser) {
+      throw new ConflictException('Failed to retrieve user after registration')
+    }
+
     const [sharedProjects, organisationMemberships] = await Promise.all([
       this.authService.getSharedProjectsForUser(newUser.id),
       this.userService.getOrganisationsForUser(newUser.id),
     ])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const updatedUser = await this.userService.findUserById(newUser.id)
const [sharedProjects, organisationMemberships] = await Promise.all([
this.authService.getSharedProjectsForUser(newUser.id),
this.userService.getOrganisationsForUser(newUser.id),
])
updatedUser.sharedProjects = sharedProjects
updatedUser.organisationMemberships = organisationMemberships
const updatedUser = await this.userService.findUserById(newUser.id)
if (!updatedUser) {
throw new ConflictException('Failed to retrieve user after registration')
}
const [sharedProjects, organisationMemberships] = await Promise.all([
this.authService.getSharedProjectsForUser(newUser.id),
this.userService.getOrganisationsForUser(newUser.id),
])
updatedUser.sharedProjects = sharedProjects
updatedUser.organisationMemberships = organisationMemberships
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/auth/auth.controller.ts` around lines 663 - 672, The
code calls userService.findUserById and then unconditionally assigns
updatedUser.sharedProjects and updatedUser.organisationMemberships, but
findUserById may return null; guard against a null updatedUser by checking its
existence before assigning (e.g., if (!updatedUser) { handle/error/throw }):
locate the updatedUser variable and the call to userService.findUserById, and
ensure you either throw or return an appropriate error/response when updatedUser
is null before using authService.getSharedProjectsForUser and
userService.getOrganisationsForUser results or move those calls after the null
check so you only assign properties on a non-null updatedUser.

Comment on lines +208 to +225
const project = await this.projectService.findOne({
where: { id: pending.projectId },
relations: ['share'],
})

if (project) {
const alreadyShared = project.share?.some(
(s) => s.user?.id === user.id,
)

if (!alreadyShared) {
const share = new ProjectShare()
share.role = pending.role as any
share.user = user
share.project = project
share.confirmed = true

await this.projectService.createShare(share)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ProjectShare entity relation config =="
fd 'project-share.entity.ts$' | while read -r file; do
  echo "-- $file"
  rg -n -C3 'ManyToOne|OneToMany|eager|user' "$file"
done

echo
echo "== Call sites that rely on share.user or explicit share relations =="
rg -n -C3 "share\\.user\\?\\.id|relations: \\[[^]]*share|share\\.user" backend/apps/cloud/src

Repository: Swetrix/swetrix

Length of output: 12168


Load share.user when checking for duplicate shares.

The ProjectShare.user relation is not eager-loaded. The query at line 210 loads relations: ['share'] but the code at line 214-217 accesses s.user?.id without loading the nested user. This check will always fail to detect existing shares, allowing duplicates.

All other call sites in the codebase explicitly load share.user (e.g., project.controller.ts:1085, project.service.ts:247). This code must do the same.

🛠️ Proposed fix
         const project = await this.projectService.findOne({
           where: { id: pending.projectId },
-          relations: ['share'],
+          relations: ['share', 'share.user'],
         })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const project = await this.projectService.findOne({
where: { id: pending.projectId },
relations: ['share'],
})
if (project) {
const alreadyShared = project.share?.some(
(s) => s.user?.id === user.id,
)
if (!alreadyShared) {
const share = new ProjectShare()
share.role = pending.role as any
share.user = user
share.project = project
share.confirmed = true
await this.projectService.createShare(share)
const project = await this.projectService.findOne({
where: { id: pending.projectId },
relations: ['share', 'share.user'],
})
if (project) {
const alreadyShared = project.share?.some(
(s) => s.user?.id === user.id,
)
if (!alreadyShared) {
const share = new ProjectShare()
share.role = pending.role as any
share.user = user
share.project = project
share.confirmed = true
await this.projectService.createShare(share)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/auth/auth.service.ts` around lines 208 - 225, The
duplicate-share check fails because projectService.findOne only loads relations:
['share'] but not the nested share.user; update the findOne call used here
(where pending.projectId is loaded) to include the nested relation for users
(e.g., relations: ['share', 'share.user']) so that the check using
project.share?.some(s => s.user?.id === user.id) can correctly detect existing
ProjectShare entries; ensure ProjectShare and methods like
this.projectService.findOne and this.projectService.createShare remain unchanged
except for adding the nested relation.

Comment on lines +218 to +254
try {
const pendingInvitation = await this.pendingInvitationService.create({
email: inviteDTO.email,
type: PendingInvitationType.ORGANISATION_MEMBER,
organisationId: orgId,
role: inviteDTO.role,
inviterId: userId,
})

const origin =
isDevelopment && typeof headers?.origin === 'string'
? headers.origin
: PRODUCTION_ORIGIN
const url = `${origin}/signup/invitation/${pendingInvitation.id}?email=${encodeURIComponent(inviteDTO.email)}`

await this.mailerService.sendEmail(
inviteDTO.email,
LetterTemplate.OrganisationInvitationUnregistered,
{
url,
email: user.email,
name: organisation.name,
role: inviteDTO.role,
},
)

return await this.organisationService.findOne({
where: { id: orgId },
relations: ['members', 'members.user'],
})
} catch (reason) {
this.logger.error(
{ orgId: organisation?.id, email: inviteDTO.email, reason },
'Could not create pending invitation for organisation',
)
throw new BadRequestException('Failed to invite member to organisation')
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Rollback only pre-send failures in the unregistered invite flow.

create() persists immediately, so a mailer failure leaves a dangling pending invitation that blocks every retry via existingPending. Keeping the post-send return path in the same try also means a successfully sent email can still surface as a failed invite.

🛠️ Proposed fix
-      try {
-        const pendingInvitation = await this.pendingInvitationService.create({
+      let pendingInvitation
+      try {
+        pendingInvitation = await this.pendingInvitationService.create({
           email: inviteDTO.email,
           type: PendingInvitationType.ORGANISATION_MEMBER,
           organisationId: orgId,
           role: inviteDTO.role,
           inviterId: userId,
@@
         await this.mailerService.sendEmail(
           inviteDTO.email,
           LetterTemplate.OrganisationInvitationUnregistered,
           {
             url,
@@
             role: inviteDTO.role,
           },
         )
-
-        return await this.organisationService.findOne({
-          where: { id: orgId },
-          relations: ['members', 'members.user'],
-        })
       } catch (reason) {
+        if (pendingInvitation?.id) {
+          await this.pendingInvitationService
+            .delete(pendingInvitation.id)
+            .catch(() => undefined)
+        }
         this.logger.error(
           { orgId: organisation?.id, email: inviteDTO.email, reason },
           'Could not create pending invitation for organisation',
         )
         throw new BadRequestException('Failed to invite member to organisation')
       }
+
+      return organisation
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/organisation/organisation.controller.ts` around lines
218 - 254, The current flow creates a PendingInvitation via
pendingInvitationService.create then does mailerService.sendEmail inside the
same try/catch, which can leave a dangling DB record if send fails and also can
mis-report success; change it to first create the pendingInvitation, then wrap
only the mail send in a try/catch: call mailerService.sendEmail(...) and on
failure call pendingInvitationService.delete(pendingInvitation.id) (or
pendingInvitationService.remove/purge equivalent) to rollback the created
invitation, log the error with reason and email/orgId, then throw the
BadRequestException; only after a successful send call
organisationService.findOne(...) and return that result. Ensure you reference
pendingInvitation.id when deleting and keep logging using this.logger.error with
{ orgId: organisation?.id, email: inviteDTO.email, reason }.

@Blaumaus Blaumaus merged commit 08e80f7 into main Mar 9, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant