/** * One-time seed for Parsian (clientCode=8) Tehran branches + file reviewers + file makers. * * Usage (before starting the app): * npm run seed:parsian-tehran * * Optional env: * SEED_PARSIAN_TEHRAN_DEFAULT_PASSWORD=Parsian@724 * * Backward-compatible env alias: * SEED_FIELD_EXPERT_DEFAULT_PASSWORD=Parsian@724 */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import * as crypto from "node:crypto"; import mongoose, { Schema, Types } from "mongoose"; type BranchSeed = { code: string; name: string; fullName?: string; city: string; state: string; address: string; phoneNumber?: string; isActive?: boolean; }; type ExpertLocationSeed = { id: string; name: string; }; type FileReviewerSeed = { nationalCode: string; mobile?: string; firstName: string; lastName: string; locations: ExpertLocationSeed[]; ThirdPartyExpertiseClaim?: string; CarBodyExpertiseClaim?: string; }; type FileMakerSeed = { nationalCode: string; mobile?: string; firstName: string; lastName: string; locations: ExpertLocationSeed[]; ThirdPartyClaimExpertId?: string; CarBodyClaimExpertId?: string; }; function stripQuotes(value: string): string { const trimmed = value.trim(); if ( (trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"')) ) { return trimmed.slice(1, -1); } return trimmed; } function stripInlineComment(value: string): string { const hashIdx = value.indexOf(" #"); return hashIdx === -1 ? value : value.slice(0, hashIdx).trim(); } function expandEnvValue(value: string, env: NodeJS.ProcessEnv): string { return value.replace(/\$\{([^}]+)\}/g, (_, key: string) => env[key] ?? ""); } function loadEnvFile() { const envPath = join(process.cwd(), ".env"); if (!existsSync(envPath)) return; const raw: Record = {}; for (const line of readFileSync(envPath, "utf8").split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const idx = trimmed.indexOf("="); if (idx === -1) continue; const key = trimmed.slice(0, idx).trim(); const value = stripInlineComment(trimmed.slice(idx + 1).trim()); raw[key] = value; } for (const [key, value] of Object.entries(raw)) { if (process.env[key]) continue; process.env[key] = stripQuotes(value); } for (let pass = 0; pass < 5; pass++) { let changed = false; for (const key of Object.keys(process.env)) { const current = process.env[key]; if (!current || !current.includes("${")) continue; const expanded = expandEnvValue(stripQuotes(current), process.env); if (expanded !== current) { process.env[key] = expanded; changed = true; } } if (!changed) break; } for (const key of Object.keys(process.env)) { const value = process.env[key]; if (value) process.env[key] = stripQuotes(value); } } function resolveMongoUri(): string { const uri = process.env.MONGO_URI?.trim(); if (!uri) { throw new Error("MONGO_URI is not set in .env"); } if (!uri.startsWith("mongodb://") && !uri.startsWith("mongodb+srv://")) { throw new Error( `Invalid MONGO_URI after env expansion: "${uri.slice(0, 40)}..."`, ); } return uri; } async function ensureUserIndexes(collection: mongoose.Collection) { const indexes = await collection.indexes(); const emailIndex = indexes.find((idx) => idx.key?.email === 1); if (emailIndex && !emailIndex.sparse) { await collection.dropIndex(emailIndex.name); console.log(`Dropped legacy non-sparse index: ${emailIndex.name}`); } await collection.createIndex({ email: 1 }, { unique: true, sparse: true }); await collection.createIndex( { clientKey: 1, nationalCode: 1 }, { unique: true, sparse: true }, ); } function hashPassword(password: string): Promise { return new Promise((resolve, reject) => { const salt = crypto.randomBytes(16).toString("hex"); crypto.scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); resolve(`${salt}:${derivedKey.toString("hex")}`); }); }); } function normalizeLocations(locations: ExpertLocationSeed[]): ExpertLocationSeed[] { const deduped = new Map(); for (const location of locations ?? []) { const id = String(location?.id ?? "").trim(); const name = String(location?.name ?? "").trim(); if (!id || !name || deduped.has(id)) continue; deduped.set(id, { id, name }); } return [...deduped.values()]; } const ClientSchema = new Schema( { clientName: { type: Object, required: true }, clientCode: { type: Number, required: true }, useExpertMode: { type: String, required: true }, }, { collection: "clients", versionKey: false }, ); const BranchSchema = new Schema( { clientKey: { type: Schema.Types.ObjectId, required: true, index: true }, name: { type: String, required: true }, code: { type: String, required: true }, city: { type: String, required: true }, state: { type: String, required: true }, address: { type: String, required: true }, phoneNumber: { type: String }, isActive: { type: Boolean, default: true }, }, { collection: "branches", versionKey: false, timestamps: true }, ); BranchSchema.index({ clientKey: 1, code: 1 }, { unique: true }); const ExpertLocationSchema = new Schema( { id: { type: String, required: true }, name: { type: String, required: true }, }, { _id: false, id: false, versionKey: false }, ); const FileReviewerSchema = new Schema( { firstName: { type: String, required: true }, lastName: { type: String, required: true }, email: { type: String, unique: true, sparse: true }, username: { type: String }, nationalCode: { type: String, index: true, sparse: true }, clientKey: { type: Schema.Types.ObjectId, index: true }, branchId: { type: Schema.Types.ObjectId, index: true }, locations: { type: [ExpertLocationSchema], default: [] }, password: { type: String, required: true }, mobile: { type: String }, phone: { type: String }, role: { type: String, default: "file_reviewer" }, otp: { type: String, default: "" }, expertCode: { type: String, required: false }, ThirdPartyExpertiseClaim: { type: String, required: false }, CarBodyExpertiseClaim: { type: String, required: false }, }, { collection: "file-reviewer", versionKey: false, timestamps: true }, ); FileReviewerSchema.index( { clientKey: 1, nationalCode: 1 }, { unique: true, sparse: true }, ); const FileMakerSchema = new Schema( { firstName: { type: String, required: true }, lastName: { type: String, required: true }, email: { type: String, unique: true, sparse: true }, username: { type: String }, nationalCode: { type: String, index: true, sparse: true }, clientKey: { type: Schema.Types.ObjectId, index: true }, branchId: { type: Schema.Types.ObjectId, index: true }, locations: { type: [ExpertLocationSchema], default: [] }, password: { type: String, required: true }, mobile: { type: String }, phone: { type: String }, role: { type: String, default: "file_maker" }, otp: { type: String, default: "" }, expertCode: { type: String, required: false }, ThirdPartyClaimExpertId: { type: String, required: false }, CarBodyClaimExpertId: { type: String, required: false }, }, { collection: "file-maker", versionKey: false, timestamps: true }, ); FileMakerSchema.index( { clientKey: 1, nationalCode: 1 }, { unique: true, sparse: true }, ); function resolveSeedLocations( seedLocations: ExpertLocationSeed[], branchMetaByCode: Map, ) { const resolved: { id: string; name: string; branchId: Types.ObjectId }[] = []; const skippedCodes: string[] = []; for (const location of normalizeLocations(seedLocations)) { const branch = branchMetaByCode.get(location.id); if (!branch) { skippedCodes.push(location.id); continue; } resolved.push({ id: location.id, name: branch.name || location.name, branchId: branch._id, }); } return { resolved, skippedCodes, primaryBranchId: resolved[0]?.branchId, }; } async function upsertRoleUsers({ label, seeds, model, clientKey, hashedPassword, branchMetaByCode, role, codeFields, }: { label: string; seeds: Array>; model: mongoose.Model; clientKey: Types.ObjectId; hashedPassword: string; branchMetaByCode: Map; role: string; codeFields: string[]; }) { let created = 0; let updated = 0; let skipped = 0; for (const seed of seeds) { const { resolved, skippedCodes, primaryBranchId } = resolveSeedLocations( seed.locations, branchMetaByCode, ); if (skippedCodes.length > 0) { console.warn( `Skipping unknown ${label} locations for ${seed.nationalCode}: ${skippedCodes.join( ", ", )}`, ); } if (!primaryBranchId || resolved.length === 0) { console.warn( `Skipping ${label} ${seed.nationalCode}: no valid branch locations remained`, ); skipped++; continue; } const setPayload: Record = { firstName: seed.firstName, lastName: seed.lastName, username: seed.nationalCode, nationalCode: seed.nationalCode, clientKey, branchId: primaryBranchId, locations: resolved.map(({ id, name }) => ({ id, name })), role, otp: "", }; if (seed.mobile) { setPayload.mobile = seed.mobile; } const unsetPayload: Record = { expertCode: "", }; for (const field of codeFields) { if (seed[field]) { setPayload[field] = seed[field]; } else { unsetPayload[field] = ""; } } const existing = await model.findOne({ clientKey, nationalCode: seed.nationalCode, }); if (existing) { await model.updateOne( { _id: existing._id }, { $set: { ...setPayload, password: existing.password, }, $unset: unsetPayload, }, ); updated++; } else { await model.create({ ...setPayload, password: hashedPassword, }); created++; } } return { created, updated, skipped }; } async function main() { loadEnvFile(); const mongoUri = resolveMongoUri(); const dataDir = join(process.cwd(), "scripts/data/parsian-tehran"); const branchesFile = JSON.parse( readFileSync(join(dataDir, "branches.json"), "utf8"), ) as { clientCode: number; branches: BranchSeed[] }; const fileReviewersFile = JSON.parse( readFileSync(join(dataDir, "file-reviewers.json"), "utf8"), ) as { clientCode: number; fileReviewers: FileReviewerSeed[] }; const fileMakersFile = JSON.parse( readFileSync(join(dataDir, "file-makers.json"), "utf8"), ) as { clientCode: number; fileMakers: FileMakerSeed[] }; if ( branchesFile.clientCode !== fileReviewersFile.clientCode || branchesFile.clientCode !== fileMakersFile.clientCode ) { throw new Error("Seed data clientCode mismatch between branch/reviewer/maker files"); } const defaultPassword = process.env.SEED_PARSIAN_TEHRAN_DEFAULT_PASSWORD ?? process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ?? "123321"; const hashedPassword = await hashPassword(defaultPassword); await mongoose.connect(mongoUri, { tls: process.env.MONGO_TLS === "true", tlsAllowInvalidCertificates: process.env.MONGO_TLS_ALLOW_INVALID_CERTS === "true", }); const Client = mongoose.model("ClientSeedClient", ClientSchema); const Branch = mongoose.model("ClientSeedBranch", BranchSchema); const FileReviewer = mongoose.model("ClientSeedFileReviewer", FileReviewerSchema); const FileMaker = mongoose.model("ClientSeedFileMaker", FileMakerSchema); await ensureUserIndexes(FileReviewer.collection); await ensureUserIndexes(FileMaker.collection); const client = await Client.findOne({ clientCode: branchesFile.clientCode, }).lean(); if (!client?._id) { throw new Error( `Client with clientCode=${branchesFile.clientCode} not found in database`, ); } const clientKey = new Types.ObjectId(String(client._id)); const branchMetaByCode = new Map(); let branchesCreated = 0; let branchesUpdated = 0; for (const branch of branchesFile.branches) { const existing = await Branch.findOne({ clientKey, code: branch.code, }); const payload = { clientKey, name: branch.name, code: branch.code, city: branch.city, state: branch.state, address: branch.address, phoneNumber: branch.phoneNumber, isActive: branch.isActive ?? true, }; if (existing) { await Branch.updateOne({ _id: existing._id }, { $set: payload }); branchMetaByCode.set(branch.code, { _id: existing._id as Types.ObjectId, name: branch.name, }); branchesUpdated++; } else { const created = await Branch.create(payload); branchMetaByCode.set(branch.code, { _id: created._id as Types.ObjectId, name: branch.name, }); branchesCreated++; } } const fileReviewersResult = await upsertRoleUsers({ label: "file-reviewer", seeds: fileReviewersFile.fileReviewers, model: FileReviewer, clientKey, hashedPassword, branchMetaByCode, role: "file_reviewer", codeFields: ["ThirdPartyExpertiseClaim", "CarBodyExpertiseClaim"], }); const fileMakersResult = await upsertRoleUsers({ label: "file-maker", seeds: fileMakersFile.fileMakers, model: FileMaker, clientKey, hashedPassword, branchMetaByCode, role: "file_maker", codeFields: ["ThirdPartyClaimExpertId", "CarBodyClaimExpertId"], }); console.log("Parsian Tehran seed completed."); console.log({ clientCode: branchesFile.clientCode, clientKey: String(clientKey), branchesCreated, branchesUpdated, fileReviewersCreated: fileReviewersResult.created, fileReviewersUpdated: fileReviewersResult.updated, fileReviewersSkipped: fileReviewersResult.skipped, fileMakersCreated: fileMakersResult.created, fileMakersUpdated: fileMakersResult.updated, fileMakersSkipped: fileMakersResult.skipped, defaultPassword, loginHint: "Use nationalCode + password on POST /actor/login with role file_reviewer or file_maker", }); await mongoose.disconnect(); } main().catch((err) => { console.error(err); process.exit(1); });