migratePublicUsers.js
Overview
services/api/src/scripts/migratePublicUsers.js is a one-off migration script for legacy public users stored in AdminUser. It copies selected legacy records into the User collection, classifies each record first, and optionally repoints identity references in related collections. The script runs against MongoDB through Mongoose and prints a structured JSON report at the end.
Its main purpose is to move public-facing accounts into the newer User model without deleting legacy AdminUser documents. The migration is intentionally conservative: it only migrates explicit allowlist users, a separately handled legacy admin, or legacy users with enough public and product evidence.
[!IMPORTANT] The script defaults to dry-run mode. It only writes when
--applyis present, and it never deletes anyAdminUserdocument.
How it works
flowchart TB
Start[CLI entry]
Env[dotenv config]
Flags[Parse apply and dry run flags]
Mongo[Connect to MongoDB]
Fetch[Load AdminUser records]
Classify[classifyLegacyUser]
Candidate{Candidate}
Omitted[Add to omitted report]
Ambiguous[Add to ambiguous report]
Migrate[migrateCandidate]
Copy[Copy User document]
Merge[Merge into existing User]
Repoint[repointIdentityReferences]
Report[Build JSON report]
Print[Console output]
Disconnect[Disconnect Mongoose]
Start --> Env
Env --> Flags
Flags --> Mongo
Mongo --> Fetch
Fetch --> Classify
Classify --> Candidate
Candidate -->|no| Omitted
Candidate -->|ambiguous| Ambiguous
Candidate -->|yes| Migrate
Migrate -->|no target match| Copy
Migrate -->|existing email target| Merge
Merge --> Repoint
Copy --> Report
Repoint --> Report
Omitted --> Report
Ambiguous --> Report
Report --> Print
Print --> Disconnect
The script starts by loading .env from services/api/.env and then reading MONGODB_URI. After connecting, it loads all AdminUser documents sorted by email. Each record is classified first, then migrated only if it qualifies as a candidate. Finally, the script prints a JSON summary and disconnects from MongoDB.
Key components
{
"title": "migratePublicUsers.js",
"content": "Migration script that moves selected legacy public accounts from AdminUser to User, repoints related identity references, and emits a JSON report.",
"type": "function",
"filePath": "services/api/src/scripts/migratePublicUsers.js",
"badges": [
"public"
]
}
{
"title": "classifyLegacyUser",
"content": "Inspects a legacy AdminUser and decides whether it is a migration candidate, ambiguous record, or separately handled entry.",
"type": "function",
"filePath": "services/api/src/scripts/migratePublicUsers.js",
"badges": [
"public"
]
}
{
"title": "migrateCandidate",
"content": "Migrates one approved legacy user into User, merging when the email already exists and repointing related references when needed.",
"type": "function",
"filePath": "services/api/src/scripts/migratePublicUsers.js",
"badges": [
"public"
]
}
{
"title": "repointIdentityReferences",
"content": "Updates foreign-key-style identity fields across related collections so they point from the old legacy id to the new User id.",
"type": "function",
"filePath": "services/api/src/scripts/migratePublicUsers.js",
"badges": [
"public"
]
}
{
"title": "run",
"content": "Main orchestration function that connects to MongoDB, processes every AdminUser, builds the migration report, prints it, and disconnects.",
"type": "function",
"filePath": "services/api/src/scripts/migratePublicUsers.js",
"badges": [
"public"
]
}
Data & fields
[
{
"name": "MONGO_URI",
"type": "string",
"description": "MongoDB connection string. It comes from MONGODB_URI or falls back to mongodb://127.0.0.1:27017/plano_backoffice."
},
{
"name": "APPLY",
"type": "boolean",
"description": "True when --apply appears in process.argv."
},
{
"name": "DRY_RUN",
"type": "boolean",
"description": "True when --dry-run appears or when --apply is missing. This is the default mode."
},
{
"name": "EXPLICIT_SYSTEM_ROLES",
"type": "Map<string, string>",
"required": true,
"description": "Hard-coded allowlist that maps specific emails to a systemRole."
},
{
"name": "HANDLED_SEPARATELY",
"type": "Set<string>",
"required": true,
"description": "Emails that this script omits because another migration handles them."
},
{
"name": "IDENTITY_REFERENCES",
"type": "Array",
"required": true,
"description": "Collection and field map used to repoint ownership and identity references."
}
]
Classification rules
[email protected]is always migrated withsystemRole: "admin".[email protected]is always migrated withsystemRole: "user".[email protected]is always migrated withsystemRole: "user".[email protected]is omitted becausemigrateLegacyAdminhandles it separately.
A legacy user becomes a candidate when it satisfies the public-account heuristic. The script checks three signals:
accountRoleiscreatororseeker.- The record has public-profile evidence.
- The record has product or ownership evidence.
Public-profile evidence comes from username, bio, photo, coverPhoto, or website. Product evidence comes from Listing.exists({ createdBy: legacy._id }), stripeCustomerId, or stripeSubscriptionId. The record is excluded when legacy.role is super_admin.
User document shape
toUserDocument builds the target document for User. It preserves the legacy _id, copies profile fields, normalizes the email, and sets accountStatus: "active". It also sets tokenVersion: 0, defaults accountRole to creator, and defaults plan to free. deletionRequestedAt is set to null, while password reset fields are copied through unchanged.
[
{
"name": "_id",
"type": "ObjectId",
"required": true,
"description": "The legacy id is preserved in the copied User document."
},
{
"name": "email",
"type": "string",
"required": true,
"description": "Lowercased and trimmed email copied from the legacy user."
},
{
"name": "systemRole",
"type": "string",
"required": true,
"description": "Target system role assigned by classification."
},
{
"name": "accountRole",
"type": "string",
"description": "Copied from the legacy record or defaulted to creator."
},
{
"name": "plan",
"type": "string",
"description": "Copied from the legacy record or defaulted to free."
},
{
"name": "stripeCustomerId",
"type": "string",
"description": "Copied from the legacy record or defaulted to an empty string."
},
{
"name": "stripeSubscriptionId",
"type": "string",
"description": "Copied from the legacy record or defaulted to an empty string."
},
{
"name": "subscriptionStatus",
"type": "string",
"description": "Copied from the legacy record or defaulted to none."
},
{
"name": "accountStatus",
"type": "string",
"required": true,
"description": "Always set to active in the new User record."
},
{
"name": "tokenVersion",
"type": "number",
"required": true,
"description": "Reset to 0 in the copied User record."
}
]
Identity reference map
The script repoints these related fields when it merges a legacy user into an existing User with the same email:
Listing.createdByCampaign.createdByLead.ownerIdListingComment.ownerIdListingComment.authorIdListingComment.replyToUserIdListingEvent.ownerIdListingEvent.actorIdListingLike.userIdPaymentOrder.createdBy
Behaviors
normalizedEmail(user)— trims and lowercasesuser.email, then returns the normalized string.classifyLegacyUser(legacy)— returnscandidate,ambiguous, oromitted, plus the reason and, when relevant,systemRole.toUserDocument(legacy, systemRole)— converts the legacy Mongoose document into a plain object ready for insertion intoUser.mergeUpdate(legacy, systemRole)— builds the update object for existingUserrecords and strips_id,createdAt, andupdatedAt.repointIdentityReferences(fromId, toId)— runsupdateManyacross all configured identity reference fields and returns the total modified count.migrateCandidate(legacy, systemRole)— handles copy, merge, blocked, already-migrated, and dry-run states for one user.run()— orchestrates the full migration and prints the final report as formatted JSON.
Workflows
1. Load environment | Read services/api/.env and the process environment, then resolve the MongoDB URI.
2. Connect database | Open a Mongoose connection to the target database.
3. Read legacy users | Fetch all AdminUser documents ordered by email.
4. Classify each record | Decide whether the user is a candidate, ambiguous, or omitted.
5. Migrate candidates | Copy to User, merge by email when needed, and repoint related references.
6. Print report | Emit a JSON summary with mode, source, target, and outcome lists.
7. Disconnect | Close the Mongoose connection in finally.
Gotchas & edge cases
[!WARNING] The script does not delete from
AdminUser. It only copies or merges intoUser, then leaves the legacy collection intact.
[!WARNING] When an email already exists in
User, the script compares Stripe relationships before merging. IfstripeCustomerIdorstripeSubscriptionIdconflict, it returnsblocked.
[!NOTE]
sameIdchecks prevent double migration by_id. If the same id already exists inUser, the script returnsalready-migrated.
[!CAUTION] In dry-run mode, the script reports
would-copyorwould-merge-and-repointinstead of writing any document. The write path only runs when--applyis active.
[!IMPORTANT] The final report includes
mode,collectionSource,collectionTarget,candidates,ambiguous,omitted, and a note that explains the ObjectId-preserving behavior.
Related
This script depends on the same model layer used across the backend. The imported collections are AdminUser, User, Listing, Campaign, Lead, ListingComment, ListingEvent, ListingLike, and PaymentOrder. Their references match the ownership and identity links already used elsewhere in the API data model.
It also follows the repository’s script style used in other maintenance files: environment loading through dotenv, MongoDB access through mongoose, and a final JSON summary on stdout. The migration pattern is consistent with other backend scripts that use dry-run and apply flags.
[!NOTE] Related migration logic in the same area of the codebase handles other persistence tasks, such as index cleanup and account maintenance, but this file is strictly focused on public-user migration.