Repository navigation
Description
Overview
The codebase tracks project membership and manager status in four separate places simultaneously. ProjectTeamMember was designed as the canonical join table for this relationship, but the app never fully committed to it — resulting in redundant, potentially out-of-sync fields on both User and Project that are used in its place.
Action Items
Research and document the current inconsistency
The four representations of the same two facts ("is a member" / "is a manager"):
| Field | Model | Tracks |
|---|---|---|
User.projects[] |
User | basic project membership |
User.managedProjects[] |
User | manager status (denormalized on user) |
Project.managedByUsers[] |
Project | manager status (denormalized on project) |
ProjectTeamMember.vrmsProjectAdmin |
ProjectTeamMember | manager status (join table, currently unused) |
The updateManagedProjects controller keeps User.managedProjects and Project.managedByUsers in sync with each other, but it never touches ProjectTeamMember.vrmsProjectAdmin. Any code reading vrmsProjectAdmin may see stale data.
Evaluate consolidating onto ProjectTeamMember as the single source of truth
ProjectTeamMember already has everything needed:
ProjectTeamMember {
userId,
projectId,
teamMemberStatus, // Active / Inactive
vrmsProjectAdmin, // Boolean — manager flag (already exists, currently bypassed)
roleOnProject, // Developer, PM, UX, etc.
}
If the app read/wrote through this model exclusively:
- "Is user a member?" →
ProjectTeamMember.findOne({ userId, projectId }) - "Is user a manager?" →
ProjectTeamMember.findOne({ userId, projectId, vrmsProjectAdmin: true }) - "All of a user's projects with manager status?" →
ProjectTeamMember.find({ userId })— one query returns both facts per row
This would allow removing User.managedProjects, Project.managedByUsers, and User.projects as redundant fields.
If consolidation is approved, the migration would involve:
- Backfill — for every entry in
User.managedProjects, find or create the correspondingProjectTeamMemberrecord and setvrmsProjectAdmin: true - Update all write paths — the
updateManagedProjectscontroller writes toProjectTeamMember.vrmsProjectAdmininstead of the denormalized fields - Update all read paths — anything reading
User.managedProjectsorProject.managedByUsersqueriesProjectTeamMemberinstead - Drop the redundant fields once all paths are migrated
Note: Issue #2149 (manager toggle feature) requires reading from ProjectTeamMember to know a user's non-manager memberships. That ticket should write to vrmsProjectAdmin to move toward the clean model rather than adding another consumer of the redundant fields.
Resources/Instructions
backend/models/projectTeamMember.model.js— join table schemabackend/models/user.model.js—projectsandmanagedProjectsfieldsbackend/models/project.model.js—managedByUsersfieldbackend/controllers/user.controller.js—updateManagedProjects(the current write path)- Related: EPIC: As a manager, I want a Toggle Feature so that I can Enable Manager Access #2149 (manager toggle feature that surfaces this inconsistency)
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsPrioritized Backlog
Activity
trillium commentedon May 12, 2026
Migration Approach: Expand / Contract (Two-Phase)
Rather than a single cutover, this migration runs in two independent phases, each safely reversible.
Migration 1 — Expand (backfill + dual-write)
Goal: populate
ProjectTeamMemberwithout breaking anything that reads the old fields.Userdocument, iterateuser.projectsanduser.managedProjectsuser.projects, find or create theProjectTeamMemberrecord for that(userId, projectId)pairuser.managedProjects, setvrmsProjectAdmin: true— otherwisefalseupdateManagedProjectscontroller to write to both old fields (User.managedProjects,Project.managedByUsers) ANDProjectTeamMember.vrmsProjectAdminsimultaneouslyApp still reads from old fields after this step — nothing breaks.
ProjectTeamMemberis now being kept in sync going forward.Verify before proceeding: spot-check that
ProjectTeamMember.vrmsProjectAdminis consistent withUser.managedProjectsacross a sample of documents.Migration 2 — Contract (cut over + drop)
Goal: switch reads to
ProjectTeamMember, remove the redundant fields.User.managedProjects,Project.managedByUsers, orUser.projectsnow queriesProjectTeamMemberinsteadupdateManagedProjectswrites only toProjectTeamMember.vrmsProjectAdminmanagedProjectsandprojectsfromUserschema,managedByUsersfromProjectschema$unsetthose fields from existing MongoDB documents (schema removal alone does not purge stored data)Why two phases?
A single cutover — backfill + switch reads simultaneously — leaves the app in an inconsistent state if the script fails mid-run with no safe rollback.
ProjectTeamMemberis already the source of truth, old fields still present as fallback, revert the read-path code changeEach phase is independently reversible.