Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Comment thread
PierreBrisorgueil marked this conversation as resolved.

---

Expand Down Expand Up @@ -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/
Expand All @@ -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/
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
13 changes: 0 additions & 13 deletions config/templates/org-invite.html

This file was deleted.

2 changes: 1 addition & 1 deletion modules/audit/middlewares/audit.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion modules/audit/tests/audit.middleware.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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.
Expand Down Expand Up @@ -182,8 +125,5 @@ export default {
approve,
reject,
listMine,
invite,
acceptInvite,
getInvite,
requestByID,
};
2 changes: 0 additions & 2 deletions modules/organizations/lib/constants.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
export const MEMBERSHIP_STATUSES = {
ACTIVE: 'active',
PENDING: 'pending',
INVITED: 'invited',
REJECTED: 'rejected',
};

export const MEMBERSHIP_ROLES = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>} 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'); }
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Loading
Loading