From bf3ba7a35382c5cf373d6af09324ac5b30170e2c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 10 Jun 2026 20:20:53 +0200 Subject: [PATCH 1/3] feat(organizations): remove org email-invite feature + data migration Delete the organization-owned email-invite flow (owner invites an email to join their org with a role, carried on the membership doc as status:'invited' + inviteToken/invitedEmail/inviteExpiresAt). The platform invitations module, the auth signup gate, and org join-requests are untouched. The 2-step "invite to platform then addMember" flow replaces it (addMember lands in P5a). - service/controller: drop invite/acceptInvite/getInvite (+ exports), remove unused crypto import - routes: drop POST /organizations/:id/invites, GET /invites/:token, POST /invites/:token/accept - model: drop inviteToken/invitedEmail/inviteExpiresAt fields, drop 'invited' (+ never-written 'rejected') from status enum, drop sparse inviteToken_1 index; keep partial-unique (userId,organizationId) + org/status indexes - constants: MEMBERSHIP_STATUSES -> { ACTIVE, PENDING } - delete config/templates/org-invite.html - migration 20260610130000-drop-org-invited-memberships: delete leftover 'invited' rows, unset invite fields, drop inviteToken_1 index (idempotent) - docs: fix stale /members/invite route + MembershipInvite zod refs in README/MIGRATIONS, correct audit middleware doc-comment example - remove invite-lifecycle e2e (join-request lifecycle e2e stays green) Refs #3812 --- MIGRATIONS.md | 29 ++- README.md | 7 +- config/templates/org-invite.html | 13 -- modules/audit/middlewares/audit.middleware.js | 2 +- ...anizations.membershipRequest.controller.js | 60 ----- modules/organizations/lib/constants.js | 2 - ...0610130000-drop-org-invited-memberships.js | 36 +++ ...organizations.membership.model.mongoose.js | 10 +- .../organizations.membershipRequest.routes.js | 18 -- .../organizations.membership.service.js | 119 ---------- .../organizations.lifecycle.e2e.tests.js | 214 ------------------ 11 files changed, 69 insertions(+), 441 deletions(-) delete mode 100644 config/templates/org-invite.html create mode 100644 modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js delete mode 100644 modules/organizations/tests/organizations.lifecycle.e2e.tests.js diff --git a/MIGRATIONS.md b/MIGRATIONS.md index c83385844..8ea9cad57 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,controller}.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/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..98ce4beac --- /dev/null +++ b/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js @@ -0,0 +1,36 @@ +/** + * Module dependencies + */ +import mongoose from 'mongoose'; + +/** + * 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. + * + * Idempotent: re-running is a no-op (no `invited` rows / no invite fields / the + * index already absent — a missing index is swallowed). + * @returns {Promise} Resolves when the cleanup has completed. + */ +export async function up() { + const Membership = mongoose.model('Membership'); + // Org email-invite removed; leftover INVITED rows (often userId:null) become orphans → delete. + await Membership.deleteMany({ status: 'invited' }); + await Membership.updateMany( + { $or: [{ inviteToken: { $exists: true } }, { invitedEmail: { $exists: true } }, { inviteExpiresAt: { $exists: true } }] }, + { $unset: { inviteToken: '', invitedEmail: '', inviteExpiresAt: '' } }, + ); + try { await Membership.collection.dropIndex('inviteToken_1'); } catch (e) { /* absent is fine */ } +} + +/** + * 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} The created membership and invite token. - */ -const invite = async (organizationId, email, invitedBy) => { - // Check for existing invited membership by email first (handles non-registered users) - const existingInvite = await MembershipRepository.findOne({ - invitedEmail: email.toLowerCase(), - organizationId, - status: MEMBERSHIP_STATUSES.INVITED, - }); - if (existingInvite) throw new Error('An invite has already been sent to this email'); - - const existingUser = await UserService.findByEmail(email); - if (existingUser) { - const existingMembership = await MembershipRepository.findOne({ - userId: existingUser._id, - organizationId, - status: { $in: [MEMBERSHIP_STATUSES.ACTIVE, MEMBERSHIP_STATUSES.PENDING, MEMBERSHIP_STATUSES.INVITED] }, - }); - if (existingMembership) throw new Error('User is already a member or has a pending request'); - } - - const inviteToken = crypto.randomBytes(20).toString('hex'); - const membership = await MembershipRepository.create({ - userId: existingUser ? existingUser._id : null, - organizationId, - role: MEMBERSHIP_ROLES.MEMBER, - status: MEMBERSHIP_STATUSES.INVITED, - inviteToken, - invitedEmail: email.toLowerCase(), - inviteExpiresAt: new Date(Date.now() + 7 * 24 * 3600000), - }); - - if (mailer.isConfigured()) { - const org = await OrganizationRepository.get(organizationId); - if (org?.name) { - try { - await mailer.sendMail({ - to: email, - subject: `You've been invited to join ${org.name}`, - template: 'org-invite', - params: { - inviterName: [invitedBy.firstName, invitedBy.lastName].filter(Boolean).join(' '), - orgName: org.name, - url: `${getBaseUrl()}/invite?token=${inviteToken}`, - appName: config.app.title, - appContact: config.mailer.from, - }, - }); - } catch { - // Clean up persisted membership so the invite can be retried - await MembershipRepository.remove(membership); - throw new Error('Failed to send invite email'); - } - } - } - - return { membership, inviteToken }; -}; - -/** - * @function acceptInvite - * @description Accept an organization invite by token. Sets the membership to active. - * @param {String} token - The invite token. - * @param {String} userId - The ID of the accepting user. - * @returns {Promise} The updated membership. - */ -const acceptInvite = async (token, userId) => { - const membership = await MembershipRepository.findOne({ inviteToken: token, status: MEMBERSHIP_STATUSES.INVITED }); - if (!membership) throw new Error('Invalid or expired invite'); - - if (membership.inviteExpiresAt && membership.inviteExpiresAt < Date.now()) { - throw new Error('Invite has expired'); - } - - // Verify the accepting user matches the intended invite recipient - const user = await UserService.getBrut({ id: String(userId) }); - if (!user) throw new Error('User not found'); - - const invitedUserId = membership.userId?._id || membership.userId; - if (invitedUserId && String(invitedUserId) !== String(userId)) { - throw new Error('This invite belongs to another user'); - } - if (membership.invitedEmail && membership.invitedEmail.toLowerCase() !== user.email.toLowerCase()) { - throw new Error('This invite belongs to another email address'); - } - - membership.userId = userId; - membership.status = MEMBERSHIP_STATUSES.ACTIVE; - membership.inviteToken = null; - const result = await MembershipRepository.update(membership); - - if (user && !user.currentOrganization) { - await UserService.updateById(user._id, { - currentOrganization: membership.organizationId._id || membership.organizationId, - }); - } - - return result; -}; - -/** - * @function getInvite - * @description Get invite details by token. - * @param {String} token - The invite token. - * @returns {Promise} The invited membership or null. - */ -const getInvite = (token) => MembershipRepository.findOne({ inviteToken: token, status: MEMBERSHIP_STATUSES.INVITED }); - /** * @function count * @description Service to count memberships matching a filter. @@ -444,9 +328,6 @@ export default { createJoinRequest, approveRequest, rejectRequest, - invite, - acceptInvite, - getInvite, count, aggregateCountByOrganizations, deleteMany, diff --git a/modules/organizations/tests/organizations.lifecycle.e2e.tests.js b/modules/organizations/tests/organizations.lifecycle.e2e.tests.js deleted file mode 100644 index e1147d3ca..000000000 --- a/modules/organizations/tests/organizations.lifecycle.e2e.tests.js +++ /dev/null @@ -1,214 +0,0 @@ -/** - * @desc E2E tests for the full organization lifecycle flow. - * Tests: signup → invite → accept → promote → leave. - */ -import request from 'supertest'; -import path from 'path'; - -import { bootstrap } from '../../../lib/app.js'; -import mongooseService from '../../../lib/services/mongoose.js'; -import config from '../../../config/index.js'; - -describe('Organizations lifecycle E2E tests:', () => { - let UserService; - let OrganizationsRepository; - let MembershipRepository; - let agent; - - // Store original config - const originalOrganizations = { ...config.organizations }; - - /** - * @description Reset organizations config to original state. - */ - const resetOrgConfig = () => { - config.organizations = { ...originalOrganizations }; - }; - - /** - * @description Clean up a user and their associated organizations/memberships. - * @param {Object} user - The user object to clean up. - */ - const cleanupUser = async (user) => { - if (!user) return; - try { - const memberships = await MembershipRepository.list({ userId: user.id || user._id }); - for (const m of memberships) { - const orgId = m.organizationId._id || m.organizationId; - await MembershipRepository.deleteMany({ organizationId: orgId }); - await OrganizationsRepository.deleteMany({ _id: orgId }); - } - await UserService.remove(user); - } catch (_) { /* cleanup — ignore errors */ } - }; - - beforeAll(async () => { - try { - const init = await bootstrap(); - UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; - OrganizationsRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.repository.js'))).default; - MembershipRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.membership.repository.js'))).default; - agent = request.agent(init.app); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - }); - - describe('Full invite lifecycle', () => { - let userA; - let userB; - let orgA; - let agentA; - let agentB; - - afterAll(async () => { - // Clean up remaining data - try { - if (orgA) { - await MembershipRepository.deleteMany({ organizationId: orgA._id }); - await OrganizationsRepository.deleteMany({ _id: orgA._id }); - } - } catch (_) { /* cleanup */ } - // Clean up user B's auto-created org - await cleanupUser(userB); - await cleanupUser(userA); - }); - - test('should complete the full invite lifecycle: signup → invite → accept → promote → leave', async () => { - config.organizations = { enabled: true, autoCreate: true, domainMatching: false }; - agentA = request.agent(agent.app); - agentB = request.agent(agent.app); - - // Step 1: Signup user A (creates org automatically) - try { - const resultA = await agentA - .post('/api/auth/signup') - .send({ - firstName: 'LifecycleA', - lastName: 'User', - email: 'lifecycle-a@test.com', - password: 'W@os.jsI$Aw3$0m3', - provider: 'local', - }) - .expect(200); - - userA = resultA.body.user; - orgA = resultA.body.organization; - expect(orgA).toBeDefined(); - expect(orgA).not.toBeNull(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 2: User A invites user B by email - let inviteToken; - try { - const inviteResult = await agentA - .post(`/api/organizations/${orgA._id}/invites`) - .send({ email: 'lifecycle-b@test.com' }) - .expect(200); - - expect(inviteResult.body.message).toBe('invitation sent'); - inviteToken = inviteResult.body.data.inviteToken; - expect(inviteToken).toBeDefined(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 3: Signup user B - try { - const resultB = await agentB - .post('/api/auth/signup') - .send({ - firstName: 'LifecycleB', - lastName: 'User', - email: 'lifecycle-b@test.com', - password: 'W@os.jsI$Aw3$0m3', - provider: 'local', - }) - .expect(200); - - userB = resultB.body.user; - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 4: User B accepts the invite - try { - const acceptResult = await agentB - .post(`/api/invites/${inviteToken}/accept`) - .expect(200); - - expect(acceptResult.body.message).toBe('invitation accepted'); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 5: Verify user B is now active member - try { - const membersResult = await agentA - .get(`/api/organizations/${orgA._id}/members`) - .expect(200); - - const memberB = membersResult.body.data.find( - (m) => m.userId && m.userId.email === 'lifecycle-b@test.com', - ); - expect(memberB).toBeDefined(); - expect(memberB.status).toBe('active'); - expect(memberB.role).toBe('member'); - - // Step 6: User A promotes user B to owner - const promoteResult = await agentA - .put(`/api/organizations/${orgA._id}/members/${memberB._id}`) - .send({ role: 'owner' }) - .expect(200); - - expect(promoteResult.body.data.role).toBe('owner'); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 7: User A leaves the org - try { - const leaveResult = await agentA - .post(`/api/organizations/${orgA._id}/leave`) - .expect(200); - - expect(leaveResult.body.message).toBe('organization left'); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - - // Step 8: Verify user A membership is deleted - try { - const membershipA = await MembershipRepository.findOne({ - userId: userA.id, - organizationId: orgA._id, - status: 'active', - }); - expect(membershipA).toBeNull(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - }); - }); - - // Mongoose disconnect - afterAll(async () => { - resetOrgConfig(); - try { - await mongooseService.disconnect(); - } catch (err) { - console.log(err); - expect(err).toBeFalsy(); - } - }); -}); From 44d3ba2804ff62cbd668918eded9c49e557da7aa Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 10 Jun 2026 20:41:38 +0200 Subject: [PATCH 2/3] fix(organizations): migration $unset via raw collection driver (strict-mode strip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop-org-invited-memberships migration unset the legacy invite fields (inviteToken/invitedEmail/inviteExpiresAt) through the Mongoose model. Since the schema no longer declares those fields, strict mode strips them from the $unset operator, so the driver receives no $unset and the fields survive on any surviving membership forever (the migration records as executed having changed nothing). Switch both writes to the raw collection driver, the canonical fix the billing migrations already document. - migration: $unset (and deleteMany) via db.collection('memberships') instead of the model, with a comment on the strict-mode gotcha - migration: dropIndex now check-then-act via indexes() with skip-if-absent logging + narrowed NamespaceNotFound catch (was a bare catch swallowing all errors) — matches the P3 email-index migration house style - add regression integration test: inserts a raw membership carrying the 3 invite fields + a status:'invited' row, runs up(), asserts via a RAW read that the fields are gone and the invited row is deleted (a model read would mask schema-less fields and hide the bug — verified it fails on the old model $unset) - audit test: feed the live route /:orgId/members/:memberId (organizations.members) instead of the removed /:orgId/members/invite, mirroring the corrected doc-comment Refs #3812 --- .../tests/audit.middleware.unit.tests.js | 2 +- ...0610130000-drop-org-invited-memberships.js | 46 ++++++- ...Memberships.migration.integration.tests.js | 113 ++++++++++++++++++ 3 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 modules/organizations/tests/organizations.dropInvitedMemberships.migration.integration.tests.js 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/migrations/20260610130000-drop-org-invited-memberships.js b/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js index 98ce4beac..eba03e927 100644 --- a/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js +++ b/modules/organizations/migrations/20260610130000-drop-org-invited-memberships.js @@ -3,6 +3,8 @@ */ import mongoose from 'mongoose'; +const INVITE_TOKEN_INDEX = 'inviteToken_1'; + /** * Migration: Remove the organization email-invite feature's leftover data. * @@ -14,19 +16,53 @@ import mongoose from 'mongoose'; * - 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 — a missing index is swallowed). + * index already absent). * @returns {Promise} Resolves when the cleanup has completed. */ export async function up() { - const Membership = mongoose.model('Membership'); + // 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 Membership.deleteMany({ status: 'invited' }); - await Membership.updateMany( + await memberships.deleteMany({ status: 'invited' }); + + await memberships.updateMany( { $or: [{ inviteToken: { $exists: true } }, { invitedEmail: { $exists: true } }, { inviteExpiresAt: { $exists: true } }] }, { $unset: { inviteToken: '', invitedEmail: '', inviteExpiresAt: '' } }, ); - try { await Membership.collection.dropIndex('inviteToken_1'); } catch (e) { /* absent is fine */ } + + // 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'); + } } /** diff --git a/modules/organizations/tests/organizations.dropInvitedMemberships.migration.integration.tests.js b/modules/organizations/tests/organizations.dropInvitedMemberships.migration.integration.tests.js new file mode 100644 index 000000000..89f4150cd --- /dev/null +++ b/modules/organizations/tests/organizations.dropInvitedMemberships.migration.integration.tests.js @@ -0,0 +1,113 @@ +/** + * Module dependencies. + */ +import mongoose from 'mongoose'; + +import { beforeAll, afterAll, describe, test, expect } from '@jest/globals'; +import { bootstrap } from '../../../lib/app.js'; +import mongooseService from '../../../lib/services/mongoose.js'; + +import { up } from '../migrations/20260610130000-drop-org-invited-memberships.js'; + +/** + * Regression: `20260610130000-drop-org-invited-memberships` must unset the legacy + * invite fields via the RAW collection driver. + * + * The Membership schema no longer declares `inviteToken` / `invitedEmail` / + * `inviteExpiresAt`. A model-based `Membership.updateMany(..., { $unset })` would + * have strict mode strip those keys from the update operator → the driver receives + * no `$unset` and the fields survive (the bug this test guards). The migration + * therefore writes via `db.collection('memberships')`. + * + * Crucial: docs are inserted AND read back via the raw collection. A model-based + * read would project schema-less fields to `undefined` regardless of whether the + * physical document still carries them — it would hide the bug. Reading raw is the + * only way this test fails when the fix is reverted to the model-based `$unset`. + */ +describe('Migration drop-org-invited-memberships (raw $unset regression):', () => { + let memberships; + const orgId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + // Stable ids so we can target our own fixtures for read-back + cleanup. + const survivingId = new mongoose.Types.ObjectId(); + const invitedId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + await bootstrap(); + memberships = mongoose.connection.db.collection('memberships'); + + // Clean any prior fixtures (idempotent across local re-runs). + await memberships.deleteMany({ _id: { $in: [survivingId, invitedId] } }); + + // (1) A SURVIVING active membership that still physically carries the three + // legacy invite fields — inserted raw to bypass schema stripping. + await memberships.insertOne({ + _id: survivingId, + userId, + organizationId: orgId, + role: 'member', + status: 'active', + inviteToken: 'legacy-token-abc123', + invitedEmail: 'legacy@example.com', + inviteExpiresAt: new Date('2026-01-01T00:00:00.000Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + // (2) A leftover `status:'invited'` orphan row that the migration must DELETE. + await memberships.insertOne({ + _id: invitedId, + userId: null, + organizationId: orgId, + role: 'member', + status: 'invited', + inviteToken: 'orphan-token-xyz789', + invitedEmail: 'orphan@example.com', + inviteExpiresAt: new Date('2026-01-01T00:00:00.000Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Run the migration under test. + await up(); + }); + + afterAll(async () => { + try { + await memberships.deleteMany({ _id: { $in: [survivingId, invitedId] } }); + } catch (_) { /* cleanup */ } + try { + await mongooseService.disconnect(); + } catch (e) { + console.log(e); + expect(e).toBeFalsy(); + } + }); + + test('unsets inviteToken / invitedEmail / inviteExpiresAt on the surviving row (read RAW)', async () => { + // Read via the raw collection — a model read would mask schema-less fields. + const doc = await memberships.findOne({ _id: survivingId }); + + expect(doc).not.toBeNull(); + // The row itself survives (it was active, not invited). + expect(doc.status).toBe('active'); + // The three legacy fields are physically gone from the document. + expect(doc).not.toHaveProperty('inviteToken'); + expect(doc).not.toHaveProperty('invitedEmail'); + expect(doc).not.toHaveProperty('inviteExpiresAt'); + }); + + test("deletes the leftover status:'invited' row", async () => { + const orphan = await memberships.findOne({ _id: invitedId }); + expect(orphan).toBeNull(); + }); + + test('is idempotent — a second run is a no-op and leaves the surviving row clean', async () => { + await up(); + const doc = await memberships.findOne({ _id: survivingId }); + expect(doc).not.toBeNull(); + expect(doc).not.toHaveProperty('inviteToken'); + expect(doc).not.toHaveProperty('invitedEmail'); + expect(doc).not.toHaveProperty('inviteExpiresAt'); + }); +}); From 0e0f81d4e880014622d384f8127dbe3ffd510d2a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 10 Jun 2026 20:51:43 +0200 Subject: [PATCH 3/3] fix(organizations): correct MIGRATIONS.md file refs + add leave unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MIGRATIONS.md: fix service/controller file names (was membership.controller, is membershipRequest.controller + membershipRequest.routes) - organizations.controller.unit.tests.js: add leave describe block (3 tests) covering success, last-owner 422, and non-member 422 — restores coverage removed when lifecycle.e2e.tests.js was deleted in this PR --- MIGRATIONS.md | 2 +- .../organizations.controller.unit.tests.js | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 8ea9cad57..e4a6ca4a9 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -11,7 +11,7 @@ Phase 4 of the invitations↔org decouple epic (#3812). The organization's **own ### 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,controller}.js`) — `invite` / `acceptInvite` / `getInvite` deleted. +- **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. diff --git a/modules/organizations/tests/organizations.controller.unit.tests.js b/modules/organizations/tests/organizations.controller.unit.tests.js index 779eadcd8..59c7925e8 100644 --- a/modules/organizations/tests/organizations.controller.unit.tests.js +++ b/modules/organizations/tests/organizations.controller.unit.tests.js @@ -5,6 +5,7 @@ import { jest, describe, test, expect, beforeEach } from '@jest/globals'; const mockCrudRemove = jest.fn(); const mockListByUser = jest.fn(); +const mockLeave = jest.fn(); jest.unstable_mockModule('../services/organizations.crud.service.js', () => ({ default: { @@ -15,6 +16,7 @@ jest.unstable_mockModule('../services/organizations.crud.service.js', () => ({ jest.unstable_mockModule('../services/organizations.membership.service.js', () => ({ default: { listByUser: mockListByUser, + leave: mockLeave, }, })); @@ -117,4 +119,39 @@ describe('Organizations controller unit tests:', () => { expect(res.status).not.toHaveBeenCalledWith(422); }); }); + + describe('leave', () => { + test('should call MembershipService.leave with user and org ids and return success', async () => { + mockLeave.mockResolvedValue({ success: true }); + const req = mockReq(); + const res = mockRes(); + + await organizationsController.leave(req, res); + + expect(mockLeave).toHaveBeenCalledWith('u1', 'org1'); + expect(res.status).not.toHaveBeenCalledWith(422); + }); + + test('should return 422 when the last owner tries to leave', async () => { + mockLeave.mockRejectedValue(new Error('Cannot remove the last owner')); + const req = mockReq(); + const res = mockRes(); + + await organizationsController.leave(req, res); + + expect(mockLeave).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(422); + }); + + test('should return 422 when the user is not a member', async () => { + mockLeave.mockRejectedValue(new Error('You are not a member of this organization')); + const req = mockReq(); + const res = mockRes(); + + await organizationsController.leave(req, res); + + expect(mockLeave).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(422); + }); + }); });