diff --git a/MIGRATIONS.md b/MIGRATIONS.md
index c83385844..e4a6ca4a9 100644
--- a/MIGRATIONS.md
+++ b/MIGRATIONS.md
@@ -4,6 +4,27 @@ Breaking changes and upgrade notes for downstream projects.
---
+## Remove organization email-invite feature (2026-06-10)
+
+Phase 4 of the invitations↔org decouple epic (#3812). The organization's **own** email-invite flow is **deleted** — distinct from the platform `invitations` module (single-use signup-token gate), which is unchanged. This is the owner-invites-an-email-to-join-their-org flow that lived on the membership doc as `status:'invited'` + `inviteToken` / `invitedEmail` / `inviteExpiresAt`. The 2-step "invite to platform, then add the resulting user as a member" flow replaces it (`org.addMember` lands in a later phase / #3813).
+
+### What changed (this repo)
+
+- **Routes removed** (now 404): `POST /api/organizations/:organizationId/invites`, `GET /api/invites/:token`, `POST /api/invites/:token/accept`. Org **join-requests** (`/requests`, `/requests/:id/approve`, `/requests/:id/reject`, `/membership-requests/mine`) and member CRUD are **unchanged**.
+- **Service/controller** (`organizations.membership.service.js`, `organizations.membershipRequest.{controller,routes}.js`) — `invite` / `acceptInvite` / `getInvite` deleted.
+- **Membership model** (`organizations.membership.model.mongoose.js`) — dropped the `inviteToken` / `invitedEmail` / `inviteExpiresAt` fields, removed `'invited'` (and the never-written `'rejected'`) from the `status` enum, and removed the sparse `inviteToken_1` index. The partial-unique `(userId, organizationId)`, the `organizationId`, and the `(organizationId, status)` indexes are kept.
+- **Constants** — `MEMBERSHIP_STATUSES` now `{ ACTIVE, PENDING }` (`INVITED` and the dead `REJECTED` removed; `rejectRequest` hard-deletes the doc, so `rejected` was never written).
+- **Template** `config/templates/org-invite.html` deleted.
+- **Migration** `modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js`: deletes leftover `status:'invited'` memberships (often `userId:null` orphans), unsets the removed invite fields on any survivor, and drops the `inviteToken_1` index (idempotent; absent index swallowed).
+
+### Action required for downstream projects (`/update-project`)
+
+1. The module/model/migration changes are devkit-owned → arrive via `/update-stack` (`--theirs`).
+2. The migration runs at boot before `listen()` and removes any leftover org `invited` memberships + drops the index automatically. (Trawl has ~1 such test row → handled in epic Phase 9 / #3815.)
+3. No platform-invitation (`sign.cap` / `?inviteToken=` signup gate) behavior changes. Any downstream UI calling the removed `/invites` org routes must migrate to the add-member flow (#3813 / Vue #4280).
+
+---
+
## Invitations hardening: case-insensitive unique email index + two-phase invite claim (2026-06-10)
Phase 3 of the invitations↔org decouple epic (#3811). Two downstream-relevant changes.
@@ -757,9 +778,11 @@ None for Node (CASL `@casl/ability` was already installed). No new npm packages
| `GET` | `/api/admin/organizations/:organizationId` | JWT+Admin| Platform admin: get org |
| `DELETE` | `/api/admin/organizations/:organizationId` | JWT+Admin| Platform admin: delete org |
| `GET` | `/api/organizations/:organizationId/members` | JWT | List members |
-| `POST` | `/api/organizations/:organizationId/members/invite` | JWT | Invite a member |
| `PUT` | `/api/organizations/:organizationId/members/:memberId` | JWT | Update member role |
| `DELETE` | `/api/organizations/:organizationId/members/:memberId` | JWT | Remove member |
+| `POST` | `/api/organizations/:organizationId/requests` | JWT | Request to join |
+| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/approve` | JWT | Approve join request |
+| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/reject` | JWT | Reject join request |
---
@@ -1061,7 +1084,7 @@ The organizations module follows the standard Devkit module structure:
modules/organizations/
controllers/
organizations.controller.js # CRUD + adminList + organizationByID param middleware
- organizations.membership.controller.js # list, invite, updateRole, remove + memberByID
+ organizations.membership.controller.js # list, updateRole, remove + memberByID
helpers/
slug.js # slugify() + generateOrganizationSlug()
migrations/
@@ -1070,7 +1093,7 @@ modules/organizations/
organizations.model.mongoose.js # Organization Mongoose model (name, slug, domain, plan, createdBy)
organizations.schema.js # Zod validation schema
organizations.membership.model.mongoose.js # Membership Mongoose model (userId, organizationId, role)
- organizations.membership.schema.js # Zod validation (MembershipInvite, MembershipUpdate)
+ organizations.membership.schema.js # Zod validation (MembershipUpdate)
policies/
organizations.policy.js # CASL abilities for Organization + Membership subjects
repositories/
diff --git a/README.md b/README.md
index 21b7351c3..a2af2ab13 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Designed to be cloned into downstream projects and kept up-to-date via `git merg
- **User** : classic register / auth or oAuth (Google, Apple) - profile management (update, avatar upload)
- **User data privacy** : delete all - get all - send all by mail
- **Admin** : list users - get user - edit user - delete user
-- **Organizations** : multi-tenant organization management - create, update, delete orgs - member invite, role management (owner/admin/member) - platform admin org listing
+- **Organizations** : multi-tenant organization management - create, update, delete orgs - join requests (request / approve / reject), role management (owner/admin/member) - platform admin org listing
- **Signup access control** : invite-only signup (single-use token links) + hard account cap (beta gating) - admin-managed invitations, public signup auto-locks at the cap
- **CASL v2 Authorization** : document-level permission checks via [@casl/ability](https://casl.js.org/) - replaces route-level role rules with per-document conditions (ownership, org scope)
- **Migration System** : automatic database migrations at boot - tracks executed scripts in MongoDB - idempotent reruns
@@ -189,9 +189,12 @@ Both file types are optional and can be used independently or together. Per-modu
| `PUT` | `/api/organizations/:organizationId` | JWT | Update organization |
| `DELETE` | `/api/organizations/:organizationId` | JWT | Delete organization |
| `GET` | `/api/organizations/:organizationId/members` | JWT | List members |
-| `POST` | `/api/organizations/:organizationId/members/invite` | JWT | Invite member |
| `PUT` | `/api/organizations/:organizationId/members/:memberId` | JWT | Update member role |
| `DELETE` | `/api/organizations/:organizationId/members/:memberId` | JWT | Remove member |
+| `POST` | `/api/organizations/:organizationId/requests` | JWT | Request to join |
+| `GET` | `/api/organizations/:organizationId/requests` | JWT | List pending join requests |
+| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/approve` | JWT | Approve join request |
+| `PUT` | `/api/organizations/:organizationId/requests/:membershipRequestId/reject` | JWT | Reject join request |
| `GET` | `/api/admin/organizations` | JWT+Admin | List all organizations |
| `GET` | `/api/admin/organizations/:organizationId` | JWT+Admin | Get any organization |
| `DELETE` | `/api/admin/organizations/:organizationId` | JWT+Admin | Delete any organization |
diff --git a/config/templates/org-invite.html b/config/templates/org-invite.html
deleted file mode 100644
index e7ac549c5..000000000
--- a/config/templates/org-invite.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
- Hello,
- {{inviterName}} has invited you to join {{orgName}} on {{appName}}.
- Click here to accept the invitation: {{url}}
- The {{appName}} Team.
-
- If you weren't expecting this invitation, you can ignore this email.
- Contact us here.
-
-
diff --git a/modules/audit/middlewares/audit.middleware.js b/modules/audit/middlewares/audit.middleware.js
index 046da5c00..122a85bfd 100644
--- a/modules/audit/middlewares/audit.middleware.js
+++ b/modules/audit/middlewares/audit.middleware.js
@@ -21,7 +21,7 @@ const OBJECT_ID_RE = /^[0-9a-fA-F]{24}$/;
/**
* Derive a human-readable action from the Express route path.
* e.g. `/api/auth/signin` → `auth.signin`
- * `/api/organizations/:orgId/members/invite` → `organizations.invite`
+ * `/api/organizations/:orgId/members/:memberId` → `organizations.members`
* `/api/billing/checkout` → `billing.checkout`
* @param {string} routePath - The Express matched route path (req.route.path)
* @param {string} baseUrl - The Express baseUrl (req.baseUrl)
diff --git a/modules/audit/tests/audit.middleware.unit.tests.js b/modules/audit/tests/audit.middleware.unit.tests.js
index da95566ae..ccd8a2644 100644
--- a/modules/audit/tests/audit.middleware.unit.tests.js
+++ b/modules/audit/tests/audit.middleware.unit.tests.js
@@ -222,7 +222,7 @@ describe('Audit middleware unit tests:', () => {
test('should derive action from nested route path', () => {
expect(deriveAction('/signin', '/api/auth')).toBe('auth.signin');
expect(deriveAction('/checkout', '/api/billing')).toBe('billing.checkout');
- expect(deriveAction('/:orgId/members/invite', '/api/organizations')).toBe('organizations.invite');
+ expect(deriveAction('/:orgId/members/:memberId', '/api/organizations')).toBe('organizations.members');
expect(deriveAction('/:token', '/api/auth/password/reset')).toBe('auth.reset');
});
diff --git a/modules/organizations/controllers/organizations.membershipRequest.controller.js b/modules/organizations/controllers/organizations.membershipRequest.controller.js
index 53f57329e..8fdb57eb2 100644
--- a/modules/organizations/controllers/organizations.membershipRequest.controller.js
+++ b/modules/organizations/controllers/organizations.membershipRequest.controller.js
@@ -95,63 +95,6 @@ const listMine = async (req, res) => {
}
};
-/**
- * @function invite
- * @description Endpoint to invite a user to an organization by email.
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @returns {Promise}
- */
-const invite = async (req, res) => {
- try {
- const { email } = req.body;
- if (!email) return responses.error(res, 422, 'Unprocessable Entity', 'Email is required')();
- const result = await MembershipService.invite(
- req.organization._id || req.organization.id,
- email,
- req.user,
- );
- responses.success(res, 'invitation sent')(result);
- } catch (err) {
- responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
- }
-};
-
-/**
- * @function acceptInvite
- * @description Endpoint to accept an organization invite by token.
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @returns {Promise}
- */
-const acceptInvite = async (req, res) => {
- try {
- const { token } = req.params;
- const membership = await MembershipService.acceptInvite(token, req.user._id || req.user.id);
- responses.success(res, 'invitation accepted')(membership);
- } catch (err) {
- responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
- }
-};
-
-/**
- * @function getInvite
- * @description Endpoint to get invite details by token.
- * @param {Object} req - Express request object
- * @param {Object} res - Express response object
- * @returns {Promise}
- */
-const getInvite = async (req, res) => {
- try {
- const { token } = req.params;
- const membership = await MembershipService.getInvite(token);
- if (!membership) return responses.error(res, 404, 'Not Found', 'Invalid or expired invite')();
- responses.success(res, 'invite details')(membership);
- } catch (err) {
- responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
- }
-};
-
/**
* @function requestByID
* @description Middleware to fetch a pending membership by its ID.
@@ -182,8 +125,5 @@ export default {
approve,
reject,
listMine,
- invite,
- acceptInvite,
- getInvite,
requestByID,
};
diff --git a/modules/organizations/lib/constants.js b/modules/organizations/lib/constants.js
index a65a1c6e8..3110eb593 100644
--- a/modules/organizations/lib/constants.js
+++ b/modules/organizations/lib/constants.js
@@ -1,8 +1,6 @@
export const MEMBERSHIP_STATUSES = {
ACTIVE: 'active',
PENDING: 'pending',
- INVITED: 'invited',
- REJECTED: 'rejected',
};
export const MEMBERSHIP_ROLES = {
diff --git a/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js b/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js
new file mode 100644
index 000000000..eba03e927
--- /dev/null
+++ b/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js
@@ -0,0 +1,72 @@
+/**
+ * Module dependencies
+ */
+import mongoose from 'mongoose';
+
+const INVITE_TOKEN_INDEX = 'inviteToken_1';
+
+/**
+ * Migration: Remove the organization email-invite feature's leftover data.
+ *
+ * The org-owned email-invite flow (`status:'invited'` memberships carrying an
+ * `inviteToken` / `invitedEmail` / `inviteExpiresAt`) has been deleted. This
+ * migration cleans up any residual data:
+ * - deletes leftover `invited` memberships (often `userId:null` orphans that
+ * would otherwise dangle with no corresponding user);
+ * - unsets the now-removed invite fields on any surviving membership;
+ * - drops the sparse `inviteToken_1` index that backed invite lookups.
+ *
+ * Uses the RAW collection driver (not the Mongoose model) for both writes: the
+ * schema no longer declares `inviteToken` / `invitedEmail` / `inviteExpiresAt`,
+ * so a model-based `updateMany` would have strict-mode strip those keys from the
+ * `$unset` operator — the driver would receive no `$unset` and the legacy fields
+ * would survive forever (the migration would record as executed having changed
+ * nothing). Same gotcha the billing migrations document (see
+ * `20260502100000-*` and `20260503000200-slim-subscription-drop-period-fields`).
+ *
+ * Idempotent: re-running is a no-op (no `invited` rows / no invite fields / the
+ * index already absent).
+ * @returns {Promise} Resolves when the cleanup has completed.
+ */
+export async function up() {
+ // Raw collection driver so neither the deleteMany filter nor the $unset is
+ // stripped by strict-mode schema enforcement (the model no longer declares
+ // the invite fields). Model `Membership` → default collection `memberships`.
+ const db = mongoose.connection.db;
+ const memberships = db.collection('memberships');
+
+ // Org email-invite removed; leftover INVITED rows (often userId:null) become orphans → delete.
+ await memberships.deleteMany({ status: 'invited' });
+
+ await memberships.updateMany(
+ { $or: [{ inviteToken: { $exists: true } }, { invitedEmail: { $exists: true } }, { inviteExpiresAt: { $exists: true } }] },
+ { $unset: { inviteToken: '', invitedEmail: '', inviteExpiresAt: '' } },
+ );
+
+ // Check-then-act so absence is the expected path (not an error to swallow) and
+ // either outcome is observable. listIndexes throws NamespaceNotFound on a fresh
+ // DB with no memberships collection — treat that as "nothing to drop".
+ let indexes = [];
+ try {
+ indexes = await memberships.indexes();
+ } catch (err) {
+ if (err?.codeName === 'NamespaceNotFound' || err?.code === 26) {
+ console.info('[migration] drop-org-invited-memberships: memberships collection does not exist yet — nothing to drop');
+ return;
+ }
+ throw err;
+ }
+
+ if (indexes.some((ix) => ix.name === INVITE_TOKEN_INDEX)) {
+ await memberships.dropIndex(INVITE_TOKEN_INDEX);
+ console.info('[migration] drop-org-invited-memberships: dropped legacy inviteToken_1 index');
+ } else {
+ console.info('[migration] drop-org-invited-memberships: inviteToken_1 index already absent — skipping drop');
+ }
+}
+
+/**
+ * No-op down migration. The dropped data/index cannot be meaningfully restored.
+ * @returns {void}
+ */
+export function down() { console.warn('[migration] drop-org-invited-memberships DOWN: no-op'); }
diff --git a/modules/organizations/models/organizations.membership.model.mongoose.js b/modules/organizations/models/organizations.membership.model.mongoose.js
index a05db458b..e1516e819 100644
--- a/modules/organizations/models/organizations.membership.model.mongoose.js
+++ b/modules/organizations/models/organizations.membership.model.mongoose.js
@@ -27,12 +27,9 @@ const MembershipMongoose = new Schema(
},
status: {
type: String,
- enum: ['active', 'pending', 'rejected', 'invited'],
+ enum: ['active', 'pending'],
default: 'active',
},
- inviteToken: { type: String, default: null },
- invitedEmail: { type: String, default: null },
- inviteExpiresAt: { type: Date, default: null },
},
{
timestamps: true,
@@ -47,11 +44,6 @@ MembershipMongoose.index(
{ unique: true, partialFilterExpression: { userId: { $exists: true, $ne: null } } },
);
-/**
- * Sparse index on inviteToken for invite lookups
- */
-MembershipMongoose.index({ inviteToken: 1 }, { sparse: true });
-
/**
* Single-field index on organizationId for list-by-org queries
*/
diff --git a/modules/organizations/routes/organizations.membershipRequest.routes.js b/modules/organizations/routes/organizations.membershipRequest.routes.js
index dc7868f26..80f6883a8 100644
--- a/modules/organizations/routes/organizations.membershipRequest.routes.js
+++ b/modules/organizations/routes/organizations.membershipRequest.routes.js
@@ -38,24 +38,6 @@ export default (app) => {
.all(passport.authenticate('jwt', { session: false }), organizations.loadMembership, policy.isAllowed)
.put(membershipRequests.reject);
- // Invite a user to an organization (owner/admin)
- app
- .route('/api/organizations/:organizationId/invites')
- .all(passport.authenticate('jwt', { session: false }), organizations.loadMembership, policy.isAllowed)
- .post(membershipRequests.invite);
-
- // Get invite details
- app
- .route('/api/invites/:token')
- .all(passport.authenticate('jwt', { session: false }))
- .get(membershipRequests.getInvite);
-
- // Accept an invite
- app
- .route('/api/invites/:token/accept')
- .all(passport.authenticate('jwt', { session: false }))
- .post(membershipRequests.acceptInvite);
-
// Bind param middleware
app.param('membershipRequestId', membershipRequests.requestByID);
};
diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js
index e7b4ecb6e..772e0dc4b 100644
--- a/modules/organizations/services/organizations.membership.service.js
+++ b/modules/organizations/services/organizations.membership.service.js
@@ -1,8 +1,6 @@
/**
* Module dependencies
*/
-import crypto from 'crypto';
-
import config from '../../../config/index.js';
import logger from '../../../lib/services/logger.js';
import getBaseUrl from '../../../lib/helpers/getBaseUrl.js';
@@ -283,120 +281,6 @@ const leave = async (userId, organizationId) => {
return { success: true };
};
-/**
- * @function invite
- * @description Invite a user to an organization by email. Creates an invited membership with a token.
- * @param {String} organizationId - The ID of the organization.
- * @param {String} email - The email address to invite.
- * @param {Object} invitedBy - The user object of the inviter.
- * @returns {Promise