From b80733311b04e1d853dbf91ef404e3586f59ccb8 Mon Sep 17 00:00:00 2001 From: snehitakandula Date: Wed, 4 Nov 2026 20:34:50 -0500 Subject: [PATCH 1/4] feat: add mock data seeder for Total Construction Summary charts --- src/models/bmdashboard/mockDataManifest.js | 21 + src/scripts/seedTcsMockData.js | 101 ++++ .../__tests__/tcsMockDataSeeder.test.js | 194 ++++++++ src/utilities/tcsMockDataSeeder.js | 453 ++++++++++++++++++ 4 files changed, 769 insertions(+) create mode 100644 src/models/bmdashboard/mockDataManifest.js create mode 100644 src/scripts/seedTcsMockData.js create mode 100644 src/utilities/__tests__/tcsMockDataSeeder.test.js create mode 100644 src/utilities/tcsMockDataSeeder.js diff --git a/src/models/bmdashboard/mockDataManifest.js b/src/models/bmdashboard/mockDataManifest.js new file mode 100644 index 000000000..363230bce --- /dev/null +++ b/src/models/bmdashboard/mockDataManifest.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +/** + * Records the IDs inserted by a mock data seeder so its reset removes only those records. + * Used by src/scripts/seedTcsMockData.js. + */ +const mockDataManifest = new Schema({ + name: { type: String, required: true, unique: true }, + entries: [ + { + _id: false, + collection: { type: String, required: true }, + ids: [{ type: Schema.Types.ObjectId }], + }, + ], + seededAt: { type: Date, default: Date.now }, +}); + +module.exports = mongoose.model('mockDataManifest', mockDataManifest, 'mockDataManifests'); diff --git a/src/scripts/seedTcsMockData.js b/src/scripts/seedTcsMockData.js new file mode 100644 index 000000000..47ba4b880 --- /dev/null +++ b/src/scripts/seedTcsMockData.js @@ -0,0 +1,101 @@ +/** + * Load or reset mock data for the Total Construction Summary (BM Dashboard → Reports). + * + * Usage (from the HGNRest root): + * node src/scripts/seedTcsMockData.js # reset previous mock data, then load fresh data + * node src/scripts/seedTcsMockData.js --reset # remove the mock data only + * + * Mock data is attached to the existing BM projects (Building 1–3, Residential Test - Project, + * Commercial Test - Project) and covers the last 8 weeks. Every inserted ID is recorded in the + * "mockDataManifests" collection, so reset removes only mock records. + * + * Requires a valid .env file with the same MongoDB credentials used by the app: + * user, password, cluster, dbName, appName + * + * Seed logic lives in src/utilities/tcsMockDataSeeder.js. + */ + +/* eslint-disable no-console */ +require('dotenv').config(); +const mongoose = require('mongoose'); +const BuildingProject = require('../models/bmdashboard/buildingProject'); +const UserProfile = require('../models/userProfile'); +const { invTypeBase } = require('../models/bmdashboard/buildingInventoryType'); +const BuildingIssue = require('../models/bmdashboard/buildingIssue'); +const InjurySeverity = require('../models/bmdashboard/injujrySeverity'); +const InjuryCategory = require('../models/bmdashboard/buildingInjury'); +const InjuryOverTime = require('../models/bmdashboard/buildingInjuryOverTime'); +const RentalChart = require('../models/bmdashboard/buildingRentalChart'); +const ToolReturn = require('../models/bmdashboard/toolReturn'); +const ToolsDowntime = require('../models/bmdashboard/toolsDowntime'); +const ToolStoppageReason = require('../models/bmdashboard/buildingToolsStoppage'); +const ToolAvailability = require('../models/bmdashboard/toolAvailability'); +const Expenditure = require('../models/bmdashboard/expenditure'); +const ProjectCostTracking = require('../models/bmdashboard/projectCostTracking'); +const ProjectRiskProfile = require('../models/bmdashboard/projectRiskProfile'); +const TimeLog = require('../models/bmdashboard/buildingTimeLogger'); +const NewLesson = require('../models/bmdashboard/buildingNewLesson'); +const BuildingMaterial = require('../models/bmdashboard/buildingMaterial'); +const BuildingTool = require('../models/bmdashboard/buildingTool'); +const Manifest = require('../models/bmdashboard/mockDataManifest'); +const { seedTcsMockData, resetTcsMockData } = require('../utilities/tcsMockDataSeeder'); + +const models = { + buildingIssue: BuildingIssue, + injurySeverity: InjurySeverity, + injuryCategory: InjuryCategory, + injuryOverTime: InjuryOverTime, + rentalChart: RentalChart, + toolReturn: ToolReturn, + toolsDowntime: ToolsDowntime, + toolStoppageReason: ToolStoppageReason, + toolAvailability: ToolAvailability, + expenditure: Expenditure, + projectCostTracking: ProjectCostTracking, + projectRiskProfile: ProjectRiskProfile, + timeLog: TimeLog, + newLesson: NewLesson, + buildingMaterial: BuildingMaterial, + buildingTool: BuildingTool, +}; + +const { user, password, cluster, dbName, appName = 'HGNRest' } = process.env; + +async function run() { + if (!user || !password || !cluster || !dbName) { + throw new Error('Missing required env vars: user, password, cluster, dbName'); + } + + const uri = `mongodb+srv://${user}:${encodeURIComponent(password)}@${cluster}/${dbName}?retryWrites=true&w=majority&appName=${appName}`; + await mongoose.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true }); + + if (process.argv.includes('--reset')) { + const { removed } = await resetTcsMockData({ models, Manifest }); + console.log(`Removed ${removed} mock record(s)`); + } else { + const summary = await seedTcsMockData({ + models, + BuildingProject, + UserProfile, + InventoryType: invTypeBase, + Manifest, + }); + console.log(`Removed ${summary.removed} previous mock record(s)`); + if (summary.missingUser) console.warn('No active Administrator found; nothing inserted.'); + Object.entries(summary.inserted).forEach(([key, count]) => + console.log(` - ${key}: ${count} record(s)`), + ); + console.log(`Projects: ${summary.seededProjects.join(', ') || 'none'}`); + if (summary.missingProjects.length > 0) { + console.warn('BM projects not found (skipped):', summary.missingProjects.join(', ')); + } + } + + await mongoose.disconnect(); +} + +run().catch(async (err) => { + console.error(err); + await mongoose.disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/src/utilities/__tests__/tcsMockDataSeeder.test.js b/src/utilities/__tests__/tcsMockDataSeeder.test.js new file mode 100644 index 000000000..7b900790f --- /dev/null +++ b/src/utilities/__tests__/tcsMockDataSeeder.test.js @@ -0,0 +1,194 @@ +const { + MOCK_PROJECT_NAMES, + MANIFEST_NAME, + MOCK_COLLECTIONS, + createRandom, + splitInventoryTypes, + buildMockData, + seedTcsMockData, + resetTcsMockData, +} = require('../tcsMockDataSeeder'); + +const NOW = new Date('2026-11-05T12:00:00.000Z'); +const DAY_MS = 24 * 60 * 60 * 1000; +const PROJECTS = [ + { _id: 'p1', name: 'Building 1' }, + { _id: 'p2', name: 'Building 2' }, +]; +const USER = { _id: 'u1' }; + +const leanResult = (value) => ({ lean: jest.fn().mockResolvedValue(value) }); + +const makeModels = () => + Object.fromEntries( + MOCK_COLLECTIONS.map(([key]) => [ + key, + { + insertMany: jest.fn((docs) => + Promise.resolve(docs.map((doc, i) => ({ ...doc, _id: `${key}-${i}` }))), + ), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 2 }), + }, + ]), + ); + +const makeDeps = ({ projects = PROJECTS, user = USER, manifest = null } = {}) => ({ + models: makeModels(), + BuildingProject: { find: jest.fn(() => leanResult(projects)) }, + UserProfile: { findOne: jest.fn(() => leanResult(user)) }, + InventoryType: { + find: jest.fn(() => + leanResult([ + { _id: 'm1', category: 'Material' }, + { _id: 't1', category: 'Tool' }, + ]), + ), + }, + Manifest: { + findOne: jest.fn(() => leanResult(manifest)), + deleteOne: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue({}), + }, + now: NOW, +}); + +describe('tcsMockDataSeeder', () => { + describe('createRandom', () => { + it('returns the same sequence for the same seed', () => { + const a = createRandom(7); + const b = createRandom(7); + expect([a.next(), a.next(), a.next()]).toEqual([b.next(), b.next(), b.next()]); + }); + + it('keeps int() within the given range', () => { + const rand = createRandom(3); + for (let i = 0; i < 50; i += 1) { + const value = rand.int(2, 5); + expect(value).toBeGreaterThanOrEqual(2); + expect(value).toBeLessThanOrEqual(5); + } + }); + }); + + describe('splitInventoryTypes', () => { + it('separates material and tool types by category or discriminator', () => { + const result = splitInventoryTypes([ + { _id: 'm1', category: 'Material' }, + { _id: 't1', __t: 'tool_type' }, + { _id: 'x1', category: 'Consumable' }, + ]); + expect(result.materialTypes.map((t) => t._id)).toEqual(['m1']); + expect(result.toolTypes.map((t) => t._id)).toEqual(['t1']); + }); + }); + + describe('buildMockData', () => { + const data = buildMockData({ + projects: PROJECTS, + userId: 'u1', + now: NOW, + rand: createRandom(1), + materialTypes: [{ _id: 'm1' }], + toolTypes: [{ _id: 't1' }], + }); + + it('builds records for every collection', () => { + MOCK_COLLECTIONS.forEach(([key]) => { + expect(data[key].length).toBeGreaterThan(0); + }); + }); + + it('attaches issues to the given projects with valid status and severity', () => { + data.buildingIssue.forEach((issue) => { + expect(['p1', 'p2']).toContain(issue.projectId); + expect(['open', 'closed']).toContain(issue.status); + expect(['Low', 'Medium', 'High', 'Critical']).toContain(issue.severity); + expect(issue.createdBy).toBe('u1'); + }); + }); + + it('keeps dated records within the last 8 weeks', () => { + const earliest = NOW.getTime() - 8 * 7 * DAY_MS - DAY_MS; + ['buildingIssue', 'injurySeverity', 'rentalChart', 'projectCostTracking'].forEach((key) => { + data[key].forEach((doc) => { + const time = (doc.issueDate || doc.date).getTime(); + expect(time).toBeGreaterThanOrEqual(earliest); + expect(time).toBeLessThanOrEqual(NOW.getTime()); + }); + }); + }); + + it('includes material usage in the last week', () => { + const weekAgo = NOW.getTime() - 7 * DAY_MS; + const recent = data.buildingMaterial.flatMap((m) => + m.updateRecord.filter((r) => r.date.getTime() >= weekAgo), + ); + expect(recent.length).toBeGreaterThan(0); + }); + + it('respects lesson field limits', () => { + data.newLesson.forEach((lesson) => { + expect(lesson.title.length).toBeLessThanOrEqual(20); + lesson.tags.forEach((tag) => expect(tag.length).toBeLessThanOrEqual(10)); + }); + }); + }); + + describe('resetTcsMockData', () => { + it('does nothing when there is no manifest', async () => { + const deps = makeDeps(); + const result = await resetTcsMockData(deps); + expect(result).toEqual({ removed: 0 }); + expect(deps.Manifest.deleteOne).not.toHaveBeenCalled(); + }); + + it('deletes only the IDs listed in the manifest', async () => { + const deps = makeDeps({ + manifest: { entries: [{ collection: 'buildingIssue', ids: ['a', 'b'] }] }, + }); + const result = await resetTcsMockData(deps); + expect(deps.models.buildingIssue.deleteMany).toHaveBeenCalledWith({ + _id: { $in: ['a', 'b'] }, + }); + expect(deps.models.injurySeverity.deleteMany).not.toHaveBeenCalled(); + expect(deps.Manifest.deleteOne).toHaveBeenCalledWith({ name: MANIFEST_NAME }); + expect(result.removed).toBe(2); + }); + }); + + describe('seedTcsMockData', () => { + it('inserts mock data for found projects and records the IDs in the manifest', async () => { + const deps = makeDeps(); + const summary = await seedTcsMockData(deps); + + expect(deps.BuildingProject.find).toHaveBeenCalledWith( + { name: { $in: MOCK_PROJECT_NAMES } }, + '_id name', + ); + MOCK_COLLECTIONS.forEach(([key]) => { + expect(deps.models[key].insertMany).toHaveBeenCalled(); + }); + const manifest = deps.Manifest.create.mock.calls[0][0]; + expect(manifest.name).toBe(MANIFEST_NAME); + expect(manifest.entries).toHaveLength(MOCK_COLLECTIONS.length); + expect(summary.seededProjects).toEqual(['Building 1', 'Building 2']); + expect(summary.missingProjects).toEqual( + MOCK_PROJECT_NAMES.filter((name) => !['Building 1', 'Building 2'].includes(name)), + ); + }); + + it('inserts nothing when no projects are found', async () => { + const deps = makeDeps({ projects: [] }); + const summary = await seedTcsMockData(deps); + expect(summary.inserted).toEqual({}); + expect(deps.Manifest.create).not.toHaveBeenCalled(); + }); + + it('inserts nothing when no administrator user is found', async () => { + const deps = makeDeps({ user: null }); + const summary = await seedTcsMockData(deps); + expect(summary.missingUser).toBe(true); + expect(deps.Manifest.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utilities/tcsMockDataSeeder.js b/src/utilities/tcsMockDataSeeder.js new file mode 100644 index 000000000..e3b0bb985 --- /dev/null +++ b/src/utilities/tcsMockDataSeeder.js @@ -0,0 +1,453 @@ +/** + * Mock data for the Total Construction Summary (BM Dashboard → Reports). + * + * Builds realistic records for the existing BM projects over recent weeks so every + * API-backed chart on the page shows data. All inserted IDs are recorded in a + * manifest document, so reset removes only the mock records and never touches data + * created by other developers. + * + * Kept separate from the CLI runner (src/scripts/seedTcsMockData.js) so the logic can + * be unit tested with mocked models. + */ + +/** Existing BM project names, so mock data uses the same names the charts already show */ +const MOCK_PROJECT_NAMES = [ + 'Building 1', + 'Building 2', + 'Building 3', + 'Residential Test - Project', + 'Commercial Test - Project', +]; + +const MANIFEST_NAME = 'tcs-mock-data'; +const WEEKS_OF_DATA = 8; +const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; + +const ISSUE_TYPES = ['Safety', 'Labor', 'Weather', 'METs quality / functionality', 'Other']; +const SEVERITIES = ['Low', 'Medium', 'High', 'Critical']; +const INJURY_TYPES = ['Cut', 'Fall', 'Strain', 'Burn', 'Bruise']; +const DEPARTMENTS = ['Carpentry', 'Electrical', 'Plumbing', 'Masonry']; +const WORKER_CATEGORIES = ['Laborer', 'Electrician', 'Plumber', 'Carpenter']; +const INJURY_SEVERITIES = ['Minor', 'Major', 'Critical']; +const COST_CATEGORIES = ['Labor', 'Materials', 'Equipment']; +const TOOL_NAMES = ['Drill', 'Circular saw', 'Hammer', 'Ladder', 'Concrete mixer', 'Level']; +const TOOL_STATUSES = ['In Use', 'Needs to be replaced', 'Yet to receive']; +const LABOR_TASKS = ['Framing', 'Wiring', 'Plumbing rough-in', 'Drywall', 'Painting']; +const LESSON_TAGS = ['safety', 'planning', 'materials', 'weather', 'teamwork']; + +/** Small seeded random generator (linear congruential) so the same seed always gives the same data */ +const RANDOM_MODULUS = 4294967296; // 2^32 +const RANDOM_MULTIPLIER = 1664525; +const RANDOM_INCREMENT = 1013904223; + +const createRandom = (seed = 42) => { + let state = Math.abs(Math.floor(seed)) % RANDOM_MODULUS; + const next = () => { + state = (state * RANDOM_MULTIPLIER + RANDOM_INCREMENT) % RANDOM_MODULUS; + return state / RANDOM_MODULUS; + }; + return { + next, + int: (min, max) => min + Math.floor(next() * (max - min + 1)), + pick: (list) => list[Math.floor(next() * list.length)], + }; +}; + +/** A date `daysAgo` days before `now`, at a working hour */ +const daysBefore = (now, daysAgo, hour = 10) => { + const date = new Date(now.getTime() - daysAgo * DAY_MS); + date.setUTCHours(hour, 0, 0, 0); + return date; +}; + +const recentDay = (rand) => rand.int(0, WEEKS_OF_DATA * 7 - 1); + +const buildIssues = ({ projects, userId, now, rand }) => + projects.flatMap((project) => + Array.from({ length: 8 }, (_, i) => { + const issueDate = daysBefore(now, recentDay(rand)); + const isClosed = rand.next() < 0.5; + const issueType = ISSUE_TYPES[i % ISSUE_TYPES.length]; + return { + issueDate, + createdDate: issueDate, + closedDate: isClosed ? new Date(issueDate.getTime() + rand.int(1, 10) * DAY_MS) : null, + createdBy: userId, + issueTitle: [`[Mock] ${issueType} issue ${i + 1}`], + issueText: [`Mock ${issueType.toLowerCase()} issue logged on ${project.name} for testing.`], + issueType, + projectId: project._id, + status: isClosed ? 'closed' : 'open', + severity: rand.pick(SEVERITIES), + }; + }), + ); + +const buildInjurySeverities = ({ projects, now, rand }) => + projects.flatMap((project) => + Array.from({ length: 6 }, () => ({ + projectId: project._id, + date: daysBefore(now, recentDay(rand)), + injuryType: rand.pick(INJURY_TYPES), + department: rand.pick(DEPARTMENTS), + severity: rand.pick(INJURY_SEVERITIES), + count: rand.int(1, 3), + })), + ); + +const buildInjuryCategories = ({ projects, now, rand }) => + projects.flatMap((project) => + Array.from({ length: 6 }, () => ({ + projectId: project._id, + projectName: project.name, + date: daysBefore(now, recentDay(rand)), + injuryType: rand.pick(INJURY_TYPES), + workerCategory: rand.pick(WORKER_CATEGORIES), + severity: rand.pick(INJURY_SEVERITIES), + count: rand.int(1, 4), + })), + ); + +const buildInjuriesOverTime = ({ projects, now, rand }) => + projects.flatMap((project) => + Array.from({ length: WEEKS_OF_DATA }, (_, week) => ({ + projectId: project._id, + date: daysBefore(now, week * 7 + rand.int(0, 6)), + injuryType: rand.pick(INJURY_TYPES), + department: rand.pick(DEPARTMENTS), + severity: rand.pick(INJURY_SEVERITIES), + count: rand.int(0, 3), + })), + ); + +const buildRentals = ({ projects, now, rand }) => + projects.flatMap((project) => + Array.from({ length: WEEKS_OF_DATA }, (_, week) => ({ + projectId: project._id, + date: daysBefore(now, week * 7 + rand.int(0, 6)), + toolName: rand.pick(TOOL_NAMES), + rentalCost: rand.int(150, 900), + totalMaterialCost: rand.int(1000, 6000), + })), + ); + +const buildToolReturns = ({ projects, now, rand }) => + projects.flatMap((project) => + TOOL_NAMES.slice(0, 4).map((toolName) => { + const totalReturns = rand.int(4, 12); + return { + projectId: project._id, + toolName, + rentalCost: rand.int(150, 900), + totalMaterialCost: rand.int(1000, 6000), + returnedLate: rand.int(0, Math.floor(totalReturns / 2)), + totalReturns, + date: daysBefore(now, recentDay(rand)), + }; + }), + ); + +const buildToolsDowntime = ({ projects, now, rand }) => + projects.flatMap((project) => + TOOL_NAMES.slice(0, 4).map((toolName, i) => { + const startDate = daysBefore(now, rand.int(7, WEEKS_OF_DATA * 7 - 1)); + return { + toolId: `MOCK-${String(project._id).slice(-4)}-${i + 1}`, + toolName, + projectId: String(project._id), + utilizationRate: rand.int(40, 95), + downtimeHours: rand.int(2, 40), + startDate, + endDate: new Date(startDate.getTime() + rand.int(1, 5) * DAY_MS), + }; + }), + ); + +const buildToolStoppages = ({ projects, now, rand }) => + projects.flatMap((project) => + TOOL_NAMES.slice(0, 4).map((toolName) => ({ + projectId: project._id, + toolName, + usedForLifetime: rand.int(5, 30), + damaged: rand.int(0, 6), + lost: rand.int(0, 3), + date: daysBefore(now, recentDay(rand)), + })), + ); + +const buildToolAvailability = ({ projects, now, rand }) => + projects.flatMap((project) => + TOOL_NAMES.map((toolName) => ({ + projectId: project._id, + toolName, + status: rand.pick(TOOL_STATUSES), + quantity: rand.int(1, 10), + date: daysBefore(now, rand.int(0, 13)), + })), + ); + +const buildExpenditures = ({ projects, rand }) => + projects.flatMap((project) => + COST_CATEGORIES.flatMap((category) => { + const planned = rand.int(20000, 80000); + return [ + { projectId: project._id, category, type: 'planned', amount: planned }, + { + projectId: project._id, + category, + type: 'actual', + amount: Math.round(planned * (0.8 + rand.next() * 0.4)), + }, + ]; + }), + ); + +const buildCostTracking = ({ projects, now, rand }) => + projects.flatMap((project) => + Array.from({ length: WEEKS_OF_DATA }, (_, week) => + COST_CATEGORIES.map((category) => ({ + projectId: project._id, + date: daysBefore(now, week * 7), + category, + cost: rand.int(1500, 9000), + })), + ).flat(), + ); + +const buildRiskProfiles = ({ projects, now, rand }) => + projects.map((project) => { + const initialCostEstimate = rand.int(150000, 400000); + return { + projectId: project._id, + startDate: daysBefore(now, rand.int(90, 180)), + endDate: daysBefore(now, -rand.int(60, 180)), + initialCostEstimate, + currentCostIncurred: Math.round(initialCostEstimate * (0.4 + rand.next() * 0.5)), + issues: [], + plannedTime: rand.int(180, 365), + actualTimeElapsed: rand.int(90, 200), + }; + }); + +const buildTimeLogs = ({ projects, userId, now, rand }) => + projects.flatMap((project) => + Array.from({ length: WEEKS_OF_DATA }, (_, week) => { + const startTime = daysBefore(now, week * 7 + rand.int(0, 4), 8); + const duration = rand.int(3, 8) * HOUR_MS; + return { + project: project._id, + member: userId, + task: rand.pick(LABOR_TASKS), + intervals: [{ startTime, endTime: new Date(startTime.getTime() + duration), duration }], + totalElapsedTime: duration, + status: 'completed', + }; + }), + ); + +const buildLessons = ({ projects, userId, now, rand }) => + projects.flatMap((project) => + Array.from({ length: 3 }, (_, i) => ({ + title: `Mock lesson ${i + 1}`, + content: `Mock lesson learned on ${project.name}: plan deliveries and safety checks before each phase.`, + date: daysBefore(now, recentDay(rand)), + author: userId, + tags: [rand.pick(LESSON_TAGS), rand.pick(LESSON_TAGS)], + relatedProject: project._id, + allowedRoles: 'All', + })), + ); + +const buildMaterials = ({ projects, userId, now, rand, materialTypes }) => + projects.flatMap((project) => + materialTypes.slice(0, 5).map((type) => { + const stockBought = rand.int(200, 600); + const updateRecord = Array.from({ length: WEEKS_OF_DATA * 2 }, (_, i) => ({ + date: daysBefore(now, Math.floor((i * 7) / 2) + rand.int(0, 2)), + createdBy: userId, + quantityUsed: rand.int(5, 25), + quantityWasted: rand.int(0, 4), + })); + const stockUsed = updateRecord.reduce((sum, r) => sum + r.quantityUsed, 0); + const stockWasted = updateRecord.reduce((sum, r) => sum + r.quantityWasted, 0); + return { + itemType: type._id, + project: project._id, + stockBought, + stockUsed, + stockWasted, + stockAvailable: Math.max(stockBought - stockUsed - stockWasted, 0), + purchaseRecord: [ + { + date: daysBefore(now, WEEKS_OF_DATA * 7), + requestedBy: userId, + quantity: stockBought, + priority: rand.pick(['Low', 'Medium', 'High']), + status: 'Approved', + unitPrice: rand.int(5, 80), + }, + ], + updateRecord, + }; + }), + ); + +const buildTools = ({ projects, userId, now, rand, toolTypes }) => + projects.flatMap((project) => + toolTypes.slice(0, 4).map((type, i) => ({ + itemType: type._id, + project: project._id, + purchaseStatus: i % 2 === 0 ? 'Purchase' : 'Rental', + purchaseRecord: [ + { + date: daysBefore(now, WEEKS_OF_DATA * 7), + requestedBy: userId, + priority: rand.pick(['Low', 'Medium', 'High']), + quantity: rand.int(1, 5), + status: 'Approved', + unitPrice: rand.int(40, 400), + }, + ], + updateRecord: [ + { + date: daysBefore(now, rand.int(0, 13)), + createdBy: userId, + condition: rand.pick(['Good', 'Needs Repair', 'Out of Order']), + }, + ], + })), + ); + +/** Collection key → { model key, builder } */ +const MOCK_COLLECTIONS = [ + ['buildingIssue', buildIssues], + ['injurySeverity', buildInjurySeverities], + ['injuryCategory', buildInjuryCategories], + ['injuryOverTime', buildInjuriesOverTime], + ['rentalChart', buildRentals], + ['toolReturn', buildToolReturns], + ['toolsDowntime', buildToolsDowntime], + ['toolStoppageReason', buildToolStoppages], + ['toolAvailability', buildToolAvailability], + ['expenditure', buildExpenditures], + ['projectCostTracking', buildCostTracking], + ['projectRiskProfile', buildRiskProfiles], + ['timeLog', buildTimeLogs], + ['newLesson', buildLessons], + ['buildingMaterial', buildMaterials], + ['buildingTool', buildTools], +]; + +/** Splits inventory types into material and tool types (works with category or discriminator) */ +const splitInventoryTypes = (types = []) => { + const kind = (type) => String(type.category || type.__t || '').toLowerCase(); + return { + materialTypes: types.filter((type) => kind(type).includes('material')), + toolTypes: types.filter((type) => kind(type).includes('tool')), + }; +}; + +/** Builds every mock document, grouped by collection key */ +const buildMockData = (context) => + Object.fromEntries(MOCK_COLLECTIONS.map(([key, build]) => [key, build(context)])); + +/** + * Removes only the records listed in the manifest, then deletes the manifest. + * + * @returns {Promise<{removed: number}>} + */ +const resetTcsMockData = async ({ models, Manifest }) => { + const manifest = await Manifest.findOne({ name: MANIFEST_NAME }).lean(); + if (!manifest) return { removed: 0 }; + + let removed = 0; + await Promise.all( + (manifest.entries || []).map(async ({ collection, ids }) => { + const model = models[collection]; + if (!model || !ids?.length) return; + const result = await model.deleteMany({ _id: { $in: ids } }); + removed += result?.deletedCount || 0; + }), + ); + await Manifest.deleteOne({ name: MANIFEST_NAME }); + return { removed }; +}; + +/** + * Resets any previous mock data, then inserts fresh mock data for the known BM projects + * and records every inserted ID in the manifest. + * + * @returns {Promise<{removed: number, inserted: Object, seededProjects: string[], missingProjects: string[]}>} + */ +const seedTcsMockData = async ({ + models, + BuildingProject, + UserProfile, + InventoryType, + Manifest, + now = new Date(), + seed = 42, +}) => { + const { removed } = await resetTcsMockData({ models, Manifest }); + + const projects = await BuildingProject.find( + { name: { $in: MOCK_PROJECT_NAMES } }, + '_id name', + ).lean(); + const foundNames = new Set(projects.map((project) => project.name)); + const missingProjects = MOCK_PROJECT_NAMES.filter((name) => !foundNames.has(name)); + + const user = await UserProfile.findOne({ role: 'Administrator', isActive: true }, '_id').lean(); + + if (projects.length === 0 || !user) { + return { removed, inserted: {}, seededProjects: [], missingProjects, missingUser: !user }; + } + + const types = InventoryType ? await InventoryType.find({}, '_id category __t').lean() : []; + const { materialTypes, toolTypes } = splitInventoryTypes(types); + + const data = buildMockData({ + projects, + userId: user._id, + now, + rand: createRandom(seed), + materialTypes, + toolTypes, + }); + + const inserted = {}; + const entries = []; + // Insert one collection at a time so a failure leaves a clear manifest of what exists + // eslint-disable-next-line no-restricted-syntax + for (const [key, docs] of Object.entries(data)) { + const model = models[key]; + if (model && docs.length) { + // eslint-disable-next-line no-await-in-loop + const created = await model.insertMany(docs); + inserted[key] = created.length; + entries.push({ collection: key, ids: created.map((doc) => doc._id) }); + } + } + + await Manifest.create({ name: MANIFEST_NAME, entries, seededAt: now }); + + return { + removed, + inserted, + seededProjects: projects.map((project) => project.name), + missingProjects, + missingUser: false, + }; +}; + +module.exports = { + MOCK_PROJECT_NAMES, + MANIFEST_NAME, + MOCK_COLLECTIONS, + createRandom, + splitInventoryTypes, + buildMockData, + seedTcsMockData, + resetTcsMockData, +}; From a97b6c2a9e028570b18bc3ccd44475623fb23259 Mon Sep 17 00:00:00 2001 From: snehitakandula Date: Wed, 4 Nov 2026 22:32:58 -0500 Subject: [PATCH 2/4] fix: rename reserved manifest field to collectionKey --- src/models/bmdashboard/mockDataManifest.js | 2 +- src/utilities/__tests__/tcsMockDataSeeder.test.js | 2 +- src/utilities/tcsMockDataSeeder.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/models/bmdashboard/mockDataManifest.js b/src/models/bmdashboard/mockDataManifest.js index 363230bce..12dc76f06 100644 --- a/src/models/bmdashboard/mockDataManifest.js +++ b/src/models/bmdashboard/mockDataManifest.js @@ -11,7 +11,7 @@ const mockDataManifest = new Schema({ entries: [ { _id: false, - collection: { type: String, required: true }, + collectionKey: { type: String, required: true }, ids: [{ type: Schema.Types.ObjectId }], }, ], diff --git a/src/utilities/__tests__/tcsMockDataSeeder.test.js b/src/utilities/__tests__/tcsMockDataSeeder.test.js index 7b900790f..7973bfbf0 100644 --- a/src/utilities/__tests__/tcsMockDataSeeder.test.js +++ b/src/utilities/__tests__/tcsMockDataSeeder.test.js @@ -144,7 +144,7 @@ describe('tcsMockDataSeeder', () => { it('deletes only the IDs listed in the manifest', async () => { const deps = makeDeps({ - manifest: { entries: [{ collection: 'buildingIssue', ids: ['a', 'b'] }] }, + manifest: { entries: [{ collectionKey: 'buildingIssue', ids: ['a', 'b'] }] }, }); const result = await resetTcsMockData(deps); expect(deps.models.buildingIssue.deleteMany).toHaveBeenCalledWith({ diff --git a/src/utilities/tcsMockDataSeeder.js b/src/utilities/tcsMockDataSeeder.js index e3b0bb985..11ae02422 100644 --- a/src/utilities/tcsMockDataSeeder.js +++ b/src/utilities/tcsMockDataSeeder.js @@ -363,8 +363,8 @@ const resetTcsMockData = async ({ models, Manifest }) => { let removed = 0; await Promise.all( - (manifest.entries || []).map(async ({ collection, ids }) => { - const model = models[collection]; + (manifest.entries || []).map(async ({ collectionKey, ids }) => { + const model = models[collectionKey]; if (!model || !ids?.length) return; const result = await model.deleteMany({ _id: { $in: ids } }); removed += result?.deletedCount || 0; @@ -426,7 +426,7 @@ const seedTcsMockData = async ({ // eslint-disable-next-line no-await-in-loop const created = await model.insertMany(docs); inserted[key] = created.length; - entries.push({ collection: key, ids: created.map((doc) => doc._id) }); + entries.push({ collectionKey: key, ids: created.map((doc) => doc._id) }); } } From f2402c326851f91a54751df5df948b2beb800915 Mon Sep 17 00:00:00 2001 From: snehitakandula Date: Thu, 5 Nov 2026 00:38:57 -0500 Subject: [PATCH 3/4] feat: seed project status, labor cost, risk profile names and weekly material purchases --- src/scripts/seedTcsMockData.js | 7 ++ .../__tests__/tcsMockDataSeeder.test.js | 29 +++++++ src/utilities/tcsMockDataSeeder.js | 86 +++++++++++++++---- 3 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/scripts/seedTcsMockData.js b/src/scripts/seedTcsMockData.js index 47ba4b880..a247580a1 100644 --- a/src/scripts/seedTcsMockData.js +++ b/src/scripts/seedTcsMockData.js @@ -33,10 +33,13 @@ const ToolAvailability = require('../models/bmdashboard/toolAvailability'); const Expenditure = require('../models/bmdashboard/expenditure'); const ProjectCostTracking = require('../models/bmdashboard/projectCostTracking'); const ProjectRiskProfile = require('../models/bmdashboard/projectRiskProfile'); +const RiskIssue = require('../models/bmdashboard/Issues'); const TimeLog = require('../models/bmdashboard/buildingTimeLogger'); const NewLesson = require('../models/bmdashboard/buildingNewLesson'); const BuildingMaterial = require('../models/bmdashboard/buildingMaterial'); const BuildingTool = require('../models/bmdashboard/buildingTool'); +const ProjectStatus = require('../models/projectStatus'); +const LaborCost = require('../models/laborCost'); const Manifest = require('../models/bmdashboard/mockDataManifest'); const { seedTcsMockData, resetTcsMockData } = require('../utilities/tcsMockDataSeeder'); @@ -52,11 +55,14 @@ const models = { toolAvailability: ToolAvailability, expenditure: Expenditure, projectCostTracking: ProjectCostTracking, + riskIssue: RiskIssue, projectRiskProfile: ProjectRiskProfile, timeLog: TimeLog, newLesson: NewLesson, buildingMaterial: BuildingMaterial, buildingTool: BuildingTool, + projectStatus: ProjectStatus, + laborCost: LaborCost, }; const { user, password, cluster, dbName, appName = 'HGNRest' } = process.env; @@ -79,6 +85,7 @@ async function run() { UserProfile, InventoryType: invTypeBase, Manifest, + createId: () => new mongoose.Types.ObjectId(), }); console.log(`Removed ${summary.removed} previous mock record(s)`); if (summary.missingUser) console.warn('No active Administrator found; nothing inserted.'); diff --git a/src/utilities/__tests__/tcsMockDataSeeder.test.js b/src/utilities/__tests__/tcsMockDataSeeder.test.js index 7973bfbf0..690403453 100644 --- a/src/utilities/__tests__/tcsMockDataSeeder.test.js +++ b/src/utilities/__tests__/tcsMockDataSeeder.test.js @@ -126,6 +126,35 @@ describe('tcsMockDataSeeder', () => { expect(recent.length).toBeGreaterThan(0); }); + it('links each risk profile to an Issues document carrying the project name', () => { + let next = 0; + const linked = buildMockData({ + projects: PROJECTS, + userId: 'u1', + now: NOW, + rand: createRandom(1), + materialTypes: [], + toolTypes: [], + createId: () => { + next += 1; + return `issue-${next}`; + }, + }); + linked.projectRiskProfile.forEach((profile, i) => { + const issueDoc = linked.riskIssue[i]; + expect(profile.projectId).toBe(issueDoc._id); + expect(profile.issues).toEqual([issueDoc._id]); + expect(issueDoc.projectName).toBe(PROJECTS[i].name); + }); + }); + + it('creates project status and labor cost rows by project name', () => { + expect(data.projectStatus.map((row) => row.name)).toEqual(['Building 1', 'Building 2']); + data.laborCost.forEach((row) => { + expect(['Building 1', 'Building 2']).toContain(row.project_name); + }); + }); + it('respects lesson field limits', () => { data.newLesson.forEach((lesson) => { expect(lesson.title.length).toBeLessThanOrEqual(20); diff --git a/src/utilities/tcsMockDataSeeder.js b/src/utilities/tcsMockDataSeeder.js index 11ae02422..94c68be9d 100644 --- a/src/utilities/tcsMockDataSeeder.js +++ b/src/utilities/tcsMockDataSeeder.js @@ -215,16 +215,31 @@ const buildCostTracking = ({ projects, now, rand }) => ).flat(), ); -const buildRiskProfiles = ({ projects, now, rand }) => - projects.map((project) => { +/** + * The risk profile summary reads the project name from an `Issues` document whose _id is the + * risk profile's projectId, so each mock project gets one Issues document with a known _id. + */ +const buildRiskIssues = ({ projects, rand, riskIssueIds = [] }) => + projects.map((project, i) => ({ + ...(riskIssueIds[i] ? { _id: riskIssueIds[i] } : {}), + projectId: project._id, + projectName: project.name, + equipmentIssues: rand.int(0, 5), + laborIssues: rand.int(0, 5), + materialIssues: rand.int(0, 5), + })); + +const buildRiskProfiles = ({ projects, now, rand, riskIssueIds = [] }) => + projects.map((project, i) => { const initialCostEstimate = rand.int(150000, 400000); + const issueId = riskIssueIds[i]; return { - projectId: project._id, + projectId: issueId || project._id, startDate: daysBefore(now, rand.int(90, 180)), endDate: daysBefore(now, -rand.int(60, 180)), initialCostEstimate, currentCostIncurred: Math.round(initialCostEstimate * (0.4 + rand.next() * 0.5)), - issues: [], + issues: issueId ? [issueId] : [], plannedTime: rand.int(180, 365), actualTimeElapsed: rand.int(90, 200), }; @@ -262,15 +277,25 @@ const buildLessons = ({ projects, userId, now, rand }) => const buildMaterials = ({ projects, userId, now, rand, materialTypes }) => projects.flatMap((project) => materialTypes.slice(0, 5).map((type) => { - const stockBought = rand.int(200, 600); + const unitPrice = rand.int(5, 80); const updateRecord = Array.from({ length: WEEKS_OF_DATA * 2 }, (_, i) => ({ date: daysBefore(now, Math.floor((i * 7) / 2) + rand.int(0, 2)), createdBy: userId, quantityUsed: rand.int(5, 25), quantityWasted: rand.int(0, 4), })); + // One approved purchase per week so every weekly period has a material cost + const purchaseRecord = Array.from({ length: WEEKS_OF_DATA }, (_, week) => ({ + date: daysBefore(now, week * 7 + rand.int(0, 6)), + requestedBy: userId, + quantity: rand.int(30, 80), + priority: rand.pick(['Low', 'Medium', 'High']), + status: 'Approved', + unitPrice, + })); const stockUsed = updateRecord.reduce((sum, r) => sum + r.quantityUsed, 0); const stockWasted = updateRecord.reduce((sum, r) => sum + r.quantityWasted, 0); + const stockBought = purchaseRecord.reduce((sum, r) => sum + r.quantity, 0); return { itemType: type._id, project: project._id, @@ -278,21 +303,39 @@ const buildMaterials = ({ projects, userId, now, rand, materialTypes }) => stockUsed, stockWasted, stockAvailable: Math.max(stockBought - stockUsed - stockWasted, 0), - purchaseRecord: [ - { - date: daysBefore(now, WEEKS_OF_DATA * 7), - requestedBy: userId, - quantity: stockBought, - priority: rand.pick(['Low', 'Medium', 'High']), - status: 'Approved', - unitPrice: rand.int(5, 80), - }, - ], + purchaseRecord, updateRecord, }; }), ); +/** Project status cards match ProjectStatus to BM projects by exact name */ +const PROJECT_STATUSES = ['Active', 'Active', 'Completed', 'Delayed', 'Active']; + +const buildProjectStatuses = ({ projects, now, rand }) => + projects.map((project, i) => { + const status = PROJECT_STATUSES[i % PROJECT_STATUSES.length]; + return { + name: project.name, + status, + startDate: daysBefore(now, rand.int(90, 200)), + // Completed within the last week so the default weekly view counts it + // Mid-week of the previous Sunday–Saturday week, so the default "last week" view counts it + completionDate: status === 'Completed' ? daysBefore(now, now.getUTCDay() + 4) : undefined, + }; + }); + +/** LaborCost links to BM projects by project_name */ +const buildLaborCosts = ({ projects, now, rand }) => + projects.flatMap((project) => + Array.from({ length: WEEKS_OF_DATA * 2 }, (_, i) => ({ + project_name: project.name, + task: rand.pick(LABOR_TASKS), + cost: rand.int(400, 2500), + date: daysBefore(now, Math.floor((i * 7) / 2) + rand.int(0, 2)), + })), + ); + const buildTools = ({ projects, userId, now, rand, toolTypes }) => projects.flatMap((project) => toolTypes.slice(0, 4).map((type, i) => ({ @@ -332,11 +375,14 @@ const MOCK_COLLECTIONS = [ ['toolAvailability', buildToolAvailability], ['expenditure', buildExpenditures], ['projectCostTracking', buildCostTracking], + ['riskIssue', buildRiskIssues], ['projectRiskProfile', buildRiskProfiles], ['timeLog', buildTimeLogs], ['newLesson', buildLessons], ['buildingMaterial', buildMaterials], ['buildingTool', buildTools], + ['projectStatus', buildProjectStatuses], + ['laborCost', buildLaborCosts], ]; /** Splits inventory types into material and tool types (works with category or discriminator) */ @@ -349,8 +395,12 @@ const splitInventoryTypes = (types = []) => { }; /** Builds every mock document, grouped by collection key */ -const buildMockData = (context) => - Object.fromEntries(MOCK_COLLECTIONS.map(([key, build]) => [key, build(context)])); +const buildMockData = (context) => { + // IDs created up front so risk profiles can point at their Issues documents + const riskIssueIds = context.createId ? context.projects.map(() => context.createId()) : []; + const fullContext = { ...context, riskIssueIds }; + return Object.fromEntries(MOCK_COLLECTIONS.map(([key, build]) => [key, build(fullContext)])); +}; /** * Removes only the records listed in the manifest, then deletes the manifest. @@ -388,6 +438,7 @@ const seedTcsMockData = async ({ Manifest, now = new Date(), seed = 42, + createId, }) => { const { removed } = await resetTcsMockData({ models, Manifest }); @@ -414,6 +465,7 @@ const seedTcsMockData = async ({ rand: createRandom(seed), materialTypes, toolTypes, + createId, }); const inserted = {}; From 7f9f9814be62baaee9f7de8cf32dde1bfab57607 Mon Sep 17 00:00:00 2001 From: snehitakandula Date: Thu, 5 Nov 2026 16:32:30 -0500 Subject: [PATCH 4/4] chore: remove lint warnings from mock data seeder --- src/scripts/seedTcsMockData.js | 2 +- src/utilities/tcsMockDataSeeder.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/scripts/seedTcsMockData.js b/src/scripts/seedTcsMockData.js index a247580a1..aeb7749d8 100644 --- a/src/scripts/seedTcsMockData.js +++ b/src/scripts/seedTcsMockData.js @@ -104,5 +104,5 @@ async function run() { run().catch(async (err) => { console.error(err); await mongoose.disconnect().catch(() => {}); - process.exit(1); + process.exitCode = 1; }); diff --git a/src/utilities/tcsMockDataSeeder.js b/src/utilities/tcsMockDataSeeder.js index 94c68be9d..3dd652016 100644 --- a/src/utilities/tcsMockDataSeeder.js +++ b/src/utilities/tcsMockDataSeeder.js @@ -1,3 +1,5 @@ +/* eslint-disable no-magic-numbers -- mock data uses literal counts, ranges and prices on purpose */ + /** * Mock data for the Total Construction Summary (BM Dashboard → Reports). *