services/api/src/scripts/migrateLegacyAdmin.js
Overview
services/api/src/scripts/migrateLegacyAdmin.js is a one-off migration script for consolidating a legacy administrator record into the main User collection. It connects to MongoDB, loads environment variables, compares a target user with a legacy admin account, and conditionally transfers ownership references before deleting the legacy document. The script is designed to run in either dry-run mode or apply mode, and it prints a structured JSON summary at the end. fileciteturn1file1turn1file10turn1file11
This script sits in the API service layer and works directly with persistence models from services/api/src/models. It touches AdminUser, User, Listing, Campaign, Lead, PaymentOrder, ListingComment, ListingEvent, and ListingLike, which shows that the migration spans both profile data and relationship data across several collections.
[!IMPORTANT] The script only deletes the legacy admin document when ownership references are moved and no blockers remain. It also refuses to proceed if the target user is missing or if both identities share the same ObjectId. fileciteturn1file10turn1file11
How it works
flowchart TD
Start([Start]) --> LoadEnv[Load dotenv and MongoDB URI]
LoadEnv --> Connect[Connect to MongoDB]
Connect --> FindTarget[Find target User by email]
FindTarget --> FindLegacy[Find legacy AdminUser by email]
FindLegacy --> CountRefs[Count ownership and personal references]
CountRefs --> CheckStripe[Check Stripe conflicts]
CheckStripe --> DecideApply{APPLY and no conflicts}
DecideApply -->|Yes| UpdateTarget[Update target profile fields]
UpdateTarget --> Repoint[Repoint ownership references to target User]
DecideApply -->|No| Summarize[Build summary only]
Repoint --> Recount[Recount legacy ownership references]
Recount --> Blockers[Collect blockers]
Summarize --> Blockers
Blockers --> DeleteLegacy{APPLY and no blockers}
DeleteLegacy -->|Yes| RemoveLegacy[Delete legacy AdminUser]
DeleteLegacy -->|No| Report[Print JSON report]
RemoveLegacy --> Report
Report --> Disconnect[Disconnect MongoDB]
The main path is simple. The script loads configuration, resolves the target and legacy accounts, inspects references, and then either writes changes or only reports what it found. The write path updates the target user, repoints ownership fields in related collections, and deletes the legacy admin only when no blockers remain. fileciteturn1file10turn1file11
Key components
{
"title": "countReferences",
"content": "Counts how many documents in each referenced collection still point to a given user id. It returns an object keyed by reference name.",
"type": "function",
"filePath": "services/api/src/scripts/migrateLegacyAdmin.js",
"badges": [
"public"
]
}
{
"title": "nonZero",
"content": "Filters a counts object and returns only the entries whose value is greater than zero. It is used to build blocker lists.",
"type": "function",
"filePath": "services/api/src/scripts/migrateLegacyAdmin.js",
"badges": [
"public"
]
}
{
"title": "buildProfileUpdate",
"content": "Builds the update payload for the target User. It copies selected profile and billing fields from the legacy AdminUser only when the target does not already have them.",
"type": "function",
"filePath": "services/api/src/scripts/migrateLegacyAdmin.js",
"badges": [
"public"
]
}
{
"title": "stripeConflict",
"content": "Detects conflicting Stripe identifiers between the target user and the legacy admin account. If either Stripe customer or subscription id differs on both sides, the migration is blocked.",
"type": "function",
"filePath": "services/api/src/scripts/migrateLegacyAdmin.js",
"badges": [
"public"
]
}
{
"title": "run",
"content": "Main migration routine. It connects to MongoDB, loads both accounts, transfers references when allowed, deletes the legacy document when safe, and prints a JSON result.",
"type": "function",
"filePath": "services/api/src/scripts/migrateLegacyAdmin.js",
"badges": [
"public"
]
}
Data & fields
The script uses a small set of runtime inputs and several reference definitions. These values are fixed in the file, and the migration logic depends on them directly. fileciteturn1file1turn1file10
[
{
"name": "MONGO_URI",
"type": "string",
"required": true,
"description": "MongoDB connection string. It uses MONGODB_URI when present, otherwise defaults to mongodb://127.0.0.1:27017/plano_backoffice."
},
{
"name": "TARGET_EMAIL",
"type": "string",
"required": true,
"description": "Email address of the destination User account. The script expects this user to exist before migration."
},
{
"name": "LEGACY_EMAIL",
"type": "string",
"required": true,
"description": "Email address of the legacy AdminUser account that will be migrated."
},
{
"name": "APPLY",
"type": "boolean",
"required": true,
"description": "True when --apply is present on the command line."
},
{
"name": "DRY_RUN",
"type": "boolean",
"required": true,
"description": "True when --dry-run is present or --apply is absent."
},
{
"name": "OWNERSHIP_REFERENCES",
"type": "array",
"required": true,
"description": "Reference definitions whose foreign key values are repointed from the legacy admin id to the target user id.",
"children": [
{
"name": "name",
"type": "string",
"required": true,
"description": "Human-readable reference name such as listings.createdBy or leads.ownerId."
},
{
"name": "model",
"type": "Mongoose model",
"required": true,
"description": "Model used to count and update documents."
},
{
"name": "field",
"type": "string",
"required": true,
"description": "Field name that stores the user id."
}
]
},
{
"name": "PERSONAL_REFERENCES",
"type": "array",
"required": true,
"description": "Reference definitions that are only counted as blockers. These are not repointed by the script.",
"children": [
{
"name": "name",
"type": "string",
"required": true,
"description": "Human-readable reference name such as listing_comments.authorId."
},
{
"name": "model",
"type": "Mongoose model",
"required": true,
"description": "Model used to count documents."
},
{
"name": "field",
"type": "string",
"required": true,
"description": "Field name that stores the user id."
}
]
}
]
Reference groups
OWNERSHIP_REFERENCES[badge:required] — Ownership links that are reassigned to the target user whenAPPLYis true and there are no Stripe conflicts. These includelistings.createdBy,campaigns.createdBy,leads.ownerId,payment_orders.createdBy,listing_comments.ownerId, andlisting_events.ownerId. fileciteturn1file1turn1file10PERSONAL_REFERENCES[badge:required] — User identity links that are only counted as blockers. These includelisting_comments.authorId,listing_comments.replyToUserId,listing_events.actorId, andlisting_likes.userId. fileciteturn1file1turn1file10
Updated profile fields
systemRole[badge:required]string— Set toadminin the generated update.accountRole[badge:required]string— Set tocreatorin the generated update.bio[badge:optional]string— Copied from legacy data only when the target field is empty.photo[badge:optional]string— Copied only when the target field is empty.coverPhoto[badge:optional]string— Copied only when the target field is empty.website[badge:optional]string— Copied only when the target field is empty.location[badge:optional]string— Copied only when the target field is empty.seo[badge:optional]object— Rebuilt only when at least one nested SEO field is missing on the target and present on the legacy record.plan[badge:optional]string— Copied from the legacy record when the target plan isfreeand the legacy plan is notfree.stripeCustomerId[badge:optional]string— Copied only when the target has no customer id.stripeSubscriptionId[badge:optional]string— Copied only when the target has no subscription id.subscriptionStatus[badge:optional]string— Copied alongsidestripeSubscriptionId.currentPeriodEnd[badge:optional]Date | null— Copied alongsidestripeSubscriptionId.cancelAtPeriodEnd[badge:optional]boolean— Copied alongsidestripeSubscriptionId.
The script rebuilds seo as a plain object with three values when needed.
descriptors[badge:optional]stringaiKeywords[badge:optional]stringschemaType[badge:optional]string
Each value falls back to the target value, then the legacy value, then an empty string.
Behaviors
The file contains a small number of functions, but each one has a precise role in the migration flow. The logic is intentionally conservative and only writes when the script is explicitly applied. fileciteturn1file1turn1file10turn1file11
countReferences(definitions, userId)— Counts documents in each model whose referenced field matches the supplied user id, and returns an object keyed by reference name.nonZero(counts)— Returns only the count entries with values greater than zero. It is used to build human-readable blocker messages. fileciteturn1file1turn1file11buildProfileUpdate(target, legacy)— Produces the$setpayload for the target user. It always setssystemRoleandaccountRole, then selectively copies profile, SEO, and Stripe-related fields.stripeConflict(target, legacy)— Returnstruewhen both records have a Stripe customer id or subscription id and the values differ.run()— Orchestrates the full migration. It validates the target user, loads the legacy admin, counts references, writes updates when allowed, deletes the legacy document only when safe, and prints the final JSON summary. fileciteturn1file10turn1file11
Workflows
1. Load configuration | The script resolves `__dirname`, loads `.env`, and reads `MONGODB_URI` or the local default connection string.
2. Parse execution mode | `--apply` enables writes. `--dry-run` or the absence of `--apply` keeps the script in dry-run mode.
3. Connect to MongoDB | `mongoose.connect(MONGO_URI)` opens the database connection before any reads or writes.
4. Resolve the two identities | The script loads the target `User` by `TARGET_EMAIL` and the legacy `AdminUser` by `LEGACY_EMAIL`.
5. Inspect references | It counts ownership references and personal blockers for the legacy id. fileciteturn1file10turn1file11
6. Apply the transfer | When `APPLY` is true and no Stripe conflict exists, it updates the target user and repoints ownership references. fileciteturn1file10turn1file11
7. Delete the legacy record | The script deletes the `AdminUser` only when no blockers remain after the transfer.
8. Print the report | The script prints a JSON object with mode, ids, reference counts, blockers, deletion state, and final status.
9. Disconnect | The script always disconnects from MongoDB in `finally`, even after failures.
Gotchas & edge cases
[!WARNING] The script throws an error when the target user does not exist. The target email must be present in
Userbefore migration starts.
[!WARNING] The script throws an error if the target and legacy records resolve to the same ObjectId. That condition is treated as an unexpected identity collision.
[!WARNING] Stripe conflicts block the write path. If both documents contain different Stripe customer ids or subscription ids, the script records a blocker and does not repoint references.
[!CAUTION] Personal references are never repointed. They are only counted and reported as blockers, which can prevent deletion even after ownership has been transferred. fileciteturn1file1turn1file11
[!NOTE] If the legacy admin document does not exist, the script reports
already-completeand exits without error. That branch is handled before any write operations.
[!NOTE] In dry-run mode, the script does not write to MongoDB. It still gathers ownership counts and blocker information, then reports
no-writes.
Related
This script connects directly to the repository’s persistence models, not to HTTP controllers or route handlers. The verified model imports are services/api/src/models/AdminUser.js, services/api/src/models/User.js, services/api/src/models/Listing.js, services/api/src/models/Campaign.js, services/api/src/models/Lead.js, services/api/src/models/PaymentOrder.js, services/api/src/models/ListingComment.js, services/api/src/models/ListingEvent.js, and services/api/src/models/ListingLike.js.
It also follows the same script pattern used by other maintenance utilities in the API service, where execution is controlled through command-line flags and the result is emitted as JSON. The code shown in the repository uses that style across several migration and cleanup scripts. fileciteturn1file2turn1file4turn1file7
If you want, I can also produce a second document that focuses on the surrounding migration scripts and explains how this one relates to migratePublicUsers.js and the other account-cleanup jobs.