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..34a5bfc 100644 --- a/README.md +++ b/README.md @@ -54,18 +54,21 @@ 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: 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. It will only run when `NODE_ENV` is `development` or `test`. + API is available at `http://localhost:3000` Health check: `GET /health` Swagger docs: `http://localhost:3000/api-docs` @@ -113,7 +116,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/PrismaSeedAndSchema.md b/docs/PrismaSeedAndSchema.md new file mode 100644 index 0000000..eff80fd --- /dev/null +++ b/docs/PrismaSeedAndSchema.md @@ -0,0 +1,311 @@ +# 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. 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 + +--- + +## 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. + +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 +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. 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: + +- `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. That keeps reruns more stable and avoids unnecessary writes to seeded users. + +--- + +## 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 +``` + +`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 +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. diff --git a/docs/Setup.md b/docs/Setup.md index 537ce6e..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: @@ -93,10 +94,13 @@ 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. +> 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 @@ -133,6 +137,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 @@ -151,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: @@ -198,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: @@ -218,12 +232,13 @@ 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 | | `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/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 7d2744c..4edd7e6 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,21 +1,1190 @@ -import { PrismaClient, UserRole } from "@prisma/client"; +import { PrismaClient, Prisma } from "@prisma/client"; + +import { comparePassword, hashPassword } from "../src/lib/bcrypt"; const prisma = new PrismaClient(); -const main = async (): Promise => { - await prisma.user.upsert({ - where: { email: "admin@treeo2.local" }, +type Tx = Prisma.TransactionClient; + +type RoleName = "Farmer" | "Inspector" | "Manager" | "Admin" | "Developer"; + +type UserSeed = { + email: string; + password: string; + name: string; + roleName: RoleName; + 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; +}; + +type PreparedUserSeed = Omit & { + passwordHash: string; +}; + +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 }, +) { + return tx.country.upsert({ + where: { iso2: data.iso2 }, + update: data, + create: data, + }); +} + +async function upsertCulture(tx: Tx, data: { code: string; name: string }) { + return tx.culture.upsert({ + where: { code: data.code }, + update: data, + create: data, + }); +} + +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, + }, + }, + update: { value: data.value }, + create: data, + }); +} + +async function upsertRole(tx: Tx, name: string) { + return tx.role.upsert({ + where: { name }, update: {}, - create: { + create: { name }, + }); +} + +async function upsertPartner(tx: Tx, name: string) { + 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({ + where: { id: existing.id }, + data: { name }, + }); + } + + return tx.partner.create({ data: { name } }); +} + +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 = 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({ + where: { id: existing.id }, + data, + }); + } + + return tx.location.create({ data }); +} + +async function upsertAdministrativeLevel( + tx: Tx, + data: { countryId: number; level: number; name: string }, +) { + 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({ + where: { id: existing.id }, + data, + }); + } + + return tx.administrativeLevel.create({ data }); +} + +async function upsertTreeType( + tx: Tx, + data: { + name: string; + key: string; + scientificName: string; + dryWeightDensity: Prisma.Decimal; + }, +) { + 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({ + 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 = 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({ + 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; + }, +) { + 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, + accessToken: data.accessToken, + accessTokenCreated: data.accessTokenCreated, + resetToken: data.resetToken, + resetTokenExpires: data.resetTokenExpires, + }; + + const existing = await tx.user.findUnique({ + where: { email: data.email }, + }); + + if (existing) { + return tx.user.update({ + where: { id: existing.id }, + data: updateData, + }); + } + + return tx.user.create({ + data: { + email: data.email, + 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, + accessToken: data.accessToken, + accessTokenCreated: data.accessTokenCreated, + resetToken: data.resetToken, + resetTokenExpires: data.resetTokenExpires, + }, + }); +} + +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 }, +) { + 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({ + 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 existingRows = await tx.treeScan.findMany({ + where: { fobId: data.fobId }, + }); + + const existing = getSingleOrThrow( + existingRows, + `Cannot seed TreeScan deterministically. Multiple rows found for fobId: ${data.fobId}`, + ); + + 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 = 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({ + where: { id: existing.id }, + data, + }); + } + + return tx.treeScanAudit.create({ data }); +} + +async function upsertAdopter(tx: Tx, data: { name: string; email: string }) { + 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({ + where: { id: existing.id }, + data, + }); + } + + return tx.adopter.create({ data }); +} + +async function upsertAdoption( + tx: Tx, + data: { adopterId: number; fobId: string; adoptedAt: Date }, +) { + 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({ + 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 = 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({ + where: { id: existing.id }, + data, + }); + } + + return tx.report.create({ data }); +} + +async function main(): Promise { + console.log("Starting seed..."); + + 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") { + throw new Error("Set ALLOW_SAMPLE_SEED=true to run the sample seed script."); + } + + 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", + }; + + const users: UserSeed[] = [ + { email: "admin@treeo2.local", + password: seedPasswords.admin, name: "TreeO2 Admin", - role: UserRole.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", + 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", + }); + + 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 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, + HER: hera.id, + 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, + }; + 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, + }, + }; }); -}; + + const seededUserIds = await prisma.$transaction(async (tx) => { + for (const user of preparedUsers) { + await upsertUser(tx, { + email: user.email, + passwordHash: user.passwordHash, + name: user.name, + roleId: referenceData.roleIdsByName[user.roleName], + cardId: user.cardId, + governmentId: user.governmentId, + gender: user.gender, + disability: user.disability, + countryId: referenceData.countryIdsByIso2[user.countryIso2], + adminLocationId: user.adminLocationCode + ? referenceData.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: 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: 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: 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: seededUserIds.inspector1Id, + projectId: referenceData.heraProjectId, + uploadedAt: new Date("2025-02-01T09:00:00Z"), + }); + + const baucauBatch = await upsertScanBatch(tx, { + inspectorId: seededUserIds.inspector2Id, + projectId: referenceData.baucauProjectId, + uploadedAt: new Date("2025-02-10T11:30:00Z"), + }); + + const treeScan1 = await upsertTreeScan(tx, { + fobId: "FOB-0001", + projectId: referenceData.heraProjectId, + farmerId: seededUserIds.farmer1Id, + inspectorId: seededUserIds.inspector1Id, + speciesId: referenceData.mahoganyId, + 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: referenceData.heraProjectId, + farmerId: seededUserIds.farmer1Id, + inspectorId: seededUserIds.inspector1Id, + speciesId: referenceData.sandalwoodId, + 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: seededUserIds.managerId, + correctionReason: "Corrected planting month after review.", + isValid: true, + validationNotes: "Data verified by manager.", + }); + + const treeScan3 = await upsertTreeScan(tx, { + fobId: "FOB-0101", + projectId: referenceData.baucauProjectId, + farmerId: seededUserIds.farmer2Id, + inspectorId: seededUserIds.inspector2Id, + speciesId: referenceData.teakId, + 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: seededUserIds.managerId, + 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: seededUserIds.managerId, + status: "COMPLETE", + 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: seededUserIds.adminId, + status: "PENDING", + parameters: { inspectorId: seededUserIds.inspector1Id }, + outputUrl: null, + completedAt: null, + }); + }); + + console.log("Seed completed successfully."); + console.log("Test login accounts were seeded."); +} void main() - .catch(async (err: unknown) => { + .catch((err: unknown) => { console.error("Seed failed", err); process.exit(1); }) 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"] +}