Skip to content

✨ org.addMember(userId) + consent (Node, P5a) #3813

Description

@PierreBrisorgueil

Phase 5a (Node) of the invitations↔org decouple epic. Plan Phase 5a. Pairs with P5b (Vue, #4281) — gate on /dev:verify-qa.

The "(2) add to org" half — net-new (only an internal create() exists today).

⚠️ E15 — do NOT reuse bare PENDING. Existing PENDING = a user-initiated join-request the owner approves. An undiscriminated owner-add would be owner-approvable → bypassing the invited user's consent.

  • organizations.membership.model.mongoose.js — add source: { enum:['join_request','owner_add'] } with NO default + a pre('validate') hook rejecting status:'PENDING' without an explicit source (E19 — a default would silently re-introduce the consent-bypass).
  • organizations.membership.service.jsaddMember(orgId, userId, role, addedBy)PENDING + source:'owner_add' (reject duplicates, last-owner safe); acceptMembership(membershipId, acceptingUserId)PENDING→ACTIVE only when source:'owner_add' AND userId===acceptingUserId (invited user, never owner).
  • ⚠️ E16 — exhaustive PENDING-query audit (Phase-0 of /feature): rg "status.*pending|\$in.*pending" across modules/organizations/** AND modules/billing/** (seat/quota) AND the Vue org store (pendingMembers/dashboards/exports). Per path decide include/exclude owner_add: join-request list/approveRequest/rejectRequest/createJoinRequest = join_request only; member-count = both; billing seats = decide explicitly. Add a PENDING_SOURCE constant set, enforce it — no bare status:'pending' query left unreviewed.
  • ⚠️ E17 — migration-ordering defensive read: until backfill-membership-source is confirmed run, the join-request filter reads $or:[{source:'join_request'},{source:{$exists:false}}] (pre-existing PENDING rows have no source → must not vanish from approval); remove the fallback post-backfill.
  • controller handlers + routes POST /api/organizations/:organizationId/members (owner/admin) + POST /api/organizations/memberships/:membershipId/accept (invited user).
  • Durable surface = the owner_add PENDING row (via listPendingByUser), shown in P5b; snackbar transient only.
  • Migration modules/organizations/migrations/<ts>-backfill-membership-source.js: set source:'join_request' on existing PENDING rows.

Acceptance: owner-add never appears as a join-request; only the invited user accepts it; no silent ACTIVE adds; no bare PENDING query miscounts owner_add (incl billing seats); pre-existing PENDING rows survive pre-backfill; migration backfills source.


🛑 Fable review corrections (2026-06-10, code-verified vs origin/master)

  • 🔴 CRITICAL casing: status stored lowercase (PENDING:'pending'). The prescribed pre('validate') this.status === 'PENDING' NEVER matches → consent guard is inert. Use MEMBERSHIP_STATUSES.PENDING everywhere (hook, acceptMembership, all filters).
  • 🔴 Status defaults to 'active': addMember MUST set status: MEMBERSHIP_STATUSES.PENDING explicitly + a test that a fresh owner-add is never ACTIVE (silent-ACTIVE is a worse bypass than the removed source-default).
  • 🔴 searchByNameOrEmail is GREENFIELD (0 hits Node+Vue) — P5b prescribes calling it but nothing builds it. Build the Node endpoint here: exact-email match (not fuzzy enumeration — GDPR/CASL directory-leak surface), org-owner/admin gated.
  • E16 scope missed modules/auth: auth.controller.js (signin ~L252, /me ~L333) calls listPendingByUserpendingRequests in the auth payload — decide owner_add include/exclude (may feed the P5b snackbar; it's a behavior change, don't leave unreviewed). Extend the grep to modules/auth/**.
  • createJoinRequest enforces ONE PENDING globally per user ({userId, status:PENDING}) — an owner_add PENDING blocks that user's join-requests org-wide and vice versa unless source-filtered. Fix + test both directions.
  • Billing seats = MOOT: billing has zero membership-count coupling (metered quotas; all billing membership queries filter ACTIVE). Drop the "seats" decision; keep the cheap grep, expect no hits.
  • Stale owner_add rows linger forever (no TTL/prune) → the E2 lazy-sweep must cover stale owner_add PENDING too, else a dead invite permanently blocks re-add.
  • Accept-route subject mapping: /invites falls through to Organization in organizations.policy.js; pick the /memberships/:id/accept segment so it resolves to the intended subject (policy test).
  • Expose a "my pending owner-adds" GET for P5b (or have P5b consume the auth-payload pendingRequests) — listPendingByUser exists service-side but no route returns it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions