From 77fed833eac0953609dbffee97d2d701176df0ec Mon Sep 17 00:00:00 2001 From: Alan maria George Date: Wed, 22 Apr 2026 22:03:06 +1000 Subject: [PATCH 1/9] Add seed data aligned with latest Prisma schema --- prisma/seed.ts | 610 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 598 insertions(+), 12 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 7d2744c..9e4cca5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,24 +1,610 @@ -import { PrismaClient, UserRole } from "@prisma/client"; +/* eslint-disable no-console */ +import { PrismaClient, Prisma } from "@prisma/client"; const prisma = new PrismaClient(); -const main = async (): Promise => { - await prisma.user.upsert({ - where: { email: "admin@treeo2.local" }, - update: {}, - create: { +async function main(): Promise { + console.log("Starting seed..."); + + // 1. Countries + await prisma.country.createMany({ + data: [ + { id: 1, name: "Timor-Leste", iso2: "TL", iso3: "TLS" }, + { id: 2, name: "Australia", iso2: "AU", iso3: "AUS" }, + ], + skipDuplicates: true, + }); + + // 2. Cultures + await prisma.culture.createMany({ + data: [ + { code: "en", name: "English" }, + { code: "tet", name: "Tetum" }, + ], + skipDuplicates: true, + }); + + // 3. Localized Strings + await prisma.localizedString.createMany({ + data: [ + { + id: 1, + cultureCode: "en", + stringKey: "app.title", + value: "TreeO2", + context: "application", + }, + { + id: 2, + cultureCode: "tet", + stringKey: "app.title", + value: "TreeO2", + context: "application", + }, + { + id: 3, + cultureCode: "en", + stringKey: "report.status.completed", + value: "Completed", + context: "report", + }, + { + id: 4, + cultureCode: "tet", + stringKey: "report.status.completed", + value: "Kompletu", + context: "report", + }, + ], + skipDuplicates: true, + }); + + // 4. Roles + await prisma.role.createMany({ + data: [ + { id: 1, name: "Admin" }, + { id: 2, name: "Manager" }, + { id: 3, name: "Inspector" }, + { id: 4, name: "Farmer" }, + { id: 5, name: "Developer" }, + ], + skipDuplicates: true, + }); + + // 5. Partners + await prisma.partner.createMany({ + data: [ + { id: 1, name: "xpand Foundation" }, + { id: 2, name: "Green Timor Initiative" }, + ], + skipDuplicates: true, + }); + + // 6. Locations + await prisma.location.createMany({ + data: [ + { + id: 1, + countryId: 1, + parentId: null, + level: 1, + name: "Dili", + code: "DIL", + latitude: new Prisma.Decimal("-8.556900"), + longitude: new Prisma.Decimal("125.560300"), + }, + { + id: 2, + countryId: 1, + parentId: 1, + level: 2, + name: "Cristo Rei", + code: "CRI", + latitude: new Prisma.Decimal("-8.540000"), + longitude: new Prisma.Decimal("125.610000"), + }, + { + id: 3, + countryId: 1, + parentId: 2, + level: 3, + name: "Hera", + code: "HER", + latitude: new Prisma.Decimal("-8.533300"), + longitude: new Prisma.Decimal("125.633300"), + }, + { + id: 4, + countryId: 1, + parentId: null, + level: 1, + name: "Baucau", + code: "BAU", + latitude: new Prisma.Decimal("-8.466700"), + longitude: new Prisma.Decimal("126.450000"), + }, + ], + skipDuplicates: true, + }); + + // 7. Administrative Levels + await prisma.administrativeLevel.createMany({ + data: [ + { id: 1, countryId: 1, level: 1, name: "Municipality" }, + { id: 2, countryId: 1, level: 2, name: "Administrative Post" }, + { id: 3, countryId: 1, level: 3, name: "Village" }, + ], + skipDuplicates: true, + }); + + // 8. Tree Types + await prisma.treeType.createMany({ + data: [ + { + id: 1, + name: "Mahogany", + key: "mahogany", + scientificName: "Swietenia macrophylla", + dryWeightDensity: new Prisma.Decimal("595.000"), + }, + { + id: 2, + name: "Teak", + key: "teak", + scientificName: "Tectona grandis", + dryWeightDensity: new Prisma.Decimal("660.000"), + }, + { + id: 3, + name: "Sandalwood", + key: "sandalwood", + scientificName: "Santalum album", + dryWeightDensity: new Prisma.Decimal("870.000"), + }, + ], + skipDuplicates: true, + }); + + // 9. Projects + await prisma.project.createMany({ + data: [ + { + id: 1, + name: "Hera Reforestation 2025", + description: "Community-based tree restoration project in Hera.", + countryId: 1, + adminLocationId: 3, + isActive: true, + }, + { + id: 2, + name: "Baucau Agroforestry Pilot", + description: "Agroforestry monitoring and survival tracking in Baucau.", + countryId: 1, + adminLocationId: 4, + isActive: true, + }, + ], + skipDuplicates: true, + }); + + // 10. Users + const users = [ + { email: "admin@treeo2.local", + passwordHash: "hashed_admin_pw", name: "TreeO2 Admin", - role: UserRole.ADMIN, + roleId: 1, + cardId: "CARD-ADM-001", + governmentId: "GOV-ADM-001", + gender: "Male", + disability: false, + countryId: 1, + adminLocationId: 1, + streetAddress: "Dili Central Office", + preferredLanguage: "en", + photoId: null, + biography: "System administrator for TreeO2.", + notes: "Primary admin account.", + accountActive: true, + dateJoined: new Date("2025-01-05T00:00:00Z"), + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, + }, + { + email: "manager@treeo2.local", + passwordHash: "hashed_manager_pw", + name: "Project Manager", + roleId: 2, + cardId: "CARD-MGR-001", + governmentId: "GOV-MGR-001", + gender: "Female", + disability: false, + countryId: 1, + adminLocationId: 1, + streetAddress: "Dili Operations", + preferredLanguage: "en", + photoId: null, + biography: "Oversees project delivery and monitoring.", + notes: "Assigned to multiple projects.", + accountActive: true, + dateJoined: new Date("2025-01-10T00:00:00Z"), + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, }, + { + email: "inspector1@treeo2.local", + passwordHash: "hashed_inspector1_pw", + name: "Field Inspector One", + roleId: 3, + cardId: "CARD-INS-001", + governmentId: "GOV-INS-001", + gender: "Male", + disability: false, + countryId: 1, + adminLocationId: 2, + streetAddress: "Cristo Rei Field Office", + preferredLanguage: "tet", + photoId: null, + biography: "Conducts on-site inspections.", + notes: "Experienced in field validations.", + accountActive: true, + dateJoined: new Date("2025-01-12T00:00:00Z"), + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, + }, + { + email: "inspector2@treeo2.local", + passwordHash: "hashed_inspector2_pw", + name: "Field Inspector Two", + roleId: 3, + cardId: "CARD-INS-002", + governmentId: "GOV-INS-002", + gender: "Female", + disability: false, + countryId: 1, + adminLocationId: 4, + streetAddress: "Baucau Field Office", + preferredLanguage: "tet", + photoId: null, + biography: "Supports rural inspection activities.", + notes: "Assigned to Baucau pilot.", + accountActive: true, + dateJoined: new Date("2025-01-13T00:00:00Z"), + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, + }, + { + email: "farmer1@treeo2.local", + passwordHash: "hashed_farmer1_pw", + name: "Farmer One", + roleId: 4, + cardId: "CARD-FAR-001", + governmentId: "GOV-FAR-001", + gender: "Female", + disability: false, + countryId: 1, + adminLocationId: 3, + streetAddress: "Hera Village", + preferredLanguage: "tet", + photoId: null, + biography: "Participating farmer in Hera region.", + notes: "Linked to reforestation project.", + accountActive: true, + dateJoined: new Date("2025-01-15T00:00:00Z"), + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, + }, + { + email: "farmer2@treeo2.local", + passwordHash: "hashed_farmer2_pw", + name: "Farmer Two", + roleId: 4, + cardId: "CARD-FAR-002", + governmentId: "GOV-FAR-002", + gender: "Male", + disability: false, + countryId: 1, + adminLocationId: 4, + streetAddress: "Baucau Rural Area", + preferredLanguage: "tet", + photoId: null, + biography: "Farmer involved in agroforestry activities.", + notes: "Linked to Baucau pilot.", + accountActive: true, + dateJoined: new Date("2025-01-16T00:00:00Z"), + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, + }, + { + email: "developer@treeo2.local", + passwordHash: "hashed_developer_pw", + name: "Developer User", + roleId: 5, + cardId: "CARD-DEV-001", + governmentId: "GOV-DEV-001", + gender: "Male", + disability: false, + countryId: 2, + adminLocationId: null, + streetAddress: "Melbourne Support Hub", + preferredLanguage: "en", + photoId: null, + biography: "Maintains the technical platform.", + notes: "Support and development account.", + accountActive: true, + dateJoined: new Date("2025-01-18T00:00:00Z"), + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, + }, + ] as const; + + for (const user of users) { + await prisma.user.upsert({ + where: { email: user.email }, + update: { ...user }, + create: { ...user }, + }); + } + + const admin = await prisma.user.findUniqueOrThrow({ + where: { email: "admin@treeo2.local" }, + }); + const manager = await prisma.user.findUniqueOrThrow({ + where: { email: "manager@treeo2.local" }, + }); + const inspector1 = await prisma.user.findUniqueOrThrow({ + where: { email: "inspector1@treeo2.local" }, + }); + const inspector2 = await prisma.user.findUniqueOrThrow({ + where: { email: "inspector2@treeo2.local" }, + }); + const farmer1 = await prisma.user.findUniqueOrThrow({ + where: { email: "farmer1@treeo2.local" }, + }); + const farmer2 = await prisma.user.findUniqueOrThrow({ + where: { email: "farmer2@treeo2.local" }, + }); + const developer = await prisma.user.findUniqueOrThrow({ + where: { email: "developer@treeo2.local" }, }); -}; + + // 11. User Role Assignments + await prisma.userRoleAssignment.createMany({ + data: [ + { userId: admin.id, roleId: 1 }, + { userId: manager.id, roleId: 2 }, + { userId: inspector1.id, roleId: 3 }, + { userId: inspector2.id, roleId: 3 }, + { userId: farmer1.id, roleId: 4 }, + { userId: farmer2.id, roleId: 4 }, + { userId: developer.id, roleId: 5 }, + ], + skipDuplicates: true, + }); + + // 12. User Projects + await prisma.userProject.createMany({ + data: [ + { userId: manager.id, projectId: 1 }, + { userId: manager.id, projectId: 2 }, + { userId: inspector1.id, projectId: 1 }, + { userId: inspector2.id, projectId: 2 }, + { userId: farmer1.id, projectId: 1 }, + { userId: farmer2.id, projectId: 2 }, + ], + skipDuplicates: true, + }); + + // 13. Project Tree Types + await prisma.projectTreeType.createMany({ + data: [ + { projectId: 1, treeTypeId: 1 }, + { projectId: 1, treeTypeId: 3 }, + { projectId: 2, treeTypeId: 2 }, + ], + skipDuplicates: true, + }); + + // 14. Scan Batches + await prisma.scanBatch.createMany({ + data: [ + { + id: 1, + inspectorId: inspector1.id, + projectId: 1, + uploadedAt: new Date("2025-02-01T09:00:00Z"), + }, + { + id: 2, + inspectorId: inspector2.id, + projectId: 2, + uploadedAt: new Date("2025-02-10T11:30:00Z"), + }, + ], + skipDuplicates: true, + }); + + // 15. Tree Scans + await prisma.treeScan.createMany({ + data: [ + { + id: 1, + fobId: "FOB-0001", + projectId: 1, + farmerId: farmer1.id, + inspectorId: inspector1.id, + speciesId: 1, + estimatedPlantedYear: 2023, + estimatedPlantedMonth: 6, + plantedDate: new Date("2023-06-15T00:00:00Z"), + heightM: new Prisma.Decimal("1.450"), + circumferenceCm: new Prisma.Decimal("8.400"), + diameterCm: new Prisma.Decimal("2.700"), + latitude: -8.5331, + longitude: 125.6331, + photoId: null, + batchId: 1, + deviceId: "DEVICE-01", + isArchived: false, + isCorrected: false, + correctedBy: null, + correctionReason: null, + isValid: true, + validationNotes: "Healthy sapling observed.", + }, + { + id: 2, + fobId: "FOB-0002", + projectId: 1, + farmerId: farmer1.id, + inspectorId: inspector1.id, + speciesId: 3, + estimatedPlantedYear: 2022, + estimatedPlantedMonth: 11, + plantedDate: new Date("2022-11-20T00:00:00Z"), + heightM: new Prisma.Decimal("0.950"), + circumferenceCm: new Prisma.Decimal("5.600"), + diameterCm: new Prisma.Decimal("1.800"), + latitude: -8.5335, + longitude: 125.6338, + photoId: null, + batchId: 1, + deviceId: "DEVICE-01", + isArchived: false, + isCorrected: true, + correctedBy: manager.id, + correctionReason: "Corrected planting month after review.", + isValid: true, + validationNotes: "Data verified by manager.", + }, + { + id: 3, + fobId: "FOB-0101", + projectId: 2, + farmerId: farmer2.id, + inspectorId: inspector2.id, + speciesId: 2, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 3, + plantedDate: new Date("2024-03-05T00:00:00Z"), + heightM: new Prisma.Decimal("1.800"), + circumferenceCm: new Prisma.Decimal("10.200"), + diameterCm: new Prisma.Decimal("3.100"), + latitude: -8.4662, + longitude: 126.4491, + photoId: null, + batchId: 2, + deviceId: "DEVICE-02", + isArchived: false, + isCorrected: false, + correctedBy: null, + correctionReason: null, + isValid: true, + validationNotes: "Strong early growth.", + }, + ], + skipDuplicates: true, + }); + + // 16. Tree Scan Audit + await prisma.treeScanAudit.createMany({ + data: [ + { + id: 1, + treeScanId: 2, + changedBy: manager.id, + changeReason: "Updated planting month", + oldData: { estimatedPlantedMonth: 10 }, + newData: { estimatedPlantedMonth: 11 }, + changedAt: new Date("2025-02-02T12:00:00Z"), + }, + ], + skipDuplicates: true, + }); + + // 17. Adopters + await prisma.adopter.createMany({ + data: [ + { id: 1, name: "Green Earth Donor", email: "donor1@example.com" }, + { id: 2, name: "Eco Supporter", email: "donor2@example.com" }, + ], + skipDuplicates: true, + }); + + // 18. Adoptions + await prisma.adoption.createMany({ + data: [ + { + id: 1, + adopterId: 1, + fobId: "FOB-0001", + adoptedAt: new Date("2025-02-15T00:00:00Z"), + }, + { + id: 2, + adopterId: 2, + fobId: "FOB-0101", + adoptedAt: new Date("2025-02-20T00:00:00Z"), + }, + ], + skipDuplicates: true, + }); + + // 19. Reports + await prisma.report.createMany({ + data: [ + { + id: 1, + reportType: "Tree Survival Summary", + requestedBy: manager.id, + status: "COMPLETED", + parameters: { projectId: 1, month: "2025-02" }, + outputUrl: "https://xyz.com/reports/tree-survival-summary.pdf", + completedAt: new Date("2025-02-28T10:00:00Z"), + }, + { + id: 2, + reportType: "Inspector Activity Report", + requestedBy: admin.id, + status: "PENDING", + parameters: { inspectorId: inspector1.id }, + outputUrl: null, + completedAt: null, + }, + ], + skipDuplicates: true, + }); + + console.log("Seed completed successfully."); +} void main() - .catch(async (err: unknown) => { + .catch((err: unknown) => { console.error("Seed failed", err); process.exit(1); }) - .finally(async () => { - await prisma.$disconnect(); - }); + .finally(() => { + void prisma.$disconnect(); + }); \ No newline at end of file From 13704faee7e400c5fc483425d9c4be7ed6f8a0a6 Mon Sep 17 00:00:00 2001 From: Nikhil Jadav Date: Fri, 24 Apr 2026 21:25:09 +1000 Subject: [PATCH 2/9] fix(seed): make prisma seed safer and align roles with app mapping --- prisma/seed.ts | 1399 +++++++++++++++++++++++++++++------------------- 1 file changed, 855 insertions(+), 544 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 9e4cca5..e2d2210 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,603 +1,914 @@ /* eslint-disable no-console */ import { PrismaClient, Prisma } from "@prisma/client"; +import { hashPassword } from "../src/lib/bcrypt"; + const prisma = new PrismaClient(); -async function main(): Promise { - console.log("Starting seed..."); +type Tx = Prisma.TransactionClient; + +type UserSeed = { + email: string; + password: string; + name: string; + roleName: string; + cardId: string; + governmentId: string; + gender: string; + disability: boolean; + countryIso2: "TL" | "AU"; + adminLocationCode: "DIL" | "CRI" | "HER" | "BAU" | null; + streetAddress: string; + preferredLanguage: string; + biography: string; + notes: string; + accountActive: boolean; + dateJoined: Date; +}; - // 1. Countries - await prisma.country.createMany({ - data: [ - { id: 1, name: "Timor-Leste", iso2: "TL", iso3: "TLS" }, - { id: 2, name: "Australia", iso2: "AU", iso3: "AUS" }, - ], - skipDuplicates: true, +async function upsertCountry( + tx: Tx, + data: { name: string; iso2: string; iso3: string }, +) { + return tx.country.upsert({ + where: { iso2: data.iso2 }, + update: data, + create: data, }); +} - // 2. Cultures - await prisma.culture.createMany({ - data: [ - { code: "en", name: "English" }, - { code: "tet", name: "Tetum" }, - ], - skipDuplicates: true, +async function upsertCulture(tx: Tx, data: { code: string; name: string }) { + return tx.culture.upsert({ + where: { code: data.code }, + update: data, + create: data, }); +} - // 3. Localized Strings - await prisma.localizedString.createMany({ - data: [ - { - id: 1, - cultureCode: "en", - stringKey: "app.title", - value: "TreeO2", - context: "application", - }, - { - id: 2, - cultureCode: "tet", - stringKey: "app.title", - value: "TreeO2", - context: "application", - }, - { - id: 3, - cultureCode: "en", - stringKey: "report.status.completed", - value: "Completed", - context: "report", - }, - { - id: 4, - cultureCode: "tet", - stringKey: "report.status.completed", - value: "Kompletu", - context: "report", +async function upsertLocalizedString( + tx: Tx, + data: { + cultureCode: string; + stringKey: string; + value: string; + context: string; + }, +) { + return tx.localizedString.upsert({ + where: { + cultureCode_stringKey_context: { + cultureCode: data.cultureCode, + stringKey: data.stringKey, + context: data.context, }, - ], - skipDuplicates: true, + }, + update: { value: data.value }, + create: data, }); +} - // 4. Roles - await prisma.role.createMany({ - data: [ - { id: 1, name: "Admin" }, - { id: 2, name: "Manager" }, - { id: 3, name: "Inspector" }, - { id: 4, name: "Farmer" }, - { id: 5, name: "Developer" }, - ], - skipDuplicates: true, +async function upsertRole(tx: Tx, name: string) { + return tx.role.upsert({ + where: { name }, + update: {}, + create: { name }, }); +} - // 5. Partners - await prisma.partner.createMany({ - data: [ - { id: 1, name: "xpand Foundation" }, - { id: 2, name: "Green Timor Initiative" }, - ], - skipDuplicates: true, - }); +async function upsertPartner(tx: Tx, name: string) { + const existing = await tx.partner.findFirst({ where: { name } }); - // 6. Locations - await prisma.location.createMany({ - data: [ - { - id: 1, - countryId: 1, - parentId: null, - level: 1, - name: "Dili", - code: "DIL", - latitude: new Prisma.Decimal("-8.556900"), - longitude: new Prisma.Decimal("125.560300"), - }, - { - id: 2, - countryId: 1, - parentId: 1, - level: 2, - name: "Cristo Rei", - code: "CRI", - latitude: new Prisma.Decimal("-8.540000"), - longitude: new Prisma.Decimal("125.610000"), - }, - { - id: 3, - countryId: 1, - parentId: 2, - level: 3, - name: "Hera", - code: "HER", - latitude: new Prisma.Decimal("-8.533300"), - longitude: new Prisma.Decimal("125.633300"), - }, - { - id: 4, - countryId: 1, - parentId: null, - level: 1, - name: "Baucau", - code: "BAU", - latitude: new Prisma.Decimal("-8.466700"), - longitude: new Prisma.Decimal("126.450000"), - }, - ], - skipDuplicates: true, - }); + if (existing) { + return tx.partner.update({ + where: { id: existing.id }, + data: { name }, + }); + } - // 7. Administrative Levels - await prisma.administrativeLevel.createMany({ - data: [ - { id: 1, countryId: 1, level: 1, name: "Municipality" }, - { id: 2, countryId: 1, level: 2, name: "Administrative Post" }, - { id: 3, countryId: 1, level: 3, name: "Village" }, - ], - skipDuplicates: true, - }); + return tx.partner.create({ data: { name } }); +} - // 8. Tree Types - await prisma.treeType.createMany({ - data: [ - { - id: 1, - name: "Mahogany", - key: "mahogany", - scientificName: "Swietenia macrophylla", - dryWeightDensity: new Prisma.Decimal("595.000"), - }, - { - id: 2, - name: "Teak", - key: "teak", - scientificName: "Tectona grandis", - dryWeightDensity: new Prisma.Decimal("660.000"), - }, - { - id: 3, - name: "Sandalwood", - key: "sandalwood", - scientificName: "Santalum album", - dryWeightDensity: new Prisma.Decimal("870.000"), - }, - ], - skipDuplicates: true, +async function upsertLocation( + tx: Tx, + data: { + countryId: number; + parentId: number | null; + level: number; + name: string; + code: string | null; + latitude: Prisma.Decimal | null; + longitude: Prisma.Decimal | null; + }, +) { + const existing = await tx.location.findFirst({ + where: { + countryId: data.countryId, + code: data.code, + }, }); - // 9. Projects - await prisma.project.createMany({ - data: [ - { - id: 1, - name: "Hera Reforestation 2025", - description: "Community-based tree restoration project in Hera.", - countryId: 1, - adminLocationId: 3, - isActive: true, - }, - { - id: 2, - name: "Baucau Agroforestry Pilot", - description: "Agroforestry monitoring and survival tracking in Baucau.", - countryId: 1, - adminLocationId: 4, - isActive: true, - }, - ], - skipDuplicates: true, - }); + if (existing) { + return tx.location.update({ + where: { id: existing.id }, + data, + }); + } - // 10. Users - const users = [ - { - email: "admin@treeo2.local", - passwordHash: "hashed_admin_pw", - name: "TreeO2 Admin", - roleId: 1, - cardId: "CARD-ADM-001", - governmentId: "GOV-ADM-001", - gender: "Male", - disability: false, - countryId: 1, - adminLocationId: 1, - streetAddress: "Dili Central Office", - preferredLanguage: "en", - photoId: null, - biography: "System administrator for TreeO2.", - notes: "Primary admin account.", - accountActive: true, - dateJoined: new Date("2025-01-05T00:00:00Z"), - canSignIn: true, - accessToken: null, - accessTokenCreated: null, - resetToken: null, - resetTokenExpires: null, - }, - { - email: "manager@treeo2.local", - passwordHash: "hashed_manager_pw", - name: "Project Manager", - roleId: 2, - cardId: "CARD-MGR-001", - governmentId: "GOV-MGR-001", - gender: "Female", - disability: false, - countryId: 1, - adminLocationId: 1, - streetAddress: "Dili Operations", - preferredLanguage: "en", - photoId: null, - biography: "Oversees project delivery and monitoring.", - notes: "Assigned to multiple projects.", - accountActive: true, - dateJoined: new Date("2025-01-10T00:00:00Z"), - canSignIn: true, - accessToken: null, - accessTokenCreated: null, - resetToken: null, - resetTokenExpires: null, - }, - { - email: "inspector1@treeo2.local", - passwordHash: "hashed_inspector1_pw", - name: "Field Inspector One", - roleId: 3, - cardId: "CARD-INS-001", - governmentId: "GOV-INS-001", - gender: "Male", - disability: false, - countryId: 1, - adminLocationId: 2, - streetAddress: "Cristo Rei Field Office", - preferredLanguage: "tet", - photoId: null, - biography: "Conducts on-site inspections.", - notes: "Experienced in field validations.", - accountActive: true, - dateJoined: new Date("2025-01-12T00:00:00Z"), - canSignIn: true, - accessToken: null, - accessTokenCreated: null, - resetToken: null, - resetTokenExpires: null, - }, - { - email: "inspector2@treeo2.local", - passwordHash: "hashed_inspector2_pw", - name: "Field Inspector Two", - roleId: 3, - cardId: "CARD-INS-002", - governmentId: "GOV-INS-002", - gender: "Female", - disability: false, - countryId: 1, - adminLocationId: 4, - streetAddress: "Baucau Field Office", - preferredLanguage: "tet", - photoId: null, - biography: "Supports rural inspection activities.", - notes: "Assigned to Baucau pilot.", - accountActive: true, - dateJoined: new Date("2025-01-13T00:00:00Z"), - canSignIn: true, - accessToken: null, - accessTokenCreated: null, - resetToken: null, - resetTokenExpires: null, - }, - { - email: "farmer1@treeo2.local", - passwordHash: "hashed_farmer1_pw", - name: "Farmer One", - roleId: 4, - cardId: "CARD-FAR-001", - governmentId: "GOV-FAR-001", - gender: "Female", - disability: false, - countryId: 1, - adminLocationId: 3, - streetAddress: "Hera Village", - preferredLanguage: "tet", - photoId: null, - biography: "Participating farmer in Hera region.", - notes: "Linked to reforestation project.", - accountActive: true, - dateJoined: new Date("2025-01-15T00:00:00Z"), - canSignIn: true, - accessToken: null, - accessTokenCreated: null, - resetToken: null, - resetTokenExpires: null, - }, - { - email: "farmer2@treeo2.local", - passwordHash: "hashed_farmer2_pw", - name: "Farmer Two", - roleId: 4, - cardId: "CARD-FAR-002", - governmentId: "GOV-FAR-002", - gender: "Male", - disability: false, - countryId: 1, - adminLocationId: 4, - streetAddress: "Baucau Rural Area", - preferredLanguage: "tet", - photoId: null, - biography: "Farmer involved in agroforestry activities.", - notes: "Linked to Baucau pilot.", - accountActive: true, - dateJoined: new Date("2025-01-16T00:00:00Z"), - canSignIn: true, - accessToken: null, - accessTokenCreated: null, - resetToken: null, - resetTokenExpires: null, - }, - { - email: "developer@treeo2.local", - passwordHash: "hashed_developer_pw", - name: "Developer User", - roleId: 5, - cardId: "CARD-DEV-001", - governmentId: "GOV-DEV-001", - gender: "Male", - disability: false, - countryId: 2, - adminLocationId: null, - streetAddress: "Melbourne Support Hub", - preferredLanguage: "en", - photoId: null, - biography: "Maintains the technical platform.", - notes: "Support and development account.", - accountActive: true, - dateJoined: new Date("2025-01-18T00:00:00Z"), - canSignIn: true, - accessToken: null, - accessTokenCreated: null, - resetToken: null, - resetTokenExpires: null, + return tx.location.create({ data }); +} + +async function upsertAdministrativeLevel( + tx: Tx, + data: { countryId: number; level: number; name: string }, +) { + const existing = await tx.administrativeLevel.findFirst({ + where: { + countryId: data.countryId, + level: data.level, }, - ] as const; + }); - for (const user of users) { - await prisma.user.upsert({ - where: { email: user.email }, - update: { ...user }, - create: { ...user }, + if (existing) { + return tx.administrativeLevel.update({ + where: { id: existing.id }, + data, }); } - const admin = await prisma.user.findUniqueOrThrow({ - where: { email: "admin@treeo2.local" }, - }); - const manager = await prisma.user.findUniqueOrThrow({ - where: { email: "manager@treeo2.local" }, - }); - const inspector1 = await prisma.user.findUniqueOrThrow({ - where: { email: "inspector1@treeo2.local" }, + return tx.administrativeLevel.create({ data }); +} + +async function upsertTreeType( + tx: Tx, + data: { + name: string; + key: string; + scientificName: string; + dryWeightDensity: Prisma.Decimal; + }, +) { + const existing = await tx.treeType.findFirst({ + where: { key: data.key }, }); - const inspector2 = await prisma.user.findUniqueOrThrow({ - where: { email: "inspector2@treeo2.local" }, + + if (existing) { + return tx.treeType.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.treeType.create({ data }); +} + +async function upsertProject( + tx: Tx, + data: { + name: string; + description: string; + countryId: number; + adminLocationId: number; + isActive: boolean; + }, +) { + const existing = await tx.project.findFirst({ + where: { name: data.name }, }); - const farmer1 = await prisma.user.findUniqueOrThrow({ - where: { email: "farmer1@treeo2.local" }, + + if (existing) { + return tx.project.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.project.create({ data }); +} + +async function upsertUser( + tx: Tx, + data: { + email: string; + passwordHash: string; + name: string; + roleId: number; + cardId: string; + governmentId: string; + gender: string; + disability: boolean; + countryId: number; + adminLocationId: number | null; + streetAddress: string; + preferredLanguage: string; + photoId: null; + biography: string; + notes: string; + accountActive: boolean; + dateJoined: Date; + canSignIn: boolean; + accessToken: null; + accessTokenCreated: null; + resetToken: null; + resetTokenExpires: null; + }, +) { + return tx.user.upsert({ + where: { email: data.email }, + update: data, + create: data, }); - const farmer2 = await prisma.user.findUniqueOrThrow({ - where: { email: "farmer2@treeo2.local" }, +} + +async function upsertScanBatch( + tx: Tx, + data: { inspectorId: number; projectId: number; uploadedAt: Date }, +) { + const existing = await tx.scanBatch.findFirst({ + where: { + inspectorId: data.inspectorId, + projectId: data.projectId, + uploadedAt: data.uploadedAt, + }, }); - const developer = await prisma.user.findUniqueOrThrow({ - where: { email: "developer@treeo2.local" }, + + if (existing) { + return tx.scanBatch.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.scanBatch.create({ data }); +} + +async function upsertTreeScan( + tx: Tx, + data: { + fobId: string; + projectId: number; + farmerId: number; + inspectorId: number; + speciesId: number; + estimatedPlantedYear: number; + estimatedPlantedMonth: number; + plantedDate: Date; + heightM: Prisma.Decimal; + circumferenceCm: Prisma.Decimal; + diameterCm: Prisma.Decimal; + latitude: number; + longitude: number; + photoId: null; + batchId: number; + deviceId: string; + isArchived: boolean; + isCorrected: boolean; + correctedBy: number | null; + correctionReason: string | null; + isValid: boolean; + validationNotes: string; + }, +) { + const existing = await tx.treeScan.findFirst({ + where: { fobId: data.fobId }, }); - // 11. User Role Assignments - await prisma.userRoleAssignment.createMany({ - data: [ - { userId: admin.id, roleId: 1 }, - { userId: manager.id, roleId: 2 }, - { userId: inspector1.id, roleId: 3 }, - { userId: inspector2.id, roleId: 3 }, - { userId: farmer1.id, roleId: 4 }, - { userId: farmer2.id, roleId: 4 }, - { userId: developer.id, roleId: 5 }, - ], - skipDuplicates: true, + if (existing) { + return tx.treeScan.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.treeScan.create({ data }); +} + +async function upsertTreeScanAudit( + tx: Tx, + data: { + treeScanId: number; + changedBy: number; + changeReason: string; + oldData: Prisma.InputJsonValue; + newData: Prisma.InputJsonValue; + changedAt: Date; + }, +) { + const existing = await tx.treeScanAudit.findFirst({ + where: { + treeScanId: data.treeScanId, + changedBy: data.changedBy, + changedAt: data.changedAt, + }, }); - // 12. User Projects - await prisma.userProject.createMany({ - data: [ - { userId: manager.id, projectId: 1 }, - { userId: manager.id, projectId: 2 }, - { userId: inspector1.id, projectId: 1 }, - { userId: inspector2.id, projectId: 2 }, - { userId: farmer1.id, projectId: 1 }, - { userId: farmer2.id, projectId: 2 }, - ], - skipDuplicates: true, + if (existing) { + return tx.treeScanAudit.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.treeScanAudit.create({ data }); +} + +async function upsertAdopter( + tx: Tx, + data: { name: string; email: string }, +) { + const existing = await tx.adopter.findFirst({ + where: { email: data.email }, }); - // 13. Project Tree Types - await prisma.projectTreeType.createMany({ - data: [ - { projectId: 1, treeTypeId: 1 }, - { projectId: 1, treeTypeId: 3 }, - { projectId: 2, treeTypeId: 2 }, - ], - skipDuplicates: true, + if (existing) { + return tx.adopter.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.adopter.create({ data }); +} + +async function upsertAdoption( + tx: Tx, + data: { adopterId: number; fobId: string; adoptedAt: Date }, +) { + const existing = await tx.adoption.findFirst({ + where: { + adopterId: data.adopterId, + fobId: data.fobId, + }, }); - // 14. Scan Batches - await prisma.scanBatch.createMany({ - data: [ - { - id: 1, - inspectorId: inspector1.id, - projectId: 1, - uploadedAt: new Date("2025-02-01T09:00:00Z"), - }, - { - id: 2, - inspectorId: inspector2.id, - projectId: 2, - uploadedAt: new Date("2025-02-10T11:30:00Z"), - }, - ], - skipDuplicates: true, + if (existing) { + return tx.adoption.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.adoption.create({ data }); +} + +async function upsertReport( + tx: Tx, + data: { + reportType: string; + requestedBy: number; + status: "PENDING" | "PROCESSING" | "COMPLETE" | "FAILED"; + parameters: Prisma.InputJsonValue; + outputUrl: string | null; + completedAt: Date | null; + }, +) { + const existing = await tx.report.findFirst({ + where: { + reportType: data.reportType, + requestedBy: data.requestedBy, + }, }); - // 15. Tree Scans - await prisma.treeScan.createMany({ - data: [ + if (existing) { + return tx.report.update({ + where: { id: existing.id }, + data, + }); + } + + return tx.report.create({ data }); +} + +async function main(): Promise { + console.log("Starting seed..."); + + const passwordHashes = { + admin: await hashPassword("Admin@123"), + manager: await hashPassword("Manager@123"), + inspector1: await hashPassword("Inspector1@123"), + inspector2: await hashPassword("Inspector2@123"), + farmer1: await hashPassword("Farmer1@123"), + farmer2: await hashPassword("Farmer2@123"), + developer: await hashPassword("Developer@123"), + }; + + await prisma.$transaction(async (tx) => { + const timorLeste = await upsertCountry(tx, { + name: "Timor-Leste", + iso2: "TL", + iso3: "TLS", + }); + const australia = await upsertCountry(tx, { + name: "Australia", + iso2: "AU", + iso3: "AUS", + }); + + await upsertCulture(tx, { code: "en", name: "English" }); + await upsertCulture(tx, { code: "tet", name: "Tetum" }); + + await upsertLocalizedString(tx, { + cultureCode: "en", + stringKey: "app.title", + value: "TreeO2", + context: "application", + }); + await upsertLocalizedString(tx, { + cultureCode: "tet", + stringKey: "app.title", + value: "TreeO2", + context: "application", + }); + await upsertLocalizedString(tx, { + cultureCode: "en", + stringKey: "report.status.complete", + value: "Complete", + context: "report", + }); + await upsertLocalizedString(tx, { + cultureCode: "tet", + stringKey: "report.status.complete", + value: "Kompletu", + context: "report", + }); + + // Match the app's current numeric role mapping on a fresh database. + await upsertRole(tx, "Farmer"); + await upsertRole(tx, "Inspector"); + await upsertRole(tx, "Manager"); + await upsertRole(tx, "Admin"); + await upsertRole(tx, "Developer"); + + await upsertPartner(tx, "xpand Foundation"); + await upsertPartner(tx, "Green Timor Initiative"); + + const dili = await upsertLocation(tx, { + countryId: timorLeste.id, + parentId: null, + level: 1, + name: "Dili", + code: "DIL", + latitude: new Prisma.Decimal("-8.556900"), + longitude: new Prisma.Decimal("125.560300"), + }); + const cristoRei = await upsertLocation(tx, { + countryId: timorLeste.id, + parentId: dili.id, + level: 2, + name: "Cristo Rei", + code: "CRI", + latitude: new Prisma.Decimal("-8.540000"), + longitude: new Prisma.Decimal("125.610000"), + }); + const hera = await upsertLocation(tx, { + countryId: timorLeste.id, + parentId: cristoRei.id, + level: 3, + name: "Hera", + code: "HER", + latitude: new Prisma.Decimal("-8.533300"), + longitude: new Prisma.Decimal("125.633300"), + }); + const baucau = await upsertLocation(tx, { + countryId: timorLeste.id, + parentId: null, + level: 1, + name: "Baucau", + code: "BAU", + latitude: new Prisma.Decimal("-8.466700"), + longitude: new Prisma.Decimal("126.450000"), + }); + + await upsertAdministrativeLevel(tx, { + countryId: timorLeste.id, + level: 1, + name: "Municipality", + }); + await upsertAdministrativeLevel(tx, { + countryId: timorLeste.id, + level: 2, + name: "Administrative Post", + }); + await upsertAdministrativeLevel(tx, { + countryId: timorLeste.id, + level: 3, + name: "Village", + }); + + const mahogany = await upsertTreeType(tx, { + name: "Mahogany", + key: "mahogany", + scientificName: "Swietenia macrophylla", + dryWeightDensity: new Prisma.Decimal("595.000"), + }); + const teak = await upsertTreeType(tx, { + name: "Teak", + key: "teak", + scientificName: "Tectona grandis", + dryWeightDensity: new Prisma.Decimal("660.000"), + }); + const sandalwood = await upsertTreeType(tx, { + name: "Sandalwood", + key: "sandalwood", + scientificName: "Santalum album", + dryWeightDensity: new Prisma.Decimal("870.000"), + }); + + const heraProject = await upsertProject(tx, { + name: "Hera Reforestation 2025", + description: "Community-based tree restoration project in Hera.", + countryId: timorLeste.id, + adminLocationId: hera.id, + isActive: true, + }); + const baucauProject = await upsertProject(tx, { + name: "Baucau Agroforestry Pilot", + description: "Agroforestry monitoring and survival tracking in Baucau.", + countryId: timorLeste.id, + adminLocationId: baucau.id, + isActive: true, + }); + + const roles = { + farmer: await tx.role.findUniqueOrThrow({ where: { name: "Farmer" } }), + inspector: await tx.role.findUniqueOrThrow({ + where: { name: "Inspector" }, + }), + manager: await tx.role.findUniqueOrThrow({ where: { name: "Manager" } }), + admin: await tx.role.findUniqueOrThrow({ where: { name: "Admin" } }), + developer: await tx.role.findUniqueOrThrow({ + where: { name: "Developer" }, + }), + }; + + const locationIdsByCode = { + DIL: dili.id, + CRI: cristoRei.id, + HER: hera.id, + BAU: baucau.id, + } as const; + + const users: UserSeed[] = [ { - id: 1, - fobId: "FOB-0001", - projectId: 1, - farmerId: farmer1.id, - inspectorId: inspector1.id, - speciesId: 1, - estimatedPlantedYear: 2023, - estimatedPlantedMonth: 6, - plantedDate: new Date("2023-06-15T00:00:00Z"), - heightM: new Prisma.Decimal("1.450"), - circumferenceCm: new Prisma.Decimal("8.400"), - diameterCm: new Prisma.Decimal("2.700"), - latitude: -8.5331, - longitude: 125.6331, - photoId: null, - batchId: 1, - deviceId: "DEVICE-01", - isArchived: false, - isCorrected: false, - correctedBy: null, - correctionReason: null, - isValid: true, - validationNotes: "Healthy sapling observed.", + email: "admin@treeo2.local", + password: passwordHashes.admin, + name: "TreeO2 Admin", + roleName: "Admin", + cardId: "CARD-ADM-001", + governmentId: "GOV-ADM-001", + gender: "Male", + disability: false, + countryIso2: "TL", + adminLocationCode: "DIL", + streetAddress: "Dili Central Office", + preferredLanguage: "en", + biography: "System administrator for TreeO2.", + notes: "Primary admin account.", + accountActive: true, + dateJoined: new Date("2025-01-05T00:00:00Z"), }, { - id: 2, - fobId: "FOB-0002", - projectId: 1, - farmerId: farmer1.id, - inspectorId: inspector1.id, - speciesId: 3, - estimatedPlantedYear: 2022, - estimatedPlantedMonth: 11, - plantedDate: new Date("2022-11-20T00:00:00Z"), - heightM: new Prisma.Decimal("0.950"), - circumferenceCm: new Prisma.Decimal("5.600"), - diameterCm: new Prisma.Decimal("1.800"), - latitude: -8.5335, - longitude: 125.6338, - photoId: null, - batchId: 1, - deviceId: "DEVICE-01", - isArchived: false, - isCorrected: true, - correctedBy: manager.id, - correctionReason: "Corrected planting month after review.", - isValid: true, - validationNotes: "Data verified by manager.", + email: "manager@treeo2.local", + password: passwordHashes.manager, + name: "Project Manager", + roleName: "Manager", + cardId: "CARD-MGR-001", + governmentId: "GOV-MGR-001", + gender: "Female", + disability: false, + countryIso2: "TL", + adminLocationCode: "DIL", + streetAddress: "Dili Operations", + preferredLanguage: "en", + biography: "Oversees project delivery and monitoring.", + notes: "Assigned to multiple projects.", + accountActive: true, + dateJoined: new Date("2025-01-10T00:00:00Z"), }, { - id: 3, - fobId: "FOB-0101", - projectId: 2, - farmerId: farmer2.id, - inspectorId: inspector2.id, - speciesId: 2, - estimatedPlantedYear: 2024, - estimatedPlantedMonth: 3, - plantedDate: new Date("2024-03-05T00:00:00Z"), - heightM: new Prisma.Decimal("1.800"), - circumferenceCm: new Prisma.Decimal("10.200"), - diameterCm: new Prisma.Decimal("3.100"), - latitude: -8.4662, - longitude: 126.4491, - photoId: null, - batchId: 2, - deviceId: "DEVICE-02", - isArchived: false, - isCorrected: false, - correctedBy: null, - correctionReason: null, - isValid: true, - validationNotes: "Strong early growth.", + email: "inspector1@treeo2.local", + password: passwordHashes.inspector1, + name: "Field Inspector One", + roleName: "Inspector", + cardId: "CARD-INS-001", + governmentId: "GOV-INS-001", + gender: "Male", + disability: false, + countryIso2: "TL", + adminLocationCode: "CRI", + streetAddress: "Cristo Rei Field Office", + preferredLanguage: "tet", + biography: "Conducts on-site inspections.", + notes: "Experienced in field validations.", + accountActive: true, + dateJoined: new Date("2025-01-12T00:00:00Z"), }, - ], - skipDuplicates: true, - }); - - // 16. Tree Scan Audit - await prisma.treeScanAudit.createMany({ - data: [ { - id: 1, - treeScanId: 2, - changedBy: manager.id, - changeReason: "Updated planting month", - oldData: { estimatedPlantedMonth: 10 }, - newData: { estimatedPlantedMonth: 11 }, - changedAt: new Date("2025-02-02T12:00:00Z"), + email: "inspector2@treeo2.local", + password: passwordHashes.inspector2, + name: "Field Inspector Two", + roleName: "Inspector", + cardId: "CARD-INS-002", + governmentId: "GOV-INS-002", + gender: "Female", + disability: false, + countryIso2: "TL", + adminLocationCode: "BAU", + streetAddress: "Baucau Field Office", + preferredLanguage: "tet", + biography: "Supports rural inspection activities.", + notes: "Assigned to Baucau pilot.", + accountActive: true, + dateJoined: new Date("2025-01-13T00:00:00Z"), }, - ], - skipDuplicates: true, - }); - - // 17. Adopters - await prisma.adopter.createMany({ - data: [ - { id: 1, name: "Green Earth Donor", email: "donor1@example.com" }, - { id: 2, name: "Eco Supporter", email: "donor2@example.com" }, - ], - skipDuplicates: true, - }); - - // 18. Adoptions - await prisma.adoption.createMany({ - data: [ { - id: 1, - adopterId: 1, - fobId: "FOB-0001", - adoptedAt: new Date("2025-02-15T00:00:00Z"), + email: "farmer1@treeo2.local", + password: passwordHashes.farmer1, + name: "Farmer One", + roleName: "Farmer", + cardId: "CARD-FAR-001", + governmentId: "GOV-FAR-001", + gender: "Female", + disability: false, + countryIso2: "TL", + adminLocationCode: "HER", + streetAddress: "Hera Village", + preferredLanguage: "tet", + biography: "Participating farmer in Hera region.", + notes: "Linked to reforestation project.", + accountActive: true, + dateJoined: new Date("2025-01-15T00:00:00Z"), }, { - id: 2, - adopterId: 2, - fobId: "FOB-0101", - adoptedAt: new Date("2025-02-20T00:00:00Z"), - }, - ], - skipDuplicates: true, - }); - - // 19. Reports - await prisma.report.createMany({ - data: [ - { - id: 1, - reportType: "Tree Survival Summary", - requestedBy: manager.id, - status: "COMPLETED", - parameters: { projectId: 1, month: "2025-02" }, - outputUrl: "https://xyz.com/reports/tree-survival-summary.pdf", - completedAt: new Date("2025-02-28T10:00:00Z"), + email: "farmer2@treeo2.local", + password: passwordHashes.farmer2, + name: "Farmer Two", + roleName: "Farmer", + cardId: "CARD-FAR-002", + governmentId: "GOV-FAR-002", + gender: "Male", + disability: false, + countryIso2: "TL", + adminLocationCode: "BAU", + streetAddress: "Baucau Rural Area", + preferredLanguage: "tet", + biography: "Farmer involved in agroforestry activities.", + notes: "Linked to Baucau pilot.", + accountActive: true, + dateJoined: new Date("2025-01-16T00:00:00Z"), }, { - id: 2, - reportType: "Inspector Activity Report", - requestedBy: admin.id, - status: "PENDING", - parameters: { inspectorId: inspector1.id }, - outputUrl: null, - completedAt: null, + email: "developer@treeo2.local", + password: passwordHashes.developer, + name: "Developer User", + roleName: "Developer", + cardId: "CARD-DEV-001", + governmentId: "GOV-DEV-001", + gender: "Male", + disability: false, + countryIso2: "AU", + adminLocationCode: null, + streetAddress: "Melbourne Support Hub", + preferredLanguage: "en", + biography: "Maintains the technical platform.", + notes: "Support and development account.", + accountActive: true, + dateJoined: new Date("2025-01-18T00:00:00Z"), }, - ], - skipDuplicates: true, + ]; + + const countryIdsByIso2 = { + TL: timorLeste.id, + AU: australia.id, + } as const; + + const roleIdsByName = { + Farmer: roles.farmer.id, + Inspector: roles.inspector.id, + Manager: roles.manager.id, + Admin: roles.admin.id, + Developer: roles.developer.id, + } as const; + + for (const user of users) { + await upsertUser(tx, { + email: user.email, + passwordHash: user.password, + name: user.name, + roleId: roleIdsByName[user.roleName as keyof typeof roleIdsByName], + cardId: user.cardId, + governmentId: user.governmentId, + gender: user.gender, + disability: user.disability, + countryId: countryIdsByIso2[user.countryIso2], + adminLocationId: user.adminLocationCode + ? locationIdsByCode[user.adminLocationCode] + : null, + streetAddress: user.streetAddress, + preferredLanguage: user.preferredLanguage, + photoId: null, + biography: user.biography, + notes: user.notes, + accountActive: user.accountActive, + dateJoined: user.dateJoined, + canSignIn: true, + accessToken: null, + accessTokenCreated: null, + resetToken: null, + resetTokenExpires: null, + }); + } + + const admin = await tx.user.findUniqueOrThrow({ + where: { email: "admin@treeo2.local" }, + }); + const manager = await tx.user.findUniqueOrThrow({ + where: { email: "manager@treeo2.local" }, + }); + const inspector1 = await tx.user.findUniqueOrThrow({ + where: { email: "inspector1@treeo2.local" }, + }); + const inspector2 = await tx.user.findUniqueOrThrow({ + where: { email: "inspector2@treeo2.local" }, + }); + const farmer1 = await tx.user.findUniqueOrThrow({ + where: { email: "farmer1@treeo2.local" }, + }); + const farmer2 = await tx.user.findUniqueOrThrow({ + where: { email: "farmer2@treeo2.local" }, + }); + const developer = await tx.user.findUniqueOrThrow({ + where: { email: "developer@treeo2.local" }, + }); + + await tx.userRoleAssignment.createMany({ + data: [ + { userId: admin.id, roleId: roles.admin.id }, + { userId: manager.id, roleId: roles.manager.id }, + { userId: inspector1.id, roleId: roles.inspector.id }, + { userId: inspector2.id, roleId: roles.inspector.id }, + { userId: farmer1.id, roleId: roles.farmer.id }, + { userId: farmer2.id, roleId: roles.farmer.id }, + { userId: developer.id, roleId: roles.developer.id }, + ], + skipDuplicates: true, + }); + + await tx.userProject.createMany({ + data: [ + { userId: manager.id, projectId: heraProject.id }, + { userId: manager.id, projectId: baucauProject.id }, + { userId: inspector1.id, projectId: heraProject.id }, + { userId: inspector2.id, projectId: baucauProject.id }, + { userId: farmer1.id, projectId: heraProject.id }, + { userId: farmer2.id, projectId: baucauProject.id }, + ], + skipDuplicates: true, + }); + + await tx.projectTreeType.createMany({ + data: [ + { projectId: heraProject.id, treeTypeId: mahogany.id }, + { projectId: heraProject.id, treeTypeId: sandalwood.id }, + { projectId: baucauProject.id, treeTypeId: teak.id }, + ], + skipDuplicates: true, + }); + + const heraBatch = await upsertScanBatch(tx, { + inspectorId: inspector1.id, + projectId: heraProject.id, + uploadedAt: new Date("2025-02-01T09:00:00Z"), + }); + const baucauBatch = await upsertScanBatch(tx, { + inspectorId: inspector2.id, + projectId: baucauProject.id, + uploadedAt: new Date("2025-02-10T11:30:00Z"), + }); + + const treeScan1 = await upsertTreeScan(tx, { + fobId: "FOB-0001", + projectId: heraProject.id, + farmerId: farmer1.id, + inspectorId: inspector1.id, + speciesId: mahogany.id, + estimatedPlantedYear: 2023, + estimatedPlantedMonth: 6, + plantedDate: new Date("2023-06-15T00:00:00Z"), + heightM: new Prisma.Decimal("1.450"), + circumferenceCm: new Prisma.Decimal("8.400"), + diameterCm: new Prisma.Decimal("2.700"), + latitude: -8.5331, + longitude: 125.6331, + photoId: null, + batchId: heraBatch.id, + deviceId: "DEVICE-01", + isArchived: false, + isCorrected: false, + correctedBy: null, + correctionReason: null, + isValid: true, + validationNotes: "Healthy sapling observed.", + }); + const treeScan2 = await upsertTreeScan(tx, { + fobId: "FOB-0002", + projectId: heraProject.id, + farmerId: farmer1.id, + inspectorId: inspector1.id, + speciesId: sandalwood.id, + estimatedPlantedYear: 2022, + estimatedPlantedMonth: 11, + plantedDate: new Date("2022-11-20T00:00:00Z"), + heightM: new Prisma.Decimal("0.950"), + circumferenceCm: new Prisma.Decimal("5.600"), + diameterCm: new Prisma.Decimal("1.800"), + latitude: -8.5335, + longitude: 125.6338, + photoId: null, + batchId: heraBatch.id, + deviceId: "DEVICE-01", + isArchived: false, + isCorrected: true, + correctedBy: manager.id, + correctionReason: "Corrected planting month after review.", + isValid: true, + validationNotes: "Data verified by manager.", + }); + const treeScan3 = await upsertTreeScan(tx, { + fobId: "FOB-0101", + projectId: baucauProject.id, + farmerId: farmer2.id, + inspectorId: inspector2.id, + speciesId: teak.id, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 3, + plantedDate: new Date("2024-03-05T00:00:00Z"), + heightM: new Prisma.Decimal("1.800"), + circumferenceCm: new Prisma.Decimal("10.200"), + diameterCm: new Prisma.Decimal("3.100"), + latitude: -8.4662, + longitude: 126.4491, + photoId: null, + batchId: baucauBatch.id, + deviceId: "DEVICE-02", + isArchived: false, + isCorrected: false, + correctedBy: null, + correctionReason: null, + isValid: true, + validationNotes: "Strong early growth.", + }); + + await upsertTreeScanAudit(tx, { + treeScanId: treeScan2.id, + changedBy: manager.id, + changeReason: "Updated planting month", + oldData: { estimatedPlantedMonth: 10 }, + newData: { estimatedPlantedMonth: 11 }, + changedAt: new Date("2025-02-02T12:00:00Z"), + }); + + const adopter1 = await upsertAdopter(tx, { + name: "Green Earth Donor", + email: "donor1@example.com", + }); + const adopter2 = await upsertAdopter(tx, { + name: "Eco Supporter", + email: "donor2@example.com", + }); + + await upsertAdoption(tx, { + adopterId: adopter1.id, + fobId: treeScan1.fobId, + adoptedAt: new Date("2025-02-15T00:00:00Z"), + }); + await upsertAdoption(tx, { + adopterId: adopter2.id, + fobId: treeScan3.fobId, + adoptedAt: new Date("2025-02-20T00:00:00Z"), + }); + + await upsertReport(tx, { + reportType: "Tree Survival Summary", + requestedBy: manager.id, + status: "COMPLETE", + parameters: { projectId: heraProject.id, month: "2025-02" }, + outputUrl: "https://xyz.com/reports/tree-survival-summary.pdf", + completedAt: new Date("2025-02-28T10:00:00Z"), + }); + await upsertReport(tx, { + reportType: "Inspector Activity Report", + requestedBy: admin.id, + status: "PENDING", + parameters: { inspectorId: inspector1.id }, + outputUrl: null, + completedAt: null, + }); }); console.log("Seed completed successfully."); + console.log("Test login passwords are set to:"); + console.log("Admin@123, Manager@123, Inspector1@123, Inspector2@123"); + console.log("Farmer1@123, Farmer2@123, Developer@123"); } void main() @@ -607,4 +918,4 @@ void main() }) .finally(() => { void prisma.$disconnect(); - }); \ No newline at end of file + }); From 4c338d976f457611cdf2dcbd1f8c0295a6e81590 Mon Sep 17 00:00:00 2001 From: Alan maria George Date: Sat, 25 Apr 2026 21:27:51 +1000 Subject: [PATCH 3/9] Address seed script review feedback --- prisma/seed.ts | 157 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 45 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index e2d2210..f81889b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { PrismaClient, Prisma } from "@prisma/client"; import { hashPassword } from "../src/lib/bcrypt"; @@ -7,11 +6,13 @@ const prisma = new PrismaClient(); type Tx = Prisma.TransactionClient; +type RoleName = "Farmer" | "Inspector" | "Manager" | "Admin" | "Developer"; + type UserSeed = { email: string; - password: string; + passwordHash: string; name: string; - roleName: string; + roleName: RoleName; cardId: string; governmentId: string; gender: string; @@ -212,9 +213,29 @@ async function upsertUser( resetTokenExpires: null; }, ) { + const updateData = { + passwordHash: data.passwordHash, + name: data.name, + roleId: data.roleId, + cardId: data.cardId, + governmentId: data.governmentId, + gender: data.gender, + disability: data.disability, + countryId: data.countryId, + adminLocationId: data.adminLocationId, + streetAddress: data.streetAddress, + preferredLanguage: data.preferredLanguage, + photoId: data.photoId, + biography: data.biography, + notes: data.notes, + accountActive: data.accountActive, + dateJoined: data.dateJoined, + canSignIn: data.canSignIn, + }; + return tx.user.upsert({ where: { email: data.email }, - update: data, + update: updateData, create: data, }); } @@ -268,10 +289,18 @@ async function upsertTreeScan( validationNotes: string; }, ) { - const existing = await tx.treeScan.findFirst({ + const existingRows = await tx.treeScan.findMany({ where: { fobId: data.fobId }, }); + if (existingRows.length > 1) { + throw new Error( + `Cannot seed TreeScan deterministically. Multiple rows found for fobId: ${data.fobId}`, + ); + } + + const existing = existingRows[0]; + if (existing) { return tx.treeScan.update({ where: { id: existing.id }, @@ -311,10 +340,7 @@ async function upsertTreeScanAudit( return tx.treeScanAudit.create({ data }); } -async function upsertAdopter( - tx: Tx, - data: { name: string; email: string }, -) { +async function upsertAdopter(tx: Tx, data: { name: string; email: string }) { const existing = await tx.adopter.findFirst({ where: { email: data.email }, }); @@ -381,14 +407,34 @@ async function upsertReport( async function main(): Promise { console.log("Starting seed..."); + if (process.env.NODE_ENV === "production") { + throw new Error("Seed script must not be run in production."); + } + + if (process.env.ALLOW_SAMPLE_SEED !== "true") { + throw new Error("Set ALLOW_SAMPLE_SEED=true to run the sample seed script."); + } + const passwordHashes = { - admin: await hashPassword("Admin@123"), - manager: await hashPassword("Manager@123"), - inspector1: await hashPassword("Inspector1@123"), - inspector2: await hashPassword("Inspector2@123"), - farmer1: await hashPassword("Farmer1@123"), - farmer2: await hashPassword("Farmer2@123"), - developer: await hashPassword("Developer@123"), + admin: await hashPassword(process.env.SEED_ADMIN_PASSWORD ?? "Admin@123"), + manager: await hashPassword( + process.env.SEED_MANAGER_PASSWORD ?? "Manager@123", + ), + inspector1: await hashPassword( + process.env.SEED_INSPECTOR1_PASSWORD ?? "Inspector1@123", + ), + inspector2: await hashPassword( + process.env.SEED_INSPECTOR2_PASSWORD ?? "Inspector2@123", + ), + farmer1: await hashPassword( + process.env.SEED_FARMER1_PASSWORD ?? "Farmer1@123", + ), + farmer2: await hashPassword( + process.env.SEED_FARMER2_PASSWORD ?? "Farmer2@123", + ), + developer: await hashPassword( + process.env.SEED_DEVELOPER_PASSWORD ?? "Developer@123", + ), }; await prisma.$transaction(async (tx) => { @@ -397,6 +443,7 @@ async function main(): Promise { iso2: "TL", iso3: "TLS", }); + const australia = await upsertCountry(tx, { name: "Australia", iso2: "AU", @@ -412,18 +459,21 @@ async function main(): Promise { value: "TreeO2", context: "application", }); + await upsertLocalizedString(tx, { cultureCode: "tet", stringKey: "app.title", value: "TreeO2", context: "application", }); + await upsertLocalizedString(tx, { cultureCode: "en", stringKey: "report.status.complete", value: "Complete", context: "report", }); + await upsertLocalizedString(tx, { cultureCode: "tet", stringKey: "report.status.complete", @@ -431,7 +481,6 @@ async function main(): Promise { context: "report", }); - // Match the app's current numeric role mapping on a fresh database. await upsertRole(tx, "Farmer"); await upsertRole(tx, "Inspector"); await upsertRole(tx, "Manager"); @@ -450,6 +499,7 @@ async function main(): Promise { latitude: new Prisma.Decimal("-8.556900"), longitude: new Prisma.Decimal("125.560300"), }); + const cristoRei = await upsertLocation(tx, { countryId: timorLeste.id, parentId: dili.id, @@ -459,6 +509,7 @@ async function main(): Promise { latitude: new Prisma.Decimal("-8.540000"), longitude: new Prisma.Decimal("125.610000"), }); + const hera = await upsertLocation(tx, { countryId: timorLeste.id, parentId: cristoRei.id, @@ -468,6 +519,7 @@ async function main(): Promise { latitude: new Prisma.Decimal("-8.533300"), longitude: new Prisma.Decimal("125.633300"), }); + const baucau = await upsertLocation(tx, { countryId: timorLeste.id, parentId: null, @@ -483,11 +535,13 @@ async function main(): Promise { level: 1, name: "Municipality", }); + await upsertAdministrativeLevel(tx, { countryId: timorLeste.id, level: 2, name: "Administrative Post", }); + await upsertAdministrativeLevel(tx, { countryId: timorLeste.id, level: 3, @@ -500,12 +554,14 @@ async function main(): Promise { scientificName: "Swietenia macrophylla", dryWeightDensity: new Prisma.Decimal("595.000"), }); + const teak = await upsertTreeType(tx, { name: "Teak", key: "teak", scientificName: "Tectona grandis", dryWeightDensity: new Prisma.Decimal("660.000"), }); + const sandalwood = await upsertTreeType(tx, { name: "Sandalwood", key: "sandalwood", @@ -520,6 +576,7 @@ async function main(): Promise { adminLocationId: hera.id, isActive: true, }); + const baucauProject = await upsertProject(tx, { name: "Baucau Agroforestry Pilot", description: "Agroforestry monitoring and survival tracking in Baucau.", @@ -547,10 +604,23 @@ async function main(): Promise { BAU: baucau.id, } as const; + const countryIdsByIso2 = { + TL: timorLeste.id, + AU: australia.id, + } as const; + + const roleIdsByName: Record = { + Farmer: roles.farmer.id, + Inspector: roles.inspector.id, + Manager: roles.manager.id, + Admin: roles.admin.id, + Developer: roles.developer.id, + }; + const users: UserSeed[] = [ { email: "admin@treeo2.local", - password: passwordHashes.admin, + passwordHash: passwordHashes.admin, name: "TreeO2 Admin", roleName: "Admin", cardId: "CARD-ADM-001", @@ -568,7 +638,7 @@ async function main(): Promise { }, { email: "manager@treeo2.local", - password: passwordHashes.manager, + passwordHash: passwordHashes.manager, name: "Project Manager", roleName: "Manager", cardId: "CARD-MGR-001", @@ -586,7 +656,7 @@ async function main(): Promise { }, { email: "inspector1@treeo2.local", - password: passwordHashes.inspector1, + passwordHash: passwordHashes.inspector1, name: "Field Inspector One", roleName: "Inspector", cardId: "CARD-INS-001", @@ -604,7 +674,7 @@ async function main(): Promise { }, { email: "inspector2@treeo2.local", - password: passwordHashes.inspector2, + passwordHash: passwordHashes.inspector2, name: "Field Inspector Two", roleName: "Inspector", cardId: "CARD-INS-002", @@ -622,7 +692,7 @@ async function main(): Promise { }, { email: "farmer1@treeo2.local", - password: passwordHashes.farmer1, + passwordHash: passwordHashes.farmer1, name: "Farmer One", roleName: "Farmer", cardId: "CARD-FAR-001", @@ -640,7 +710,7 @@ async function main(): Promise { }, { email: "farmer2@treeo2.local", - password: passwordHashes.farmer2, + passwordHash: passwordHashes.farmer2, name: "Farmer Two", roleName: "Farmer", cardId: "CARD-FAR-002", @@ -658,7 +728,7 @@ async function main(): Promise { }, { email: "developer@treeo2.local", - password: passwordHashes.developer, + passwordHash: passwordHashes.developer, name: "Developer User", roleName: "Developer", cardId: "CARD-DEV-001", @@ -676,25 +746,12 @@ async function main(): Promise { }, ]; - const countryIdsByIso2 = { - TL: timorLeste.id, - AU: australia.id, - } as const; - - const roleIdsByName = { - Farmer: roles.farmer.id, - Inspector: roles.inspector.id, - Manager: roles.manager.id, - Admin: roles.admin.id, - Developer: roles.developer.id, - } as const; - for (const user of users) { await upsertUser(tx, { email: user.email, - passwordHash: user.password, + passwordHash: user.passwordHash, name: user.name, - roleId: roleIdsByName[user.roleName as keyof typeof roleIdsByName], + roleId: roleIdsByName[user.roleName], cardId: user.cardId, governmentId: user.governmentId, gender: user.gender, @@ -721,21 +778,27 @@ async function main(): Promise { const admin = await tx.user.findUniqueOrThrow({ where: { email: "admin@treeo2.local" }, }); + const manager = await tx.user.findUniqueOrThrow({ where: { email: "manager@treeo2.local" }, }); + const inspector1 = await tx.user.findUniqueOrThrow({ where: { email: "inspector1@treeo2.local" }, }); + const inspector2 = await tx.user.findUniqueOrThrow({ where: { email: "inspector2@treeo2.local" }, }); + const farmer1 = await tx.user.findUniqueOrThrow({ where: { email: "farmer1@treeo2.local" }, }); + const farmer2 = await tx.user.findUniqueOrThrow({ where: { email: "farmer2@treeo2.local" }, }); + const developer = await tx.user.findUniqueOrThrow({ where: { email: "developer@treeo2.local" }, }); @@ -779,6 +842,7 @@ async function main(): Promise { projectId: heraProject.id, uploadedAt: new Date("2025-02-01T09:00:00Z"), }); + const baucauBatch = await upsertScanBatch(tx, { inspectorId: inspector2.id, projectId: baucauProject.id, @@ -809,6 +873,7 @@ async function main(): Promise { isValid: true, validationNotes: "Healthy sapling observed.", }); + const treeScan2 = await upsertTreeScan(tx, { fobId: "FOB-0002", projectId: heraProject.id, @@ -833,6 +898,7 @@ async function main(): Promise { isValid: true, validationNotes: "Data verified by manager.", }); + const treeScan3 = await upsertTreeScan(tx, { fobId: "FOB-0101", projectId: baucauProject.id, @@ -871,6 +937,7 @@ async function main(): Promise { name: "Green Earth Donor", email: "donor1@example.com", }); + const adopter2 = await upsertAdopter(tx, { name: "Eco Supporter", email: "donor2@example.com", @@ -881,6 +948,7 @@ async function main(): Promise { fobId: treeScan1.fobId, adoptedAt: new Date("2025-02-15T00:00:00Z"), }); + await upsertAdoption(tx, { adopterId: adopter2.id, fobId: treeScan3.fobId, @@ -895,6 +963,7 @@ async function main(): Promise { outputUrl: "https://xyz.com/reports/tree-survival-summary.pdf", completedAt: new Date("2025-02-28T10:00:00Z"), }); + await upsertReport(tx, { reportType: "Inspector Activity Report", requestedBy: admin.id, @@ -906,9 +975,7 @@ async function main(): Promise { }); console.log("Seed completed successfully."); - console.log("Test login passwords are set to:"); - console.log("Admin@123, Manager@123, Inspector1@123, Inspector2@123"); - console.log("Farmer1@123, Farmer2@123, Developer@123"); + console.log("Test login accounts were seeded."); } void main() @@ -916,6 +983,6 @@ void main() console.error("Seed failed", err); process.exit(1); }) - .finally(() => { - void prisma.$disconnect(); - }); + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file From c4bd64062a42323cccfcf89a28bb37012eb79035 Mon Sep 17 00:00:00 2001 From: Nikhil Jadav Date: Mon, 27 Apr 2026 19:17:52 +1000 Subject: [PATCH 4/9] fix(seed): harden sample seed workflow and document env requirements --- .env.example | 10 +++ README.md | 6 +- docs/Setup.md | 6 +- prisma/seed.ts | 171 +++++++++++++++++++++++++++++++++---------------- 4 files changed, 133 insertions(+), 60 deletions(-) diff --git a/.env.example b/.env.example index 7a043f1..2849bd5 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,13 @@ RATE_LIMIT_MAX=100 # Enable logger for local LOG_TO_FILE=false + +# Sample seed data +ALLOW_SAMPLE_SEED=false +SEED_ADMIN_PASSWORD=Admin@123 +SEED_MANAGER_PASSWORD=Manager@123 +SEED_INSPECTOR1_PASSWORD=Inspector1@123 +SEED_INSPECTOR2_PASSWORD=Inspector2@123 +SEED_FARMER1_PASSWORD=Farmer1@123 +SEED_FARMER2_PASSWORD=Farmer2@123 +SEED_DEVELOPER_PASSWORD=Developer@123 diff --git a/README.md b/README.md index c2b27c5..2889396 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,14 @@ npm run prisma:migrate:dev npm run prisma:push # 6. Seed local data -npm run prisma:seed +ALLOW_SAMPLE_SEED=true npm run prisma:seed # 7. Start dev server npm run dev ``` +The sample seed is for local development only. It expects a clean local database and is intentionally blocked unless `ALLOW_SAMPLE_SEED=true` is provided. + API is available at `http://localhost:3000` Health check: `GET /health` Swagger docs: `http://localhost:3000/api-docs` @@ -113,7 +115,7 @@ tests/ | `npm run prisma:push` | Push schema directly to the database without creating migrations | | `npm run prisma:migrate:dev` | Create and apply a development migration | | `npm run prisma:migrate:deploy` | Apply migrations | -| `npm run prisma:seed` | Seed local data | +| `npm run prisma:seed` | Seed local sample data after setting `ALLOW_SAMPLE_SEED=true` | | `npm test` | Run Jest tests | ## Prisma Schema Layout diff --git a/docs/Setup.md b/docs/Setup.md index 537ce6e..04b4dbf 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -93,10 +93,12 @@ This project uses Prisma's multi-file schema layout: ### 6. Seed the database (optional) ```bash -npm run prisma:seed +ALLOW_SAMPLE_SEED=true npm run prisma:seed ``` > Seeds are for local/dev only — never run against production. +> The sample seed expects a clean local database and is intentionally blocked unless `ALLOW_SAMPLE_SEED=true` is set. +> Optional password overrides are available through `SEED_ADMIN_PASSWORD`, `SEED_MANAGER_PASSWORD`, `SEED_INSPECTOR1_PASSWORD`, `SEED_INSPECTOR2_PASSWORD`, `SEED_FARMER1_PASSWORD`, `SEED_FARMER2_PASSWORD`, and `SEED_DEVELOPER_PASSWORD`. ### 7. Start the dev server @@ -223,7 +225,7 @@ npm run prisma:push | `npm run prisma:push` | Push schema directly to the database without creating migrations | | `npm run prisma:migrate:dev` | Create and apply a development migration | | `npm run prisma:migrate:deploy` | Apply migrations | -| `npm run prisma:seed` | Seed local data | +| `npm run prisma:seed` | Seed local sample data after setting `ALLOW_SAMPLE_SEED=true` | | `npm test` | Run Jest tests | | `npm run test:watch` | Run tests in watch mode | | `npm run test:coverage` | Run tests with coverage report | diff --git a/prisma/seed.ts b/prisma/seed.ts index f81889b..618304a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -27,6 +27,14 @@ type UserSeed = { dateJoined: Date; }; +function getSingleOrThrow(rows: T[], message: string): T | null { + if (rows.length > 1) { + throw new Error(message); + } + + return rows[0] ?? null; +} + async function upsertCountry( tx: Tx, data: { name: string; iso2: string; iso3: string }, @@ -77,7 +85,10 @@ async function upsertRole(tx: Tx, name: string) { } async function upsertPartner(tx: Tx, name: string) { - const existing = await tx.partner.findFirst({ where: { name } }); + const existing = getSingleOrThrow( + await tx.partner.findMany({ where: { name } }), + `Cannot seed Partner deterministically. Multiple rows found for name: ${name}`, + ); if (existing) { return tx.partner.update({ @@ -101,12 +112,15 @@ async function upsertLocation( longitude: Prisma.Decimal | null; }, ) { - const existing = await tx.location.findFirst({ - where: { - countryId: data.countryId, - code: data.code, - }, - }); + const existing = getSingleOrThrow( + await tx.location.findMany({ + where: { + countryId: data.countryId, + code: data.code, + }, + }), + `Cannot seed Location deterministically. Multiple rows found for countryId=${data.countryId} and code=${String(data.code)}`, + ); if (existing) { return tx.location.update({ @@ -122,12 +136,15 @@ async function upsertAdministrativeLevel( tx: Tx, data: { countryId: number; level: number; name: string }, ) { - const existing = await tx.administrativeLevel.findFirst({ - where: { - countryId: data.countryId, - level: data.level, - }, - }); + const existing = getSingleOrThrow( + await tx.administrativeLevel.findMany({ + where: { + countryId: data.countryId, + level: data.level, + }, + }), + `Cannot seed AdministrativeLevel deterministically. Multiple rows found for countryId=${data.countryId} and level=${data.level}`, + ); if (existing) { return tx.administrativeLevel.update({ @@ -148,9 +165,12 @@ async function upsertTreeType( dryWeightDensity: Prisma.Decimal; }, ) { - const existing = await tx.treeType.findFirst({ - where: { key: data.key }, - }); + const existing = getSingleOrThrow( + await tx.treeType.findMany({ + where: { key: data.key }, + }), + `Cannot seed TreeType deterministically. Multiple rows found for key: ${data.key}`, + ); if (existing) { return tx.treeType.update({ @@ -172,9 +192,12 @@ async function upsertProject( isActive: boolean; }, ) { - const existing = await tx.project.findFirst({ - where: { name: data.name }, - }); + const existing = getSingleOrThrow( + await tx.project.findMany({ + where: { name: data.name }, + }), + `Cannot seed Project deterministically. Multiple rows found for name: ${data.name}`, + ); if (existing) { return tx.project.update({ @@ -244,13 +267,16 @@ async function upsertScanBatch( tx: Tx, data: { inspectorId: number; projectId: number; uploadedAt: Date }, ) { - const existing = await tx.scanBatch.findFirst({ - where: { - inspectorId: data.inspectorId, - projectId: data.projectId, - uploadedAt: data.uploadedAt, - }, - }); + const existing = getSingleOrThrow( + await tx.scanBatch.findMany({ + where: { + inspectorId: data.inspectorId, + projectId: data.projectId, + uploadedAt: data.uploadedAt, + }, + }), + `Cannot seed ScanBatch deterministically. Multiple rows found for inspectorId=${data.inspectorId}, projectId=${data.projectId}, uploadedAt=${data.uploadedAt.toISOString()}`, + ); if (existing) { return tx.scanBatch.update({ @@ -293,13 +319,10 @@ async function upsertTreeScan( where: { fobId: data.fobId }, }); - if (existingRows.length > 1) { - throw new Error( - `Cannot seed TreeScan deterministically. Multiple rows found for fobId: ${data.fobId}`, - ); - } - - const existing = existingRows[0]; + const existing = getSingleOrThrow( + existingRows, + `Cannot seed TreeScan deterministically. Multiple rows found for fobId: ${data.fobId}`, + ); if (existing) { return tx.treeScan.update({ @@ -322,13 +345,16 @@ async function upsertTreeScanAudit( changedAt: Date; }, ) { - const existing = await tx.treeScanAudit.findFirst({ - where: { - treeScanId: data.treeScanId, - changedBy: data.changedBy, - changedAt: data.changedAt, - }, - }); + const existing = getSingleOrThrow( + await tx.treeScanAudit.findMany({ + where: { + treeScanId: data.treeScanId, + changedBy: data.changedBy, + changedAt: data.changedAt, + }, + }), + `Cannot seed TreeScanAudit deterministically. Multiple rows found for treeScanId=${data.treeScanId}, changedBy=${data.changedBy}, changedAt=${data.changedAt.toISOString()}`, + ); if (existing) { return tx.treeScanAudit.update({ @@ -341,9 +367,12 @@ async function upsertTreeScanAudit( } async function upsertAdopter(tx: Tx, data: { name: string; email: string }) { - const existing = await tx.adopter.findFirst({ - where: { email: data.email }, - }); + const existing = getSingleOrThrow( + await tx.adopter.findMany({ + where: { email: data.email }, + }), + `Cannot seed Adopter deterministically. Multiple rows found for email: ${data.email}`, + ); if (existing) { return tx.adopter.update({ @@ -359,12 +388,15 @@ async function upsertAdoption( tx: Tx, data: { adopterId: number; fobId: string; adoptedAt: Date }, ) { - const existing = await tx.adoption.findFirst({ - where: { - adopterId: data.adopterId, - fobId: data.fobId, - }, - }); + const existing = getSingleOrThrow( + await tx.adoption.findMany({ + where: { + adopterId: data.adopterId, + fobId: data.fobId, + }, + }), + `Cannot seed Adoption deterministically. Multiple rows found for adopterId=${data.adopterId} and fobId=${data.fobId}`, + ); if (existing) { return tx.adoption.update({ @@ -387,12 +419,15 @@ async function upsertReport( completedAt: Date | null; }, ) { - const existing = await tx.report.findFirst({ - where: { - reportType: data.reportType, - requestedBy: data.requestedBy, - }, - }); + const existing = getSingleOrThrow( + await tx.report.findMany({ + where: { + reportType: data.reportType, + requestedBy: data.requestedBy, + }, + }), + `Cannot seed Report deterministically. Multiple rows found for reportType=${data.reportType} and requestedBy=${data.requestedBy}`, + ); if (existing) { return tx.report.update({ @@ -597,6 +632,30 @@ async function main(): Promise { }), }; + const expectedRoleIds: Record = { + Farmer: 1, + Inspector: 2, + Manager: 3, + Admin: 4, + Developer: 5, + }; + + const actualRoleIds: Record = { + Farmer: roles.farmer.id, + Inspector: roles.inspector.id, + Manager: roles.manager.id, + Admin: roles.admin.id, + Developer: roles.developer.id, + }; + + for (const roleName of Object.keys(expectedRoleIds) as RoleName[]) { + if (actualRoleIds[roleName] !== expectedRoleIds[roleName]) { + throw new Error( + `Role ID mismatch for ${roleName}. Expected ${expectedRoleIds[roleName]}, found ${actualRoleIds[roleName]}. Reset the local database and rerun migrations before seeding.`, + ); + } + } + const locationIdsByCode = { DIL: dili.id, CRI: cristoRei.id, @@ -985,4 +1044,4 @@ void main() }) .finally(async () => { await prisma.$disconnect(); - }); \ No newline at end of file + }); From 30759ae0de0cc1a4b0a7099135930baa2c453403 Mon Sep 17 00:00:00 2001 From: Nikhil Jadav Date: Mon, 27 Apr 2026 19:41:29 +1000 Subject: [PATCH 5/9] fix: prisma validation error fixed --- docs/Setup.md | 8 ++++ prisma/seed.ts | 105 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 77 insertions(+), 36 deletions(-) diff --git a/docs/Setup.md b/docs/Setup.md index 04b4dbf..72883ae 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -135,6 +135,14 @@ All variables are validated on startup via Zod. The server will exit immediately | `JWT_EXPIRES_IN` | No | `24h` | Token expiry — e.g. `1h`, `7d`, `24h` | | `RATE_LIMIT_WINDOW_MS` | No | `900000` | Rate limit window in ms (default: 15 min) | | `RATE_LIMIT_MAX` | No | `100` | Max requests per window per IP | +| `ALLOW_SAMPLE_SEED` | No | `false` | Safety flag required to allow local sample seeding | +| `SEED_ADMIN_PASSWORD` | No | `Admin@123` | Optional override for the sample admin account password | +| `SEED_MANAGER_PASSWORD` | No | `Manager@123` | Optional override for the sample manager account password | +| `SEED_INSPECTOR1_PASSWORD` | No | `Inspector1@123` | Optional override for the first sample inspector account password | +| `SEED_INSPECTOR2_PASSWORD` | No | `Inspector2@123` | Optional override for the second sample inspector account password | +| `SEED_FARMER1_PASSWORD` | No | `Farmer1@123` | Optional override for the first sample farmer account password | +| `SEED_FARMER2_PASSWORD` | No | `Farmer2@123` | Optional override for the second sample farmer account password | +| `SEED_DEVELOPER_PASSWORD` | No | `Developer@123` | Optional override for the sample developer account password | To generate a strong `JWT_SECRET`: ```bash diff --git a/prisma/seed.ts b/prisma/seed.ts index 618304a..b96f084 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,6 +1,6 @@ import { PrismaClient, Prisma } from "@prisma/client"; -import { hashPassword } from "../src/lib/bcrypt"; +import { comparePassword, hashPassword } from "../src/lib/bcrypt"; const prisma = new PrismaClient(); @@ -10,7 +10,7 @@ type RoleName = "Farmer" | "Inspector" | "Manager" | "Admin" | "Developer"; type UserSeed = { email: string; - passwordHash: string; + password: string; name: string; roleName: RoleName; cardId: string; @@ -213,7 +213,7 @@ async function upsertUser( tx: Tx, data: { email: string; - passwordHash: string; + password: string; name: string; roleId: number; cardId: string; @@ -236,8 +236,21 @@ async function upsertUser( resetTokenExpires: null; }, ) { + const existing = await tx.user.findUnique({ + where: { email: data.email }, + }); + + let passwordHash = await hashPassword(data.password); + + if ( + existing?.passwordHash && + (await comparePassword(data.password, existing.passwordHash)) + ) { + passwordHash = existing.passwordHash; + } + const updateData = { - passwordHash: data.passwordHash, + passwordHash, name: data.name, roleId: data.roleId, cardId: data.cardId, @@ -254,12 +267,44 @@ async function upsertUser( accountActive: data.accountActive, dateJoined: data.dateJoined, canSignIn: data.canSignIn, + accessToken: data.accessToken, + accessTokenCreated: data.accessTokenCreated, + resetToken: data.resetToken, + resetTokenExpires: data.resetTokenExpires, }; - return tx.user.upsert({ - where: { email: data.email }, - update: updateData, - create: data, + if (existing) { + return tx.user.update({ + where: { id: existing.id }, + data: updateData, + }); + } + + return tx.user.create({ + data: { + email: data.email, + passwordHash, + name: data.name, + roleId: data.roleId, + cardId: data.cardId, + governmentId: data.governmentId, + gender: data.gender, + disability: data.disability, + countryId: data.countryId, + adminLocationId: data.adminLocationId, + streetAddress: data.streetAddress, + preferredLanguage: data.preferredLanguage, + photoId: data.photoId, + biography: data.biography, + notes: data.notes, + accountActive: data.accountActive, + dateJoined: data.dateJoined, + canSignIn: data.canSignIn, + accessToken: data.accessToken, + accessTokenCreated: data.accessTokenCreated, + resetToken: data.resetToken, + resetTokenExpires: data.resetTokenExpires, + }, }); } @@ -450,26 +495,14 @@ async function main(): Promise { throw new Error("Set ALLOW_SAMPLE_SEED=true to run the sample seed script."); } - const passwordHashes = { - admin: await hashPassword(process.env.SEED_ADMIN_PASSWORD ?? "Admin@123"), - manager: await hashPassword( - process.env.SEED_MANAGER_PASSWORD ?? "Manager@123", - ), - inspector1: await hashPassword( - process.env.SEED_INSPECTOR1_PASSWORD ?? "Inspector1@123", - ), - inspector2: await hashPassword( - process.env.SEED_INSPECTOR2_PASSWORD ?? "Inspector2@123", - ), - farmer1: await hashPassword( - process.env.SEED_FARMER1_PASSWORD ?? "Farmer1@123", - ), - farmer2: await hashPassword( - process.env.SEED_FARMER2_PASSWORD ?? "Farmer2@123", - ), - developer: await hashPassword( - process.env.SEED_DEVELOPER_PASSWORD ?? "Developer@123", - ), + const seedPasswords = { + admin: process.env.SEED_ADMIN_PASSWORD ?? "Admin@123", + manager: process.env.SEED_MANAGER_PASSWORD ?? "Manager@123", + inspector1: process.env.SEED_INSPECTOR1_PASSWORD ?? "Inspector1@123", + inspector2: process.env.SEED_INSPECTOR2_PASSWORD ?? "Inspector2@123", + farmer1: process.env.SEED_FARMER1_PASSWORD ?? "Farmer1@123", + farmer2: process.env.SEED_FARMER2_PASSWORD ?? "Farmer2@123", + developer: process.env.SEED_DEVELOPER_PASSWORD ?? "Developer@123", }; await prisma.$transaction(async (tx) => { @@ -679,7 +712,7 @@ async function main(): Promise { const users: UserSeed[] = [ { email: "admin@treeo2.local", - passwordHash: passwordHashes.admin, + password: seedPasswords.admin, name: "TreeO2 Admin", roleName: "Admin", cardId: "CARD-ADM-001", @@ -697,7 +730,7 @@ async function main(): Promise { }, { email: "manager@treeo2.local", - passwordHash: passwordHashes.manager, + password: seedPasswords.manager, name: "Project Manager", roleName: "Manager", cardId: "CARD-MGR-001", @@ -715,7 +748,7 @@ async function main(): Promise { }, { email: "inspector1@treeo2.local", - passwordHash: passwordHashes.inspector1, + password: seedPasswords.inspector1, name: "Field Inspector One", roleName: "Inspector", cardId: "CARD-INS-001", @@ -733,7 +766,7 @@ async function main(): Promise { }, { email: "inspector2@treeo2.local", - passwordHash: passwordHashes.inspector2, + password: seedPasswords.inspector2, name: "Field Inspector Two", roleName: "Inspector", cardId: "CARD-INS-002", @@ -751,7 +784,7 @@ async function main(): Promise { }, { email: "farmer1@treeo2.local", - passwordHash: passwordHashes.farmer1, + password: seedPasswords.farmer1, name: "Farmer One", roleName: "Farmer", cardId: "CARD-FAR-001", @@ -769,7 +802,7 @@ async function main(): Promise { }, { email: "farmer2@treeo2.local", - passwordHash: passwordHashes.farmer2, + password: seedPasswords.farmer2, name: "Farmer Two", roleName: "Farmer", cardId: "CARD-FAR-002", @@ -787,7 +820,7 @@ async function main(): Promise { }, { email: "developer@treeo2.local", - passwordHash: passwordHashes.developer, + password: seedPasswords.developer, name: "Developer User", roleName: "Developer", cardId: "CARD-DEV-001", @@ -808,7 +841,7 @@ async function main(): Promise { for (const user of users) { await upsertUser(tx, { email: user.email, - passwordHash: user.passwordHash, + password: user.password, name: user.name, roleId: roleIdsByName[user.roleName], cardId: user.cardId, From 04592f5376484df60bad90c61d2e26b48eb6e318 Mon Sep 17 00:00:00 2001 From: Nikhil Jadav Date: Mon, 27 Apr 2026 19:57:33 +1000 Subject: [PATCH 6/9] docs, automation and validation issue addressed --- README.md | 3 ++- docs/Setup.md | 5 +++++ package.json | 3 ++- prisma/seed.ts | 8 ++++++-- tsconfig.seed.json | 9 +++++++++ 5 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 tsconfig.seed.json diff --git a/README.md b/README.md index 2889396..34a5bfc 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ docker compose up -d postgres npm run prisma:generate # 5. Apply schema changes +# Choose one: # If migration files already exist, use: npm run prisma:migrate:dev # If migration files do not exist yet and you only need to sync the local DB, use: @@ -66,7 +67,7 @@ ALLOW_SAMPLE_SEED=true npm run prisma:seed npm run dev ``` -The sample seed is for local development only. It expects a clean local database and is intentionally blocked unless `ALLOW_SAMPLE_SEED=true` is provided. +The sample seed is for local development only. It expects a clean local database and is intentionally blocked unless `ALLOW_SAMPLE_SEED=true` is provided. It will only run when `NODE_ENV` is `development` or `test`. API is available at `http://localhost:3000` Health check: `GET /health` diff --git a/docs/Setup.md b/docs/Setup.md index 72883ae..a62ee97 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -78,6 +78,7 @@ docker compose ps ```bash npm run prisma:generate +# Choose one: # If migration files already exist, use: npm run prisma:migrate:dev # If migration files do not exist yet and you only need to sync the local DB, use: @@ -98,6 +99,7 @@ ALLOW_SAMPLE_SEED=true npm run prisma:seed > Seeds are for local/dev only — never run against production. > The sample seed expects a clean local database and is intentionally blocked unless `ALLOW_SAMPLE_SEED=true` is set. +> The sample seed will only run when `NODE_ENV` is `development` or `test`. > Optional password overrides are available through `SEED_ADMIN_PASSWORD`, `SEED_MANAGER_PASSWORD`, `SEED_INSPECTOR1_PASSWORD`, `SEED_INSPECTOR2_PASSWORD`, `SEED_FARMER1_PASSWORD`, `SEED_FARMER2_PASSWORD`, and `SEED_DEVELOPER_PASSWORD`. ### 7. Start the dev server @@ -161,6 +163,7 @@ docker compose up -d postgres # Terminal 2 — Prisma client/schema sync npm run prisma:generate +# Choose one: # If migration files already exist, use: npm run prisma:migrate:dev # If migration files do not exist yet and you only need to sync the local DB, use: @@ -208,6 +211,7 @@ docker compose down ```bash docker compose down -v # removes the postgres_data volume docker compose up -d postgres +# Choose one: # If migration files already exist, use: npm run prisma:migrate:dev # If migration files do not exist yet and you only need to sync the local DB, use: @@ -228,6 +232,7 @@ npm run prisma:push | `npm run format` | Auto-format all source files with Prettier | | `npm run format:check` | Check formatting without writing | | `npm run type-check` | TypeScript type check without emitting | +| `npm run type-check:seed` | Type-check `prisma/seed.ts` and its dependencies | | `npm run validate` | Run type-check + lint + format check (run before PRs) | | `npm run prisma:generate` | Generate Prisma client from the `prisma/` directory schema | | `npm run prisma:push` | Push schema directly to the database without creating migrations | diff --git a/package.json b/package.json index d5b3cd4..6d2cf04 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "type-check": "tsc --noEmit", - "validate": "npm run type-check && npm run lint && npm run format:check", + "type-check:seed": "tsc -p tsconfig.seed.json", + "validate": "npm run type-check && npm run type-check:seed && npm run lint && npm run format:check", "test": "jest --passWithNoTests", "test:watch": "jest --watch", "test:coverage": "jest --coverage --passWithNoTests", diff --git a/prisma/seed.ts b/prisma/seed.ts index b96f084..7049c2c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -487,8 +487,12 @@ async function upsertReport( async function main(): Promise { console.log("Starting seed..."); - if (process.env.NODE_ENV === "production") { - throw new Error("Seed script must not be run in production."); + const runtimeEnv = process.env.NODE_ENV ?? "development"; + + if (runtimeEnv !== "development" && runtimeEnv !== "test") { + throw new Error( + `Seed script must only run in development or test. Current NODE_ENV: ${runtimeEnv}`, + ); } if (process.env.ALLOW_SAMPLE_SEED !== "true") { diff --git a/tsconfig.seed.json b/tsconfig.seed.json new file mode 100644 index 0000000..f0682a7 --- /dev/null +++ b/tsconfig.seed.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["prisma/seed.ts", "src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} From c9c6a0840d212df68c305c8f5bc5d362dffd5dec Mon Sep 17 00:00:00 2001 From: Nikhil Jadav Date: Mon, 27 Apr 2026 20:07:02 +1000 Subject: [PATCH 7/9] doc for schemas and seed data added --- docs/PrismaSeedAndSchema.md | 296 ++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 docs/PrismaSeedAndSchema.md diff --git a/docs/PrismaSeedAndSchema.md b/docs/PrismaSeedAndSchema.md new file mode 100644 index 0000000..6632548 --- /dev/null +++ b/docs/PrismaSeedAndSchema.md @@ -0,0 +1,296 @@ +# Prisma Schema and Seed Data Guide + +This note explains how I expect us to work with Prisma schema files and seed data in this project. + +The goal is simple: + +- keep the Prisma schema easy to maintain as the project grows +- avoid schema conflicts when multiple people are editing models +- make local sample data predictable and safe to use in development + +--- + +## 1. Prisma Schema Layout + +This project uses Prisma's multi-file schema setup. + +The source of truth is the full `prisma/` directory, not only `prisma/schema.prisma`. + +Current structure: + +```text +prisma/ +├── schema.prisma +├── seed.ts +├── migrations/ +└── models/ + ├── adopter.prisma + ├── report.prisma + ├── scanBatch.prisma + └── ... +``` + +`prisma.config.ts` points Prisma to `./prisma`, so Prisma reads the whole folder as one schema. + +### What goes in `prisma/schema.prisma` + +I want `prisma/schema.prisma` to stay limited to shared Prisma configuration: + +- `generator` +- `datasource` +- shared enums + +### What goes in `prisma/models/*.prisma` + +Each model should live in its own file under `prisma/models/`. + +Examples: + +- `report.prisma` for `Report` +- `scanBatch.prisma` for `ScanBatch` +- `adopter.prisma` for `Adopter` + +This keeps the schema easier to review and reduces merge conflicts when different people are working on different parts of the data model. + +--- + +## 2. How I Expect Schema Changes To Be Done + +If you need to change the database schema: + +1. update the relevant model file in `prisma/models/` +2. update `prisma/schema.prisma` only if the change is about shared enums or Prisma config +3. generate the Prisma client +4. apply schema changes to the local database +5. verify the app still works + +Recommended commands: + +```bash +npm run prisma:generate +``` + +Then choose one: + +```bash +npm run prisma:migrate:dev +``` + +Use this when: + +- migration files already exist +- you are making a normal team change +- you want the schema history tracked properly + +Or: + +```bash +npm run prisma:push +``` + +Use this only when: + +- migration files do not exist yet +- you are doing quick local sync/prototyping +- the database is disposable + +I do not want `db push` to become the default team workflow if migrations are available. + +--- + +## 3. Local Run Flow + +For normal local development, I expect this flow: + +```bash +docker compose up -d postgres +npm run prisma:generate +npm run prisma:migrate:dev +npm run dev +``` + +If migration files do not exist yet: + +```bash +docker compose up -d postgres +npm run prisma:generate +npm run prisma:push +npm run dev +``` + +Important: + +- if the backend runs locally, `DATABASE_URL` should use `localhost` +- if the backend runs inside Docker Compose, `DATABASE_URL` should use `postgres` + +--- + +## 4. What The Seed Script Is For + +`prisma/seed.ts` is for local sample data only. + +It gives us a usable local database with: + +- sample users +- sample roles +- sample projects +- sample tree scans +- sample adopters and adoptions +- sample reports + +This is meant to help with local testing, demos, and development flow. It is not meant to create production data. + +--- + +## 5. How The Seed Works + +The seed script inserts or updates records in a controlled order so relations can be created safely. + +In simple terms, it does this: + +1. checks whether it is allowed to run +2. reads sample passwords from environment variables or fallback defaults +3. hashes those passwords with bcrypt +4. inserts shared reference data such as countries, cultures, roles, and tree types +5. inserts or updates users +6. links users to roles and projects +7. inserts scans, audits, adopters, adoptions, and reports + +The script is designed to be safer on rerun than a naive seed: + +- it avoids fragile hardcoded autoincrement IDs for the main entities +- it uses deterministic lookups where possible +- it fails loudly if duplicate rows would make the result ambiguous +- it resets user token fields on rerun + +--- + +## 6. Seed Safety Rules + +I added a few guardrails intentionally. + +The sample seed will only run when: + +- `ALLOW_SAMPLE_SEED=true` +- `NODE_ENV` is `development` or `test` + +This is there to reduce the chance of someone loading demo data into the wrong environment. + +Run the seed with: + +```bash +ALLOW_SAMPLE_SEED=true npm run prisma:seed +``` + +Recommended full sequence: + +```bash +docker compose up -d postgres +npm run prisma:generate +npm run prisma:migrate:dev +ALLOW_SAMPLE_SEED=true npm run prisma:seed +``` + +If there are no migrations yet: + +```bash +docker compose up -d postgres +npm run prisma:generate +npm run prisma:push +ALLOW_SAMPLE_SEED=true npm run prisma:seed +``` + +--- + +## 7. How User Passwords Are Handled In Seed Data + +For sample users, the seed does not store plain-text passwords in the database. + +Instead: + +1. it reads a password value from `.env` if you provide one +2. otherwise it falls back to the default sample password +3. it hashes the password using `bcrypt` +4. it stores only `passwordHash` in the database + +Available password override variables: + +- `SEED_ADMIN_PASSWORD` +- `SEED_MANAGER_PASSWORD` +- `SEED_INSPECTOR1_PASSWORD` +- `SEED_INSPECTOR2_PASSWORD` +- `SEED_FARMER1_PASSWORD` +- `SEED_FARMER2_PASSWORD` +- `SEED_DEVELOPER_PASSWORD` + +Example: + +```env +ALLOW_SAMPLE_SEED=true +SEED_ADMIN_PASSWORD=MyLocalAdminPassword123 +``` + +That means the seeded admin user can sign in with `MyLocalAdminPassword123`, but the database will still store only the bcrypt hash. + +The seed also avoids regenerating a new hash on every rerun if the configured password has not changed. + +--- + +## 8. Important Expectations For Seed Data + +I want the seed to stay predictable and local-friendly. + +Please keep these rules in mind: + +- do not add production-only assumptions into the sample seed +- do not use the seed as a substitute for proper migrations +- do not hardcode fragile numeric IDs for autoincrement models +- do not commit real secrets or real user credentials +- do not assume the sample seed should run in staging or production-like environments + +If a change makes the seed depend on a very specific dirty local DB state, that is a smell and should be cleaned up. + +--- + +## 9. Validation I Expect Before A PR + +If you touched Prisma schema or seed data, I expect these checks: + +```bash +npx prisma validate +npm run prisma:generate +npm run type-check +npm run type-check:seed +``` + +If your database is running and you changed seed behavior, also run: + +```bash +ALLOW_SAMPLE_SEED=true npm run prisma:seed +``` + +If you changed schema models, also make sure the database was updated using either: + +```bash +npm run prisma:migrate:dev +``` + +or: + +```bash +npm run prisma:push +``` + +depending on the situation. + +--- + +## 10. Final Rule Of Thumb + +If the change is about structure, put it in the schema. + +If the change is about sample/demo records, put it in the seed. + +If the change is about real database history, use migrations. + +That separation is what will keep Prisma manageable for us over time. From 8549db7a70907fbff35cad5a79900a8d33269380 Mon Sep 17 00:00:00 2001 From: Nikhil Jadav Date: Thu, 30 Apr 2026 16:20:37 +1000 Subject: [PATCH 8/9] split seed data in multiple transaction --- prisma/seed.ts | 485 ++++++++++++++++++++++++++++++------------------- 1 file changed, 297 insertions(+), 188 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 7049c2c..4edd7e6 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -27,6 +27,10 @@ type UserSeed = { dateJoined: Date; }; +type PreparedUserSeed = Omit & { + passwordHash: string; +}; + function getSingleOrThrow(rows: T[], message: string): T | null { if (rows.length > 1) { throw new Error(message); @@ -213,7 +217,7 @@ async function upsertUser( tx: Tx, data: { email: string; - password: string; + passwordHash: string; name: string; roleId: number; cardId: string; @@ -236,21 +240,8 @@ async function upsertUser( resetTokenExpires: null; }, ) { - const existing = await tx.user.findUnique({ - where: { email: data.email }, - }); - - let passwordHash = await hashPassword(data.password); - - if ( - existing?.passwordHash && - (await comparePassword(data.password, existing.passwordHash)) - ) { - passwordHash = existing.passwordHash; - } - const updateData = { - passwordHash, + passwordHash: data.passwordHash, name: data.name, roleId: data.roleId, cardId: data.cardId, @@ -273,6 +264,10 @@ async function upsertUser( resetTokenExpires: data.resetTokenExpires, }; + const existing = await tx.user.findUnique({ + where: { email: data.email }, + }); + if (existing) { return tx.user.update({ where: { id: existing.id }, @@ -283,7 +278,7 @@ async function upsertUser( return tx.user.create({ data: { email: data.email, - passwordHash, + passwordHash: data.passwordHash, name: data.name, roleId: data.roleId, cardId: data.cardId, @@ -308,6 +303,60 @@ async function upsertUser( }); } +async function prepareSeedUsers(users: UserSeed[]): Promise { + const existingUsers = await prisma.user.findMany({ + where: { + email: { + in: users.map((user) => user.email), + }, + }, + select: { + email: true, + passwordHash: true, + }, + }); + + const existingUsersByEmail = new Map( + existingUsers.map((user) => [user.email, user.passwordHash]), + ); + + const preparedUsers: PreparedUserSeed[] = []; + + for (const user of users) { + const existingPasswordHash = existingUsersByEmail.get(user.email) ?? null; + + let passwordHash = await hashPassword(user.password); + + if ( + existingPasswordHash && + (await comparePassword(user.password, existingPasswordHash)) + ) { + passwordHash = existingPasswordHash; + } + + preparedUsers.push({ + email: user.email, + passwordHash, + name: user.name, + roleName: user.roleName, + cardId: user.cardId, + governmentId: user.governmentId, + gender: user.gender, + disability: user.disability, + countryIso2: user.countryIso2, + adminLocationCode: user.adminLocationCode, + streetAddress: user.streetAddress, + preferredLanguage: user.preferredLanguage, + biography: user.biography, + notes: user.notes, + accountActive: user.accountActive, + dateJoined: user.dateJoined, + }); + } + + return preparedUsers; +} + async function upsertScanBatch( tx: Tx, data: { inspectorId: number; projectId: number; uploadedAt: Date }, @@ -509,7 +558,138 @@ async function main(): Promise { developer: process.env.SEED_DEVELOPER_PASSWORD ?? "Developer@123", }; - await prisma.$transaction(async (tx) => { + const users: UserSeed[] = [ + { + email: "admin@treeo2.local", + password: seedPasswords.admin, + name: "TreeO2 Admin", + roleName: "Admin", + cardId: "CARD-ADM-001", + governmentId: "GOV-ADM-001", + gender: "Male", + disability: false, + countryIso2: "TL", + adminLocationCode: "DIL", + streetAddress: "Dili Central Office", + preferredLanguage: "en", + biography: "System administrator for TreeO2.", + notes: "Primary admin account.", + accountActive: true, + dateJoined: new Date("2025-01-05T00:00:00Z"), + }, + { + email: "manager@treeo2.local", + password: seedPasswords.manager, + name: "Project Manager", + roleName: "Manager", + cardId: "CARD-MGR-001", + governmentId: "GOV-MGR-001", + gender: "Female", + disability: false, + countryIso2: "TL", + adminLocationCode: "DIL", + streetAddress: "Dili Operations", + preferredLanguage: "en", + biography: "Oversees project delivery and monitoring.", + notes: "Assigned to multiple projects.", + accountActive: true, + dateJoined: new Date("2025-01-10T00:00:00Z"), + }, + { + email: "inspector1@treeo2.local", + password: seedPasswords.inspector1, + name: "Field Inspector One", + roleName: "Inspector", + cardId: "CARD-INS-001", + governmentId: "GOV-INS-001", + gender: "Male", + disability: false, + countryIso2: "TL", + adminLocationCode: "CRI", + streetAddress: "Cristo Rei Field Office", + preferredLanguage: "tet", + biography: "Conducts on-site inspections.", + notes: "Experienced in field validations.", + accountActive: true, + dateJoined: new Date("2025-01-12T00:00:00Z"), + }, + { + email: "inspector2@treeo2.local", + password: seedPasswords.inspector2, + name: "Field Inspector Two", + roleName: "Inspector", + cardId: "CARD-INS-002", + governmentId: "GOV-INS-002", + gender: "Female", + disability: false, + countryIso2: "TL", + adminLocationCode: "BAU", + streetAddress: "Baucau Field Office", + preferredLanguage: "tet", + biography: "Supports rural inspection activities.", + notes: "Assigned to Baucau pilot.", + accountActive: true, + dateJoined: new Date("2025-01-13T00:00:00Z"), + }, + { + email: "farmer1@treeo2.local", + password: seedPasswords.farmer1, + name: "Farmer One", + roleName: "Farmer", + cardId: "CARD-FAR-001", + governmentId: "GOV-FAR-001", + gender: "Female", + disability: false, + countryIso2: "TL", + adminLocationCode: "HER", + streetAddress: "Hera Village", + preferredLanguage: "tet", + biography: "Participating farmer in Hera region.", + notes: "Linked to reforestation project.", + accountActive: true, + dateJoined: new Date("2025-01-15T00:00:00Z"), + }, + { + email: "farmer2@treeo2.local", + password: seedPasswords.farmer2, + name: "Farmer Two", + roleName: "Farmer", + cardId: "CARD-FAR-002", + governmentId: "GOV-FAR-002", + gender: "Male", + disability: false, + countryIso2: "TL", + adminLocationCode: "BAU", + streetAddress: "Baucau Rural Area", + preferredLanguage: "tet", + biography: "Farmer involved in agroforestry activities.", + notes: "Linked to Baucau pilot.", + accountActive: true, + dateJoined: new Date("2025-01-16T00:00:00Z"), + }, + { + email: "developer@treeo2.local", + password: seedPasswords.developer, + name: "Developer User", + roleName: "Developer", + cardId: "CARD-DEV-001", + governmentId: "GOV-DEV-001", + gender: "Male", + disability: false, + countryIso2: "AU", + adminLocationCode: null, + streetAddress: "Melbourne Support Hub", + preferredLanguage: "en", + biography: "Maintains the technical platform.", + notes: "Support and development account.", + accountActive: true, + dateJoined: new Date("2025-01-18T00:00:00Z"), + }, + ]; + + const preparedUsers = await prepareSeedUsers(users); + + const referenceData = await prisma.$transaction(async (tx) => { const timorLeste = await upsertCountry(tx, { name: "Timor-Leste", iso2: "TL", @@ -712,149 +892,39 @@ async function main(): Promise { Admin: roles.admin.id, Developer: roles.developer.id, }; - - const users: UserSeed[] = [ - { - email: "admin@treeo2.local", - password: seedPasswords.admin, - name: "TreeO2 Admin", - roleName: "Admin", - cardId: "CARD-ADM-001", - governmentId: "GOV-ADM-001", - gender: "Male", - disability: false, - countryIso2: "TL", - adminLocationCode: "DIL", - streetAddress: "Dili Central Office", - preferredLanguage: "en", - biography: "System administrator for TreeO2.", - notes: "Primary admin account.", - accountActive: true, - dateJoined: new Date("2025-01-05T00:00:00Z"), - }, - { - email: "manager@treeo2.local", - password: seedPasswords.manager, - name: "Project Manager", - roleName: "Manager", - cardId: "CARD-MGR-001", - governmentId: "GOV-MGR-001", - gender: "Female", - disability: false, - countryIso2: "TL", - adminLocationCode: "DIL", - streetAddress: "Dili Operations", - preferredLanguage: "en", - biography: "Oversees project delivery and monitoring.", - notes: "Assigned to multiple projects.", - accountActive: true, - dateJoined: new Date("2025-01-10T00:00:00Z"), - }, - { - email: "inspector1@treeo2.local", - password: seedPasswords.inspector1, - name: "Field Inspector One", - roleName: "Inspector", - cardId: "CARD-INS-001", - governmentId: "GOV-INS-001", - gender: "Male", - disability: false, - countryIso2: "TL", - adminLocationCode: "CRI", - streetAddress: "Cristo Rei Field Office", - preferredLanguage: "tet", - biography: "Conducts on-site inspections.", - notes: "Experienced in field validations.", - accountActive: true, - dateJoined: new Date("2025-01-12T00:00:00Z"), - }, - { - email: "inspector2@treeo2.local", - password: seedPasswords.inspector2, - name: "Field Inspector Two", - roleName: "Inspector", - cardId: "CARD-INS-002", - governmentId: "GOV-INS-002", - gender: "Female", - disability: false, - countryIso2: "TL", - adminLocationCode: "BAU", - streetAddress: "Baucau Field Office", - preferredLanguage: "tet", - biography: "Supports rural inspection activities.", - notes: "Assigned to Baucau pilot.", - accountActive: true, - dateJoined: new Date("2025-01-13T00:00:00Z"), - }, - { - email: "farmer1@treeo2.local", - password: seedPasswords.farmer1, - name: "Farmer One", - roleName: "Farmer", - cardId: "CARD-FAR-001", - governmentId: "GOV-FAR-001", - gender: "Female", - disability: false, - countryIso2: "TL", - adminLocationCode: "HER", - streetAddress: "Hera Village", - preferredLanguage: "tet", - biography: "Participating farmer in Hera region.", - notes: "Linked to reforestation project.", - accountActive: true, - dateJoined: new Date("2025-01-15T00:00:00Z"), - }, - { - email: "farmer2@treeo2.local", - password: seedPasswords.farmer2, - name: "Farmer Two", - roleName: "Farmer", - cardId: "CARD-FAR-002", - governmentId: "GOV-FAR-002", - gender: "Male", - disability: false, - countryIso2: "TL", - adminLocationCode: "BAU", - streetAddress: "Baucau Rural Area", - preferredLanguage: "tet", - biography: "Farmer involved in agroforestry activities.", - notes: "Linked to Baucau pilot.", - accountActive: true, - dateJoined: new Date("2025-01-16T00:00:00Z"), + return { + countryIdsByIso2, + locationIdsByCode, + roleIdsByName, + heraProjectId: heraProject.id, + baucauProjectId: baucauProject.id, + mahoganyId: mahogany.id, + teakId: teak.id, + sandalwoodId: sandalwood.id, + roleAssignmentIds: { + admin: roles.admin.id, + manager: roles.manager.id, + inspector: roles.inspector.id, + farmer: roles.farmer.id, + developer: roles.developer.id, }, - { - email: "developer@treeo2.local", - password: seedPasswords.developer, - name: "Developer User", - roleName: "Developer", - cardId: "CARD-DEV-001", - governmentId: "GOV-DEV-001", - gender: "Male", - disability: false, - countryIso2: "AU", - adminLocationCode: null, - streetAddress: "Melbourne Support Hub", - preferredLanguage: "en", - biography: "Maintains the technical platform.", - notes: "Support and development account.", - accountActive: true, - dateJoined: new Date("2025-01-18T00:00:00Z"), - }, - ]; + }; + }); - for (const user of users) { + const seededUserIds = await prisma.$transaction(async (tx) => { + for (const user of preparedUsers) { await upsertUser(tx, { email: user.email, - password: user.password, + passwordHash: user.passwordHash, name: user.name, - roleId: roleIdsByName[user.roleName], + roleId: referenceData.roleIdsByName[user.roleName], cardId: user.cardId, governmentId: user.governmentId, gender: user.gender, disability: user.disability, - countryId: countryIdsByIso2[user.countryIso2], + countryId: referenceData.countryIdsByIso2[user.countryIso2], adminLocationId: user.adminLocationCode - ? locationIdsByCode[user.adminLocationCode] + ? referenceData.locationIdsByCode[user.adminLocationCode] : null, streetAddress: user.streetAddress, preferredLanguage: user.preferredLanguage, @@ -901,56 +971,95 @@ async function main(): Promise { await tx.userRoleAssignment.createMany({ data: [ - { userId: admin.id, roleId: roles.admin.id }, - { userId: manager.id, roleId: roles.manager.id }, - { userId: inspector1.id, roleId: roles.inspector.id }, - { userId: inspector2.id, roleId: roles.inspector.id }, - { userId: farmer1.id, roleId: roles.farmer.id }, - { userId: farmer2.id, roleId: roles.farmer.id }, - { userId: developer.id, roleId: roles.developer.id }, + { userId: admin.id, roleId: referenceData.roleAssignmentIds.admin }, + { + userId: manager.id, + roleId: referenceData.roleAssignmentIds.manager, + }, + { + userId: inspector1.id, + roleId: referenceData.roleAssignmentIds.inspector, + }, + { + userId: inspector2.id, + roleId: referenceData.roleAssignmentIds.inspector, + }, + { + userId: farmer1.id, + roleId: referenceData.roleAssignmentIds.farmer, + }, + { + userId: farmer2.id, + roleId: referenceData.roleAssignmentIds.farmer, + }, + { + userId: developer.id, + roleId: referenceData.roleAssignmentIds.developer, + }, ], skipDuplicates: true, }); await tx.userProject.createMany({ data: [ - { userId: manager.id, projectId: heraProject.id }, - { userId: manager.id, projectId: baucauProject.id }, - { userId: inspector1.id, projectId: heraProject.id }, - { userId: inspector2.id, projectId: baucauProject.id }, - { userId: farmer1.id, projectId: heraProject.id }, - { userId: farmer2.id, projectId: baucauProject.id }, + { userId: manager.id, projectId: referenceData.heraProjectId }, + { userId: manager.id, projectId: referenceData.baucauProjectId }, + { userId: inspector1.id, projectId: referenceData.heraProjectId }, + { userId: inspector2.id, projectId: referenceData.baucauProjectId }, + { userId: farmer1.id, projectId: referenceData.heraProjectId }, + { userId: farmer2.id, projectId: referenceData.baucauProjectId }, ], skipDuplicates: true, }); await tx.projectTreeType.createMany({ data: [ - { projectId: heraProject.id, treeTypeId: mahogany.id }, - { projectId: heraProject.id, treeTypeId: sandalwood.id }, - { projectId: baucauProject.id, treeTypeId: teak.id }, + { + projectId: referenceData.heraProjectId, + treeTypeId: referenceData.mahoganyId, + }, + { + projectId: referenceData.heraProjectId, + treeTypeId: referenceData.sandalwoodId, + }, + { + projectId: referenceData.baucauProjectId, + treeTypeId: referenceData.teakId, + }, ], skipDuplicates: true, }); + return { + adminId: admin.id, + managerId: manager.id, + inspector1Id: inspector1.id, + inspector2Id: inspector2.id, + farmer1Id: farmer1.id, + farmer2Id: farmer2.id, + developerId: developer.id, + }; + }); + + await prisma.$transaction(async (tx) => { const heraBatch = await upsertScanBatch(tx, { - inspectorId: inspector1.id, - projectId: heraProject.id, + inspectorId: seededUserIds.inspector1Id, + projectId: referenceData.heraProjectId, uploadedAt: new Date("2025-02-01T09:00:00Z"), }); const baucauBatch = await upsertScanBatch(tx, { - inspectorId: inspector2.id, - projectId: baucauProject.id, + inspectorId: seededUserIds.inspector2Id, + projectId: referenceData.baucauProjectId, uploadedAt: new Date("2025-02-10T11:30:00Z"), }); const treeScan1 = await upsertTreeScan(tx, { fobId: "FOB-0001", - projectId: heraProject.id, - farmerId: farmer1.id, - inspectorId: inspector1.id, - speciesId: mahogany.id, + projectId: referenceData.heraProjectId, + farmerId: seededUserIds.farmer1Id, + inspectorId: seededUserIds.inspector1Id, + speciesId: referenceData.mahoganyId, estimatedPlantedYear: 2023, estimatedPlantedMonth: 6, plantedDate: new Date("2023-06-15T00:00:00Z"), @@ -972,10 +1081,10 @@ async function main(): Promise { const treeScan2 = await upsertTreeScan(tx, { fobId: "FOB-0002", - projectId: heraProject.id, - farmerId: farmer1.id, - inspectorId: inspector1.id, - speciesId: sandalwood.id, + projectId: referenceData.heraProjectId, + farmerId: seededUserIds.farmer1Id, + inspectorId: seededUserIds.inspector1Id, + speciesId: referenceData.sandalwoodId, estimatedPlantedYear: 2022, estimatedPlantedMonth: 11, plantedDate: new Date("2022-11-20T00:00:00Z"), @@ -989,7 +1098,7 @@ async function main(): Promise { deviceId: "DEVICE-01", isArchived: false, isCorrected: true, - correctedBy: manager.id, + correctedBy: seededUserIds.managerId, correctionReason: "Corrected planting month after review.", isValid: true, validationNotes: "Data verified by manager.", @@ -997,10 +1106,10 @@ async function main(): Promise { const treeScan3 = await upsertTreeScan(tx, { fobId: "FOB-0101", - projectId: baucauProject.id, - farmerId: farmer2.id, - inspectorId: inspector2.id, - speciesId: teak.id, + projectId: referenceData.baucauProjectId, + farmerId: seededUserIds.farmer2Id, + inspectorId: seededUserIds.inspector2Id, + speciesId: referenceData.teakId, estimatedPlantedYear: 2024, estimatedPlantedMonth: 3, plantedDate: new Date("2024-03-05T00:00:00Z"), @@ -1022,7 +1131,7 @@ async function main(): Promise { await upsertTreeScanAudit(tx, { treeScanId: treeScan2.id, - changedBy: manager.id, + changedBy: seededUserIds.managerId, changeReason: "Updated planting month", oldData: { estimatedPlantedMonth: 10 }, newData: { estimatedPlantedMonth: 11 }, @@ -1053,18 +1162,18 @@ async function main(): Promise { await upsertReport(tx, { reportType: "Tree Survival Summary", - requestedBy: manager.id, + requestedBy: seededUserIds.managerId, status: "COMPLETE", - parameters: { projectId: heraProject.id, month: "2025-02" }, + parameters: { projectId: referenceData.heraProjectId, month: "2025-02" }, outputUrl: "https://xyz.com/reports/tree-survival-summary.pdf", completedAt: new Date("2025-02-28T10:00:00Z"), }); await upsertReport(tx, { reportType: "Inspector Activity Report", - requestedBy: admin.id, + requestedBy: seededUserIds.adminId, status: "PENDING", - parameters: { inspectorId: inspector1.id }, + parameters: { inspectorId: seededUserIds.inspector1Id }, outputUrl: null, completedAt: null, }); From b4de5ba7209a9a758452efa6fa6df5ada19fcf08 Mon Sep 17 00:00:00 2001 From: Nikhil Jadav Date: Thu, 30 Apr 2026 17:54:24 +1000 Subject: [PATCH 9/9] docs updated for multi-transaction seed --- docs/PrismaSeedAndSchema.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/PrismaSeedAndSchema.md b/docs/PrismaSeedAndSchema.md index 6632548..eff80fd 100644 --- a/docs/PrismaSeedAndSchema.md +++ b/docs/PrismaSeedAndSchema.md @@ -150,18 +150,27 @@ In simple terms, it does this: 1. checks whether it is allowed to run 2. reads sample passwords from environment variables or fallback defaults -3. hashes those passwords with bcrypt +3. prepares the final password hashes before opening the main database transactions 4. inserts shared reference data such as countries, cultures, roles, and tree types 5. inserts or updates users 6. links users to roles and projects 7. inserts scans, audits, adopters, adoptions, and reports +The seed is intentionally split into smaller transaction phases instead of one large transaction: + +- phase 1: reference data +- phase 2: users and user relationships +- phase 3: scans, adopters, adoptions, and reports + +I want it structured this way so the seed is more durable on reruns and does not depend on one long interactive Prisma transaction staying open. + The script is designed to be safer on rerun than a naive seed: - it avoids fragile hardcoded autoincrement IDs for the main entities - it uses deterministic lookups where possible - it fails loudly if duplicate rows would make the result ambiguous - it resets user token fields on rerun +- it keeps expensive bcrypt work outside the main write transactions --- @@ -176,6 +185,8 @@ The sample seed will only run when: This is there to reduce the chance of someone loading demo data into the wrong environment. +It also reduces the chance of transaction timeout issues, because the seed no longer keeps password work and all record creation inside one single long-running transaction. + Run the seed with: ```bash @@ -210,8 +221,10 @@ Instead: 1. it reads a password value from `.env` if you provide one 2. otherwise it falls back to the default sample password -3. it hashes the password using `bcrypt` -4. it stores only `passwordHash` in the database +3. before the main write transactions start, it checks whether the existing seeded user already has a matching password hash +4. if the password is unchanged, it keeps the existing hash +5. if the password changed, it hashes the new password using `bcrypt` +6. it stores only `passwordHash` in the database Available password override variables: @@ -232,7 +245,7 @@ SEED_ADMIN_PASSWORD=MyLocalAdminPassword123 That means the seeded admin user can sign in with `MyLocalAdminPassword123`, but the database will still store only the bcrypt hash. -The seed also avoids regenerating a new hash on every rerun if the configured password has not changed. +The seed also avoids regenerating a new hash on every rerun if the configured password has not changed. That keeps reruns more stable and avoids unnecessary writes to seeded users. --- @@ -263,6 +276,8 @@ npm run type-check npm run type-check:seed ``` +`npm run type-check:seed` is important here because `prisma/seed.ts` sits outside the normal `src/` TypeScript include path. I added that dedicated check so seed-only type errors do not slip through unnoticed. + If your database is running and you changed seed behavior, also run: ```bash