diff --git a/app-backend/.env.example b/app-backend/.env.example index 8fbe50f0d..40d839c72 100644 --- a/app-backend/.env.example +++ b/app-backend/.env.example @@ -4,10 +4,15 @@ # and does not read this file. NODE_ENV=development PORT=5000 -MONGO_URI=mongodb://secureshift_app:secureshift_app_password@mongodb:27017/secureshift_local?authSource=secureshift_local +MONGO_URI=mongodb://secureshift_app:secureshift_app_password@localhost:27017/secureshift_local?authSource=secureshift_local JWT_SECRET=local-dev-jwt-secret-change-me LICENCE_ENC_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= +# Local seed commands are disabled unless this is explicitly changed to true. +SEED_ALLOW_LOCAL=false +# Set only for a deliberate `npm run seed:reset` invocation. +SEED_RESET_CONFIRM= + # Email is disabled for local Docker onboarding. Replace with real SMTP values only in a private .env. EMAIL_ENABLED=false SMTP_HOST=localhost diff --git a/app-backend/README.md b/app-backend/README.md index 2c50e1c33..8816ee781 100644 --- a/app-backend/README.md +++ b/app-backend/README.md @@ -134,6 +134,71 @@ npm run test Unit and integration tests are managed via Jest (or Mocha/Chai if used). +## Local development seed data + +The seed commands are for local development only. They refuse production, Atlas/SRV, remote hosts, +and every database except the exact names `secureshift_local`, `secureshift_dev`, and +`secureshift_test`. The seed CLI uses only the explicit `MONGO_URI`; it never uses the server +connection fallback. + +### Backend outside Docker, MongoDB in Docker + +Start MongoDB from the repository root: + +```bash +docker compose up -d mongodb +``` + +When Node runs directly on the host, use `localhost` and the published MongoDB port. In +`app-backend/.env`, configure the authenticated URI and explicitly enable local seeding: + +```env +NODE_ENV=development +SEED_ALLOW_LOCAL=true +MONGO_URI=mongodb://secureshift_app:secureshift_app_password@localhost:27017/secureshift_local?authSource=secureshift_local +``` + +Then run from `app-backend`: + +```bash +npm run seed +SEED_RESET_CONFIRM=SecureShiftLocalReset npm run seed:reset +``` + +### Seed alongside Docker Compose + +Containers on the Compose network reach MongoDB by its service hostname, `mongodb`, rather than +`localhost`. The backend service already has this authenticated internal URI: + +```env +MONGO_URI=mongodb://secureshift_app:secureshift_app_password@mongodb:27017/secureshift_local?authSource=secureshift_local +``` + +From the repository root, run one-off backend containers with the required seed opt-in: + +```bash +docker compose run --rm -e SEED_ALLOW_LOCAL=true backend npm run seed +docker compose run --rm -e SEED_ALLOW_LOCAL=true -e SEED_RESET_CONFIRM=SecureShiftLocalReset backend npm run seed:reset +``` + +The seed is idempotent and uses stable ObjectIds. Running it again updates the same local records. +Reset deletes only those stable seed IDs and requires the exact confirmation value shown above. + +All test accounts use the local-only password `SecureShift1!`: + +| Role | Scenario | Email | +| --- | --- | --- | +| Admin | Admin access | `admin.local@secureshift.test` | +| Employer | Operations employer | `ops.local@secureshift.test` | +| Employer | Venue employer | `venue.local@secureshift.test` | +| Guard | Approved licence | `mia.guard@secureshift.test` | +| Guard | Pending licence | `noah.guard@secureshift.test` | +| Guard | Rejected licence | `isha.guard@secureshift.test` | +| Guard | Expired licence | `liam.guard@secureshift.test` | + +Employer and guard login still uses the normal OTP flow. This seed does not bypass OTP. Admin login +uses the existing admin authentication endpoint. + --- ## 📄 License diff --git a/app-backend/package.json b/app-backend/package.json index db178bb27..89b1b8dcb 100644 --- a/app-backend/package.json +++ b/app-backend/package.json @@ -14,6 +14,8 @@ "lint:fix": "eslint \"src/**/*.js\" --fix", "test": "jest --runInBand", "test:watch": "jest --watch", + "seed": "node -r dotenv/config src/scripts/seed/index.js", + "seed:reset": "node -r dotenv/config src/scripts/seed/index.js --reset", "format": "prettier --write \"src/**/*.js\"", "format:check": "prettier --check \"src/**/*.js\"" }, diff --git a/app-backend/src/scripts/seed/data.js b/app-backend/src/scripts/seed/data.js new file mode 100644 index 000000000..930e18140 --- /dev/null +++ b/app-backend/src/scripts/seed/data.js @@ -0,0 +1,517 @@ +import { SEED_IDS } from "./ids.js"; + +export const SEED_PASSWORD = "SecureShift1!"; + +const addDays = (date, days) => { + const result = new Date(date); + result.setUTCDate(result.getUTCDate() + days); + return result; +}; + +const atLocalTime = (date, hours, minutes = 0) => { + const result = new Date(date); + result.setHours(hours, minutes, 0, 0); + return result; +}; + +const weekBounds = (date) => { + const start = new Date(date); + const day = start.getUTCDay(); + start.setUTCDate(start.getUTCDate() - day + (day === 0 ? -6 : 1)); + start.setUTCHours(0, 0, 0, 0); + + const end = addDays(start, 6); + end.setUTCHours(23, 59, 59, 999); + return { start, end }; +}; + +export const buildSeedData = (now = new Date()) => { + const today = new Date(now); + today.setHours(12, 0, 0, 0); + const completedDate = today; + const payrollPeriod = weekBounds(completedDate); + const adminId = SEED_IDS.users.admin; + + return { + roles: [ + { + _id: SEED_IDS.roles.superAdmin, + name: "super_admin", + description: "System-wide super administrator", + permissions: ["*"], + isSystem: true, + }, + { + _id: SEED_IDS.roles.admin, + name: "admin", + description: "System admin with broad permissions", + permissions: [ + "user:read", + "user:write", + "shift:read", + "shift:write", + "shift:assign", + ], + isSystem: true, + }, + { + _id: SEED_IDS.roles.branchAdmin, + name: "branch_admin", + description: "Branch-level administrator", + permissions: [ + "user:read", + "shift:read", + "shift:write", + "shift:assign", + "branch:read", + ], + isSystem: true, + }, + { + _id: SEED_IDS.roles.employer, + name: "employer", + description: "Employer role", + permissions: [ + "shift:read", + "shift:write", + "payment:read", + "payment:write", + ], + isSystem: true, + }, + { + _id: SEED_IDS.roles.guard, + name: "guard", + description: "Guard role", + permissions: ["shift:read", "shift:accept", "shift:checkin"], + isSystem: true, + }, + { + _id: SEED_IDS.roles.client, + name: "client", + description: "Client role", + permissions: ["shift:read", "payment:write"], + isSystem: true, + }, + ], + users: { + admin: { + _id: adminId, + name: "Ava Patel", + email: "admin.local@secureshift.test", + phone: "+61410000001", + address: { suburb: "Adelaide", state: "SA", postcode: "5000" }, + }, + employers: [ + { + _id: SEED_IDS.users.employerOperations, + name: "Olivia Chen", + email: "ops.local@secureshift.test", + phone: "+61410000002", + ABN: "51824753556", + address: { suburb: "Adelaide", state: "SA", postcode: "5000" }, + favourites: [SEED_IDS.users.guardApproved], + }, + { + _id: SEED_IDS.users.employerVenue, + name: "Marcus Reed", + email: "venue.local@secureshift.test", + phone: "+61410000003", + ABN: "83123456789", + address: { suburb: "North Adelaide", state: "SA", postcode: "5006" }, + }, + ], + guards: [ + { + _id: SEED_IDS.users.guardApproved, + name: "Mia Thompson", + email: "mia.guard@secureshift.test", + phone: "+61410000011", + address: { suburb: "Norwood", state: "SA", postcode: "5067" }, + license: { + imageUrl: "/seed/documents/mia-licence.jpg", + status: "verified", + reviewedAt: addDays(today, -10), + verifiedBy: adminId, + expiryDate: addDays(today, 180), + }, + documents: [ + { + _id: SEED_IDS.documents.approvedLicence, + type: "license", + imageUrl: "/seed/documents/mia-licence.jpg", + verificationStatus: "verified", + expiryStatus: "valid", + expiryDate: addDays(today, 180), + reviewedAt: addDays(today, -10), + verifiedBy: adminId, + }, + { + _id: SEED_IDS.documents.approvedFirstAid, + type: "firstAid", + imageUrl: "/seed/documents/mia-first-aid.jpg", + verificationStatus: "verified", + expiryStatus: "valid", + expiryDate: addDays(today, 120), + reviewedAt: addDays(today, -10), + verifiedBy: adminId, + }, + ], + rating: 4.8, + numberOfReviews: 18, + }, + { + _id: SEED_IDS.users.guardPending, + name: "Noah Williams", + email: "noah.guard@secureshift.test", + phone: "+61410000012", + address: { suburb: "Unley", state: "SA", postcode: "5061" }, + license: { + imageUrl: "/seed/documents/noah-licence.jpg", + status: "pending", + expiryDate: addDays(today, 240), + }, + documents: [ + { + _id: SEED_IDS.documents.pendingLicence, + type: "license", + imageUrl: "/seed/documents/noah-licence.jpg", + verificationStatus: "pending", + expiryStatus: "valid", + expiryDate: addDays(today, 240), + }, + ], + rating: 4.2, + numberOfReviews: 5, + }, + { + _id: SEED_IDS.users.guardRejected, + name: "Isha Singh", + email: "isha.guard@secureshift.test", + phone: "+61410000013", + address: { suburb: "Prospect", state: "SA", postcode: "5082" }, + license: { + imageUrl: "/seed/documents/isha-licence.jpg", + status: "rejected", + reviewedAt: addDays(today, -3), + verifiedBy: adminId, + rejectionReason: + "Local seed scenario: licence image is unreadable.", + expiryDate: addDays(today, 90), + }, + documents: [ + { + _id: SEED_IDS.documents.rejectedLicence, + type: "license", + imageUrl: "/seed/documents/isha-licence.jpg", + verificationStatus: "rejected", + expiryStatus: "valid", + expiryDate: addDays(today, 90), + reviewedAt: addDays(today, -3), + verifiedBy: adminId, + rejectionReason: + "Local seed scenario: licence image is unreadable.", + }, + ], + }, + { + _id: SEED_IDS.users.guardExpired, + name: "Liam Carter", + email: "liam.guard@secureshift.test", + phone: "+61410000014", + address: { suburb: "Glenelg", state: "SA", postcode: "5045" }, + license: { + imageUrl: "/seed/documents/liam-licence.jpg", + status: "verified", + reviewedAt: addDays(today, -200), + verifiedBy: adminId, + expiryDate: addDays(today, -1), + }, + documents: [ + { + _id: SEED_IDS.documents.expiredLicence, + type: "license", + imageUrl: "/seed/documents/liam-licence.jpg", + verificationStatus: "verified", + expiryStatus: "expired", + expiryDate: addDays(today, -1), + reviewedAt: addDays(today, -200), + verifiedBy: adminId, + }, + ], + rating: 3.9, + numberOfReviews: 7, + }, + ], + }, + branches: [ + { + _id: SEED_IDS.branches.operations, + name: "Adelaide Operations Centre", + code: "SEED-OPS-ADL", + employerId: SEED_IDS.users.employerOperations, + createdBy: SEED_IDS.users.employerOperations, + location: { + line1: "100 King William Street", + city: "Adelaide", + state: "SA", + postcode: "5000", + country: "Australia", + }, + isActive: true, + }, + { + _id: SEED_IDS.branches.venue, + name: "North Adelaide Venue", + code: "SEED-VENUE-NTH", + employerId: SEED_IDS.users.employerVenue, + createdBy: SEED_IDS.users.employerVenue, + location: { + line1: "20 O Connell Street", + city: "North Adelaide", + state: "SA", + postcode: "5006", + country: "Australia", + }, + isActive: true, + }, + ], + availability: [ + { + _id: SEED_IDS.availability.approved, + user: SEED_IDS.users.guardApproved, + days: [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + timeSlots: ["06:00-14:00", "14:00-22:00"], + status: "AVAILABLE", + }, + { + _id: SEED_IDS.availability.pending, + user: SEED_IDS.users.guardPending, + days: ["Monday", "Wednesday", "Friday", "Sunday"], + timeSlots: ["08:00-18:00"], + status: "AVAILABLE", + }, + { + _id: SEED_IDS.availability.rejected, + user: SEED_IDS.users.guardRejected, + days: ["Tuesday", "Thursday"], + timeSlots: ["09:00-17:00"], + status: "OFF_DUTY", + }, + { + _id: SEED_IDS.availability.expired, + user: SEED_IDS.users.guardExpired, + days: ["Friday", "Saturday", "Sunday"], + timeSlots: ["18:00-23:00"], + status: "BUSY", + }, + ], + shifts: [ + { + _id: SEED_IDS.shifts.open, + title: "Office Lobby Security", + date: addDays(today, 3), + startTime: "08:00", + endTime: "16:00", + shiftType: "Day", + breakTime: 30, + siteId: SEED_IDS.branches.operations, + location: { + street: "100 King William Street", + suburb: "Adelaide", + state: "SA", + postcode: "5000", + latitude: -34.9285, + longitude: 138.6007, + }, + field: "Corporate", + description: "Front desk and visitor access coverage.", + urgency: "normal", + payRate: 34, + status: "open", + createdBy: SEED_IDS.users.employerOperations, + }, + { + _id: SEED_IDS.shifts.applied, + title: "Warehouse Gate Security", + date: addDays(today, 2), + startTime: "14:00", + endTime: "22:00", + shiftType: "Day", + breakTime: 30, + siteId: SEED_IDS.branches.operations, + location: { + street: "100 King William Street", + suburb: "Adelaide", + state: "SA", + postcode: "5000", + latitude: -34.9285, + longitude: 138.6007, + }, + field: "Logistics", + description: "Vehicle entry and dispatch gate coverage.", + urgency: "priority", + payRate: 36, + status: "applied", + applicants: [SEED_IDS.users.guardPending, SEED_IDS.users.guardApproved], + createdBy: SEED_IDS.users.employerOperations, + }, + { + _id: SEED_IDS.shifts.assigned, + title: "Evening Event Security", + date: addDays(today, 1), + startTime: "16:00", + endTime: "23:00", + shiftType: "Night", + breakTime: 30, + siteId: SEED_IDS.branches.operations, + location: { + street: "100 King William Street", + suburb: "Adelaide", + state: "SA", + postcode: "5000", + latitude: -34.9285, + longitude: 138.6007, + }, + field: "Events", + description: "Guest entry and event floor patrols.", + urgency: "last-minute", + payRate: 40, + status: "assigned", + guardIds: [SEED_IDS.users.guardApproved], + acceptedBy: SEED_IDS.users.guardApproved, + createdBy: SEED_IDS.users.employerOperations, + }, + { + _id: SEED_IDS.shifts.completed, + title: "Completed Corporate Patrol", + date: completedDate, + startTime: "06:00", + endTime: "14:00", + shiftType: "Day", + breakTime: 30, + siteId: SEED_IDS.branches.operations, + location: { + street: "100 King William Street", + suburb: "Adelaide", + state: "SA", + postcode: "5000", + latitude: -34.9285, + longitude: 138.6007, + }, + field: "Corporate", + description: + "Completed patrol used for attendance and payroll screens.", + payRate: 38, + status: "completed", + guardIds: [SEED_IDS.users.guardApproved], + acceptedBy: SEED_IDS.users.guardApproved, + guardRating: 5, + ratedByEmployer: true, + createdBy: SEED_IDS.users.employerOperations, + }, + { + _id: SEED_IDS.shifts.venueOpen, + title: "Venue Entry Security", + date: addDays(today, 4), + startTime: "18:00", + endTime: "23:30", + shiftType: "Night", + breakTime: 20, + siteId: SEED_IDS.branches.venue, + location: { + street: "20 O Connell Street", + suburb: "North Adelaide", + state: "SA", + postcode: "5006", + latitude: -34.906, + longitude: 138.596, + }, + field: "Hospitality", + description: "Entry queue and venue floor security.", + urgency: "normal", + payRate: 42, + status: "open", + createdBy: SEED_IDS.users.employerVenue, + }, + ], + attendance: { + _id: SEED_IDS.attendance.completed, + guardId: SEED_IDS.users.guardApproved, + shiftId: SEED_IDS.shifts.completed, + siteLocation: { type: "Point", coordinates: [138.6007, -34.9285] }, + checkInTime: atLocalTime(completedDate, 6), + checkOutTime: atLocalTime(completedDate, 14), + checkInLocation: { type: "Point", coordinates: [138.6007, -34.9285] }, + checkOutLocation: { type: "Point", coordinates: [138.6007, -34.9285] }, + locationVerified: true, + }, + payroll: { + _id: SEED_IDS.payroll.completedWeekly, + guardId: SEED_IDS.users.guardApproved, + employerId: SEED_IDS.users.employerOperations, + periodType: "weekly", + periodStart: payrollPeriod.start, + periodEnd: payrollPeriod.end, + entries: [], + status: "PENDING", + }, + messages: [ + { + _id: SEED_IDS.messages.employerToGuard, + sender: SEED_IDS.users.employerOperations, + receiver: SEED_IDS.users.guardApproved, + content: "Thanks Mia. Site access details are in the assigned shift.", + timestamp: addDays(today, -1), + isRead: true, + }, + { + _id: SEED_IDS.messages.guardToEmployer, + sender: SEED_IDS.users.guardApproved, + receiver: SEED_IDS.users.employerOperations, + content: "Received, thank you. I will arrive ten minutes early.", + timestamp: addDays(today, -1), + isRead: false, + }, + ], + notifications: [ + { + _id: SEED_IDS.notifications.application, + userId: SEED_IDS.users.employerOperations, + type: "SHIFT_APPLIED", + title: "New shift application", + message: "Noah Williams applied for Warehouse Gate Security.", + data: { + shiftId: SEED_IDS.shifts.applied, + guardId: SEED_IDS.users.guardPending, + }, + isRead: false, + }, + { + _id: SEED_IDS.notifications.approval, + userId: SEED_IDS.users.guardApproved, + type: "SHIFT_APPROVED", + title: "Shift approved", + message: "You were assigned to Evening Event Security.", + data: { shiftId: SEED_IDS.shifts.assigned }, + isRead: false, + }, + { + _id: SEED_IDS.notifications.documentExpiry, + userId: SEED_IDS.users.guardExpired, + type: "DOCUMENT_EXPIRING", + title: "Licence expired", + message: "Your seeded security licence has expired.", + data: { documentId: SEED_IDS.documents.expiredLicence }, + isRead: false, + }, + ], + }; +}; diff --git a/app-backend/src/scripts/seed/ids.js b/app-backend/src/scripts/seed/ids.js new file mode 100644 index 000000000..8320c2c89 --- /dev/null +++ b/app-backend/src/scripts/seed/ids.js @@ -0,0 +1,64 @@ +import crypto from "node:crypto"; +import mongoose from "mongoose"; + +const objectIdFor = (label) => { + const hex = crypto + .createHash("sha256") + .update(`secureshift-local-seed:${label}`) + .digest("hex") + .slice(0, 24); + + return new mongoose.Types.ObjectId(hex); +}; + +const mapIds = (labels, prefix) => + Object.fromEntries( + labels.map((label) => [label, objectIdFor(`${prefix}:${label}`)]), + ); + +export const SEED_IDS = { + roles: mapIds( + ["superAdmin", "admin", "branchAdmin", "employer", "guard", "client"], + "role", + ), + users: mapIds( + [ + "admin", + "employerOperations", + "employerVenue", + "guardApproved", + "guardPending", + "guardRejected", + "guardExpired", + ], + "user", + ), + branches: mapIds(["operations", "venue"], "branch"), + availability: mapIds( + ["approved", "pending", "rejected", "expired"], + "availability", + ), + shifts: mapIds( + ["open", "applied", "assigned", "completed", "venueOpen"], + "shift", + ), + attendance: mapIds(["completed"], "attendance"), + payroll: mapIds(["completedWeekly"], "payroll"), + messages: mapIds(["employerToGuard", "guardToEmployer"], "message"), + notifications: mapIds( + ["application", "approval", "documentExpiry"], + "notification", + ), + documents: mapIds( + [ + "approvedLicence", + "approvedFirstAid", + "pendingLicence", + "rejectedLicence", + "expiredLicence", + ], + "document", + ), +}; + +export const idsFor = (group) => Object.values(SEED_IDS[group]); diff --git a/app-backend/src/scripts/seed/index.js b/app-backend/src/scripts/seed/index.js new file mode 100644 index 000000000..24e603ba3 --- /dev/null +++ b/app-backend/src/scripts/seed/index.js @@ -0,0 +1,26 @@ +import mongoose from "mongoose"; +import { resetLocalData, seedLocalData } from "./seed.js"; +import { assertSeedSafety } from "./safety.js"; + +const reset = process.argv.includes("--reset"); + +const main = async () => { + const { mongoUri, target } = assertSeedSafety(process.env, { reset }); + console.log( + `SecureShift local seed target: ${target.hosts.join(",")}/${target.database}`, + ); + + await mongoose.connect(mongoUri); + + const result = reset ? await resetLocalData() : await seedLocalData(); + console.log(JSON.stringify(result, null, 2)); +}; + +main() + .catch((error) => { + console.error(`Local seed failed: ${error.message}`); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + }); diff --git a/app-backend/src/scripts/seed/safety.js b/app-backend/src/scripts/seed/safety.js new file mode 100644 index 000000000..41b6a5bea --- /dev/null +++ b/app-backend/src/scripts/seed/safety.js @@ -0,0 +1,84 @@ +const LOCAL_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "::1", + "mongo", + "mongodb", + "host.docker.internal", +]); + +const SAFE_DATABASE_NAMES = new Set([ + "secureshift_local", + "secureshift_dev", + "secureshift_test", +]); + +const parseMongoTarget = (mongoUri) => { + if (!mongoUri.startsWith("mongodb://")) { + throw new Error( + "Seed requires a local mongodb:// URI; mongodb+srv:// targets are refused", + ); + } + + const withoutScheme = mongoUri.slice("mongodb://".length).split("?")[0]; + const authorityAndPath = withoutScheme.slice( + withoutScheme.lastIndexOf("@") + 1, + ); + const slashIndex = authorityAndPath.indexOf("/"); + + if (slashIndex === -1) { + throw new Error("MONGO_URI must include an explicit database name"); + } + + const authority = authorityAndPath.slice(0, slashIndex); + const database = decodeURIComponent(authorityAndPath.slice(slashIndex + 1)); + const hosts = authority.split(",").map((entry) => { + const bracketedIpv6 = entry.match(/^\[([^\]]+)](?::\d+)?$/); + if (bracketedIpv6) return bracketedIpv6[1].toLowerCase(); + return entry.replace(/:\d+$/, "").toLowerCase(); + }); + + if (!hosts.length || hosts.some((host) => !LOCAL_HOSTS.has(host))) { + throw new Error( + "Seed target host is not in the local development allowlist", + ); + } + + if (!SAFE_DATABASE_NAMES.has(database)) { + throw new Error( + "Seed database must be exactly secureshift_local, secureshift_dev, or secureshift_test", + ); + } + + return { hosts, database }; +}; + +export const assertSeedSafety = (env = process.env, { reset = false } = {}) => { + if ( + String(env.NODE_ENV || "") + .trim() + .toLowerCase() === "production" + ) { + throw new Error("Seed is disabled when NODE_ENV=production"); + } + + if (env.SEED_ALLOW_LOCAL !== "true") { + throw new Error("Seed requires SEED_ALLOW_LOCAL=true"); + } + + const mongoUri = String(env.MONGO_URI || "").trim(); + if (!mongoUri) { + throw new Error("Seed requires an explicit MONGO_URI"); + } + + if (reset && env.SEED_RESET_CONFIRM !== "SecureShiftLocalReset") { + throw new Error("Reset requires SEED_RESET_CONFIRM=SecureShiftLocalReset"); + } + + return { + mongoUri, + target: parseMongoTarget(mongoUri), + }; +}; + +export { parseMongoTarget }; diff --git a/app-backend/src/scripts/seed/seed.js b/app-backend/src/scripts/seed/seed.js new file mode 100644 index 000000000..f0615c8f0 --- /dev/null +++ b/app-backend/src/scripts/seed/seed.js @@ -0,0 +1,190 @@ +import Admin from "../../models/Admin.js"; +import Availability from "../../models/Availability.js"; +import Branch from "../../models/Branch.js"; +import Employer from "../../models/Employer.js"; +import Guard from "../../models/Guard.js"; +import Message from "../../models/Message.js"; +import Notification from "../../models/Notification.js"; +import Payroll from "../../models/Payroll.js"; +import Role from "../../models/Role.js"; +import Shift from "../../models/Shift.js"; +import ShiftAttendance from "../../models/ShiftAttendance.js"; +import User from "../../models/User.js"; +import { syncPayrollForShiftIds } from "../../services/payroll.service.js"; +import { buildSeedData, SEED_PASSWORD } from "./data.js"; +import { idsFor, SEED_IDS } from "./ids.js"; + +const saveDocument = async (Model, data) => { + const existing = await Model.findById(data._id); + const document = existing || new Model({ _id: data._id }); + document.set(data); + await document.save(); + return document; +}; + +const assertUniqueValueIsOwned = async (Model, query, id, label) => { + const existing = await Model.findOne(query).select("_id").lean(); + if (existing && String(existing._id) !== String(id)) { + throw new Error( + `${label} already exists with a non-seed ObjectId; reset that conflict manually`, + ); + } +}; + +const savePersona = async (Model, data) => { + await assertUniqueValueIsOwned( + User, + { email: data.email }, + data._id, + `User ${data.email}`, + ); + + let user = await Model.findById(data._id).select("+password"); + if (!user) { + user = new Model({ ...data, password: SEED_PASSWORD }); + } else { + user.set(data); + if (!(await user.matchPassword(SEED_PASSWORD))) { + user.password = SEED_PASSWORD; + } + } + + await user.save(); + return user; +}; + +const ensurePayrollSlotIsOwned = async (payroll) => { + const existing = await Payroll.findOne({ + guardId: payroll.guardId, + employerId: payroll.employerId, + periodType: payroll.periodType, + periodStart: payroll.periodStart, + periodEnd: payroll.periodEnd, + }) + .select("_id") + .lean(); + + if (existing && String(existing._id) !== String(payroll._id)) { + throw new Error( + "Seed payroll period already exists with a non-seed ObjectId", + ); + } +}; + +export const getSeedCounts = async () => ({ + roles: await Role.countDocuments({ _id: { $in: idsFor("roles") } }), + users: await User.countDocuments({ _id: { $in: idsFor("users") } }), + branches: await Branch.countDocuments({ _id: { $in: idsFor("branches") } }), + availability: await Availability.countDocuments({ + _id: { $in: idsFor("availability") }, + }), + shifts: await Shift.countDocuments({ _id: { $in: idsFor("shifts") } }), + attendance: await ShiftAttendance.countDocuments({ + _id: { $in: idsFor("attendance") }, + }), + payroll: await Payroll.countDocuments({ _id: { $in: idsFor("payroll") } }), + messages: await Message.countDocuments({ _id: { $in: idsFor("messages") } }), + notifications: await Notification.countDocuments({ + _id: { $in: idsFor("notifications") }, + }), +}); + +export const seedLocalData = async ({ now = new Date() } = {}) => { + const data = buildSeedData(now); + + for (const role of data.roles) { + await assertUniqueValueIsOwned( + Role, + { name: role.name }, + role._id, + `Role ${role.name}`, + ); + await saveDocument(Role, role); + } + + const admin = await savePersona(Admin, data.users.admin); + for (const employer of data.users.employers) { + await savePersona(Employer, employer); + } + for (const guard of data.users.guards) { + await savePersona(Guard, guard); + } + + for (const branch of data.branches) { + await assertUniqueValueIsOwned( + Branch, + { code: branch.code }, + branch._id, + `Branch ${branch.code}`, + ); + await saveDocument(Branch, branch); + } + + for (const availability of data.availability) { + await assertUniqueValueIsOwned( + Availability, + { user: availability.user }, + availability._id, + `Availability for ${availability.user}`, + ); + await saveDocument(Availability, availability); + } + + for (const shift of data.shifts) await saveDocument(Shift, shift); + await saveDocument(ShiftAttendance, data.attendance); + + await ensurePayrollSlotIsOwned(data.payroll); + await saveDocument(Payroll, data.payroll); + await syncPayrollForShiftIds({ + shiftIds: [SEED_IDS.shifts.completed], + periodType: data.payroll.periodType, + }); + + for (const message of data.messages) await saveDocument(Message, message); + for (const notification of data.notifications) + await saveDocument(Notification, notification); + + return { + counts: await getSeedCounts(), + adminId: String(admin._id), + }; +}; + +export const resetLocalData = async () => { + const deleted = {}; + + deleted.notifications = ( + await Notification.deleteMany({ _id: { $in: idsFor("notifications") } }) + ).deletedCount; + deleted.messages = ( + await Message.deleteMany({ _id: { $in: idsFor("messages") } }) + ).deletedCount; + deleted.payroll = ( + await Payroll.deleteMany({ _id: { $in: idsFor("payroll") } }) + ).deletedCount; + deleted.attendance = ( + await ShiftAttendance.deleteMany({ _id: { $in: idsFor("attendance") } }) + ).deletedCount; + deleted.shifts = ( + await Shift.deleteMany({ _id: { $in: idsFor("shifts") } }) + ).deletedCount; + deleted.availability = ( + await Availability.deleteMany({ _id: { $in: idsFor("availability") } }) + ).deletedCount; + deleted.branches = ( + await Branch.deleteMany({ _id: { $in: idsFor("branches") } }) + ).deletedCount; + deleted.users = ( + await User.deleteMany({ _id: { $in: idsFor("users") } }) + ).deletedCount; + deleted.roles = ( + await Role.deleteMany({ _id: { $in: idsFor("roles") } }) + ).deletedCount; + + return { + deleted, + remaining: await getSeedCounts(), + }; +}; + +export { SEED_IDS }; diff --git a/app-backend/src/services/payroll.service.js b/app-backend/src/services/payroll.service.js index b6c8b7516..9431682b8 100644 --- a/app-backend/src/services/payroll.service.js +++ b/app-backend/src/services/payroll.service.js @@ -525,6 +525,39 @@ const ensureScopedPayrollDocs = async (payrollIds, user) => { const formatCurrency = (value) => `$${Number(value || 0).toFixed(2)}`; +export const syncPayrollForShiftIds = async ({ shiftIds, periodType }) => { + const uniqueShiftIds = Array.from(new Set((shiftIds || []).map(String))); + + if (!uniqueShiftIds.length) { + throw createHttpError(400, 'shiftIds must be a non-empty array'); + } + + if (!ALLOWED_PERIOD_TYPES.has(periodType)) { + throw createHttpError(400, 'periodType must be one of daily, weekly, or monthly'); + } + + const shifts = await Shift.find({ + _id: { $in: uniqueShiftIds }, + status: 'completed', + acceptedBy: { $ne: null }, + }) + .populate('acceptedBy', 'name') + .populate('createdBy', 'name') + .sort({ date: 1, startTime: 1 }); + + if (shifts.length !== uniqueShiftIds.length) { + throw createHttpError(404, 'One or more scoped payroll shifts were not found or incomplete'); + } + + const attendanceRecords = await ShiftAttendance.find({ + shiftId: { $in: uniqueShiftIds }, + }); + const computedEntries = buildComputedEntries(shifts, attendanceRecords); + const groups = buildPayrollGroups(computedEntries, periodType); + + return syncPayrollDocuments(groups); +}; + export const getPayrollRecords = async (query, user) => { const userContext = getUserContext(user); const range = parseDateRange(query); diff --git a/app-backend/tests/seed.payroll.test.js b/app-backend/tests/seed.payroll.test.js new file mode 100644 index 000000000..1779ad4b0 --- /dev/null +++ b/app-backend/tests/seed.payroll.test.js @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, jest, test } from "@jest/globals"; +import Payroll from "../src/models/Payroll.js"; +import Shift from "../src/models/Shift.js"; +import ShiftAttendance from "../src/models/ShiftAttendance.js"; +import { syncPayrollForShiftIds } from "../src/services/payroll.service.js"; + +jest.mock("../src/models/Payroll.js", () => ({ + __esModule: true, + default: { + bulkWrite: jest.fn(), + find: jest.fn(), + }, +})); + +jest.mock("../src/models/Shift.js", () => ({ + __esModule: true, + default: { find: jest.fn() }, +})); + +jest.mock("../src/models/ShiftAttendance.js", () => ({ + __esModule: true, + default: { find: jest.fn() }, +})); + +const queryResolvingTo = (value) => { + const query = { + populate: jest.fn(() => query), + sort: jest.fn().mockResolvedValue(value), + }; + return query; +}; + +describe("seed payroll isolation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("syncs payroll from only the explicitly scoped completed shift", async () => { + const seededShiftId = "64b000000000000000000001"; + const unrelatedShiftId = "64b000000000000000000002"; + const guardId = "64b000000000000000000003"; + const employerId = "64b000000000000000000004"; + const shiftDate = new Date("2026-07-13T12:00:00.000Z"); + const seededShift = { + _id: seededShiftId, + acceptedBy: guardId, + createdBy: employerId, + date: shiftDate, + startTime: "06:00", + endTime: "14:00", + breakTime: 30, + field: "Corporate", + payRate: 38, + }; + + Shift.find.mockReturnValue(queryResolvingTo([seededShift])); + ShiftAttendance.find.mockResolvedValue([ + { + shiftId: seededShiftId, + guardId, + checkInTime: new Date("2026-07-13T06:00:00.000Z"), + checkOutTime: new Date("2026-07-13T14:00:00.000Z"), + }, + ]); + Payroll.bulkWrite.mockResolvedValue({}); + Payroll.find.mockReturnValue(queryResolvingTo([])); + + await syncPayrollForShiftIds({ + shiftIds: [seededShiftId], + periodType: "weekly", + }); + + expect(Shift.find).toHaveBeenCalledWith({ + _id: { $in: [seededShiftId] }, + status: "completed", + acceptedBy: { $ne: null }, + }); + expect(ShiftAttendance.find).toHaveBeenCalledWith({ + shiftId: { $in: [seededShiftId] }, + }); + + const operations = Payroll.bulkWrite.mock.calls[0][0]; + const entries = operations[0].updateOne.update.$set.entries; + expect(entries).toHaveLength(1); + expect(String(entries[0].shiftId)).toBe(seededShiftId); + expect( + entries.some((entry) => String(entry.shiftId) === unrelatedShiftId), + ).toBe(false); + }); +}); diff --git a/app-backend/tests/seed.safety.test.js b/app-backend/tests/seed.safety.test.js new file mode 100644 index 000000000..589ba32a7 --- /dev/null +++ b/app-backend/tests/seed.safety.test.js @@ -0,0 +1,89 @@ +import { describe, expect, test } from "@jest/globals"; +import { + assertSeedSafety, + parseMongoTarget, +} from "../src/scripts/seed/safety.js"; + +const safeEnv = { + NODE_ENV: "development", + SEED_ALLOW_LOCAL: "true", + MONGO_URI: "mongodb://localhost:27017/secureshift_local", +}; + +describe("local seed safety guard", () => { + test.each(["secureshift_local", "secureshift_dev", "secureshift_test"])( + "accepts the exact approved database name %s", + (database) => { + expect( + assertSeedSafety({ + ...safeEnv, + MONGO_URI: `mongodb://localhost:27017/${database}`, + }).target, + ).toEqual({ hosts: ["localhost"], database }); + }, + ); + + test.each([ + [{ ...safeEnv, NODE_ENV: "production" }, "NODE_ENV=production"], + [{ ...safeEnv, NODE_ENV: " production " }, "NODE_ENV=production"], + [{ ...safeEnv, SEED_ALLOW_LOCAL: "false" }, "SEED_ALLOW_LOCAL=true"], + [{ ...safeEnv, MONGO_URI: "" }, "explicit MONGO_URI"], + [ + { + ...safeEnv, + MONGO_URI: "mongodb+srv://example.mongodb.net/secureshift_dev", + }, + "mongodb://", + ], + [ + { ...safeEnv, MONGO_URI: "mongodb://db.example.com/secureshift_dev" }, + "host", + ], + [ + { ...safeEnv, MONGO_URI: "mongodb://localhost/secureshift" }, + "database must be exactly", + ], + ])("rejects an unsafe environment", (env, expectedMessage) => { + expect(() => assertSeedSafety(env)).toThrow(expectedMessage); + }); + + test.each([ + "secureshift_production", + "production_test", + "secureshift_prod", + "secureshift_live", + "secureshift_staging", + "secureshift_local_backup", + "other_test", + ])("rejects production-like or non-approved database name %s", (database) => { + expect(() => + assertSeedSafety({ + ...safeEnv, + MONGO_URI: `mongodb://localhost:27017/${database}`, + }), + ).toThrow("database must be exactly"); + }); + + test("requires the exact reset confirmation", () => { + expect(() => assertSeedSafety(safeEnv, { reset: true })).toThrow( + "SEED_RESET_CONFIRM", + ); + expect(() => + assertSeedSafety( + { ...safeEnv, SEED_RESET_CONFIRM: "SecureShiftLocalReset" }, + { reset: true }, + ), + ).not.toThrow(); + }); + + test("parses credentials without exposing them in the target description", () => { + expect( + parseMongoTarget( + "mongodb://user:redacted@127.0.0.1:27017/secureshift_test", + ), + ).toEqual({ + hosts: ["127.0.0.1"], + database: "secureshift_test", + }); + }); +});