From ec4f88784bf5fd41ec4b4ebc94840e2cb75ad56d Mon Sep 17 00:00:00 2001 From: Hashini-Deshappriya Date: Tue, 12 May 2026 23:12:22 +1000 Subject: [PATCH 01/26] Adopter API docs added --- docs/API.md | 439 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) diff --git a/docs/API.md b/docs/API.md index 9c78c27..50d7ac8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2186,3 +2186,442 @@ The User-Project Assignment API follows the TreeO2 backend engineering standard: - Scalable structure for future project-user access rules --- +###########################Hashini +## 14. Adopters API + +This module manages adopter records used within the TreeO2 platform. It provides CRUD operations with validation, authentication, role-based access control, pagination support, and automated test coverage. + +**Module Path:** `src/modules/adopters/` + +### Files +- `adopters.routes.ts` +- `adopters.controller.ts` +- `adopters.service.ts` +- `index.ts` + +### 14.1 Purpose + +The Adopters API is responsible for managing adopter records in the system. + +Adopters represent individuals or organisations associated with tree adoption activities. + +The module currently supports: +- listing adopters with pagination +- retrieving a single adopter +- creating adopters +- updating adopters +- deleting adopters + +### 14.2 Architecture Flow + +Every request follows the standard backend module structure: + +```text +Route → Controller → Service → Prisma ORM → PostgreSQL → Response +``` + +#### Responsibilities + +#### Routes +- Define endpoints +- Apply authentication middleware +- Apply role-based authorization +- Contain Swagger documentation + +#### Controller +- Receive request data +- Read params/query/body +- Call service methods +- Return HTTP responses + +#### Service +- Apply validation and business rules +- Execute Prisma queries +- Throw structured AppError responses +- Prevent invalid operations + +### 14.3 Security + +All endpoints are protected using Bearer Token authentication. + +Middleware used: +- `authMiddleware` +- `roleMiddleware` + +### 14.4 Access Control Matrix + +| Endpoint | ADMIN | MANAGER | INSPECTOR | FARMER | DEVELOPER | +| --------------------- | ----- | ------- | --------- | ------ | --------- | +| GET /adopters | Yes | Yes | No | No | No | +| GET /adopters/{id} | Yes | Yes | No | No | No | +| POST /adopters | Yes | No | No | No | No | +| PUT /adopters/{id} | Yes | No | No | No | No | +| DELETE /adopters/{id} | Yes | No | No | No | No | + + +### 14.5 Endpoints + +#### GET /adopters + +Retrieve a paginated list of adopters. + +| Name | Type | Required | Default | +| ----- | ------- | -------- | ------- | +| page | integer | No | 1 | +| limit | integer | No | 10 | + +Example Request + +GET /adopters?page=1&limit=10 + +##### Response + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com", + "createdAt": "2026-05-12T10:00:00.000Z" + } + ], + "meta": { + "page": 1, + "limit": 10, + "total": 1 + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid pagination values +- `401` Authentication required +- `403` Insufficient permissions + +#### GET /adopters/{id} +Retrieve a single adopter by ID. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com", + "createdAt": "2026-05-12T10:00:00.000Z" + } +} +``` +Status Codes +- 200 Success +- 400 Invalid adopter ID +- 401 Authentication required +- 403 Insufficient permissions +- 404 Adopter not found + +#### POST /adopters + +Create a new adopter. + + +##### Request Body + +```json +{ + "name": "Hashini", + "email": "hashini@gmail.com" +} +``` +Required Fields +- name + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com" + } +} +``` +##### Status Codes +- `201` Created +- `400` Invalid payload +- `401` Authentication required +- `403` Insufficient permissions +- `409` Duplicate adopter + +#### PUT /adopters/{id} + +Update an existing adopter. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +##### Request Body +Any subset of fields may be provided. +```json +{ + "name": "Updated Name", + "email": "updated@gmail.com" +} +``` +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Updated Name", + "email": "updated@gmail.com" + } +} +``` +##### Status Codes +- `200` Success +- `400` Invalid request / empty payload / invalid ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +#### DELETE /adopters/{id} +Delete an adopter. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +``` +##### Response + +```json +{ + "success": true, + "data": { + "message": "Adopter deleted successfully" + } +} +``` +##### Status Codes +- `200` Success +- `400` Invalid request / empty payload / invalid ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +### 14.6 Validation Rules + +#### Pagination Validation +- `page` must be: numeric , integer , greater than 0 +- `limit` must be: numeric , integer , greater than 0 + +#### Create Validation + +Rules: +- name is required +- name must be non-empty after trim +- email must be a string if provided + +Accepted example: +```json +{ + "name": "Hashini", + "email": "hashini@gmail.com" +} +``` + +#### Update Validation + +Rules: +- at least one field must be provided +- partial updates are allowed +- name must not be empty if provided +- email must be a string if provided + +Accepted example: +```json +{ + "email": "updated@gmail.com" +} +``` +#### ID Validation + +id must be: +- numeric +- integer +- positive + +### 14.7 Error Handling + +Uses centralised error middleware with AppError and ERROR_CODES. + +#### Standard Error Response + +```json +{ + "success": false, + "message": "Adopter not found", + "code": "DATA_001" +} +``` + +#### Common Errors +- Authentication required (401) +- Insufficient permissions (403) +- Invalid pagination (400) +- Invalid adopter ID (400) +- Missing required fields (400) +- Empty update payload (400) +- Invalid email (400) +- Adopter not found (404) +- Internal server error (500) + +### 14.8 Swagger Documentation + +All endpoints are documented in: + +`adopters.routes.ts` + +Available at: + +`http://localhost:3000/api-docs` + +Swagger supports: +- Interactive testing +- Request examples +- Response definitions +- Security schemas + +### 14.9 Testing + +#### Test Files +- `tests/unit/adopters.test.ts` +- `tests/integration/adopters.test.ts` + +#### Unit Tests Covered +These tests exercise the service layer directly. + +#### Covered Scenarios + +##### listAdopters +- returns paginated adopters +- validates invalid page values +- validates invalid limit values + +##### getAdopterById +- returns adopter +- throws for invalid id +- throws when adopter missing + +##### createAdopter +- creates adopter successfully +- rejects missing name +- rejects empty name +- rejects invalid email type + +##### updateAdopter +- updates adopter successfully +- supports partial updates +- rejects empty payload +- rejects invalid name +- rejects invalid email +- throws when adopter missing + +##### deleteAdopter +- deletes adopter successfully +- throws when adopter missing +- rejects invalid id + +#### Integration Tests Covered +These tests exercise the full API flow: +route → middleware → controller → service → Prisma → response + +#### Covered scenarios: + +#### Authentication +- returns 401 when token missing +#### Authorization +- Manager can access GET routes +- non-admin roles blocked from mutations +- returns 403 for unauthorized roles + +#### GET /adopters +- returns paginated results +- validates pagination query params + +#### GET /adopters/{id} +- returns adopter +- returns 400 for invalid ID +- returns 404 when missing + +#### POST /adopters +- creates adopter successfully +- validates request body +- rejects invalid email + +#### PUT /adopters/{id} +- updates adopter +- supports partial update +- returns 404 when adopter missing + +#### DELETE /adopters/{id} +- deletes adopter +- returns 404 when adopter missing + +### 14.10 Test Strategy Used +Current test strategy for this module: + +- Jest is used as the test runner +- integration tests use supertest +- Prisma is mocked in unit tests +- integration tests use the real database flow +- auth behaviour uses the current development auth scaffold +- integration tests create and clean up their own data + +### 14.11 How To Run Adopter Tests +Run unit tests only: + +npm test -- tests/unit/adopters.test.ts + +Run integration tests only: + +npm test -- tests/integration/adopters.test.ts + +### 14.12 Summary +The Adopters API follows the TreeO2 backend engineering standard: + +- Modular architecture +- Secure authentication +- Role-based access control +- Clean separation of concerns +- Strong validation +- Relationship management between users and projects +- Swagger documentation +- Unit testing for service/business logic +- Integration testing for full API flow +- Scalable structure for future project-user access rules +--- + From 25550f964c46a454ef5e38f9391cc8b36bca017c Mon Sep 17 00:00:00 2001 From: Hashini-Deshappriya <39541942+Hashini-Deshappriya@users.noreply.github.com> Date: Wed, 13 May 2026 17:55:10 +1000 Subject: [PATCH 02/26] Revise Adopters API documentation Reorganize and enhance documentation for Adopters API, including purpose, architecture, security, endpoints, validation rules, error handling, and testing strategies. --- docs/API.md | 450 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 439 insertions(+), 11 deletions(-) diff --git a/docs/API.md b/docs/API.md index 265fec2..224ec93 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2186,17 +2186,6 @@ The User-Project Assignment API follows the TreeO2 backend engineering standard: - Scalable structure for future project-user access rules --- -###########################Hashini -## 14. Adopters API - -This module manages adopter records used within the TreeO2 platform. It provides CRUD operations with validation, authentication, role-based access control, pagination support, and automated test coverage. - -**Module Path:** `src/modules/adopters/` - -### Files -- `adopters.routes.ts` -- `adopters.controller.ts` -- `adopters.service.ts` ## 14. Partners API This module manages partner organisations in the TreeO2 platform. It provides full CRUD operations with validation and role-based access control. @@ -2853,4 +2842,443 @@ The Partners API follows the TreeO2 backend engineering standard: - Automated tests - Scalable structure for future enhancements +## 15. Adopters API + +This module manages adopter records used within the TreeO2 platform. It provides CRUD operations with validation, authentication, role-based access control, pagination support, and automated test coverage. + +**Module Path:** `src/modules/adopters/` + +### Files +- `adopters.routes.ts` +- `adopters.controller.ts` +- `adopters.service.ts` +- `index.ts` + +### 15.1 Purpose + +The Adopters API is responsible for managing adopter records in the system. + +Adopters represent individuals or organisations associated with tree adoption activities. + +The module currently supports: +- listing adopters with pagination +- retrieving a single adopter +- creating adopters +- updating adopters +- deleting adopters + +### 15.2 Architecture Flow + +Every request follows the standard backend module structure: + +```text +Route → Controller → Service → Prisma ORM → PostgreSQL → Response +``` + +#### Responsibilities + +#### Routes +- Define endpoints +- Apply authentication middleware +- Apply role-based authorization +- Contain Swagger documentation + +#### Controller +- Receive request data +- Read params/query/body +- Call service methods +- Return HTTP responses + +#### Service +- Apply validation and business rules +- Execute Prisma queries +- Throw structured AppError responses +- Prevent invalid operations + +### 15.3 Security + +All endpoints are protected using Bearer Token authentication. + +Middleware used: +- `authMiddleware` +- `roleMiddleware` + +### 15.4 Access Control Matrix + +| Endpoint | ADMIN | MANAGER | INSPECTOR | FARMER | DEVELOPER | +| --------------------- | ----- | ------- | --------- | ------ | --------- | +| GET /adopters | Yes | Yes | No | No | No | +| GET /adopters/{id} | Yes | Yes | No | No | No | +| POST /adopters | Yes | No | No | No | No | +| PUT /adopters/{id} | Yes | No | No | No | No | +| DELETE /adopters/{id} | Yes | No | No | No | No | + + +### 15.5 Endpoints + +#### GET /adopters + +Retrieve a paginated list of adopters. + +| Name | Type | Required | Default | +| ----- | ------- | -------- | ------- | +| page | integer | No | 1 | +| limit | integer | No | 10 | + +Example Request + +GET /adopters?page=1&limit=10 + +##### Response + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com", + "createdAt": "2026-05-12T10:00:00.000Z" + } + ], + "meta": { + "page": 1, + "limit": 10, + "total": 1 + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid pagination values +- `401` Authentication required +- `403` Insufficient permissions + +#### GET /adopters/{id} +Retrieve a single adopter by ID. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com", + "createdAt": "2026-05-12T10:00:00.000Z" + } +} +``` +Status Codes +- 200 Success +- 400 Invalid adopter ID +- 401 Authentication required +- 403 Insufficient permissions +- 404 Adopter not found + +#### POST /adopters + +Create a new adopter. + + +##### Request Body + +```json +{ + "name": "Hashini", + "email": "hashini@gmail.com" +} +``` +Required Fields +- name + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com" + } +} +``` +##### Status Codes +- `201` Created +- `400` Invalid payload +- `401` Authentication required +- `403` Insufficient permissions +- `409` Duplicate adopter + +#### PUT /adopters/{id} + +Update an existing adopter. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +##### Request Body +Any subset of fields may be provided. +```json +{ + "name": "Updated Name", + "email": "updated@gmail.com" +} +``` +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Updated Name", + "email": "updated@gmail.com" + } +} +``` +##### Status Codes +- `200` Success +- `400` Invalid request / empty payload / invalid ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +#### DELETE /adopters/{id} +Delete an adopter. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +``` +##### Response + +```json +{ + "success": true, + "data": { + "message": "Adopter deleted successfully" + } +} +``` +##### Status Codes +- `200` Success +- `400` Invalid request / empty payload / invalid ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +### 15.6 Validation Rules + +#### Pagination Validation +- `page` must be: numeric , integer , greater than 0 +- `limit` must be: numeric , integer , greater than 0 + +#### Create Validation + +Rules: +- name is required +- name must be non-empty after trim +- email must be a string if provided + +Accepted example: +```json +{ + "name": "Hashini", + "email": "hashini@gmail.com" +} +``` + +#### Update Validation + +Rules: +- at least one field must be provided +- partial updates are allowed +- name must not be empty if provided +- email must be a string if provided + +Accepted example: +```json +{ + "email": "updated@gmail.com" +} +``` +#### ID Validation + +id must be: +- numeric +- integer +- positive + +### 15.7 Error Handling + +Uses centralised error middleware with AppError and ERROR_CODES. + +#### Standard Error Response + +```json +{ + "success": false, + "message": "Adopter not found", + "code": "DATA_001" +} +``` + +#### Common Errors +- Authentication required (401) +- Insufficient permissions (403) +- Invalid pagination (400) +- Invalid adopter ID (400) +- Missing required fields (400) +- Empty update payload (400) +- Invalid email (400) +- Adopter not found (404) +- Internal server error (500) + +### 15.8 Swagger Documentation + +All endpoints are documented in: + +`adopters.routes.ts` + +Available at: + +`http://localhost:3000/api-docs` + +Swagger supports: +- Interactive testing +- Request examples +- Response definitions +- Security schemas + +### 15.9 Testing + +#### Test Files +- `tests/unit/adopters.test.ts` +- `tests/integration/adopters.test.ts` + +#### Unit Tests Covered +These tests exercise the service layer directly. + +#### Covered Scenarios + +##### listAdopters +- returns paginated adopters +- validates invalid page values +- validates invalid limit values + +##### getAdopterById +- returns adopter +- throws for invalid id +- throws when adopter missing + +##### createAdopter +- creates adopter successfully +- rejects missing name +- rejects empty name +- rejects invalid email type + +##### updateAdopter +- updates adopter successfully +- supports partial updates +- rejects empty payload +- rejects invalid name +- rejects invalid email +- throws when adopter missing + +##### deleteAdopter +- deletes adopter successfully +- throws when adopter missing +- rejects invalid id + +#### Integration Tests Covered +These tests exercise the full API flow: +route → middleware → controller → service → Prisma → response + +#### Covered scenarios: + +#### Authentication +- returns 401 when token missing +#### Authorization +- Manager can access GET routes +- non-admin roles blocked from mutations +- returns 403 for unauthorized roles + +#### GET /adopters +- returns paginated results +- validates pagination query params + +#### GET /adopters/{id} +- returns adopter +- returns 400 for invalid ID +- returns 404 when missing + +#### POST /adopters +- creates adopter successfully +- validates request body +- rejects invalid email + +#### PUT /adopters/{id} +- updates adopter +- supports partial update +- returns 404 when adopter missing + +#### DELETE /adopters/{id} +- deletes adopter +- returns 404 when adopter missing + +### 15.10 Test Strategy Used +Current test strategy for this module: + +- Jest is used as the test runner +- integration tests use supertest +- Prisma is mocked in unit tests +- integration tests use the real database flow +- auth behaviour uses the current development auth scaffold +- integration tests create and clean up their own data + +### 15.11 How To Run Adopter Tests +Run unit tests only: + +npm test -- tests/unit/adopters.test.ts + +Run integration tests only: + +npm test -- tests/integration/adopters.test.ts + +### 15.12 Summary +The Adopters API follows the TreeO2 backend engineering standard: + +- Modular architecture +- Secure authentication +- Role-based access control +- Clean separation of concerns +- Strong validation +- Relationship management between users and projects +- Swagger documentation +- Unit testing for service/business logic +- Integration testing for full API flow +- Scalable structure for future project-user access rules +--- + + From 2c56e25c5634d84c206f96c94b3d89b1fd324478 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:53:44 +1000 Subject: [PATCH 03/26] feat(scan-batches): add validation schemas and constants --- .../scan-batches/scan-batches.constants.ts | 86 ++++++++++++++ .../scan-batches/scan-batches.schema.ts | 108 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/modules/scan-batches/scan-batches.constants.ts create mode 100644 src/modules/scan-batches/scan-batches.schema.ts diff --git a/src/modules/scan-batches/scan-batches.constants.ts b/src/modules/scan-batches/scan-batches.constants.ts new file mode 100644 index 0000000..244b89d --- /dev/null +++ b/src/modules/scan-batches/scan-batches.constants.ts @@ -0,0 +1,86 @@ +export const SCAN_BATCHES_MESSAGES = { + FETCHED: "Scan batches fetched successfully", + FETCHED_ONE: "Scan batch fetched successfully", + CREATED: "Scan batch uploaded successfully", + DELETED: "Scan batch deleted successfully", + + NOT_FOUND: "Scan batch not found", + INVALID_ID: "Invalid scan batch ID", + + CREATE_FAILED: "Failed to create scan batch", + DELETE_FAILED: "Failed to delete scan batch", + + INVALID_SCANS_ARRAY: "Scans must be provided as a non-empty array", + + INSPECTOR_REQUIRED: "Inspector ID is required", + PROJECT_REQUIRED: "Project ID is required", + + INSPECTOR_NOT_FOUND: "Inspector not found", + PROJECT_NOT_FOUND: "Project not found", + PROJECT_INACTIVE: "Project is not active and cannot accept scan uploads", + + INVALID_INSPECTOR_ROLE: "User must have Inspector role", + INVALID_FARMER_ROLE: "Selected farmer_id must belong to a Farmer user", + + FARMER_NOT_FOUND: "Farmer not found", + FARMER_NOT_ASSIGNED: "Farmer is not assigned to the selected project", + + SPECIES_NOT_FOUND: "Tree species not found", + SPECIES_NOT_IN_PROJECT: "Tree species is not assigned to this project", + + INSPECTOR_NOT_ASSIGNED: "Inspector is not assigned to the selected project", + + UNAUTHORIZED_ACCESS: "You do not have permission to access this scan batch", + ADMIN_DELETE_ONLY: "Only Admin users can delete scan batches", + + INVALID_PLANTED_DATE: "Planted date cannot be in the future", + INVALID_SCAN_DATE: "Scan timestamp cannot be in the future", + INVALID_PLANTED_YEAR: "Estimated planted year must be between 1950 and the current year", + INVALID_PLANTED_MONTH: "Estimated planted month must be between 1 and 12", + INVALID_MEASUREMENT: "Tree measurement value is outside the allowed range", + + DELETE_BLOCKED_HAS_SCANS: + "Scan batch cannot be deleted because it has related tree scans", +} as const; + +export const SCAN_BATCHES_ERRORS = { + VALIDATION_ERROR: "VALIDATION_ERROR", + NOT_FOUND: "SCAN_BATCH_NOT_FOUND", + FORBIDDEN: "SCAN_BATCH_FORBIDDEN", + CREATE_FAILED: "SCAN_BATCH_CREATE_FAILED", + DELETE_FAILED: "SCAN_BATCH_DELETE_FAILED", + DELETE_BLOCKED: "SCAN_BATCH_DELETE_BLOCKED", + + PROJECT_INACTIVE: "PROJECT_INACTIVE", + INVALID_ROLE: "INVALID_ROLE", + NOT_ASSIGNED: "NOT_ASSIGNED_TO_PROJECT", + SPECIES_NOT_IN_PROJECT: "SPECIES_NOT_IN_PROJECT", + INVALID_DATE: "INVALID_DATE", + INVALID_MEASUREMENT: "INVALID_MEASUREMENT", +} as const; + +export const SCAN_BATCHES_DEFAULTS = { + PAGE: 1, + LIMIT: 20, + MAX_LIMIT: 100, +} as const; + +export const SCAN_BATCHES_ROLES = { + ADMIN: "Admin", + MANAGER: "Manager", + INSPECTOR: "Inspector", + FARMER: "Farmer", +} as const; + +export const SCAN_BATCHES_LIMITS = { + MAX_SCANS_PER_BATCH: 500, + + MIN_PLANTED_YEAR: 1950, + + MAX_HEIGHT_M: 100, + MAX_DIAMETER_CM: 1000, + MAX_CIRCUMFERENCE_CM: 4000, + + FOB_ID_MAX_LENGTH: 80, + DEVICE_ID_MAX_LENGTH: 100, +} as const; \ No newline at end of file diff --git a/src/modules/scan-batches/scan-batches.schema.ts b/src/modules/scan-batches/scan-batches.schema.ts new file mode 100644 index 0000000..3c39b42 --- /dev/null +++ b/src/modules/scan-batches/scan-batches.schema.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { + SCAN_BATCHES_DEFAULTS, + SCAN_BATCHES_LIMITS, + SCAN_BATCHES_MESSAGES, +} from "../constants/scan-batches.constants"; + +const currentYear = new Date().getFullYear(); + +const futureDateValidator = (value: string | Date): boolean => { + const parsedDate = value instanceof Date ? value : new Date(value); + return !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() <= Date.now(); +}; + +const optionalPositiveNumber = (max: number) => + z.coerce + .number() + .positive() + .max(max, SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT) + .optional() + .nullable(); + +const scanSchema = z.object({ + fob_id: z + .string() + .trim() + .min(1, "fob_id is required") + .max(SCAN_BATCHES_LIMITS.FOB_ID_MAX_LENGTH), + + farmer_id: z.coerce.number().int().positive("farmer_id must be a positive integer"), + + species_id: z.coerce.number().int().positive("species_id must be a positive integer"), + + estimated_planted_year: z.coerce + .number() + .int() + .min(SCAN_BATCHES_LIMITS.MIN_PLANTED_YEAR, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR) + .max(currentYear, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR), + + estimated_planted_month: z.coerce + .number() + .int() + .min(1, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH) + .max(12, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH), + + planted_date: z.coerce + .date() + .refine(futureDateValidator, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_DATE) + .optional() + .nullable(), + + height_m: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_HEIGHT_M), + + diameter_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM), + + circumference_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM), + + latitude: z.coerce.number().min(-90).max(90).optional().nullable(), + + longitude: z.coerce.number().min(-180).max(180).optional().nullable(), + + photo_id: z.string().uuid("photo_id must be a valid UUID").optional().nullable(), + + device_id: z + .string() + .trim() + .max(SCAN_BATCHES_LIMITS.DEVICE_ID_MAX_LENGTH) + .optional() + .nullable(), +}); + +export const createScanBatchSchema = z.object({ + project_id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.PROJECT_REQUIRED), + + uploaded_at: z.coerce + .date() + .refine(futureDateValidator, "Uploaded date cannot be in the future") + .optional() + .nullable(), + + scans: z + .array(scanSchema) + .min(1, SCAN_BATCHES_MESSAGES.INVALID_SCANS_ARRAY) + .max(SCAN_BATCHES_LIMITS.MAX_SCANS_PER_BATCH), +}); + +export const getScanBatchesQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(SCAN_BATCHES_DEFAULTS.PAGE), + + limit: z.coerce + .number() + .int() + .positive() + .max(SCAN_BATCHES_DEFAULTS.MAX_LIMIT) + .default(SCAN_BATCHES_DEFAULTS.LIMIT), + + project_id: z.coerce.number().int().positive().optional(), + + inspector_id: z.coerce.number().int().positive().optional(), +}); + +export const scanBatchIdParamSchema = z.object({ + id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.INVALID_ID), +}); + +export type CreateScanBatchInput = z.infer; +export type GetScanBatchesQueryInput = z.infer; +export type ScanBatchIdParamInput = z.infer; \ No newline at end of file From 9c1cb4d5a38051f07add57efa78c7fb86042ba60 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:53:55 +1000 Subject: [PATCH 04/26] feat(scan-batches): implement scan batch service logic --- .../scan-batches/scanBatches.service.ts | 1925 +++++++++++++++++ 1 file changed, 1925 insertions(+) diff --git a/src/modules/scan-batches/scanBatches.service.ts b/src/modules/scan-batches/scanBatches.service.ts index e69de29..973ce05 100644 --- a/src/modules/scan-batches/scanBatches.service.ts +++ b/src/modules/scan-batches/scanBatches.service.ts @@ -0,0 +1,1925 @@ +import { Prisma } from "@prisma/client"; +import prisma from "../config/prisma"; +import { AppError } from "../middleware/errorHandler"; +import { + CreateScanBatchInput, + GetScanBatchesQueryInput, +} from "../schemas/scan-batches.schema"; +import { + SCAN_BATCHES_DEFAULTS, + SCAN_BATCHES_ERRORS, + SCAN_BATCHES_LIMITS, + SCAN_BATCHES_MESSAGES, + SCAN_BATCHES_ROLES, +} from "../constants/scan-batches.constants"; + +interface CurrentUser { + id: number; + role: string; +} + +type CreateScanBatchServiceInput = CreateScanBatchInput & { + inspector_id: number; +}; + +// Fetch paginated scan batches with role-based access filtering +export const getScanBatches = async ( + query: GetScanBatchesQueryInput, + currentUser: CurrentUser, +) => { + const page = query.page || SCAN_BATCHES_DEFAULTS.PAGE; + const limit = query.limit || SCAN_BATCHES_DEFAULTS.LIMIT; + const skip = (page - 1) * limit; + + const where: Prisma.ScanBatchWhereInput = {}; + + if (query.project_id) { + where.projectId = query.project_id; + } + + if (query.inspector_id) { + where.inspectorId = query.inspector_id; + } + + if (currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR) { + where.inspectorId = currentUser.id; + } + + if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { + where.project = { + userProjects: { + some: { + userId: currentUser.id, + }, + }, + }; + } + + const [scanBatches, total] = await Promise.all([ + prisma.scanBatch.findMany({ + where, + skip, + take: limit, + orderBy: { + uploadedAt: "desc", + }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + _count: { + select: { + treeScans: true, + }, + }, + }, + }), + prisma.scanBatch.count({ where }), + ]); + + return { + data: scanBatches, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; +}; + + + +TreeO2 Project Proposal.pdf +PDF +go over it completely and i will give you code for scan batches api just tell if there any major bugs + +Thought for a few seconds +Yes — I’ll check your Scan Batches API against the full TreeO2 spec. + +Main things I’ll look for: + +Inspector/Admin/Manager role access + +batch upload creates scan_batches + related tree_scans + +all scans in one batch use same project_id and inspector + +inspector is active and assigned to the project + +farmer belongs to project + +tree type exists for that project + +dates are not future + +planted month is 1–12 and year is valid + +no destructive delete if dependent scans exist + +proper transactions so partial batch upload cannot happen + +correct status codes and error format + +The spec says /scan-batches should support GET, GET by id, POST by Inspector, and DELETE by Admin, with batch upload validation rules for same scanner/project and historical scan preservation. + + +Send me the code. + + +constants: +export const SCAN_BATCHES_MESSAGES = { + FETCHED: "Scan batches fetched successfully", + FETCHED_ONE: "Scan batch fetched successfully", + CREATED: "Scan batch uploaded successfully", + DELETED: "Scan batch deleted successfully", + + NOT_FOUND: "Scan batch not found", + INVALID_ID: "Invalid scan batch ID", + + CREATE_FAILED: "Failed to create scan batch", + DELETE_FAILED: "Failed to delete scan batch", + + INVALID_SCANS_ARRAY: "Scans must be provided as a non-empty array", + + INSPECTOR_REQUIRED: "Inspector ID is required", + PROJECT_REQUIRED: "Project ID is required", + + INSPECTOR_NOT_FOUND: "Inspector not found", + PROJECT_NOT_FOUND: "Project not found", + PROJECT_INACTIVE: "Project is not active and cannot accept scan uploads", + + INVALID_INSPECTOR_ROLE: "User must have Inspector role", + INVALID_FARMER_ROLE: "Selected farmer_id must belong to a Farmer user", + + FARMER_NOT_FOUND: "Farmer not found", + FARMER_NOT_ASSIGNED: "Farmer is not assigned to the selected project", + + SPECIES_NOT_FOUND: "Tree species not found", + SPECIES_NOT_IN_PROJECT: "Tree species is not assigned to this project", + + INSPECTOR_NOT_ASSIGNED: "Inspector is not assigned to the selected project", + + UNAUTHORIZED_ACCESS: "You do not have permission to access this scan batch", + ADMIN_DELETE_ONLY: "Only Admin users can delete scan batches", + + INVALID_PLANTED_DATE: "Planted date cannot be in the future", + INVALID_SCAN_DATE: "Scan timestamp cannot be in the future", + INVALID_PLANTED_YEAR: "Estimated planted year must be between 1950 and the current year", + INVALID_PLANTED_MONTH: "Estimated planted month must be between 1 and 12", + INVALID_MEASUREMENT: "Tree measurement value is outside the allowed range", + + DELETE_BLOCKED_HAS_SCANS: + "Scan batch cannot be deleted because it has related tree scans", +} as const; + +export const SCAN_BATCHES_ERRORS = { + VALIDATION_ERROR: "VALIDATION_ERROR", + NOT_FOUND: "SCAN_BATCH_NOT_FOUND", + FORBIDDEN: "SCAN_BATCH_FORBIDDEN", + CREATE_FAILED: "SCAN_BATCH_CREATE_FAILED", + DELETE_FAILED: "SCAN_BATCH_DELETE_FAILED", + DELETE_BLOCKED: "SCAN_BATCH_DELETE_BLOCKED", + + PROJECT_INACTIVE: "PROJECT_INACTIVE", + INVALID_ROLE: "INVALID_ROLE", + NOT_ASSIGNED: "NOT_ASSIGNED_TO_PROJECT", + SPECIES_NOT_IN_PROJECT: "SPECIES_NOT_IN_PROJECT", + INVALID_DATE: "INVALID_DATE", + INVALID_MEASUREMENT: "INVALID_MEASUREMENT", +} as const; + +export const SCAN_BATCHES_DEFAULTS = { + PAGE: 1, + LIMIT: 20, + MAX_LIMIT: 100, +} as const; + +export const SCAN_BATCHES_ROLES = { + ADMIN: "Admin", + MANAGER: "Manager", + INSPECTOR: "Inspector", + FARMER: "Farmer", +} as const; + +export const SCAN_BATCHES_LIMITS = { + MAX_SCANS_PER_BATCH: 500, + + MIN_PLANTED_YEAR: 1950, + + MAX_HEIGHT_M: 100, + MAX_DIAMETER_CM: 1000, + MAX_CIRCUMFERENCE_CM: 4000, + + FOB_ID_MAX_LENGTH: 80, + DEVICE_ID_MAX_LENGTH: 100, +} as const; + +schemas: +import { z } from "zod"; +import { + SCAN_BATCHES_DEFAULTS, + SCAN_BATCHES_LIMITS, + SCAN_BATCHES_MESSAGES, +} from "../constants/scan-batches.constants"; + +const currentYear = new Date().getFullYear(); + +const futureDateValidator = (value: string | Date): boolean => { + const parsedDate = value instanceof Date ? value : new Date(value); + return !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() <= Date.now(); +}; + +const optionalPositiveNumber = (max: number) => + z.coerce + .number() + .positive() + .max(max, SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT) + .optional() + .nullable(); + +const scanSchema = z.object({ + fob_id: z + .string() + .trim() + .min(1, "fob_id is required") + .max(SCAN_BATCHES_LIMITS.FOB_ID_MAX_LENGTH), + + farmer_id: z.coerce.number().int().positive("farmer_id must be a positive integer"), + + species_id: z.coerce.number().int().positive("species_id must be a positive integer"), + + estimated_planted_year: z.coerce + .number() + .int() + .min(SCAN_BATCHES_LIMITS.MIN_PLANTED_YEAR, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR) + .max(currentYear, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR), + + estimated_planted_month: z.coerce + .number() + .int() + .min(1, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH) + .max(12, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH), + + planted_date: z.coerce + .date() + .refine(futureDateValidator, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_DATE) + .optional() + .nullable(), + + height_m: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_HEIGHT_M), + + diameter_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM), + + circumference_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM), + + latitude: z.coerce.number().min(-90).max(90).optional().nullable(), + + longitude: z.coerce.number().min(-180).max(180).optional().nullable(), + + photo_id: z.string().uuid("photo_id must be a valid UUID").optional().nullable(), + + device_id: z + .string() + .trim() + .max(SCAN_BATCHES_LIMITS.DEVICE_ID_MAX_LENGTH) + .optional() + .nullable(), +}); + +export const createScanBatchSchema = z.object({ + project_id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.PROJECT_REQUIRED), + + uploaded_at: z.coerce + .date() + .refine(futureDateValidator, "Uploaded date cannot be in the future") + .optional() + .nullable(), + + scans: z + .array(scanSchema) + .min(1, SCAN_BATCHES_MESSAGES.INVALID_SCANS_ARRAY) + .max(SCAN_BATCHES_LIMITS.MAX_SCANS_PER_BATCH), +}); + +export const getScanBatchesQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(SCAN_BATCHES_DEFAULTS.PAGE), + + limit: z.coerce + .number() + .int() + .positive() + .max(SCAN_BATCHES_DEFAULTS.MAX_LIMIT) + .default(SCAN_BATCHES_DEFAULTS.LIMIT), + + project_id: z.coerce.number().int().positive().optional(), + + inspector_id: z.coerce.number().int().positive().optional(), +}); + +export const scanBatchIdParamSchema = z.object({ + id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.INVALID_ID), +}); + +export type CreateScanBatchInput = z.infer; +export type GetScanBatchesQueryInput = z.infer; +export type ScanBatchIdParamInput = z.infer; + +service: +import { Prisma } from "@prisma/client"; +import prisma from "../config/prisma"; +import { AppError } from "../middleware/errorHandler"; +import { + CreateScanBatchInput, + GetScanBatchesQueryInput, +} from "../schemas/scan-batches.schema"; +import { + SCAN_BATCHES_DEFAULTS, + SCAN_BATCHES_ERRORS, + SCAN_BATCHES_LIMITS, + SCAN_BATCHES_MESSAGES, + SCAN_BATCHES_ROLES, +} from "../constants/scan-batches.constants"; + +interface CurrentUser { + id: number; + role: string; +} + +type CreateScanBatchServiceInput = CreateScanBatchInput & { + inspector_id: number; +}; + +export const getScanBatches = async ( + query: GetScanBatchesQueryInput, + currentUser: CurrentUser, +) => { + const page = query.page || SCAN_BATCHES_DEFAULTS.PAGE; + const limit = query.limit || SCAN_BATCHES_DEFAULTS.LIMIT; + const skip = (page - 1) * limit; + + const where: Prisma.ScanBatchWhereInput = {}; + + if (query.project_id) { + where.projectId = query.project_id; + } + + if (query.inspector_id) { + where.inspectorId = query.inspector_id; + } + + if (currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR) { + where.inspectorId = currentUser.id; + } + + if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { + where.project = { + userProjects: { + some: { + userId: currentUser.id, + }, + }, + }; + } + + const [scanBatches, total] = await Promise.all([ + prisma.scanBatch.findMany({ + where, + skip, + take: limit, + orderBy: { + uploadedAt: "desc", + }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + _count: { + select: { + treeScans: true, + }, + }, + }, + }), + prisma.scanBatch.count({ where }), + ]); + + return { + data: scanBatches, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; +}; + +export const getScanBatchById = async ( + id: number, + currentUser: CurrentUser, +) => { + const scanBatch = await prisma.scanBatch.findUnique({ + where: { id }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + treeScans: { + orderBy: { + createdAt: "desc", + }, + }, + }, + }); + + if (!scanBatch) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if ( + currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR && + scanBatch.inspectorId !== currentUser.id + ) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + + if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { + const hasAccess = await prisma.userProject.findFirst({ + where: { + userId: currentUser.id, + projectId: scanBatch.projectId, + }, + }); + + if (!hasAccess) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + } + + return scanBatch; +}; + +export const createScanBatch = async (data: CreateScanBatchServiceInput) => { + const inspector = await prisma.user.findUnique({ + where: { id: data.inspector_id }, + include: { + role: true, + }, + }); + + if (!inspector) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (inspector.role?.name !== SCAN_BATCHES_ROLES.INSPECTOR) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INVALID_INSPECTOR_ROLE, + SCAN_BATCHES_ERRORS.INVALID_ROLE, + ); + } + + if (!inspector.accountActive || !inspector.canSignIn) { + throw new AppError( + 403, + "Inspector account is inactive or cannot sign in", + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + + const project = await prisma.project.findUnique({ + where: { id: data.project_id }, + }); + + if (!project) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.PROJECT_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (!project.isActive) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.PROJECT_INACTIVE, + SCAN_BATCHES_ERRORS.PROJECT_INACTIVE, + ); + } + + const inspectorAssignment = await prisma.userProject.findFirst({ + where: { + userId: data.inspector_id, + projectId: data.project_id, + }, + }); + + if (!inspectorAssignment) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_ASSIGNED, + SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + ); + } + + for (const scan of data.scans) { + const farmer = await prisma.user.findUnique({ + where: { id: scan.farmer_id }, + include: { + role: true, + }, + }); + + if (!farmer) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (farmer.role?.name !== SCAN_BATCHES_ROLES.FARMER) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INVALID_FARMER_ROLE, + SCAN_BATCHES_ERRORS.INVALID_ROLE, + ); + } + + const farmerAssignment = await prisma.userProject.findFirst({ + where: { + userId: scan.farmer_id, + projectId: data.project_id, + }, + }); + + if (!farmerAssignment) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.FARMER_NOT_ASSIGNED, + SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + ); + } + + const species = await prisma.treeType.findUnique({ + where: { id: scan.species_id }, + }); + + if (!species) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.SPECIES_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + const projectSpecies = await prisma.projectTreeType.findFirst({ + where: { + projectId: data.project_id, + treeTypeId: scan.species_id, + }, + }); + + if (!projectSpecies) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.SPECIES_NOT_IN_PROJECT, + SCAN_BATCHES_ERRORS.SPECIES_NOT_IN_PROJECT, + ); + } + + if ( + scan.height_m && + scan.height_m > SCAN_BATCHES_LIMITS.MAX_HEIGHT_M + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + + if ( + scan.diameter_cm && + scan.diameter_cm > SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + + if ( + scan.circumference_cm && + scan.circumference_cm > SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + } + + return prisma.$transaction(async (tx) => { + const scanBatch = await tx.scanBatch.create({ + data: { + inspectorId: data.inspector_id, + projectId: data.project_id, + uploadedAt: data.uploaded_at ?? new Date(), + }, + }); + + await tx.treeScan.createMany({ + data: data.scans.map((scan) => ({ + fobId: scan.fob_id, + projectId: data.project_id, + farmerId: scan.farmer_id, + inspectorId: data.inspector_id, + speciesId: scan.species_id, + estimatedPlantedYear: scan.estimated_planted_year, + estimatedPlantedMonth: scan.estimated_planted_month, + plantedDate: scan.planted_date ?? null, + heightM: scan.height_m ?? null, + diameterCm: scan.diameter_cm ?? null, + circumferenceCm: scan.circumference_cm ?? null, + latitude: scan.latitude ?? null, + longitude: scan.longitude ?? null, + photoId: scan.photo_id ?? null, + deviceId: scan.device_id ?? null, + batchId: scanBatch.id, + })), + }); + + return tx.scanBatch.findUnique({ + where: { + id: scanBatch.id, + }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + treeScans: true, + }, + }); + }); +}; + +export const deleteScanBatch = async (id: number) => { + const scanBatch = await prisma.scanBatch.findUnique({ + where: { id }, + include: { + _count: { + select: { + treeScans: true, + }, + }, + }, + }); + + if (!scanBatch) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (scanBatch._count.treeScans > 0) { + throw new AppError( + 409, + SCAN_BATCHES_MESSAGES.DELETE_BLOCKED_HAS_SCANS, + SCAN_BATCHES_ERRORS.DELETE_BLOCKED, + ); + } + + await prisma.scanBatch.delete({ + where: { id }, + }); + + return { + success: true, + message: SCAN_BATCHES_MESSAGES.DELETED, + }; +}; + +controller: +// controllers/scanBatches.controller.ts + +import { Request, Response, NextFunction } from "express"; +import { + createScanBatch, + deleteScanBatch, + getScanBatchById, + getScanBatches, +} from "../services/scanBatches.service"; +import { + createScanBatchSchema, + getScanBatchesQuerySchema, + scanBatchIdParamSchema, +} from "../schemas/scan-batches.schema"; +import { SCAN_BATCHES_MESSAGES } from "../constants/scan-batches.constants"; + +export const getScanBatchesController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const query = getScanBatchesQuerySchema.parse(req.query); + + const result = await getScanBatches(query, { + id: req.user!.id, + role: req.user!.role, + }); + + res.status(200).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.FETCHED, + ...result, + }); + } catch (error) { + next(error); + } +}; + +export const getScanBatchByIdController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { id } = scanBatchIdParamSchema.parse(req.params); + + const scanBatch = await getScanBatchById(id, { + id: req.user!.id, + role: req.user!.role, + }); + + res.status(200).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.FETCHED_ONE, + data: scanBatch, + }); + } catch (error) { + next(error); + } +}; + +export const createScanBatchController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const validatedData = createScanBatchSchema.parse(req.body); + + const scanBatch = await createScanBatch({ + ...validatedData, + inspector_id: req.user!.id, + }); + + res.status(201).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.CREATED, + data: scanBatch, + }); + } catch (error) { + next(error); + } +}; + +export const deleteScanBatchController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { id } = scanBatchIdParamSchema.parse(req.params); + + const result = await deleteScanBatch(id); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +Thought for a few seconds +Overall: no major blocking bug in the basic flow. It is mostly solid. But I found 3 important issues you should fix. + +Major issues +Manager role can maybe create scan batch if route allows it +Your service forces inspector_id: req.user!.id, then checks that user role is Inspector. So Manager/Admin will fail in service, but better to block this clearly in route middleware. Spec says /scan-batches POST is for Inspector only. + + +Batch validation is not inside the transaction +You validate farmers/species/project assignments before transaction, then create batch and scans inside transaction. This is mostly okay, but there is still a small race-condition risk if assignments/project status change between validation and insert. Not huge for capstone, but better practice is to put validation + creation in the same transaction. + +Possible Prisma relation mismatch +You use: + +include: { role: true } +and: + +inspector.role?.name +This only works if your User model has a single relation called role. But your spec has both role integer and user_roles junction table. If your actual Prisma schema uses roleId relation, fine. If it uses many-to-many roles, this will break. + +Medium issues +You do not validate scan_timestamp, but the spec mentions scan timestamp cannot be future. + +uploaded_at is allowed from request body. That is okay, but normally backend should control upload time. + +deleteScanBatch has no role check inside service. If route middleware is missing, anyone authenticated could delete. Make sure route has Admin-only middleware. + +You check measurements twice: Zod already checks max, then service checks again. Not harmful, just duplicated. + +optionalPositiveNumber() rejects 0. If a measurement can be zero during early/unknown scan, this may be too strict. If measurements are optional, it is okay. + +Good things +Uses pagination. + +Inspector can only see own batches. + +Manager access is project-limited. + +Batch creation uses transaction for insert. + +Blocks deletion when tree scans exist. + +Validates farmer assignment, inspector assignment, tree species assignment, active project, future dates, planted year/month. + +Verdict: good implementation, not risky, but I would fix role middleware, Prisma role relation confirmation, and Admin-only delete route before approving. + + +docs: +/** + * @swagger + * tags: + * name: Scan Batches + * description: Scan batch upload and management endpoints + */ + +/** + * @swagger + * /scan-batches: + * get: + * summary: Retrieve scan batches + * description: Admin can view all batches. Managers can view batches for assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * example: 1 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * example: 20 + * - in: query + * name: project_id + * schema: + * type: integer + * example: 1 + * - in: query + * name: inspector_id + * schema: + * type: integer + * example: 2 + * responses: + * 200: + * description: Scan batches fetched successfully + * 400: + * description: Invalid query parameters + * 401: + * description: Authentication required + * 403: + * description: Insufficient permissions + */ + +/** + * @swagger + * /scan-batches/{id}: + * get: + * summary: Retrieve a scan batch by ID + * description: Admin can view any batch. Managers can view batches from assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch fetched successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: You do not have permission to access this scan batch + * 404: + * description: Scan batch not found + */ + +/** + * @swagger + * /scan-batches: + * post: + * summary: Upload a new scan batch + * description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Duplicate fob_id values inside the same batch upload are rejected to prevent accidental duplicate submissions. Fob recycling is not automatically applied. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - inspector_id + * - project_id + * - scans + * properties: + * inspector_id: + * type: integer + * minimum: 1 + * example: 2 + * project_id: + * type: integer + * minimum: 1 + * example: 1 + * uploaded_at: + * type: string + * format: date-time + * example: 2024-05-20T10:35:00.000Z + * scans: + * type: array + * minItems: 1 + * maxItems: 500 + * items: + * type: object + * required: + * - fob_id + * - farmer_id + * - species_id + * - estimated_planted_year + * - estimated_planted_month + * properties: + * fob_id: + * type: string + * maxLength: 80 + * example: NFC-001 + * farmer_id: + * type: integer + * minimum: 1 + * example: 10 + * species_id: + * type: integer + * minimum: 1 + * example: 2 + * estimated_planted_year: + * type: integer + * minimum: 1950 + * example: 2024 + * estimated_planted_month: + * type: integer + * minimum: 1 + * maximum: 12 + * example: 5 + * planted_date: + * type: string + * format: date + * example: 2024-05-20 + * height_m: + * type: number + * minimum: 0 + * maximum: 100 + * example: 2.5 + * circumference_cm: + * type: number + * minimum: 0 + * maximum: 4000 + * example: 45.3 + * diameter_cm: + * type: number + * minimum: 0 + * maximum: 1000 + * example: 14.4 + * latitude: + * type: number + * minimum: -90 + * maximum: 90 + * example: -8.5569 + * longitude: + * type: number + * minimum: -180 + * maximum: 180 + * example: 125.5603 + * device_id: + * type: string + * maxLength: 100 + * example: MOB-001 + * photo_id: + * type: string + * format: uuid + * example: 550e8400-e29b-41d4-a716-446655440000 + * example: + * inspector_id: 2 + * project_id: 1 + * uploaded_at: 2024-05-20T10:35:00.000Z + * scans: + * - fob_id: NFC-001 + * farmer_id: 10 + * species_id: 2 + * estimated_planted_year: 2024 + * estimated_planted_month: 5 + * planted_date: 2024-05-20 + * height_m: 2.5 + * circumference_cm: 45.3 + * diameter_cm: 14.4 + * latitude: -8.5569 + * longitude: 125.5603 + * device_id: MOB-001 + * responses: + * 201: + * description: Scan batch uploaded successfully + * 400: + * description: Validation failed + * 401: + * description: Authentication required + * 403: + * description: User is not allowed to upload this scan batch + * 404: + * description: Inspector, project, farmer, or species not found + * 409: + * description: Duplicate fob_id found within the same batch upload + * 422: + * description: Business rule validation failed, such as inactive project, farmer not assigned, species not assigned to project, or invalid measurement/date values + */ + +/** + * @swagger + * /scan-batches/{id}: + * delete: + * summary: Delete a scan batch + * description: Admin-only endpoint. A scan batch cannot be deleted if it has related tree scans. This protects historical scan data. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch deleted successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: Only Admin users can delete scan batches + * 404: + * description: Scan batch not found + * 409: + * description: Scan batch cannot be deleted because it has related tree scans + */ + +routes: +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate"; +import { authorize } from "../middleware/authorize"; + +import { + getScanBatchesController, + getScanBatchByIdController, + createScanBatchController, + deleteScanBatchController, +} from "../controllers/scanBatches.controller"; + +import { SCAN_BATCHES_ROLES } from "../constants/scan-batches.constants"; + +import "../docs/scan-batches.docs"; + +const router = Router(); + +// GET /scan-batches +// Admin: all batches, Manager: assigned project batches, Inspector: own batches +router.get( + "/", + authenticate, + authorize( + SCAN_BATCHES_ROLES.ADMIN, + SCAN_BATCHES_ROLES.MANAGER, + SCAN_BATCHES_ROLES.INSPECTOR, + ), + getScanBatchesController, +); + +// GET /scan-batches/:id +// Admin: any batch, Manager: assigned project batch, Inspector: own batch +router.get( + "/:id", + authenticate, + authorize( + SCAN_BATCHES_ROLES.ADMIN, + SCAN_BATCHES_ROLES.MANAGER, + SCAN_BATCHES_ROLES.INSPECTOR, + ), + getScanBatchByIdController, +); + +// POST /scan-batches +// Inspector only: upload a scan batch with related tree scans +router.post( + "/", + authenticate, + authorize(SCAN_BATCHES_ROLES.INSPECTOR), + createScanBatchController, +); + +// DELETE /scan-batches/:id +// Admin only: delete batch only when it has no related tree scans +router.delete( + "/:id", + authenticate, + authorize(SCAN_BATCHES_ROLES.ADMIN), + deleteScanBatchController, +); + +export default router; + +Good — the routes fix the biggest role issue. Now POST is Inspector-only and DELETE is Admin-only. + +Major issue left: Swagger docs do not match your actual API. + +In your controller, inspector_id comes from: + +inspector_id: req.user!.id +So the request body does not accept inspector_id. But your Swagger says inspector_id is required. Remove inspector_id from docs request body. + +Also this line in docs is wrong: + +Duplicate fob_id values inside the same batch upload are rejected + +Your schema/service does not reject duplicate fob_id inside the batch. Either remove that sentence + 409 response, or add duplicate checking in schema/service. + +Recommended quick fix in docs: + +required: + - project_id + - scans +Remove: + +inspector_id: + type: integer +Remove this response unless you implement it: + +409: + description: Duplicate fob_id found within the same batch upload +Everything else looks aligned: GET access, GET by ID access, POST Inspector-only, DELETE Admin-only, and delete blocked when related scans exist. + + +tell me exactly where to make changes + +Make changes only in docs/scan-batches.docs.ts. + +1. In POST /scan-batches, remove inspector_id from required +Find: + +required: + - inspector_id + - project_id + - scans +Change to: + +required: + - project_id + - scans +2. Remove inspector_id property +Delete this block: + +inspector_id: + type: integer + minimum: 1 + example: 2 +3. Remove inspector_id from example +Find: + +example: + inspector_id: 2 + project_id: 1 +Change to: + +example: + project_id: 1 +4. Fix duplicate fob sentence +Find this in description: + +Duplicate fob_id values inside the same batch upload are rejected to prevent accidental duplicate submissions. +Delete that sentence. + +5. Remove 409 response +Delete this block: + +409: + description: Duplicate fob_id found within the same batch upload +That’s it. Your routes are fine. + + +Almost done. You still need to remove two duplicate-fob references. + +1. Change POST description +Find this line: + +* description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Duplicate fob_id values inside the same batch upload are rejected to prevent accidental duplicate submissions. Fob recycling is not automatically applied. +Change to: + +* description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Fob recycling is not automatically applied. +2. Remove POST 409 response +Delete this block under POST responses: + +* 409: +* description: Duplicate fob_id found within the same batch upload +Keep the DELETE 409 response. That one is correct. + + +/** + * @swagger + * tags: + * name: Scan Batches + * description: Scan batch upload and management endpoints + */ + +/** + * @swagger + * /scan-batches: + * get: + * summary: Retrieve scan batches + * description: Admin can view all batches. Managers can view batches for assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * example: 1 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * example: 20 + * - in: query + * name: project_id + * schema: + * type: integer + * example: 1 + * - in: query + * name: inspector_id + * schema: + * type: integer + * example: 2 + * responses: + * 200: + * description: Scan batches fetched successfully + * 400: + * description: Invalid query parameters + * 401: + * description: Authentication required + * 403: + * description: Insufficient permissions + */ + +/** + * @swagger + * /scan-batches/{id}: + * get: + * summary: Retrieve a scan batch by ID + * description: Admin can view any batch. Managers can view batches from assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch fetched successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: You do not have permission to access this scan batch + * 404: + * description: Scan batch not found + */ + +/** + * @swagger + * /scan-batches: + * post: + * summary: Upload a new scan batch + * description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Fob recycling is not automatically applied. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - project_id + * - scans + * properties: + * project_id: + * type: integer + * minimum: 1 + * example: 1 + * uploaded_at: + * type: string + * format: date-time + * example: 2024-05-20T10:35:00.000Z + * scans: + * type: array + * minItems: 1 + * maxItems: 500 + * items: + * type: object + * required: + * - fob_id + * - farmer_id + * - species_id + * - estimated_planted_year + * - estimated_planted_month + * properties: + * fob_id: + * type: string + * maxLength: 80 + * example: NFC-001 + * farmer_id: + * type: integer + * minimum: 1 + * example: 10 + * species_id: + * type: integer + * minimum: 1 + * example: 2 + * estimated_planted_year: + * type: integer + * minimum: 1950 + * example: 2024 + * estimated_planted_month: + * type: integer + * minimum: 1 + * maximum: 12 + * example: 5 + * planted_date: + * type: string + * format: date + * example: 2024-05-20 + * height_m: + * type: number + * minimum: 0 + * maximum: 100 + * example: 2.5 + * circumference_cm: + * type: number + * minimum: 0 + * maximum: 4000 + * example: 45.3 + * diameter_cm: + * type: number + * minimum: 0 + * maximum: 1000 + * example: 14.4 + * latitude: + * type: number + * minimum: -90 + * maximum: 90 + * example: -8.5569 + * longitude: + * type: number + * minimum: -180 + * maximum: 180 + * example: 125.5603 + * device_id: + * type: string + * maxLength: 100 + * example: MOB-001 + * photo_id: + * type: string + * format: uuid + * example: 550e8400-e29b-41d4-a716-446655440000 + * example: + * project_id: 1 + * uploaded_at: 2024-05-20T10:35:00.000Z + * scans: + * - fob_id: NFC-001 + * farmer_id: 10 + * species_id: 2 + * estimated_planted_year: 2024 + * estimated_planted_month: 5 + * planted_date: 2024-05-20 + * height_m: 2.5 + * circumference_cm: 45.3 + * diameter_cm: 14.4 + * latitude: -8.5569 + * longitude: 125.5603 + * device_id: MOB-001 + * responses: + * 201: + * description: Scan batch uploaded successfully + * 400: + * description: Validation failed + * 401: + * description: Authentication required + * 403: + * description: User is not allowed to upload this scan batch + * 404: + * description: Inspector, project, farmer, or species not found + * 422: + * description: Business rule validation failed, such as inactive project, farmer not assigned, species not assigned to project, or invalid measurement/date values + */ + +/** + * @swagger + * /scan-batches/{id}: + * delete: + * summary: Delete a scan batch + * description: Admin-only endpoint. A scan batch cannot be deleted if it has related tree scans. This protects historical scan data. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch deleted successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: Only Admin users can delete scan batches + * 404: + * description: Scan batch not found + * 409: + * description: Scan batch cannot be deleted because it has related tree scans + */ + +Yes — now the docs are aligned with your implementation. + +At this point the Scan Batches module looks good overall: + +routes aligned with RBAC + +Swagger aligned with actual controller behavior + +validations mostly match business rules + +delete protection implemented + +pagination/access control implemented correctly + +Only remaining improvement areas are optional/polish level: + +move validations into transaction + +maybe optimize N+1 queries inside loop + +add duplicate fob_id prevention if product owner wants it + +add scan timestamp field later if required by schema + +No major architectural bug left from what you shared. + + +everything good? +if yes. now give single line comments for service file for each endpoint + +// Fetch paginated scan batches with role-based access filtering +export const getScanBatches = async ( + +// Retrieve a single scan batch with access control validation +export const getScanBatchById = async ( + id: number, + currentUser: CurrentUser, +) => { + const scanBatch = await prisma.scanBatch.findUnique({ + where: { id }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + treeScans: { + orderBy: { + createdAt: "desc", + }, + }, + }, + }); + + if (!scanBatch) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if ( + currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR && + scanBatch.inspectorId !== currentUser.id + ) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + + if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { + const hasAccess = await prisma.userProject.findFirst({ + where: { + userId: currentUser.id, + projectId: scanBatch.projectId, + }, + }); + + if (!hasAccess) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + } + + return scanBatch; +}; + +// Validate and create a scan batch with related tree scans in a transaction +export const createScanBatch = async (data: CreateScanBatchServiceInput) => { + const inspector = await prisma.user.findUnique({ + where: { id: data.inspector_id }, + include: { + role: true, + }, + }); + + if (!inspector) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (inspector.role?.name !== SCAN_BATCHES_ROLES.INSPECTOR) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INVALID_INSPECTOR_ROLE, + SCAN_BATCHES_ERRORS.INVALID_ROLE, + ); + } + + if (!inspector.accountActive || !inspector.canSignIn) { + throw new AppError( + 403, + "Inspector account is inactive or cannot sign in", + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + + const project = await prisma.project.findUnique({ + where: { id: data.project_id }, + }); + + if (!project) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.PROJECT_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (!project.isActive) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.PROJECT_INACTIVE, + SCAN_BATCHES_ERRORS.PROJECT_INACTIVE, + ); + } + + const inspectorAssignment = await prisma.userProject.findFirst({ + where: { + userId: data.inspector_id, + projectId: data.project_id, + }, + }); + + if (!inspectorAssignment) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_ASSIGNED, + SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + ); + } + + for (const scan of data.scans) { + const farmer = await prisma.user.findUnique({ + where: { id: scan.farmer_id }, + include: { + role: true, + }, + }); + + if (!farmer) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (farmer.role?.name !== SCAN_BATCHES_ROLES.FARMER) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INVALID_FARMER_ROLE, + SCAN_BATCHES_ERRORS.INVALID_ROLE, + ); + } + + const farmerAssignment = await prisma.userProject.findFirst({ + where: { + userId: scan.farmer_id, + projectId: data.project_id, + }, + }); + + if (!farmerAssignment) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.FARMER_NOT_ASSIGNED, + SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + ); + } + + const species = await prisma.treeType.findUnique({ + where: { id: scan.species_id }, + }); + + if (!species) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.SPECIES_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + const projectSpecies = await prisma.projectTreeType.findFirst({ + where: { + projectId: data.project_id, + treeTypeId: scan.species_id, + }, + }); + + if (!projectSpecies) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.SPECIES_NOT_IN_PROJECT, + SCAN_BATCHES_ERRORS.SPECIES_NOT_IN_PROJECT, + ); + } + + if ( + scan.height_m && + scan.height_m > SCAN_BATCHES_LIMITS.MAX_HEIGHT_M + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + + if ( + scan.diameter_cm && + scan.diameter_cm > SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + + if ( + scan.circumference_cm && + scan.circumference_cm > SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + } + + return prisma.$transaction(async (tx) => { + const scanBatch = await tx.scanBatch.create({ + data: { + inspectorId: data.inspector_id, + projectId: data.project_id, + uploadedAt: data.uploaded_at ?? new Date(), + }, + }); + + await tx.treeScan.createMany({ + data: data.scans.map((scan) => ({ + fobId: scan.fob_id, + projectId: data.project_id, + farmerId: scan.farmer_id, + inspectorId: data.inspector_id, + speciesId: scan.species_id, + estimatedPlantedYear: scan.estimated_planted_year, + estimatedPlantedMonth: scan.estimated_planted_month, + plantedDate: scan.planted_date ?? null, + heightM: scan.height_m ?? null, + diameterCm: scan.diameter_cm ?? null, + circumferenceCm: scan.circumference_cm ?? null, + latitude: scan.latitude ?? null, + longitude: scan.longitude ?? null, + photoId: scan.photo_id ?? null, + deviceId: scan.device_id ?? null, + batchId: scanBatch.id, + })), + }); + + return tx.scanBatch.findUnique({ + where: { + id: scanBatch.id, + }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + treeScans: true, + }, + }); + }); +}; + +// Delete a scan batch only if it has no related tree scans +export const deleteScanBatch = async (id: number) => { + const scanBatch = await prisma.scanBatch.findUnique({ + where: { id }, + include: { + _count: { + select: { + treeScans: true, + }, + }, + }, + }); + + if (!scanBatch) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (scanBatch._count.treeScans > 0) { + throw new AppError( + 409, + SCAN_BATCHES_MESSAGES.DELETE_BLOCKED_HAS_SCANS, + SCAN_BATCHES_ERRORS.DELETE_BLOCKED, + ); + } + + await prisma.scanBatch.delete({ + where: { id }, + }); + + return { + success: true, + message: SCAN_BATCHES_MESSAGES.DELETED, + }; +}; \ No newline at end of file From cdfee0ce28066c39f0f4ae20085fe7158ef0377d Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:54:08 +1000 Subject: [PATCH 05/26] feat(scan-batches): add scan batch controllers --- .../scan-batches/scanBatches.controller.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/modules/scan-batches/scanBatches.controller.ts b/src/modules/scan-batches/scanBatches.controller.ts index e69de29..5eaed4c 100644 --- a/src/modules/scan-batches/scanBatches.controller.ts +++ b/src/modules/scan-batches/scanBatches.controller.ts @@ -0,0 +1,102 @@ +import { Request, Response, NextFunction } from "express"; +import { + createScanBatch, + deleteScanBatch, + getScanBatchById, + getScanBatches, +} from "../services/scanBatches.service"; +import { + createScanBatchSchema, + getScanBatchesQuerySchema, + scanBatchIdParamSchema, +} from "../schemas/scan-batches.schema"; +import { SCAN_BATCHES_MESSAGES } from "../constants/scan-batches.constants"; + +// Handle request to fetch paginated scan batches +export const getScanBatchesController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const query = getScanBatchesQuerySchema.parse(req.query); + + const result = await getScanBatches(query, { + id: req.user!.id, + role: req.user!.role, + }); + + res.status(200).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.FETCHED, + ...result, + }); + } catch (error) { + next(error); + } +}; + +// Handle request to fetch a single scan batch by ID +export const getScanBatchByIdController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { id } = scanBatchIdParamSchema.parse(req.params); + + const scanBatch = await getScanBatchById(id, { + id: req.user!.id, + role: req.user!.role, + }); + + res.status(200).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.FETCHED_ONE, + data: scanBatch, + }); + } catch (error) { + next(error); + } +}; + +// Handle request to create a new scan batch with tree scans +export const createScanBatchController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const validatedData = createScanBatchSchema.parse(req.body); + + const scanBatch = await createScanBatch({ + ...validatedData, + inspector_id: req.user!.id, + }); + + res.status(201).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.CREATED, + data: scanBatch, + }); + } catch (error) { + next(error); + } +}; + +// Handle request to delete a scan batch +export const deleteScanBatchController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { id } = scanBatchIdParamSchema.parse(req.params); + + const result = await deleteScanBatch(id); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; \ No newline at end of file From 6b7b59f1ffc1732a27304e6f3f6b006f0a5db380 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:54:26 +1000 Subject: [PATCH 06/26] docs(scan-batches): add swagger api documentation --- src/modules/scan-batches/scan-batches.docs.ts | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/modules/scan-batches/scan-batches.docs.ts diff --git a/src/modules/scan-batches/scan-batches.docs.ts b/src/modules/scan-batches/scan-batches.docs.ts new file mode 100644 index 0000000..763669b --- /dev/null +++ b/src/modules/scan-batches/scan-batches.docs.ts @@ -0,0 +1,241 @@ +/** + * @swagger + * tags: + * name: Scan Batches + * description: Scan batch upload and management endpoints + */ + +/** + * @swagger + * /scan-batches: + * get: + * summary: Retrieve scan batches + * description: Admin can view all batches. Managers can view batches for assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * example: 1 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * example: 20 + * - in: query + * name: project_id + * schema: + * type: integer + * example: 1 + * - in: query + * name: inspector_id + * schema: + * type: integer + * example: 2 + * responses: + * 200: + * description: Scan batches fetched successfully + * 400: + * description: Invalid query parameters + * 401: + * description: Authentication required + * 403: + * description: Insufficient permissions + */ + +/** + * @swagger + * /scan-batches/{id}: + * get: + * summary: Retrieve a scan batch by ID + * description: Admin can view any batch. Managers can view batches from assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch fetched successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: You do not have permission to access this scan batch + * 404: + * description: Scan batch not found + */ + +/** + * @swagger + * /scan-batches: + * post: + * summary: Upload a new scan batch + * description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Fob recycling is not automatically applied. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - project_id + * - scans + * properties: + * project_id: + * type: integer + * minimum: 1 + * example: 1 + * uploaded_at: + * type: string + * format: date-time + * example: 2024-05-20T10:35:00.000Z + * scans: + * type: array + * minItems: 1 + * maxItems: 500 + * items: + * type: object + * required: + * - fob_id + * - farmer_id + * - species_id + * - estimated_planted_year + * - estimated_planted_month + * properties: + * fob_id: + * type: string + * maxLength: 80 + * example: NFC-001 + * farmer_id: + * type: integer + * minimum: 1 + * example: 10 + * species_id: + * type: integer + * minimum: 1 + * example: 2 + * estimated_planted_year: + * type: integer + * minimum: 1950 + * example: 2024 + * estimated_planted_month: + * type: integer + * minimum: 1 + * maximum: 12 + * example: 5 + * planted_date: + * type: string + * format: date + * example: 2024-05-20 + * height_m: + * type: number + * minimum: 0 + * maximum: 100 + * example: 2.5 + * circumference_cm: + * type: number + * minimum: 0 + * maximum: 4000 + * example: 45.3 + * diameter_cm: + * type: number + * minimum: 0 + * maximum: 1000 + * example: 14.4 + * latitude: + * type: number + * minimum: -90 + * maximum: 90 + * example: -8.5569 + * longitude: + * type: number + * minimum: -180 + * maximum: 180 + * example: 125.5603 + * device_id: + * type: string + * maxLength: 100 + * example: MOB-001 + * photo_id: + * type: string + * format: uuid + * example: 550e8400-e29b-41d4-a716-446655440000 + * example: + * project_id: 1 + * uploaded_at: 2024-05-20T10:35:00.000Z + * scans: + * - fob_id: NFC-001 + * farmer_id: 10 + * species_id: 2 + * estimated_planted_year: 2024 + * estimated_planted_month: 5 + * planted_date: 2024-05-20 + * height_m: 2.5 + * circumference_cm: 45.3 + * diameter_cm: 14.4 + * latitude: -8.5569 + * longitude: 125.5603 + * device_id: MOB-001 + * responses: + * 201: + * description: Scan batch uploaded successfully + * 400: + * description: Validation failed + * 401: + * description: Authentication required + * 403: + * description: User is not allowed to upload this scan batch + * 404: + * description: Inspector, project, farmer, or species not found + * 422: + * description: Business rule validation failed, such as inactive project, farmer not assigned, species not assigned to project, or invalid measurement/date values + */ + +/** + * @swagger + * /scan-batches/{id}: + * delete: + * summary: Delete a scan batch + * description: Admin-only endpoint. A scan batch cannot be deleted if it has related tree scans. This protects historical scan data. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch deleted successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: Only Admin users can delete scan batches + * 404: + * description: Scan batch not found + * 409: + * description: Scan batch cannot be deleted because it has related tree scans + */ \ No newline at end of file From df626d05ce688ebe1d5c9ad5a0b48966acca2c47 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:54:40 +1000 Subject: [PATCH 07/26] feat(scan-batches): register routes and module exports --- src/modules/scan-batches/index.ts | 9 +++ .../scan-batches/scanBatches.routes.ts | 62 +++++++++++++++++++ src/routes/index.ts | 2 + 3 files changed, 73 insertions(+) diff --git a/src/modules/scan-batches/index.ts b/src/modules/scan-batches/index.ts index e69de29..e86f9e4 100644 --- a/src/modules/scan-batches/index.ts +++ b/src/modules/scan-batches/index.ts @@ -0,0 +1,9 @@ +// scan-batches/index.ts + +export * from "./scan-batches.constants"; +export * from "./scan-batches.schema"; + +export * from "./scanBatches.controller"; +export * from "./scanBatches.service"; + +export { default as scanBatchesRoutes } from "./scanBatches.routes"; \ No newline at end of file diff --git a/src/modules/scan-batches/scanBatches.routes.ts b/src/modules/scan-batches/scanBatches.routes.ts index e69de29..183aacf 100644 --- a/src/modules/scan-batches/scanBatches.routes.ts +++ b/src/modules/scan-batches/scanBatches.routes.ts @@ -0,0 +1,62 @@ +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate"; +import { authorize } from "../middleware/authorize"; + +import { + getScanBatchesController, + getScanBatchByIdController, + createScanBatchController, + deleteScanBatchController, +} from "../controllers/scanBatches.controller"; + +import { SCAN_BATCHES_ROLES } from "../constants/scan-batches.constants"; + +import "../docs/scan-batches.docs"; + +const router = Router(); + +// GET /scan-batches +// Admin: all batches, Manager: assigned project batches, Inspector: own batches +router.get( + "/", + authenticate, + authorize( + SCAN_BATCHES_ROLES.ADMIN, + SCAN_BATCHES_ROLES.MANAGER, + SCAN_BATCHES_ROLES.INSPECTOR, + ), + getScanBatchesController, +); + +// GET /scan-batches/:id +// Admin: any batch, Manager: assigned project batch, Inspector: own batch +router.get( + "/:id", + authenticate, + authorize( + SCAN_BATCHES_ROLES.ADMIN, + SCAN_BATCHES_ROLES.MANAGER, + SCAN_BATCHES_ROLES.INSPECTOR, + ), + getScanBatchByIdController, +); + +// POST /scan-batches +// Inspector only: upload a scan batch with related tree scans +router.post( + "/", + authenticate, + authorize(SCAN_BATCHES_ROLES.INSPECTOR), + createScanBatchController, +); + +// DELETE /scan-batches/:id +// Admin only: delete batch only when it has no related tree scans +router.delete( + "/:id", + authenticate, + authorize(SCAN_BATCHES_ROLES.ADMIN), + deleteScanBatchController, +); + +export default router; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 3ad61b6..d6394e3 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,6 +9,7 @@ import { localizationRoutes } from "../modules/localization"; import { adoptersRouter } from "../modules/adopters"; import { userProjectAssignmentRoutes } from "../modules/user-project-assignment"; import { partnersRoutes } from "../modules/partners"; +import { scanBatchesRoutes } from "../modules/scan-batches"; import treeScansRoutes from "../modules/tree-scans"; @@ -24,6 +25,7 @@ router.use("/localized-strings", localizationRoutes); router.use("/user-projects", userProjectAssignmentRoutes); router.use("/project-tree-types", projectTreeTypesRoutes); router.use("/partners", partnersRoutes); +router.use("/scan-batches", scanBatchesRoutes); router.use("/tree-scans", treeScansRoutes); From 9b69f6c0836ae8ff7489e921b541787f6cc68929 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 23:50:14 +1000 Subject: [PATCH 08/26] fix scan batches lint issues and route handlers --- src/modules/scan-batches/index.ts | 2 +- .../scan-batches/scan-batches.constants.ts | 15 +- src/modules/scan-batches/scan-batches.docs.ts | 2 +- .../scan-batches/scan-batches.schema.ts | 42 +- .../scan-batches/scanBatches.controller.ts | 27 +- .../scan-batches/scanBatches.routes.ts | 68 +- .../scan-batches/scanBatches.service.ts | 1535 +---------------- 7 files changed, 111 insertions(+), 1580 deletions(-) diff --git a/src/modules/scan-batches/index.ts b/src/modules/scan-batches/index.ts index e86f9e4..c58607c 100644 --- a/src/modules/scan-batches/index.ts +++ b/src/modules/scan-batches/index.ts @@ -6,4 +6,4 @@ export * from "./scan-batches.schema"; export * from "./scanBatches.controller"; export * from "./scanBatches.service"; -export { default as scanBatchesRoutes } from "./scanBatches.routes"; \ No newline at end of file +export { default as scanBatchesRoutes } from "./scanBatches.routes"; diff --git a/src/modules/scan-batches/scan-batches.constants.ts b/src/modules/scan-batches/scan-batches.constants.ts index 244b89d..5a17d77 100644 --- a/src/modules/scan-batches/scan-batches.constants.ts +++ b/src/modules/scan-batches/scan-batches.constants.ts @@ -34,8 +34,8 @@ export const SCAN_BATCHES_MESSAGES = { ADMIN_DELETE_ONLY: "Only Admin users can delete scan batches", INVALID_PLANTED_DATE: "Planted date cannot be in the future", - INVALID_SCAN_DATE: "Scan timestamp cannot be in the future", - INVALID_PLANTED_YEAR: "Estimated planted year must be between 1950 and the current year", + INVALID_PLANTED_YEAR: + "Estimated planted year must be between 1950 and the current year", INVALID_PLANTED_MONTH: "Estimated planted month must be between 1 and 12", INVALID_MEASUREMENT: "Tree measurement value is outside the allowed range", @@ -65,7 +65,14 @@ export const SCAN_BATCHES_DEFAULTS = { MAX_LIMIT: 100, } as const; -export const SCAN_BATCHES_ROLES = { +export const SCAN_BATCHES_AUTH_ROLES = { + ADMIN: "ADMIN", + MANAGER: "MANAGER", + INSPECTOR: "INSPECTOR", + FARMER: "FARMER", +} as const; + +export const SCAN_BATCHES_DB_ROLES = { ADMIN: "Admin", MANAGER: "Manager", INSPECTOR: "Inspector", @@ -83,4 +90,4 @@ export const SCAN_BATCHES_LIMITS = { FOB_ID_MAX_LENGTH: 80, DEVICE_ID_MAX_LENGTH: 100, -} as const; \ No newline at end of file +} as const; diff --git a/src/modules/scan-batches/scan-batches.docs.ts b/src/modules/scan-batches/scan-batches.docs.ts index 763669b..f1b030d 100644 --- a/src/modules/scan-batches/scan-batches.docs.ts +++ b/src/modules/scan-batches/scan-batches.docs.ts @@ -238,4 +238,4 @@ * description: Scan batch not found * 409: * description: Scan batch cannot be deleted because it has related tree scans - */ \ No newline at end of file + */ diff --git a/src/modules/scan-batches/scan-batches.schema.ts b/src/modules/scan-batches/scan-batches.schema.ts index 3c39b42..5b61601 100644 --- a/src/modules/scan-batches/scan-batches.schema.ts +++ b/src/modules/scan-batches/scan-batches.schema.ts @@ -3,13 +3,15 @@ import { SCAN_BATCHES_DEFAULTS, SCAN_BATCHES_LIMITS, SCAN_BATCHES_MESSAGES, -} from "../constants/scan-batches.constants"; +} from "./scan-batches.constants"; const currentYear = new Date().getFullYear(); const futureDateValidator = (value: string | Date): boolean => { const parsedDate = value instanceof Date ? value : new Date(value); - return !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() <= Date.now(); + return ( + !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() <= Date.now() + ); }; const optionalPositiveNumber = (max: number) => @@ -27,14 +29,23 @@ const scanSchema = z.object({ .min(1, "fob_id is required") .max(SCAN_BATCHES_LIMITS.FOB_ID_MAX_LENGTH), - farmer_id: z.coerce.number().int().positive("farmer_id must be a positive integer"), + farmer_id: z.coerce + .number() + .int() + .positive("farmer_id must be a positive integer"), - species_id: z.coerce.number().int().positive("species_id must be a positive integer"), + species_id: z.coerce + .number() + .int() + .positive("species_id must be a positive integer"), estimated_planted_year: z.coerce .number() .int() - .min(SCAN_BATCHES_LIMITS.MIN_PLANTED_YEAR, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR) + .min( + SCAN_BATCHES_LIMITS.MIN_PLANTED_YEAR, + SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR, + ) .max(currentYear, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR), estimated_planted_month: z.coerce @@ -53,13 +64,19 @@ const scanSchema = z.object({ diameter_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM), - circumference_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM), + circumference_cm: optionalPositiveNumber( + SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM, + ), latitude: z.coerce.number().min(-90).max(90).optional().nullable(), longitude: z.coerce.number().min(-180).max(180).optional().nullable(), - photo_id: z.string().uuid("photo_id must be a valid UUID").optional().nullable(), + photo_id: z + .string() + .uuid("photo_id must be a valid UUID") + .optional() + .nullable(), device_id: z .string() @@ -70,7 +87,10 @@ const scanSchema = z.object({ }); export const createScanBatchSchema = z.object({ - project_id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.PROJECT_REQUIRED), + project_id: z.coerce + .number() + .int() + .positive(SCAN_BATCHES_MESSAGES.PROJECT_REQUIRED), uploaded_at: z.coerce .date() @@ -104,5 +124,7 @@ export const scanBatchIdParamSchema = z.object({ }); export type CreateScanBatchInput = z.infer; -export type GetScanBatchesQueryInput = z.infer; -export type ScanBatchIdParamInput = z.infer; \ No newline at end of file +export type GetScanBatchesQueryInput = z.infer< + typeof getScanBatchesQuerySchema +>; +export type ScanBatchIdParamInput = z.infer; diff --git a/src/modules/scan-batches/scanBatches.controller.ts b/src/modules/scan-batches/scanBatches.controller.ts index 5eaed4c..96fdd40 100644 --- a/src/modules/scan-batches/scanBatches.controller.ts +++ b/src/modules/scan-batches/scanBatches.controller.ts @@ -4,13 +4,20 @@ import { deleteScanBatch, getScanBatchById, getScanBatches, -} from "../services/scanBatches.service"; +} from "./scanBatches.service"; + import { createScanBatchSchema, getScanBatchesQuerySchema, scanBatchIdParamSchema, -} from "../schemas/scan-batches.schema"; -import { SCAN_BATCHES_MESSAGES } from "../constants/scan-batches.constants"; +} from "./scan-batches.schema"; + +import { SCAN_BATCHES_MESSAGES } from "./scan-batches.constants"; + +const getCurrentUser = (req: Request) => ({ + id: Number(req.user?.sub), + role: req.user?.role ?? "", +}); // Handle request to fetch paginated scan batches export const getScanBatchesController = async ( @@ -21,10 +28,7 @@ export const getScanBatchesController = async ( try { const query = getScanBatchesQuerySchema.parse(req.query); - const result = await getScanBatches(query, { - id: req.user!.id, - role: req.user!.role, - }); + const result = await getScanBatches(query, getCurrentUser(req)); res.status(200).json({ success: true, @@ -45,10 +49,7 @@ export const getScanBatchByIdController = async ( try { const { id } = scanBatchIdParamSchema.parse(req.params); - const scanBatch = await getScanBatchById(id, { - id: req.user!.id, - role: req.user!.role, - }); + const scanBatch = await getScanBatchById(id, getCurrentUser(req)); res.status(200).json({ success: true, @@ -71,7 +72,7 @@ export const createScanBatchController = async ( const scanBatch = await createScanBatch({ ...validatedData, - inspector_id: req.user!.id, + inspector_id: getCurrentUser(req).id, }); res.status(201).json({ @@ -99,4 +100,4 @@ export const deleteScanBatchController = async ( } catch (error) { next(error); } -}; \ No newline at end of file +}; diff --git a/src/modules/scan-batches/scanBatches.routes.ts b/src/modules/scan-batches/scanBatches.routes.ts index 183aacf..a385b24 100644 --- a/src/modules/scan-batches/scanBatches.routes.ts +++ b/src/modules/scan-batches/scanBatches.routes.ts @@ -1,62 +1,62 @@ import { Router } from "express"; -import { authenticate } from "../middleware/authenticate"; -import { authorize } from "../middleware/authorize"; +import { authMiddleware } from "../../middleware/auth.middleware"; +import { roleMiddleware } from "../../middleware/role.middleware"; import { getScanBatchesController, getScanBatchByIdController, createScanBatchController, deleteScanBatchController, -} from "../controllers/scanBatches.controller"; +} from "./scanBatches.controller"; -import { SCAN_BATCHES_ROLES } from "../constants/scan-batches.constants"; +import { SCAN_BATCHES_AUTH_ROLES } from "./scan-batches.constants"; -import "../docs/scan-batches.docs"; +import "./scan-batches.docs"; const router = Router(); -// GET /scan-batches -// Admin: all batches, Manager: assigned project batches, Inspector: own batches router.get( "/", - authenticate, - authorize( - SCAN_BATCHES_ROLES.ADMIN, - SCAN_BATCHES_ROLES.MANAGER, - SCAN_BATCHES_ROLES.INSPECTOR, - ), - getScanBatchesController, + authMiddleware, + roleMiddleware([ + SCAN_BATCHES_AUTH_ROLES.ADMIN, + SCAN_BATCHES_AUTH_ROLES.MANAGER, + SCAN_BATCHES_AUTH_ROLES.INSPECTOR, + ]), + (req, res, next) => { + void getScanBatchesController(req, res, next); + }, ); -// GET /scan-batches/:id -// Admin: any batch, Manager: assigned project batch, Inspector: own batch router.get( "/:id", - authenticate, - authorize( - SCAN_BATCHES_ROLES.ADMIN, - SCAN_BATCHES_ROLES.MANAGER, - SCAN_BATCHES_ROLES.INSPECTOR, - ), - getScanBatchByIdController, + authMiddleware, + roleMiddleware([ + SCAN_BATCHES_AUTH_ROLES.ADMIN, + SCAN_BATCHES_AUTH_ROLES.MANAGER, + SCAN_BATCHES_AUTH_ROLES.INSPECTOR, + ]), + (req, res, next) => { + void getScanBatchByIdController(req, res, next); + }, ); -// POST /scan-batches -// Inspector only: upload a scan batch with related tree scans router.post( "/", - authenticate, - authorize(SCAN_BATCHES_ROLES.INSPECTOR), - createScanBatchController, + authMiddleware, + roleMiddleware([SCAN_BATCHES_AUTH_ROLES.INSPECTOR]), + (req, res, next) => { + void createScanBatchController(req, res, next); + }, ); -// DELETE /scan-batches/:id -// Admin only: delete batch only when it has no related tree scans router.delete( "/:id", - authenticate, - authorize(SCAN_BATCHES_ROLES.ADMIN), - deleteScanBatchController, + authMiddleware, + roleMiddleware([SCAN_BATCHES_AUTH_ROLES.ADMIN]), + (req, res, next) => { + void deleteScanBatchController(req, res, next); + }, ); -export default router; \ No newline at end of file +export default router; diff --git a/src/modules/scan-batches/scanBatches.service.ts b/src/modules/scan-batches/scanBatches.service.ts index 973ce05..af48c32 100644 --- a/src/modules/scan-batches/scanBatches.service.ts +++ b/src/modules/scan-batches/scanBatches.service.ts @@ -1,17 +1,18 @@ import { Prisma } from "@prisma/client"; -import prisma from "../config/prisma"; -import { AppError } from "../middleware/errorHandler"; +import { prisma } from "../../lib/prisma"; +import { AppError } from "../../middleware/errorHandler"; import { CreateScanBatchInput, GetScanBatchesQueryInput, -} from "../schemas/scan-batches.schema"; +} from "./scan-batches.schema"; import { + SCAN_BATCHES_AUTH_ROLES, + SCAN_BATCHES_DB_ROLES, SCAN_BATCHES_DEFAULTS, SCAN_BATCHES_ERRORS, SCAN_BATCHES_LIMITS, SCAN_BATCHES_MESSAGES, - SCAN_BATCHES_ROLES, -} from "../constants/scan-batches.constants"; +} from "./scan-batches.constants"; interface CurrentUser { id: number; @@ -41,348 +42,11 @@ export const getScanBatches = async ( where.inspectorId = query.inspector_id; } - if (currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR) { + if (currentUser.role === SCAN_BATCHES_AUTH_ROLES.INSPECTOR) { where.inspectorId = currentUser.id; } - if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { - where.project = { - userProjects: { - some: { - userId: currentUser.id, - }, - }, - }; - } - - const [scanBatches, total] = await Promise.all([ - prisma.scanBatch.findMany({ - where, - skip, - take: limit, - orderBy: { - uploadedAt: "desc", - }, - include: { - inspector: { - select: { - id: true, - name: true, - email: true, - }, - }, - project: { - select: { - id: true, - name: true, - }, - }, - _count: { - select: { - treeScans: true, - }, - }, - }, - }), - prisma.scanBatch.count({ where }), - ]); - - return { - data: scanBatches, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }, - }; -}; - - - -TreeO2 Project Proposal.pdf -PDF -go over it completely and i will give you code for scan batches api just tell if there any major bugs - -Thought for a few seconds -Yes — I’ll check your Scan Batches API against the full TreeO2 spec. - -Main things I’ll look for: - -Inspector/Admin/Manager role access - -batch upload creates scan_batches + related tree_scans - -all scans in one batch use same project_id and inspector - -inspector is active and assigned to the project - -farmer belongs to project - -tree type exists for that project - -dates are not future - -planted month is 1–12 and year is valid - -no destructive delete if dependent scans exist - -proper transactions so partial batch upload cannot happen - -correct status codes and error format - -The spec says /scan-batches should support GET, GET by id, POST by Inspector, and DELETE by Admin, with batch upload validation rules for same scanner/project and historical scan preservation. - - -Send me the code. - - -constants: -export const SCAN_BATCHES_MESSAGES = { - FETCHED: "Scan batches fetched successfully", - FETCHED_ONE: "Scan batch fetched successfully", - CREATED: "Scan batch uploaded successfully", - DELETED: "Scan batch deleted successfully", - - NOT_FOUND: "Scan batch not found", - INVALID_ID: "Invalid scan batch ID", - - CREATE_FAILED: "Failed to create scan batch", - DELETE_FAILED: "Failed to delete scan batch", - - INVALID_SCANS_ARRAY: "Scans must be provided as a non-empty array", - - INSPECTOR_REQUIRED: "Inspector ID is required", - PROJECT_REQUIRED: "Project ID is required", - - INSPECTOR_NOT_FOUND: "Inspector not found", - PROJECT_NOT_FOUND: "Project not found", - PROJECT_INACTIVE: "Project is not active and cannot accept scan uploads", - - INVALID_INSPECTOR_ROLE: "User must have Inspector role", - INVALID_FARMER_ROLE: "Selected farmer_id must belong to a Farmer user", - - FARMER_NOT_FOUND: "Farmer not found", - FARMER_NOT_ASSIGNED: "Farmer is not assigned to the selected project", - - SPECIES_NOT_FOUND: "Tree species not found", - SPECIES_NOT_IN_PROJECT: "Tree species is not assigned to this project", - - INSPECTOR_NOT_ASSIGNED: "Inspector is not assigned to the selected project", - - UNAUTHORIZED_ACCESS: "You do not have permission to access this scan batch", - ADMIN_DELETE_ONLY: "Only Admin users can delete scan batches", - - INVALID_PLANTED_DATE: "Planted date cannot be in the future", - INVALID_SCAN_DATE: "Scan timestamp cannot be in the future", - INVALID_PLANTED_YEAR: "Estimated planted year must be between 1950 and the current year", - INVALID_PLANTED_MONTH: "Estimated planted month must be between 1 and 12", - INVALID_MEASUREMENT: "Tree measurement value is outside the allowed range", - - DELETE_BLOCKED_HAS_SCANS: - "Scan batch cannot be deleted because it has related tree scans", -} as const; - -export const SCAN_BATCHES_ERRORS = { - VALIDATION_ERROR: "VALIDATION_ERROR", - NOT_FOUND: "SCAN_BATCH_NOT_FOUND", - FORBIDDEN: "SCAN_BATCH_FORBIDDEN", - CREATE_FAILED: "SCAN_BATCH_CREATE_FAILED", - DELETE_FAILED: "SCAN_BATCH_DELETE_FAILED", - DELETE_BLOCKED: "SCAN_BATCH_DELETE_BLOCKED", - - PROJECT_INACTIVE: "PROJECT_INACTIVE", - INVALID_ROLE: "INVALID_ROLE", - NOT_ASSIGNED: "NOT_ASSIGNED_TO_PROJECT", - SPECIES_NOT_IN_PROJECT: "SPECIES_NOT_IN_PROJECT", - INVALID_DATE: "INVALID_DATE", - INVALID_MEASUREMENT: "INVALID_MEASUREMENT", -} as const; - -export const SCAN_BATCHES_DEFAULTS = { - PAGE: 1, - LIMIT: 20, - MAX_LIMIT: 100, -} as const; - -export const SCAN_BATCHES_ROLES = { - ADMIN: "Admin", - MANAGER: "Manager", - INSPECTOR: "Inspector", - FARMER: "Farmer", -} as const; - -export const SCAN_BATCHES_LIMITS = { - MAX_SCANS_PER_BATCH: 500, - - MIN_PLANTED_YEAR: 1950, - - MAX_HEIGHT_M: 100, - MAX_DIAMETER_CM: 1000, - MAX_CIRCUMFERENCE_CM: 4000, - - FOB_ID_MAX_LENGTH: 80, - DEVICE_ID_MAX_LENGTH: 100, -} as const; - -schemas: -import { z } from "zod"; -import { - SCAN_BATCHES_DEFAULTS, - SCAN_BATCHES_LIMITS, - SCAN_BATCHES_MESSAGES, -} from "../constants/scan-batches.constants"; - -const currentYear = new Date().getFullYear(); - -const futureDateValidator = (value: string | Date): boolean => { - const parsedDate = value instanceof Date ? value : new Date(value); - return !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() <= Date.now(); -}; - -const optionalPositiveNumber = (max: number) => - z.coerce - .number() - .positive() - .max(max, SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT) - .optional() - .nullable(); - -const scanSchema = z.object({ - fob_id: z - .string() - .trim() - .min(1, "fob_id is required") - .max(SCAN_BATCHES_LIMITS.FOB_ID_MAX_LENGTH), - - farmer_id: z.coerce.number().int().positive("farmer_id must be a positive integer"), - - species_id: z.coerce.number().int().positive("species_id must be a positive integer"), - - estimated_planted_year: z.coerce - .number() - .int() - .min(SCAN_BATCHES_LIMITS.MIN_PLANTED_YEAR, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR) - .max(currentYear, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR), - - estimated_planted_month: z.coerce - .number() - .int() - .min(1, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH) - .max(12, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH), - - planted_date: z.coerce - .date() - .refine(futureDateValidator, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_DATE) - .optional() - .nullable(), - - height_m: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_HEIGHT_M), - - diameter_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM), - - circumference_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM), - - latitude: z.coerce.number().min(-90).max(90).optional().nullable(), - - longitude: z.coerce.number().min(-180).max(180).optional().nullable(), - - photo_id: z.string().uuid("photo_id must be a valid UUID").optional().nullable(), - - device_id: z - .string() - .trim() - .max(SCAN_BATCHES_LIMITS.DEVICE_ID_MAX_LENGTH) - .optional() - .nullable(), -}); - -export const createScanBatchSchema = z.object({ - project_id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.PROJECT_REQUIRED), - - uploaded_at: z.coerce - .date() - .refine(futureDateValidator, "Uploaded date cannot be in the future") - .optional() - .nullable(), - - scans: z - .array(scanSchema) - .min(1, SCAN_BATCHES_MESSAGES.INVALID_SCANS_ARRAY) - .max(SCAN_BATCHES_LIMITS.MAX_SCANS_PER_BATCH), -}); - -export const getScanBatchesQuerySchema = z.object({ - page: z.coerce.number().int().positive().default(SCAN_BATCHES_DEFAULTS.PAGE), - - limit: z.coerce - .number() - .int() - .positive() - .max(SCAN_BATCHES_DEFAULTS.MAX_LIMIT) - .default(SCAN_BATCHES_DEFAULTS.LIMIT), - - project_id: z.coerce.number().int().positive().optional(), - - inspector_id: z.coerce.number().int().positive().optional(), -}); - -export const scanBatchIdParamSchema = z.object({ - id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.INVALID_ID), -}); - -export type CreateScanBatchInput = z.infer; -export type GetScanBatchesQueryInput = z.infer; -export type ScanBatchIdParamInput = z.infer; - -service: -import { Prisma } from "@prisma/client"; -import prisma from "../config/prisma"; -import { AppError } from "../middleware/errorHandler"; -import { - CreateScanBatchInput, - GetScanBatchesQueryInput, -} from "../schemas/scan-batches.schema"; -import { - SCAN_BATCHES_DEFAULTS, - SCAN_BATCHES_ERRORS, - SCAN_BATCHES_LIMITS, - SCAN_BATCHES_MESSAGES, - SCAN_BATCHES_ROLES, -} from "../constants/scan-batches.constants"; - -interface CurrentUser { - id: number; - role: string; -} - -type CreateScanBatchServiceInput = CreateScanBatchInput & { - inspector_id: number; -}; - -export const getScanBatches = async ( - query: GetScanBatchesQueryInput, - currentUser: CurrentUser, -) => { - const page = query.page || SCAN_BATCHES_DEFAULTS.PAGE; - const limit = query.limit || SCAN_BATCHES_DEFAULTS.LIMIT; - const skip = (page - 1) * limit; - - const where: Prisma.ScanBatchWhereInput = {}; - - if (query.project_id) { - where.projectId = query.project_id; - } - - if (query.inspector_id) { - where.inspectorId = query.inspector_id; - } - - if (currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR) { - where.inspectorId = currentUser.id; - } - - if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { + if (currentUser.role === SCAN_BATCHES_AUTH_ROLES.MANAGER) { where.project = { userProjects: { some: { @@ -435,6 +99,7 @@ export const getScanBatches = async ( }; }; +// Retrieve a single scan batch with role-based access validation export const getScanBatchById = async ( id: number, currentUser: CurrentUser, @@ -472,7 +137,7 @@ export const getScanBatchById = async ( } if ( - currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR && + currentUser.role === SCAN_BATCHES_AUTH_ROLES.INSPECTOR && scanBatch.inspectorId !== currentUser.id ) { throw new AppError( @@ -482,7 +147,7 @@ export const getScanBatchById = async ( ); } - if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { + if (currentUser.role === SCAN_BATCHES_AUTH_ROLES.MANAGER) { const hasAccess = await prisma.userProject.findFirst({ where: { userId: currentUser.id, @@ -502,11 +167,12 @@ export const getScanBatchById = async ( return scanBatch; }; +// Validate and create a scan batch with related tree scans export const createScanBatch = async (data: CreateScanBatchServiceInput) => { const inspector = await prisma.user.findUnique({ where: { id: data.inspector_id }, include: { - role: true, + primaryRole: true, }, }); @@ -518,7 +184,7 @@ export const createScanBatch = async (data: CreateScanBatchServiceInput) => { ); } - if (inspector.role?.name !== SCAN_BATCHES_ROLES.INSPECTOR) { + if (inspector.primaryRole?.name !== SCAN_BATCHES_DB_ROLES.INSPECTOR) { throw new AppError( 403, SCAN_BATCHES_MESSAGES.INVALID_INSPECTOR_ROLE, @@ -573,7 +239,7 @@ export const createScanBatch = async (data: CreateScanBatchServiceInput) => { const farmer = await prisma.user.findUnique({ where: { id: scan.farmer_id }, include: { - role: true, + primaryRole: true, }, }); @@ -585,7 +251,7 @@ export const createScanBatch = async (data: CreateScanBatchServiceInput) => { ); } - if (farmer.role?.name !== SCAN_BATCHES_ROLES.FARMER) { + if (farmer.primaryRole?.name !== SCAN_BATCHES_DB_ROLES.FARMER) { throw new AppError( 403, SCAN_BATCHES_MESSAGES.INVALID_FARMER_ROLE, @@ -635,10 +301,7 @@ export const createScanBatch = async (data: CreateScanBatchServiceInput) => { ); } - if ( - scan.height_m && - scan.height_m > SCAN_BATCHES_LIMITS.MAX_HEIGHT_M - ) { + if (scan.height_m && scan.height_m > SCAN_BATCHES_LIMITS.MAX_HEIGHT_M) { throw new AppError( 422, SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, @@ -723,6 +386,7 @@ export const createScanBatch = async (data: CreateScanBatchServiceInput) => { }); }; +// Delete a scan batch only when it has no related tree scans export const deleteScanBatch = async (id: number) => { const scanBatch = await prisma.scanBatch.findUnique({ where: { id }, @@ -760,1166 +424,3 @@ export const deleteScanBatch = async (id: number) => { message: SCAN_BATCHES_MESSAGES.DELETED, }; }; - -controller: -// controllers/scanBatches.controller.ts - -import { Request, Response, NextFunction } from "express"; -import { - createScanBatch, - deleteScanBatch, - getScanBatchById, - getScanBatches, -} from "../services/scanBatches.service"; -import { - createScanBatchSchema, - getScanBatchesQuerySchema, - scanBatchIdParamSchema, -} from "../schemas/scan-batches.schema"; -import { SCAN_BATCHES_MESSAGES } from "../constants/scan-batches.constants"; - -export const getScanBatchesController = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const query = getScanBatchesQuerySchema.parse(req.query); - - const result = await getScanBatches(query, { - id: req.user!.id, - role: req.user!.role, - }); - - res.status(200).json({ - success: true, - message: SCAN_BATCHES_MESSAGES.FETCHED, - ...result, - }); - } catch (error) { - next(error); - } -}; - -export const getScanBatchByIdController = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { id } = scanBatchIdParamSchema.parse(req.params); - - const scanBatch = await getScanBatchById(id, { - id: req.user!.id, - role: req.user!.role, - }); - - res.status(200).json({ - success: true, - message: SCAN_BATCHES_MESSAGES.FETCHED_ONE, - data: scanBatch, - }); - } catch (error) { - next(error); - } -}; - -export const createScanBatchController = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const validatedData = createScanBatchSchema.parse(req.body); - - const scanBatch = await createScanBatch({ - ...validatedData, - inspector_id: req.user!.id, - }); - - res.status(201).json({ - success: true, - message: SCAN_BATCHES_MESSAGES.CREATED, - data: scanBatch, - }); - } catch (error) { - next(error); - } -}; - -export const deleteScanBatchController = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { id } = scanBatchIdParamSchema.parse(req.params); - - const result = await deleteScanBatch(id); - - res.status(200).json(result); - } catch (error) { - next(error); - } -}; - -Thought for a few seconds -Overall: no major blocking bug in the basic flow. It is mostly solid. But I found 3 important issues you should fix. - -Major issues -Manager role can maybe create scan batch if route allows it -Your service forces inspector_id: req.user!.id, then checks that user role is Inspector. So Manager/Admin will fail in service, but better to block this clearly in route middleware. Spec says /scan-batches POST is for Inspector only. - - -Batch validation is not inside the transaction -You validate farmers/species/project assignments before transaction, then create batch and scans inside transaction. This is mostly okay, but there is still a small race-condition risk if assignments/project status change between validation and insert. Not huge for capstone, but better practice is to put validation + creation in the same transaction. - -Possible Prisma relation mismatch -You use: - -include: { role: true } -and: - -inspector.role?.name -This only works if your User model has a single relation called role. But your spec has both role integer and user_roles junction table. If your actual Prisma schema uses roleId relation, fine. If it uses many-to-many roles, this will break. - -Medium issues -You do not validate scan_timestamp, but the spec mentions scan timestamp cannot be future. - -uploaded_at is allowed from request body. That is okay, but normally backend should control upload time. - -deleteScanBatch has no role check inside service. If route middleware is missing, anyone authenticated could delete. Make sure route has Admin-only middleware. - -You check measurements twice: Zod already checks max, then service checks again. Not harmful, just duplicated. - -optionalPositiveNumber() rejects 0. If a measurement can be zero during early/unknown scan, this may be too strict. If measurements are optional, it is okay. - -Good things -Uses pagination. - -Inspector can only see own batches. - -Manager access is project-limited. - -Batch creation uses transaction for insert. - -Blocks deletion when tree scans exist. - -Validates farmer assignment, inspector assignment, tree species assignment, active project, future dates, planted year/month. - -Verdict: good implementation, not risky, but I would fix role middleware, Prisma role relation confirmation, and Admin-only delete route before approving. - - -docs: -/** - * @swagger - * tags: - * name: Scan Batches - * description: Scan batch upload and management endpoints - */ - -/** - * @swagger - * /scan-batches: - * get: - * summary: Retrieve scan batches - * description: Admin can view all batches. Managers can view batches for assigned projects. Inspectors can view only their own batches. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * minimum: 1 - * example: 1 - * - in: query - * name: limit - * schema: - * type: integer - * minimum: 1 - * maximum: 100 - * example: 20 - * - in: query - * name: project_id - * schema: - * type: integer - * example: 1 - * - in: query - * name: inspector_id - * schema: - * type: integer - * example: 2 - * responses: - * 200: - * description: Scan batches fetched successfully - * 400: - * description: Invalid query parameters - * 401: - * description: Authentication required - * 403: - * description: Insufficient permissions - */ - -/** - * @swagger - * /scan-batches/{id}: - * get: - * summary: Retrieve a scan batch by ID - * description: Admin can view any batch. Managers can view batches from assigned projects. Inspectors can view only their own batches. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * minimum: 1 - * example: 1 - * responses: - * 200: - * description: Scan batch fetched successfully - * 400: - * description: Invalid scan batch ID - * 401: - * description: Authentication required - * 403: - * description: You do not have permission to access this scan batch - * 404: - * description: Scan batch not found - */ - -/** - * @swagger - * /scan-batches: - * post: - * summary: Upload a new scan batch - * description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Duplicate fob_id values inside the same batch upload are rejected to prevent accidental duplicate submissions. Fob recycling is not automatically applied. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - inspector_id - * - project_id - * - scans - * properties: - * inspector_id: - * type: integer - * minimum: 1 - * example: 2 - * project_id: - * type: integer - * minimum: 1 - * example: 1 - * uploaded_at: - * type: string - * format: date-time - * example: 2024-05-20T10:35:00.000Z - * scans: - * type: array - * minItems: 1 - * maxItems: 500 - * items: - * type: object - * required: - * - fob_id - * - farmer_id - * - species_id - * - estimated_planted_year - * - estimated_planted_month - * properties: - * fob_id: - * type: string - * maxLength: 80 - * example: NFC-001 - * farmer_id: - * type: integer - * minimum: 1 - * example: 10 - * species_id: - * type: integer - * minimum: 1 - * example: 2 - * estimated_planted_year: - * type: integer - * minimum: 1950 - * example: 2024 - * estimated_planted_month: - * type: integer - * minimum: 1 - * maximum: 12 - * example: 5 - * planted_date: - * type: string - * format: date - * example: 2024-05-20 - * height_m: - * type: number - * minimum: 0 - * maximum: 100 - * example: 2.5 - * circumference_cm: - * type: number - * minimum: 0 - * maximum: 4000 - * example: 45.3 - * diameter_cm: - * type: number - * minimum: 0 - * maximum: 1000 - * example: 14.4 - * latitude: - * type: number - * minimum: -90 - * maximum: 90 - * example: -8.5569 - * longitude: - * type: number - * minimum: -180 - * maximum: 180 - * example: 125.5603 - * device_id: - * type: string - * maxLength: 100 - * example: MOB-001 - * photo_id: - * type: string - * format: uuid - * example: 550e8400-e29b-41d4-a716-446655440000 - * example: - * inspector_id: 2 - * project_id: 1 - * uploaded_at: 2024-05-20T10:35:00.000Z - * scans: - * - fob_id: NFC-001 - * farmer_id: 10 - * species_id: 2 - * estimated_planted_year: 2024 - * estimated_planted_month: 5 - * planted_date: 2024-05-20 - * height_m: 2.5 - * circumference_cm: 45.3 - * diameter_cm: 14.4 - * latitude: -8.5569 - * longitude: 125.5603 - * device_id: MOB-001 - * responses: - * 201: - * description: Scan batch uploaded successfully - * 400: - * description: Validation failed - * 401: - * description: Authentication required - * 403: - * description: User is not allowed to upload this scan batch - * 404: - * description: Inspector, project, farmer, or species not found - * 409: - * description: Duplicate fob_id found within the same batch upload - * 422: - * description: Business rule validation failed, such as inactive project, farmer not assigned, species not assigned to project, or invalid measurement/date values - */ - -/** - * @swagger - * /scan-batches/{id}: - * delete: - * summary: Delete a scan batch - * description: Admin-only endpoint. A scan batch cannot be deleted if it has related tree scans. This protects historical scan data. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * minimum: 1 - * example: 1 - * responses: - * 200: - * description: Scan batch deleted successfully - * 400: - * description: Invalid scan batch ID - * 401: - * description: Authentication required - * 403: - * description: Only Admin users can delete scan batches - * 404: - * description: Scan batch not found - * 409: - * description: Scan batch cannot be deleted because it has related tree scans - */ - -routes: -import { Router } from "express"; -import { authenticate } from "../middleware/authenticate"; -import { authorize } from "../middleware/authorize"; - -import { - getScanBatchesController, - getScanBatchByIdController, - createScanBatchController, - deleteScanBatchController, -} from "../controllers/scanBatches.controller"; - -import { SCAN_BATCHES_ROLES } from "../constants/scan-batches.constants"; - -import "../docs/scan-batches.docs"; - -const router = Router(); - -// GET /scan-batches -// Admin: all batches, Manager: assigned project batches, Inspector: own batches -router.get( - "/", - authenticate, - authorize( - SCAN_BATCHES_ROLES.ADMIN, - SCAN_BATCHES_ROLES.MANAGER, - SCAN_BATCHES_ROLES.INSPECTOR, - ), - getScanBatchesController, -); - -// GET /scan-batches/:id -// Admin: any batch, Manager: assigned project batch, Inspector: own batch -router.get( - "/:id", - authenticate, - authorize( - SCAN_BATCHES_ROLES.ADMIN, - SCAN_BATCHES_ROLES.MANAGER, - SCAN_BATCHES_ROLES.INSPECTOR, - ), - getScanBatchByIdController, -); - -// POST /scan-batches -// Inspector only: upload a scan batch with related tree scans -router.post( - "/", - authenticate, - authorize(SCAN_BATCHES_ROLES.INSPECTOR), - createScanBatchController, -); - -// DELETE /scan-batches/:id -// Admin only: delete batch only when it has no related tree scans -router.delete( - "/:id", - authenticate, - authorize(SCAN_BATCHES_ROLES.ADMIN), - deleteScanBatchController, -); - -export default router; - -Good — the routes fix the biggest role issue. Now POST is Inspector-only and DELETE is Admin-only. - -Major issue left: Swagger docs do not match your actual API. - -In your controller, inspector_id comes from: - -inspector_id: req.user!.id -So the request body does not accept inspector_id. But your Swagger says inspector_id is required. Remove inspector_id from docs request body. - -Also this line in docs is wrong: - -Duplicate fob_id values inside the same batch upload are rejected - -Your schema/service does not reject duplicate fob_id inside the batch. Either remove that sentence + 409 response, or add duplicate checking in schema/service. - -Recommended quick fix in docs: - -required: - - project_id - - scans -Remove: - -inspector_id: - type: integer -Remove this response unless you implement it: - -409: - description: Duplicate fob_id found within the same batch upload -Everything else looks aligned: GET access, GET by ID access, POST Inspector-only, DELETE Admin-only, and delete blocked when related scans exist. - - -tell me exactly where to make changes - -Make changes only in docs/scan-batches.docs.ts. - -1. In POST /scan-batches, remove inspector_id from required -Find: - -required: - - inspector_id - - project_id - - scans -Change to: - -required: - - project_id - - scans -2. Remove inspector_id property -Delete this block: - -inspector_id: - type: integer - minimum: 1 - example: 2 -3. Remove inspector_id from example -Find: - -example: - inspector_id: 2 - project_id: 1 -Change to: - -example: - project_id: 1 -4. Fix duplicate fob sentence -Find this in description: - -Duplicate fob_id values inside the same batch upload are rejected to prevent accidental duplicate submissions. -Delete that sentence. - -5. Remove 409 response -Delete this block: - -409: - description: Duplicate fob_id found within the same batch upload -That’s it. Your routes are fine. - - -Almost done. You still need to remove two duplicate-fob references. - -1. Change POST description -Find this line: - -* description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Duplicate fob_id values inside the same batch upload are rejected to prevent accidental duplicate submissions. Fob recycling is not automatically applied. -Change to: - -* description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Fob recycling is not automatically applied. -2. Remove POST 409 response -Delete this block under POST responses: - -* 409: -* description: Duplicate fob_id found within the same batch upload -Keep the DELETE 409 response. That one is correct. - - -/** - * @swagger - * tags: - * name: Scan Batches - * description: Scan batch upload and management endpoints - */ - -/** - * @swagger - * /scan-batches: - * get: - * summary: Retrieve scan batches - * description: Admin can view all batches. Managers can view batches for assigned projects. Inspectors can view only their own batches. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * minimum: 1 - * example: 1 - * - in: query - * name: limit - * schema: - * type: integer - * minimum: 1 - * maximum: 100 - * example: 20 - * - in: query - * name: project_id - * schema: - * type: integer - * example: 1 - * - in: query - * name: inspector_id - * schema: - * type: integer - * example: 2 - * responses: - * 200: - * description: Scan batches fetched successfully - * 400: - * description: Invalid query parameters - * 401: - * description: Authentication required - * 403: - * description: Insufficient permissions - */ - -/** - * @swagger - * /scan-batches/{id}: - * get: - * summary: Retrieve a scan batch by ID - * description: Admin can view any batch. Managers can view batches from assigned projects. Inspectors can view only their own batches. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * minimum: 1 - * example: 1 - * responses: - * 200: - * description: Scan batch fetched successfully - * 400: - * description: Invalid scan batch ID - * 401: - * description: Authentication required - * 403: - * description: You do not have permission to access this scan batch - * 404: - * description: Scan batch not found - */ - -/** - * @swagger - * /scan-batches: - * post: - * summary: Upload a new scan batch - * description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Fob recycling is not automatically applied. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - project_id - * - scans - * properties: - * project_id: - * type: integer - * minimum: 1 - * example: 1 - * uploaded_at: - * type: string - * format: date-time - * example: 2024-05-20T10:35:00.000Z - * scans: - * type: array - * minItems: 1 - * maxItems: 500 - * items: - * type: object - * required: - * - fob_id - * - farmer_id - * - species_id - * - estimated_planted_year - * - estimated_planted_month - * properties: - * fob_id: - * type: string - * maxLength: 80 - * example: NFC-001 - * farmer_id: - * type: integer - * minimum: 1 - * example: 10 - * species_id: - * type: integer - * minimum: 1 - * example: 2 - * estimated_planted_year: - * type: integer - * minimum: 1950 - * example: 2024 - * estimated_planted_month: - * type: integer - * minimum: 1 - * maximum: 12 - * example: 5 - * planted_date: - * type: string - * format: date - * example: 2024-05-20 - * height_m: - * type: number - * minimum: 0 - * maximum: 100 - * example: 2.5 - * circumference_cm: - * type: number - * minimum: 0 - * maximum: 4000 - * example: 45.3 - * diameter_cm: - * type: number - * minimum: 0 - * maximum: 1000 - * example: 14.4 - * latitude: - * type: number - * minimum: -90 - * maximum: 90 - * example: -8.5569 - * longitude: - * type: number - * minimum: -180 - * maximum: 180 - * example: 125.5603 - * device_id: - * type: string - * maxLength: 100 - * example: MOB-001 - * photo_id: - * type: string - * format: uuid - * example: 550e8400-e29b-41d4-a716-446655440000 - * example: - * project_id: 1 - * uploaded_at: 2024-05-20T10:35:00.000Z - * scans: - * - fob_id: NFC-001 - * farmer_id: 10 - * species_id: 2 - * estimated_planted_year: 2024 - * estimated_planted_month: 5 - * planted_date: 2024-05-20 - * height_m: 2.5 - * circumference_cm: 45.3 - * diameter_cm: 14.4 - * latitude: -8.5569 - * longitude: 125.5603 - * device_id: MOB-001 - * responses: - * 201: - * description: Scan batch uploaded successfully - * 400: - * description: Validation failed - * 401: - * description: Authentication required - * 403: - * description: User is not allowed to upload this scan batch - * 404: - * description: Inspector, project, farmer, or species not found - * 422: - * description: Business rule validation failed, such as inactive project, farmer not assigned, species not assigned to project, or invalid measurement/date values - */ - -/** - * @swagger - * /scan-batches/{id}: - * delete: - * summary: Delete a scan batch - * description: Admin-only endpoint. A scan batch cannot be deleted if it has related tree scans. This protects historical scan data. - * tags: [Scan Batches] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * minimum: 1 - * example: 1 - * responses: - * 200: - * description: Scan batch deleted successfully - * 400: - * description: Invalid scan batch ID - * 401: - * description: Authentication required - * 403: - * description: Only Admin users can delete scan batches - * 404: - * description: Scan batch not found - * 409: - * description: Scan batch cannot be deleted because it has related tree scans - */ - -Yes — now the docs are aligned with your implementation. - -At this point the Scan Batches module looks good overall: - -routes aligned with RBAC - -Swagger aligned with actual controller behavior - -validations mostly match business rules - -delete protection implemented - -pagination/access control implemented correctly - -Only remaining improvement areas are optional/polish level: - -move validations into transaction - -maybe optimize N+1 queries inside loop - -add duplicate fob_id prevention if product owner wants it - -add scan timestamp field later if required by schema - -No major architectural bug left from what you shared. - - -everything good? -if yes. now give single line comments for service file for each endpoint - -// Fetch paginated scan batches with role-based access filtering -export const getScanBatches = async ( - -// Retrieve a single scan batch with access control validation -export const getScanBatchById = async ( - id: number, - currentUser: CurrentUser, -) => { - const scanBatch = await prisma.scanBatch.findUnique({ - where: { id }, - include: { - inspector: { - select: { - id: true, - name: true, - email: true, - }, - }, - project: { - select: { - id: true, - name: true, - }, - }, - treeScans: { - orderBy: { - createdAt: "desc", - }, - }, - }, - }); - - if (!scanBatch) { - throw new AppError( - 404, - SCAN_BATCHES_MESSAGES.NOT_FOUND, - SCAN_BATCHES_ERRORS.NOT_FOUND, - ); - } - - if ( - currentUser.role === SCAN_BATCHES_ROLES.INSPECTOR && - scanBatch.inspectorId !== currentUser.id - ) { - throw new AppError( - 403, - SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, - SCAN_BATCHES_ERRORS.FORBIDDEN, - ); - } - - if (currentUser.role === SCAN_BATCHES_ROLES.MANAGER) { - const hasAccess = await prisma.userProject.findFirst({ - where: { - userId: currentUser.id, - projectId: scanBatch.projectId, - }, - }); - - if (!hasAccess) { - throw new AppError( - 403, - SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, - SCAN_BATCHES_ERRORS.FORBIDDEN, - ); - } - } - - return scanBatch; -}; - -// Validate and create a scan batch with related tree scans in a transaction -export const createScanBatch = async (data: CreateScanBatchServiceInput) => { - const inspector = await prisma.user.findUnique({ - where: { id: data.inspector_id }, - include: { - role: true, - }, - }); - - if (!inspector) { - throw new AppError( - 404, - SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_FOUND, - SCAN_BATCHES_ERRORS.NOT_FOUND, - ); - } - - if (inspector.role?.name !== SCAN_BATCHES_ROLES.INSPECTOR) { - throw new AppError( - 403, - SCAN_BATCHES_MESSAGES.INVALID_INSPECTOR_ROLE, - SCAN_BATCHES_ERRORS.INVALID_ROLE, - ); - } - - if (!inspector.accountActive || !inspector.canSignIn) { - throw new AppError( - 403, - "Inspector account is inactive or cannot sign in", - SCAN_BATCHES_ERRORS.FORBIDDEN, - ); - } - - const project = await prisma.project.findUnique({ - where: { id: data.project_id }, - }); - - if (!project) { - throw new AppError( - 404, - SCAN_BATCHES_MESSAGES.PROJECT_NOT_FOUND, - SCAN_BATCHES_ERRORS.NOT_FOUND, - ); - } - - if (!project.isActive) { - throw new AppError( - 422, - SCAN_BATCHES_MESSAGES.PROJECT_INACTIVE, - SCAN_BATCHES_ERRORS.PROJECT_INACTIVE, - ); - } - - const inspectorAssignment = await prisma.userProject.findFirst({ - where: { - userId: data.inspector_id, - projectId: data.project_id, - }, - }); - - if (!inspectorAssignment) { - throw new AppError( - 403, - SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_ASSIGNED, - SCAN_BATCHES_ERRORS.NOT_ASSIGNED, - ); - } - - for (const scan of data.scans) { - const farmer = await prisma.user.findUnique({ - where: { id: scan.farmer_id }, - include: { - role: true, - }, - }); - - if (!farmer) { - throw new AppError( - 404, - SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, - SCAN_BATCHES_ERRORS.NOT_FOUND, - ); - } - - if (farmer.role?.name !== SCAN_BATCHES_ROLES.FARMER) { - throw new AppError( - 403, - SCAN_BATCHES_MESSAGES.INVALID_FARMER_ROLE, - SCAN_BATCHES_ERRORS.INVALID_ROLE, - ); - } - - const farmerAssignment = await prisma.userProject.findFirst({ - where: { - userId: scan.farmer_id, - projectId: data.project_id, - }, - }); - - if (!farmerAssignment) { - throw new AppError( - 403, - SCAN_BATCHES_MESSAGES.FARMER_NOT_ASSIGNED, - SCAN_BATCHES_ERRORS.NOT_ASSIGNED, - ); - } - - const species = await prisma.treeType.findUnique({ - where: { id: scan.species_id }, - }); - - if (!species) { - throw new AppError( - 404, - SCAN_BATCHES_MESSAGES.SPECIES_NOT_FOUND, - SCAN_BATCHES_ERRORS.NOT_FOUND, - ); - } - - const projectSpecies = await prisma.projectTreeType.findFirst({ - where: { - projectId: data.project_id, - treeTypeId: scan.species_id, - }, - }); - - if (!projectSpecies) { - throw new AppError( - 403, - SCAN_BATCHES_MESSAGES.SPECIES_NOT_IN_PROJECT, - SCAN_BATCHES_ERRORS.SPECIES_NOT_IN_PROJECT, - ); - } - - if ( - scan.height_m && - scan.height_m > SCAN_BATCHES_LIMITS.MAX_HEIGHT_M - ) { - throw new AppError( - 422, - SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, - SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, - ); - } - - if ( - scan.diameter_cm && - scan.diameter_cm > SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM - ) { - throw new AppError( - 422, - SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, - SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, - ); - } - - if ( - scan.circumference_cm && - scan.circumference_cm > SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM - ) { - throw new AppError( - 422, - SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, - SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, - ); - } - } - - return prisma.$transaction(async (tx) => { - const scanBatch = await tx.scanBatch.create({ - data: { - inspectorId: data.inspector_id, - projectId: data.project_id, - uploadedAt: data.uploaded_at ?? new Date(), - }, - }); - - await tx.treeScan.createMany({ - data: data.scans.map((scan) => ({ - fobId: scan.fob_id, - projectId: data.project_id, - farmerId: scan.farmer_id, - inspectorId: data.inspector_id, - speciesId: scan.species_id, - estimatedPlantedYear: scan.estimated_planted_year, - estimatedPlantedMonth: scan.estimated_planted_month, - plantedDate: scan.planted_date ?? null, - heightM: scan.height_m ?? null, - diameterCm: scan.diameter_cm ?? null, - circumferenceCm: scan.circumference_cm ?? null, - latitude: scan.latitude ?? null, - longitude: scan.longitude ?? null, - photoId: scan.photo_id ?? null, - deviceId: scan.device_id ?? null, - batchId: scanBatch.id, - })), - }); - - return tx.scanBatch.findUnique({ - where: { - id: scanBatch.id, - }, - include: { - inspector: { - select: { - id: true, - name: true, - email: true, - }, - }, - project: { - select: { - id: true, - name: true, - }, - }, - treeScans: true, - }, - }); - }); -}; - -// Delete a scan batch only if it has no related tree scans -export const deleteScanBatch = async (id: number) => { - const scanBatch = await prisma.scanBatch.findUnique({ - where: { id }, - include: { - _count: { - select: { - treeScans: true, - }, - }, - }, - }); - - if (!scanBatch) { - throw new AppError( - 404, - SCAN_BATCHES_MESSAGES.NOT_FOUND, - SCAN_BATCHES_ERRORS.NOT_FOUND, - ); - } - - if (scanBatch._count.treeScans > 0) { - throw new AppError( - 409, - SCAN_BATCHES_MESSAGES.DELETE_BLOCKED_HAS_SCANS, - SCAN_BATCHES_ERRORS.DELETE_BLOCKED, - ); - } - - await prisma.scanBatch.delete({ - where: { id }, - }); - - return { - success: true, - message: SCAN_BATCHES_MESSAGES.DELETED, - }; -}; \ No newline at end of file From 446ae8b00c6c3af7938dc1bbb6ea11fb0ba770ba Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 23:50:53 +1000 Subject: [PATCH 09/26] add unit tests for scan batches service --- tests/unit/scan-batches.test.ts | 747 +++++++++++++++++++++++++++++++- 1 file changed, 743 insertions(+), 4 deletions(-) diff --git a/tests/unit/scan-batches.test.ts b/tests/unit/scan-batches.test.ts index 73e2381..2d7fa7d 100644 --- a/tests/unit/scan-batches.test.ts +++ b/tests/unit/scan-batches.test.ts @@ -1,5 +1,744 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); - }); +import { + createScanBatch, + deleteScanBatch, + getScanBatchById, + getScanBatches, +} from "../../src/modules/scan-batches/scanBatches.service"; + +import { + SCAN_BATCHES_AUTH_ROLES, + SCAN_BATCHES_DB_ROLES, + SCAN_BATCHES_ERRORS, + SCAN_BATCHES_MESSAGES, +} from "../../src/modules/scan-batches/scan-batches.constants"; + +jest.mock("../../src/lib/prisma", () => { + const mockPrisma: any = { + scanBatch: { + findMany: jest.fn(), + count: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + treeScan: { + createMany: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + project: { + findUnique: jest.fn(), + }, + userProject: { + findFirst: jest.fn(), + }, + treeType: { + findUnique: jest.fn(), + }, + projectTreeType: { + findFirst: jest.fn(), + }, + }; + + mockPrisma.$transaction = jest.fn((callback: any) => callback(mockPrisma)); + + return { + prisma: mockPrisma, + }; }); + +const { prisma: mockPrisma } = jest.requireMock("../../src/lib/prisma"); + +describe("ScanBatchesService", () => { + const adminUser = { + id: 1, + role: SCAN_BATCHES_AUTH_ROLES.ADMIN, + }; + + const managerUser = { + id: 3, + role: SCAN_BATCHES_AUTH_ROLES.MANAGER, + }; + + const inspectorUser = { + id: 4, + role: SCAN_BATCHES_AUTH_ROLES.INSPECTOR, + }; + + const validCreateInput = { + inspector_id: inspectorUser.id, + project_id: 1, + uploaded_at: new Date("2024-05-20T10:35:00.000Z"), + scans: [ + { + fob_id: "SWAGGER-001", + farmer_id: 16, + species_id: 1, + estimated_planted_year: 2024, + estimated_planted_month: 5, + planted_date: new Date("2024-05-20"), + height_m: 2.5, + circumference_cm: 45.3, + diameter_cm: 14.4, + latitude: -8.5569, + longitude: 125.5603, + photo_id: "550e8400-e29b-41d4-a716-446655440000", + device_id: "MOB-001", + }, + ], + }; + + const inspectorRecord = { + id: inspectorUser.id, + accountActive: true, + canSignIn: true, + primaryRole: { + id: 3, + name: SCAN_BATCHES_DB_ROLES.INSPECTOR, + }, + }; + + const farmerRecord = { + id: 16, + accountActive: true, + canSignIn: true, + primaryRole: { + id: 4, + name: SCAN_BATCHES_DB_ROLES.FARMER, + }, + }; + + const scanBatchRecord = { + id: 1, + inspectorId: inspectorUser.id, + projectId: 1, + uploadedAt: new Date("2024-05-20T10:35:00.000Z"), + inspector: { + id: inspectorUser.id, + name: "Dev Inspector", + email: "dev-inspector@treeo2.local", + }, + project: { + id: 1, + name: "Hera Reforestation 2025", + }, + treeScans: [ + { + id: 1, + fobId: "SWAGGER-001", + projectId: 1, + farmerId: 16, + inspectorId: inspectorUser.id, + speciesId: 1, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 5, + batchId: 1, + }, + ], + }; + + const mockSuccessfulCreateDependencies = () => { + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(farmerRecord); + + mockPrisma.project.findUnique.mockResolvedValue({ + id: 1, + isActive: true, + }); + + mockPrisma.userProject.findFirst.mockResolvedValue({ + userId: inspectorUser.id, + projectId: 1, + }); + + mockPrisma.treeType.findUnique.mockResolvedValue({ + id: 1, + name: "Mahogany", + }); + + mockPrisma.projectTreeType.findFirst.mockResolvedValue({ + projectId: 1, + treeTypeId: 1, + }); + + mockPrisma.scanBatch.create.mockResolvedValue({ + id: 1, + inspectorId: inspectorUser.id, + projectId: 1, + uploadedAt: validCreateInput.uploaded_at, + }); + + mockPrisma.treeScan.createMany.mockResolvedValue({ + count: 1, + }); + + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getScanBatches", () => { + // Tests admin retrieval of paginated scan batches + it("should return paginated scan batches for admin", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(1); + + const result = await getScanBatches( + { + page: 1, + limit: 20, + project_id: 1, + inspector_id: 4, + }, + adminUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + projectId: 1, + inspectorId: 4, + }, + skip: 0, + take: 20, + orderBy: { + uploadedAt: "desc", + }, + }), + ); + + expect(result).toEqual({ + data: [scanBatchRecord], + pagination: { + page: 1, + limit: 20, + total: 1, + totalPages: 1, + }, + }); + }); + + // Tests inspector-only filtering for scan batch listing + it("should scope inspector results to own batches", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(1); + + await getScanBatches( + { + page: 1, + limit: 20, + }, + inspectorUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + inspectorId: inspectorUser.id, + }, + }), + ); + }); + + // Tests manager filtering by assigned project batches + it("should scope manager results to assigned projects", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(1); + + await getScanBatches( + { + page: 1, + limit: 20, + }, + managerUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + project: { + userProjects: { + some: { + userId: managerUser.id, + }, + }, + }, + }, + }), + ); + }); + + // Tests pagination calculation for scan batch listing + it("should calculate pagination correctly", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(45); + + const result = await getScanBatches( + { + page: 2, + limit: 20, + }, + adminUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 20, + }), + ); + + expect(result.pagination).toEqual({ + page: 2, + limit: 20, + total: 45, + totalPages: 3, + }); + }); + }); + + describe("getScanBatchById", () => { + // Tests admin retrieval of a single scan batch + it("should return scan batch when admin requests valid batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + + const result = await getScanBatchById(1, adminUser); + + expect(mockPrisma.scanBatch.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 1 }, + }), + ); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests inspector access to their own scan batch + it("should allow inspector to access own scan batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + + const result = await getScanBatchById(1, inspectorUser); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests inspector forbidden access to another inspector batch + it("should throw forbidden when inspector accesses another inspector batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue({ + ...scanBatchRecord, + inspectorId: 999, + }); + + await expect(getScanBatchById(1, inspectorUser)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests manager access to a batch from assigned project + it("should allow manager to access assigned project batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + mockPrisma.userProject.findFirst.mockResolvedValue({ + userId: managerUser.id, + projectId: scanBatchRecord.projectId, + }); + + const result = await getScanBatchById(1, managerUser); + + expect(mockPrisma.userProject.findFirst).toHaveBeenCalledWith({ + where: { + userId: managerUser.id, + projectId: scanBatchRecord.projectId, + }, + }); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests manager forbidden access to an unassigned project batch + it("should throw forbidden when manager accesses unassigned project batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + mockPrisma.userProject.findFirst.mockResolvedValue(null); + + await expect(getScanBatchById(1, managerUser)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests not-found behavior for missing scan batch + it("should throw not found when scan batch does not exist", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(null); + + await expect(getScanBatchById(999, adminUser)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + }); + + describe("createScanBatch", () => { + beforeEach(() => { + mockSuccessfulCreateDependencies(); + }); + + // Tests successful creation of scan batch and child tree scans + it("should create a scan batch successfully with valid input", async () => { + const result = await createScanBatch(validCreateInput); + + expect(mockPrisma.scanBatch.create).toHaveBeenCalledWith({ + data: { + inspectorId: inspectorUser.id, + projectId: 1, + uploadedAt: validCreateInput.uploaded_at, + }, + }); + + expect(mockPrisma.treeScan.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + fobId: "SWAGGER-001", + projectId: 1, + farmerId: 16, + inspectorId: inspectorUser.id, + speciesId: 1, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 5, + batchId: 1, + }), + ], + }); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests default upload timestamp when uploaded_at is omitted + it("should use current date when uploaded_at is not provided", async () => { + const inputWithoutUploadedAt = { + ...validCreateInput, + uploaded_at: null, + }; + + await createScanBatch(inputWithoutUploadedAt); + + expect(mockPrisma.scanBatch.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + uploadedAt: expect.any(Date), + }), + }); + }); + + // Tests missing inspector validation + it("should throw not found when inspector does not exist", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests inspector role validation + it("should throw invalid role when user is not inspector", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce({ + ...inspectorRecord, + primaryRole: { + id: 2, + name: SCAN_BATCHES_DB_ROLES.MANAGER, + }, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.INVALID_INSPECTOR_ROLE, + code: SCAN_BATCHES_ERRORS.INVALID_ROLE, + }); + }); + + // Tests inactive inspector account validation + it("should throw forbidden when inspector account is inactive", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce({ + ...inspectorRecord, + accountActive: false, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: "Inspector account is inactive or cannot sign in", + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests inspector canSignIn validation + it("should throw forbidden when inspector cannot sign in", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce({ + ...inspectorRecord, + canSignIn: false, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: "Inspector account is inactive or cannot sign in", + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests missing project validation + it("should throw not found when project does not exist", async () => { + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.PROJECT_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests inspector project assignment validation + it("should throw forbidden when inspector is not assigned to project", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(farmerRecord); + + mockPrisma.userProject.findFirst.mockReset(); + mockPrisma.userProject.findFirst.mockResolvedValueOnce(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_ASSIGNED, + code: SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + }); + }); + + // Tests missing farmer validation + it("should throw not found when farmer does not exist", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(null); + + mockPrisma.userProject.findFirst.mockReset(); + mockPrisma.userProject.findFirst.mockResolvedValueOnce({ + userId: inspectorUser.id, + projectId: 1, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests farmer role validation + it("should throw invalid role when farmer_id does not belong to Farmer role", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce({ + ...farmerRecord, + primaryRole: { + id: 2, + name: SCAN_BATCHES_DB_ROLES.MANAGER, + }, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.INVALID_FARMER_ROLE, + code: SCAN_BATCHES_ERRORS.INVALID_ROLE, + }); + }); + + // Tests farmer project assignment validation + it("should throw forbidden when farmer is not assigned to project", async () => { + mockPrisma.userProject.findFirst + .mockResolvedValueOnce({ + userId: inspectorUser.id, + projectId: 1, + }) + .mockResolvedValueOnce(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.FARMER_NOT_ASSIGNED, + code: SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + }); + }); + + // Tests missing species validation + it("should throw not found when species does not exist", async () => { + mockPrisma.treeType.findUnique.mockResolvedValue(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.SPECIES_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests species-project assignment validation + it("should throw forbidden when species is not assigned to project", async () => { + mockPrisma.projectTreeType.findFirst.mockResolvedValue(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.SPECIES_NOT_IN_PROJECT, + code: SCAN_BATCHES_ERRORS.SPECIES_NOT_IN_PROJECT, + }); + }); + + // Tests height measurement upper-limit validation + it("should throw invalid measurement when height exceeds limit", async () => { + await expect( + createScanBatch({ + ...validCreateInput, + scans: [ + { + ...validCreateInput.scans[0], + height_m: 101, + }, + ], + }), + ).rejects.toMatchObject({ + statusCode: 422, + message: SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + code: SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + }); + }); + + // Tests diameter measurement upper-limit validation + it("should throw invalid measurement when diameter exceeds limit", async () => { + await expect( + createScanBatch({ + ...validCreateInput, + scans: [ + { + ...validCreateInput.scans[0], + diameter_cm: 1001, + }, + ], + }), + ).rejects.toMatchObject({ + statusCode: 422, + message: SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + code: SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + }); + }); + + // Tests circumference measurement upper-limit validation + it("should throw invalid measurement when circumference exceeds limit", async () => { + await expect( + createScanBatch({ + ...validCreateInput, + scans: [ + { + ...validCreateInput.scans[0], + circumference_cm: 4001, + }, + ], + }), + ).rejects.toMatchObject({ + statusCode: 422, + message: SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + code: SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + }); + }); + + // Tests that each scan in a multi-scan batch is validated + it("should validate every scan in a multi-scan batch", async () => { + const multiScanInput = { + ...validCreateInput, + scans: [ + validCreateInput.scans[0], + { + ...validCreateInput.scans[0], + fob_id: "SWAGGER-002", + farmer_id: 999, + }, + ], + }; + + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(farmerRecord) + .mockResolvedValueOnce(null); + + await expect(createScanBatch(multiScanInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + }); + + describe("deleteScanBatch", () => { + // Tests successful deletion of an empty scan batch + it("should delete scan batch when it exists and has no related scans", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue({ + id: 1, + _count: { + treeScans: 0, + }, + }); + + mockPrisma.scanBatch.delete.mockResolvedValue({ + id: 1, + }); + + const result = await deleteScanBatch(1); + + expect(mockPrisma.scanBatch.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + + expect(result).toEqual({ + success: true, + message: SCAN_BATCHES_MESSAGES.DELETED, + }); + }); + + // Tests not-found behavior when deleting missing scan batch + it("should throw not found when scan batch does not exist", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(null); + + await expect(deleteScanBatch(999)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests deletion protection when scan batch has related tree scans + it("should block delete when scan batch has related tree scans", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue({ + id: 1, + _count: { + treeScans: 1, + }, + }); + + await expect(deleteScanBatch(1)).rejects.toMatchObject({ + statusCode: 409, + message: SCAN_BATCHES_MESSAGES.DELETE_BLOCKED_HAS_SCANS, + code: SCAN_BATCHES_ERRORS.DELETE_BLOCKED, + }); + + expect(mockPrisma.scanBatch.delete).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file From cd4d800bf506edddd81e7a106b7032f334baee32 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 23:51:05 +1000 Subject: [PATCH 10/26] add integration tests for scan batches endpoints --- tests/integration/scan-batches.test.ts | 873 ++++++++++++++++++++++++- 1 file changed, 869 insertions(+), 4 deletions(-) diff --git a/tests/integration/scan-batches.test.ts b/tests/integration/scan-batches.test.ts index 73e2381..d4ce5c4 100644 --- a/tests/integration/scan-batches.test.ts +++ b/tests/integration/scan-batches.test.ts @@ -1,5 +1,870 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +import "dotenv/config"; +import request from "supertest"; +import { PrismaClient } from "@prisma/client"; +import app from "../../src/app"; + +const prisma = new PrismaClient(); + +const TOKENS = { + ADMIN: process.env.AUTH_DEV_ADMIN_TOKEN!, + MANAGER: process.env.AUTH_DEV_MANAGER_TOKEN!, + INSPECTOR: process.env.AUTH_DEV_INSPECTOR_TOKEN!, + FARMER: process.env.AUTH_DEV_FARMER_TOKEN!, + DEVELOPER: process.env.AUTH_DEV_DEVELOPER_TOKEN!, +}; + +const DEV_USER_IDS = { + ADMIN: 1, + FARMER: 2, + MANAGER: 3, + INSPECTOR: 4, + DEVELOPER: 5, +}; + +describe("Scan Batches Integration Tests", () => { + let countryId: number; + let adminLocationId: number; + let projectId: number; + let inactiveProjectId: number; + let farmerId: number; + let unassignedFarmerId: number; + let managerId: number; + let inspectorId: number; + let unassignedInspectorId: number; + let speciesId: number; + let unassignedSpeciesId: number; + let batchId: number; + + const validPayload = () => ({ + project_id: projectId, + uploaded_at: "2024-05-20T10:35:00.000Z", + scans: [ + { + fob_id: `SCAN-BATCH-${Date.now()}-${Math.random()}`, + farmer_id: farmerId, + species_id: speciesId, + estimated_planted_year: 2024, + estimated_planted_month: 5, + planted_date: "2024-05-20", + height_m: 2.5, + circumference_cm: 45.3, + diameter_cm: 14.4, + latitude: -8.5569, + longitude: 125.5603, + photo_id: "550e8400-e29b-41d4-a716-446655440000", + device_id: "MOB-001", + }, + ], }); -}); + + beforeAll(async () => { + await prisma.treeScanAudit.deleteMany(); + await prisma.treeScan.deleteMany(); + await prisma.scanBatch.deleteMany(); + await prisma.projectTreeType.deleteMany(); + await prisma.userProject.deleteMany(); + + await prisma.user.deleteMany({ + where: { + email: { + in: [ + "scan-batch-dev-admin@test.com", + "scan-batch-dev-farmer@test.com", + "scan-batch-dev-manager@test.com", + "scan-batch-dev-inspector@test.com", + "scan-batch-dev-developer@test.com", + "scan-batch-farmer@test.com", + "scan-batch-unassigned-farmer@test.com", + "scan-batch-unassigned-inspector@test.com", + ], + }, + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { + in: [ + DEV_USER_IDS.ADMIN, + DEV_USER_IDS.FARMER, + DEV_USER_IDS.MANAGER, + DEV_USER_IDS.INSPECTOR, + DEV_USER_IDS.DEVELOPER, + ], + }, + }, + }); + + await prisma.project.deleteMany({ + where: { + name: { + startsWith: "Scan Batch Test", + }, + }, + }); + + await prisma.treeType.deleteMany({ + where: { + key: { + in: ["scan-batch-mahogany", "scan-batch-unassigned-species"], + }, + }, + }); + + await prisma.location.deleteMany({ + where: { + name: "Scan Batch Test Location", + }, + }); + + await prisma.country.deleteMany({ + where: { + iso2: "SB", + }, + }); + + const adminRole = await prisma.role.upsert({ + where: { name: "Admin" }, + update: {}, + create: { name: "Admin" }, + }); + + const managerRole = await prisma.role.upsert({ + where: { name: "Manager" }, + update: {}, + create: { name: "Manager" }, + }); + + const inspectorRole = await prisma.role.upsert({ + where: { name: "Inspector" }, + update: {}, + create: { name: "Inspector" }, + }); + + const farmerRole = await prisma.role.upsert({ + where: { name: "Farmer" }, + update: {}, + create: { name: "Farmer" }, + }); + + const developerRole = await prisma.role.upsert({ + where: { name: "Developer" }, + update: {}, + create: { name: "Developer" }, + }); + + const country = await prisma.country.create({ + data: { + name: "Scan Batch Test Country", + iso2: "SB", + iso3: "SBT", + }, + }); + + countryId = country.id; + + const location = await prisma.location.create({ + data: { + countryId, + level: 1, + name: "Scan Batch Test Location", + }, + }); + + adminLocationId = location.id; + + const project = await prisma.project.create({ + data: { + name: "Scan Batch Test Project", + description: "Project used for scan batch tests", + countryId, + adminLocationId, + isActive: true, + }, + }); + + projectId = project.id; + + const inactiveProject = await prisma.project.create({ + data: { + name: "Scan Batch Test Inactive Project", + description: "Inactive project used for scan batch tests", + countryId, + adminLocationId, + isActive: false, + }, + }); + + inactiveProjectId = inactiveProject.id; + + await prisma.user.upsert({ + where: { id: DEV_USER_IDS.ADMIN }, + update: { + name: "Scan Batch Dev Admin", + email: "scan-batch-dev-admin@test.com", + roleId: adminRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.ADMIN, + name: "Scan Batch Dev Admin", + email: "scan-batch-dev-admin@test.com", + roleId: adminRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + await prisma.user.upsert({ + where: { id: DEV_USER_IDS.FARMER }, + update: { + name: "Scan Batch Dev Farmer", + email: "scan-batch-dev-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.FARMER, + name: "Scan Batch Dev Farmer", + email: "scan-batch-dev-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + const manager = await prisma.user.upsert({ + where: { id: DEV_USER_IDS.MANAGER }, + update: { + name: "Scan Batch Dev Manager", + email: "scan-batch-dev-manager@test.com", + roleId: managerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.MANAGER, + name: "Scan Batch Dev Manager", + email: "scan-batch-dev-manager@test.com", + roleId: managerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + managerId = manager.id; + + const inspector = await prisma.user.upsert({ + where: { id: DEV_USER_IDS.INSPECTOR }, + update: { + name: "Scan Batch Dev Inspector", + email: "scan-batch-dev-inspector@test.com", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.INSPECTOR, + name: "Scan Batch Dev Inspector", + email: "scan-batch-dev-inspector@test.com", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + inspectorId = inspector.id; + + await prisma.user.upsert({ + where: { id: DEV_USER_IDS.DEVELOPER }, + update: { + name: "Scan Batch Dev Developer", + email: "scan-batch-dev-developer@test.com", + roleId: developerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.DEVELOPER, + name: "Scan Batch Dev Developer", + email: "scan-batch-dev-developer@test.com", + roleId: developerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + await prisma.$executeRaw` + SELECT setval( + pg_get_serial_sequence('users', 'id'), + COALESCE((SELECT MAX(id) FROM "users"), 1), + true + ); + `; + + const farmer = await prisma.user.upsert({ + where: { email: "scan-batch-farmer@test.com" }, + update: { + name: "Scan Batch Farmer", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + name: "Scan Batch Farmer", + email: "scan-batch-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + farmerId = farmer.id; + + const unassignedFarmer = await prisma.user.upsert({ + where: { email: "scan-batch-unassigned-farmer@test.com" }, + update: { + name: "Scan Batch Unassigned Farmer", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + name: "Scan Batch Unassigned Farmer", + email: "scan-batch-unassigned-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + unassignedFarmerId = unassignedFarmer.id; + + const unassignedInspector = await prisma.user.upsert({ + where: { email: "scan-batch-unassigned-inspector@test.com" }, + update: { + name: "Scan Batch Unassigned Inspector", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + name: "Scan Batch Unassigned Inspector", + email: "scan-batch-unassigned-inspector@test.com", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + unassignedInspectorId = unassignedInspector.id; + + const species = await prisma.treeType.create({ + data: { + name: "Scan Batch Mahogany", + key: "scan-batch-mahogany", + scientificName: "Swietenia macrophylla", + dryWeightDensity: 550, + }, + }); + + speciesId = species.id; + + const unassignedSpecies = await prisma.treeType.create({ + data: { + name: "Scan Batch Unassigned Species", + key: "scan-batch-unassigned-species", + scientificName: "Unassigned species", + dryWeightDensity: 500, + }, + }); + + unassignedSpeciesId = unassignedSpecies.id; + + await prisma.userProject.createMany({ + data: [ + { + userId: farmerId, + projectId, + }, + { + userId: inspectorId, + projectId, + }, + { + userId: managerId, + projectId, + }, + ], + skipDuplicates: true, + }); + + await prisma.projectTreeType.create({ + data: { + projectId, + treeTypeId: speciesId, + }, + }); + }); + + beforeEach(async () => { + await prisma.treeScanAudit.deleteMany(); + await prisma.treeScan.deleteMany(); + await prisma.scanBatch.deleteMany(); + + const batch = await prisma.scanBatch.create({ + data: { + inspectorId, + projectId, + uploadedAt: new Date("2024-05-20T10:35:00.000Z"), + }, + }); + + batchId = batch.id; + + await prisma.treeScan.create({ + data: { + fobId: "SCAN-BATCH-BASE", + projectId, + farmerId, + inspectorId, + speciesId, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 5, + plantedDate: new Date("2024-05-20"), + heightM: 2.5, + circumferenceCm: 45.3, + diameterCm: 14.4, + latitude: -8.5569, + longitude: 125.5603, + photoId: "550e8400-e29b-41d4-a716-446655440000", + deviceId: "MOB-001", + batchId, + }, + }); + }); + + afterAll(async () => { + await prisma.treeScanAudit.deleteMany(); + await prisma.treeScan.deleteMany(); + await prisma.scanBatch.deleteMany(); + + await prisma.projectTreeType.deleteMany({ + where: { + projectId, + }, + }); + + await prisma.userProject.deleteMany({ + where: { + projectId, + }, + }); + + await prisma.project.deleteMany({ + where: { + id: { + in: [projectId, inactiveProjectId].filter( + (id): id is number => id !== undefined, + ), + }, + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { + in: [ + DEV_USER_IDS.ADMIN, + DEV_USER_IDS.FARMER, + DEV_USER_IDS.MANAGER, + DEV_USER_IDS.INSPECTOR, + DEV_USER_IDS.DEVELOPER, + farmerId, + unassignedFarmerId, + unassignedInspectorId, + ].filter((id): id is number => id !== undefined), + }, + }, + }); + + await prisma.treeType.deleteMany({ + where: { + id: { + in: [speciesId, unassignedSpeciesId].filter( + (id): id is number => id !== undefined, + ), + }, + }, + }); + + await prisma.location.deleteMany({ + where: { + id: adminLocationId, + }, + }); + + await prisma.country.deleteMany({ + where: { + id: countryId, + }, + }); + + await prisma.$disconnect(); + }); + + // Tests for GET /scan-batches endpoint authorization, filtering, and pagination behaviour. + describe("GET /scan-batches", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).get("/scan-batches"); + + expect(response.status).toBe(401); + }); + + it("should return 200 for ADMIN token", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + }); + + it("should return 200 for MANAGER token and only return assigned project batches", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + expect( + response.body.data.every( + (batch: { projectId: number }) => batch.projectId === projectId, + ), + ).toBe(true); + }); + + it("should return 200 for INSPECTOR token and only return own batches", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + expect( + response.body.data.every( + (batch: { inspectorId: number }) => + batch.inspectorId === inspectorId, + ), + ).toBe(true); + }); + + it("should return 403 for FARMER token", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(response.status).toBe(403); + }); + + it("should filter by project_id", async () => { + const response = await request(app) + .get(`/scan-batches?project_id=${projectId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBeGreaterThanOrEqual(1); + expect( + response.body.data.every( + (batch: { projectId: number }) => batch.projectId === projectId, + ), + ).toBe(true); + }); + + it("should return 400 for invalid pagination", async () => { + const response = await request(app) + .get("/scan-batches?page=0&limit=0") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(400); + }); + }); + + // Tests for GET /scan-batches/:id endpoint authorization, validation, and retrieval behaviour. + describe("GET /scan-batches/:id", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).get(`/scan-batches/${batchId}`); + + expect(response.status).toBe(401); + }); + + it("should return 200 for ADMIN token when batch exists", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.id).toBe(batchId); + }); + + it("should return 200 for MANAGER token when batch belongs to assigned project", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.projectId).toBe(projectId); + }); + + it("should return 200 for INSPECTOR token when batch belongs to inspector", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.inspectorId).toBe(inspectorId); + }); + + it("should return 403 for FARMER token", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid scan batch id", async () => { + const response = await request(app) + .get("/scan-batches/0") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(400); + }); + + it("should return 404 when scan batch does not exist", async () => { + const response = await request(app) + .get("/scan-batches/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(404); + }); + }); + + // Tests for POST /scan-batches endpoint validation, authorization, and batch creation behaviour. + describe("POST /scan-batches", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app) + .post("/scan-batches") + .send(validPayload()); + + expect(response.status).toBe(401); + }); + + it("should return 201 for INSPECTOR token and create scan batch with tree scans", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send(validPayload()); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.projectId).toBe(projectId); + expect(response.body.data.inspectorId).toBe(inspectorId); + expect(response.body.data.treeScans.length).toBe(1); + }); + + it("should return 403 for ADMIN token", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send(validPayload()); + + expect(response.status).toBe(403); + }); + + it("should return 403 for MANAGER token", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) + .send(validPayload()); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid payload", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [], + }); + + expect(response.status).toBe(400); + }); + + it("should return 422 for inactive project", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + project_id: inactiveProjectId, + }); + + expect(response.status).toBe(422); + }); + + it("should return 403 when farmer is not assigned to project", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + farmer_id: unassignedFarmerId, + }, + ], + }); + + expect(response.status).toBe(403); + }); + + it("should return 403 when species is not assigned to project", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + species_id: unassignedSpeciesId, + }, + ], + }); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid coordinates", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + latitude: 100, + }, + ], + }); + + expect(response.status).toBe(400); + }); + + it("should return 400 for future planted date", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + planted_date: "2035-01-01", + }, + ], + }); + + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid estimated planted month", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + estimated_planted_month: 15, + }, + ], + }); + + expect(response.status).toBe(400); + }); + }); + + // Tests for DELETE /scan-batches/:id endpoint authorization and dependency protection behaviour. + describe("DELETE /scan-batches/:id", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).delete(`/scan-batches/${batchId}`); + + expect(response.status).toBe(401); + }); + + it("should return 403 for INSPECTOR token", async () => { + const response = await request(app) + .delete(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(403); + }); + + it("should return 403 for MANAGER token", async () => { + const response = await request(app) + .delete(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(403); + }); + + it("should return 409 for ADMIN token when batch has related tree scans", async () => { + const response = await request(app) + .delete(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(409); + expect(response.body.success).toBe(false); + }); + + it("should return 200 for ADMIN token when batch has no related tree scans", async () => { + const emptyBatch = await prisma.scanBatch.create({ + data: { + inspectorId, + projectId, + uploadedAt: new Date(), + }, + }); + + const response = await request(app) + .delete(`/scan-batches/${emptyBatch.id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it("should return 404 when scan batch does not exist", async () => { + const response = await request(app) + .delete("/scan-batches/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(404); + }); + }); +}); \ No newline at end of file From c951416db47f8d2988e7e71a8debce46bdef260a Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 8 May 2026 14:35:58 +1000 Subject: [PATCH 11/26] feat: add user_project_roles schema and relations --- prisma/models/project.prisma | 2 ++ prisma/models/role.prisma | 2 ++ prisma/models/user.prisma | 4 +--- prisma/models/userProjectRole.prisma | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 prisma/models/userProjectRole.prisma diff --git a/prisma/models/project.prisma b/prisma/models/project.prisma index 77985f9..1c1cf63 100644 --- a/prisma/models/project.prisma +++ b/prisma/models/project.prisma @@ -15,6 +15,8 @@ model Project { scanBatches ScanBatch[] @relation("ProjectScanBatches") treeScans TreeScan[] @relation("ProjectTreeScans") + userProjectRoles UserProjectRole[] + @@index([countryId]) @@index([adminLocationId]) @@map("projects") diff --git a/prisma/models/role.prisma b/prisma/models/role.prisma index a15227a..ceffb9d 100644 --- a/prisma/models/role.prisma +++ b/prisma/models/role.prisma @@ -5,6 +5,8 @@ model Role { primaryUsers User[] @relation("PrimaryRole") roleAssignments UserRoleAssignment[] + userProjectRoles UserProjectRole[] + @@map("roles") } diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 1116652..a56dc6f 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -37,8 +37,6 @@ model User { correctedTreeScans TreeScan[] @relation("CorrectedTreeScans") treeScanAudits TreeScanAudit[] @relation("TreeScanAuditChangedBy") - @@index([roleId]) - @@index([countryId]) - @@index([adminLocationId]) + @@map("users") } \ No newline at end of file diff --git a/prisma/models/userProjectRole.prisma b/prisma/models/userProjectRole.prisma new file mode 100644 index 0000000..a65c570 --- /dev/null +++ b/prisma/models/userProjectRole.prisma @@ -0,0 +1,15 @@ +model UserProjectRole { + userId Int @map("user_id") + projectId Int @map("project_id") + roleId Int @map("role_id") + assignedBy Int @map("assigned_by") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation("UserProjectRoles", fields: [userId], references: [id]) + project Project @relation(fields: [projectId], references: [id]) + role Role @relation(fields: [roleId], references: [id]) + assignedByUser User @relation("AssignedByUser", fields: [assignedBy], references: [id]) + + @@id([userId, projectId, roleId]) + @@map("user_project_roles") +} \ No newline at end of file From a38a0fc2d0c08abee9ed91fd15a93d32ffe0f081 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 08:02:49 +1000 Subject: [PATCH 12/26] fix: added runInBand flag to test:coverage in package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9a2f90d..9c8ba91 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "validate": "npm run type-check && npm run lint && npm run format:check", "test": "jest --passWithNoTests", "test:watch": "jest --watch", - "test:coverage": "jest --coverage --passWithNoTests", + "test:coverage": "jest --coverage --passWithNoTests --runInBand", "prisma:generate": "prisma generate --schema prisma", "prisma:push": "prisma db push --schema prisma", "prisma:migrate:dev": "prisma migrate dev --schema prisma", @@ -72,4 +72,4 @@ "ts-node-dev": "^2.0.0", "typescript": "^5.5.3" } -} \ No newline at end of file +} From 9e68e27035e2d182f9124eb7f3dd2d8b4bb5da68 Mon Sep 17 00:00:00 2001 From: tinak250 Date: Sat, 16 May 2026 18:07:51 +1000 Subject: [PATCH 13/26] Implement adoptions API with tests and documentation --- docs/API.md | 393 +++++++++++++++++- src/modules/adoptions/adoptions.controller.ts | 88 ++++ src/modules/adoptions/adoptions.routes.ts | 66 +++ src/modules/adoptions/adoptions.service.ts | 198 +++++++++ src/modules/adoptions/index.ts | 3 + src/routes/index.ts | 2 + tests/integration/adoptions.test.ts | 149 ++++++- tests/unit/adoptions.test.ts | 258 +++++++++++- 8 files changed, 1148 insertions(+), 9 deletions(-) diff --git a/docs/API.md b/docs/API.md index f120673..17eba12 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3573,4 +3573,395 @@ The Tree Scans API follows the TreeO2 backend engineering standard: - Swagger documentation - Automated testing - Scalable backend structure ---- \ No newline at end of file +--- + +## 16. Adoptions API + +This module manages adoption records in the TreeO2 platform. It provides full CRUD operations for recording, retrieving, updating, and deleting tree adoption records. + +**Module Path:** `src/modules/adoptions/` + +### Files + +- `adoptions.routes.ts` +- `adoptions.controller.ts` +- `adoptions.service.ts` +- `index.ts` + +### 16.1 Purpose + +The Adoptions API is responsible for managing adoption records linked to adopters and tree FOB identifiers. + +An adoption record stores: +- the adopter linked to the adoption +- the tree FOB ID +- the adoption date +- the creation timestamp + +### 16.2 Architecture Flow + +Every request follows the standard backend module structure: + +```text +Route -> Controller -> Service -> Prisma ORM -> PostgreSQL -> Response +``` + +### Responsibilities + +#### Routes +- Define endpoints +- Apply authentication middleware +- Apply role-based authorization +- Contain Swagger documentation + +#### Controller +- Receive request data +- Read params and body +- Call service methods +- Return HTTP response + +#### Service +- Validate user input +- Apply business rules +- Execute database queries +- Throw structured errors + +### 16.3 Security + +All endpoints are protected using Bearer Token authentication. + +Middleware used: +- `authMiddleware` +- `roleMiddleware` + +### 16.4 Access Control Matrix + +| Endpoint | ADMIN | MANAGER | INSPECTOR | FARMER | DEVELOPER | +|---|---|---|---|---|---| +| GET /adoptions | Yes | Yes | No | No | No | +| GET /adoptions/{id} | Yes | Yes | No | No | No | +| POST /adoptions | Yes | No | No | No | No | +| PUT /adoptions/{id} | Yes | No | No | No | No | +| DELETE /adoptions/{id} | Yes | No | No | No | No | + +### 16.5 Endpoints + +#### GET /adoptions + +Retrieve paginated adoption records. + +##### Query Parameters + +| Name | Type | Required | +|---|---|---| +| page | integer | No | +| limit | integer | No | + +##### Response + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14T00:00:00.000Z" + } + ] +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid pagination parameters +- `401` Authentication required +- `403` Insufficient permissions + +--- + +#### GET /adoptions/{id} + +Retrieve a single adoption by ID. + +##### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14T00:00:00.000Z" + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid adoption ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adoption not found + +--- + +#### POST /adoptions + +Create a new adoption record. + +##### Request Body + +```json +{ + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14" +} +``` + +##### Required Fields +- `adopter_id` +- `fob_id` +- `adopted_at` + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14T00:00:00.000Z" + } +} +``` + +##### Status Codes +- `201` Created +- `400` Invalid payload or missing required fields +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +--- + +#### PUT /adoptions/{id} + +Update an existing adoption record. + +##### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +##### Request Body + +Any subset of fields may be provided. + +```json +{ + "fob_id": "NFC-UPDATED" +} +``` + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-UPDATED", + "adopted_at": "2026-05-14T00:00:00.000Z" + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid request, invalid ID, empty payload, or future adoption date +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adoption or adopter not found + +--- + +#### DELETE /adoptions/{id} + +Delete an adoption record. + +##### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +##### Response + +```json +{ + "success": true, + "data": { + "message": "Adoption deleted successfully" + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid adoption ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adoption not found + +### 16.6 Validation Rules + +#### Create Validation +- `adopter_id` must be a positive integer +- `fob_id` must be a non-empty string +- `adopted_at` must be a valid date +- `adopted_at` cannot be in the future +- adopter must exist before adoption is created + +#### Update Validation +- adoption ID must be a positive integer +- at least one field must be provided +- if `adopter_id` is provided, it must be a positive integer and existing adopter +- if `fob_id` is provided, it must be non-empty +- if `adopted_at` is provided, it must be valid and not in the future + +#### Delete Validation +- adoption must exist before deletion + +### 16.7 Error Handling + +Uses centralised error middleware. + +#### Standard Error Response + +```json +{ + "success": false, + "message": "Adoption not found" +} +``` + +#### Common Errors +- Authentication required +- Insufficient permissions +- Invalid adoption ID +- Missing required fields +- Invalid adoption date +- Adoption date cannot be in the future +- Adopter not found +- Adoption not found +- Empty update payload +- Internal server error + +### 16.8 Swagger Documentation + +All endpoints are documented in: + +`adoptions.routes.ts` + +Available at: + +`http://localhost:3000/api-docs` + +Swagger supports: +- Interactive testing +- Request examples +- Response definitions +- Security schemas + +### 16.9 Testing + +#### Test Files +- `tests/unit/adoptions.test.ts` +- `tests/integration/adoptions.test.ts` + +#### Covered Scenarios + +##### Authentication +- No token returns `401` + +##### Authorization +- Admin and Manager can access GET endpoints +- Only Admin can create, update, and delete +- Other roles return `403` + +##### Read +- Get all adoptions returns list +- Get adoption by ID returns correct record +- Missing adoption returns `404` +- Invalid ID returns `400` + +##### Create +- Valid adoption created with `201` +- Missing fields rejected with `400` +- Invalid adoption date rejected with `400` +- Missing adopter rejected with `404` + +##### Update +- Valid update succeeds with `200` +- Empty payload rejected with `400` +- Invalid ID rejected with `400` +- Missing adoption returns `404` + +##### Delete +- Valid delete succeeds with `200` +- Missing adoption returns `404` +- Invalid ID returns `400` + +### 16.10 How to Run Adoptions Tests + +Run unit tests only: + +```bash +npm test -- --runInBand tests/unit/adoptions.test.ts +``` + +Run integration tests only: + +```bash +npm test -- --runInBand tests/integration/adoptions.test.ts +``` + +Run both: + +```bash +npm test -- --runInBand tests/unit/adoptions.test.ts tests/integration/adoptions.test.ts +``` + +### 16.11 Current Limitations + +- auth and role checks depend on the existing scaffold and are not fully production-complete yet +- adoption records currently rely on FOB identifiers without deeper tree validation logic + +### 16.12 Summary + +The Adoptions API follows the TreeO2 backend engineering standard: + +- Modular architecture +- Secure authentication +- Role-based access control +- Clean separation of concerns +- Strong validation +- Full CRUD support +- Prisma-backed data access +- Swagger documentation +- Automated tests +- Scalable structure for future enhancements \ No newline at end of file diff --git a/src/modules/adoptions/adoptions.controller.ts b/src/modules/adoptions/adoptions.controller.ts index e69de29..12743b6 100644 --- a/src/modules/adoptions/adoptions.controller.ts +++ b/src/modules/adoptions/adoptions.controller.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { + adoptionsService, + type CreateAdoptionInput, + type UpdateAdoptionInput, +} from "./adoptions.service"; + +export class AdoptionsController { + async createAdoption(req: Request, res: Response, next: NextFunction) { + try { + const payload = req.body as CreateAdoptionInput; + + const result = await adoptionsService.createAdoption(payload); + + return res.status(201).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async listAdoptions(req: Request, res: Response, next: NextFunction) { + try { + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 10; + + const result = await adoptionsService.listAdoptions(page, limit); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async getAdoptionById(req: Request, res: Response, next: NextFunction) { + try { + const id = Number(req.params.id); + + const result = await adoptionsService.getAdoptionById(id); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async updateAdoption(req: Request, res: Response, next: NextFunction) { + try { + const id = Number(req.params.id); + + const payload = req.body as UpdateAdoptionInput; + + const result = await adoptionsService.updateAdoption(id, payload); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async deleteAdoption(req: Request, res: Response, next: NextFunction) { + try { + const id = Number(req.params.id); + + const result = await adoptionsService.deleteAdoption(id); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } +} + +export const adoptionsController = new AdoptionsController(); \ No newline at end of file diff --git a/src/modules/adoptions/adoptions.routes.ts b/src/modules/adoptions/adoptions.routes.ts index e69de29..b332815 100644 --- a/src/modules/adoptions/adoptions.routes.ts +++ b/src/modules/adoptions/adoptions.routes.ts @@ -0,0 +1,66 @@ +import { Router } from "express"; +import { adoptionsController } from "./adoptions.controller"; +import { authMiddleware } from "../../middleware/auth.middleware"; +import { roleMiddleware } from "../../middleware/role.middleware"; + +const router = Router(); + +/** + * CREATE ADOPTION (ADMIN ONLY) + */ +router.post( + "/", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => void adoptionsController.createAdoption(req, res, next), +); + +/** + * LIST ADOPTIONS (ADMIN + MANAGER) + */ +router.get( + "/", + authMiddleware, + roleMiddleware(["ADMIN", "MANAGER"]), + (req, res, next) => { + void adoptionsController.listAdoptions(req, res, next); + }, +); + +/** + * GET BY ID (ADMIN + MANAGER) + */ +router.get( + "/:id", + authMiddleware, + roleMiddleware(["ADMIN", "MANAGER"]), + (req, res, next) => { + void adoptionsController.getAdoptionById(req, res, next); + }, +); + +/** + * UPDATE (ADMIN ONLY) + */ +router.put( + "/:id", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => { + void adoptionsController.updateAdoption(req, res, next); + }, +); + +/** + * DELETE (ADMIN ONLY) + */ +router.delete( + "/:id", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => { + void adoptionsController.deleteAdoption(req, res, next); + }, +); + +export default router; \ No newline at end of file diff --git a/src/modules/adoptions/adoptions.service.ts b/src/modules/adoptions/adoptions.service.ts index e69de29..5a314f0 100644 --- a/src/modules/adoptions/adoptions.service.ts +++ b/src/modules/adoptions/adoptions.service.ts @@ -0,0 +1,198 @@ +import { prisma } from "../../lib/prisma"; +import { AppError } from "../../middleware/errorHandler"; +import { ERROR_CODES } from "../../utils/errorCodes"; + +interface CreateAdoptionInput { + adopter_id: number; + fob_id: string; + adopted_at: string; +} + +interface UpdateAdoptionInput { + adopter_id?: number; + fob_id?: string; + adopted_at?: string; +} + +const assertValidId = (id: number) => { + if (!Number.isInteger(id) || id <= 0) { + throw new AppError(400, ERROR_CODES.VAL_002, ERROR_CODES.VAL_002); + } +}; + +const assertValidPagination = (page: number, limit: number) => { + if ( + !Number.isInteger(page) || + !Number.isInteger(limit) || + page <= 0 || + limit <= 0 + ) { + throw new AppError( + 400, + "Invalid pagination parameters", + ERROR_CODES.VAL_002, + ); + } +}; + +const assertValidDate = (date: string) => { + const parsedDate = new Date(date); + + if (Number.isNaN(parsedDate.getTime())) { + throw new AppError(400, "Invalid adoption date", ERROR_CODES.VAL_002); + } + + if (parsedDate > new Date()) { + throw new AppError( + 400, + "Adoption date cannot be in the future", + ERROR_CODES.VAL_003, + ); + } + + return parsedDate; +}; + +const assertCreatePayload = (data: CreateAdoptionInput) => { + assertValidId(Number(data.adopter_id)); + + if (!data.fob_id?.trim()) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + + if (!data.adopted_at) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + + assertValidDate(data.adopted_at); +}; + +const assertUpdatePayload = (data: UpdateAdoptionInput) => { + if (Object.keys(data).length === 0) { + throw new AppError( + 400, + "No fields provided for update", + ERROR_CODES.VAL_003, + ); + } + + if (data.adopter_id !== undefined) { + assertValidId(Number(data.adopter_id)); + } + + if (data.fob_id !== undefined && !data.fob_id.trim()) { + throw new AppError(400, "Invalid fob_id", ERROR_CODES.VAL_002); + } + + if (data.adopted_at !== undefined) { + assertValidDate(data.adopted_at); + } +}; + +export class AdoptionsService { + async listAdoptions(page = 1, limit = 10) { + assertValidPagination(page, limit); + + const skip = (page - 1) * limit; + + const [data, total] = await Promise.all([ + prisma.adoption.findMany({ + skip, + take: limit, + orderBy: { id: "desc" }, + }), + prisma.adoption.count(), + ]); + + return { + data, + meta: { + page, + limit, + total, + }, + }; + } + + async createAdoption(data: CreateAdoptionInput) { + assertCreatePayload(data); + + const adopter = await prisma.adopter.findUnique({ + where: { id: Number(data.adopter_id) }, + }); + + if (!adopter) { + throw new AppError(404, "Adopter not found", ERROR_CODES.DATA_001); + } + + return prisma.adoption.create({ + data: { + adopterId: Number(data.adopter_id), + fobId: data.fob_id.trim(), + adoptedAt: assertValidDate(data.adopted_at), + }, + }); + } + + async getAdoptionById(id: number) { + assertValidId(id); + + const adoption = await prisma.adoption.findUnique({ + where: { id }, + }); + + if (!adoption) { + throw new AppError(404, "Adoption not found", ERROR_CODES.DATA_001); + } + + return adoption; + } + + async updateAdoption(id: number, data: UpdateAdoptionInput) { + assertValidId(id); + assertUpdatePayload(data); + + await this.getAdoptionById(id); + + if (data.adopter_id !== undefined) { + const adopter = await prisma.adopter.findUnique({ + where: { id: Number(data.adopter_id) }, + }); + + if (!adopter) { + throw new AppError(404, "Adopter not found", ERROR_CODES.DATA_001); + } + } + + return prisma.adoption.update({ + where: { id }, + data: { + ...(data.adopter_id !== undefined + ? { adopterId: Number(data.adopter_id) } + : {}), + ...(data.fob_id !== undefined ? { fobId: data.fob_id.trim() } : {}), + ...(data.adopted_at !== undefined + ? { adoptedAt: assertValidDate(data.adopted_at) } + : {}), + }, + }); + } + + async deleteAdoption(id: number) { + assertValidId(id); + + await this.getAdoptionById(id); + + await prisma.adoption.delete({ + where: { id }, + }); + + return { + message: "Adoption deleted successfully", + }; + } +} + +export const adoptionsService = new AdoptionsService(); + +export type { CreateAdoptionInput, UpdateAdoptionInput }; \ No newline at end of file diff --git a/src/modules/adoptions/index.ts b/src/modules/adoptions/index.ts index e69de29..a6f0df2 100644 --- a/src/modules/adoptions/index.ts +++ b/src/modules/adoptions/index.ts @@ -0,0 +1,3 @@ +export { default as adoptionsRoutes } from "./adoptions.routes"; +export { adoptionsController } from "./adoptions.controller"; +export { adoptionsService } from "./adoptions.service"; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index d6394e3..d055767 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,6 +7,7 @@ import { treeTypesRoutes } from "../modules/tree-types"; import { projectManagementRoutes } from "../modules/project-management"; import { localizationRoutes } from "../modules/localization"; import { adoptersRouter } from "../modules/adopters"; +import { adoptionsRoutes } from "../modules/adoptions"; import { userProjectAssignmentRoutes } from "../modules/user-project-assignment"; import { partnersRoutes } from "../modules/partners"; import { scanBatchesRoutes } from "../modules/scan-batches"; @@ -18,6 +19,7 @@ const router = Router(); router.use("/health", healthRoutes); router.use("/auth", authRoutes); router.use("/adopters", adoptersRouter); +router.use("/adoptions", adoptionsRoutes); router.use("/users", userRoutes); router.use("/tree-types", treeTypesRoutes); router.use("/projects", projectManagementRoutes); diff --git a/tests/integration/adoptions.test.ts b/tests/integration/adoptions.test.ts index 73e2381..b87c13d 100644 --- a/tests/integration/adoptions.test.ts +++ b/tests/integration/adoptions.test.ts @@ -1,5 +1,146 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +process.env.NODE_ENV = "development"; +process.env.AUTH_DEV_MODE = "true"; + +import { describe, expect, it, beforeAll } from "@jest/globals"; +import request from "supertest"; +import app from "../../src/app"; + +const TOKENS = { + ADMIN: process.env.AUTH_DEV_ADMIN_TOKEN!, + MANAGER: process.env.AUTH_DEV_MANAGER_TOKEN!, + INSPECTOR: process.env.AUTH_DEV_INSPECTOR_TOKEN!, + FARMER: process.env.AUTH_DEV_FARMER_TOKEN!, +}; + +describe("Adoptions API Integration Tests", () => { + let adopterId: number; + let adoptionId: number; + + beforeAll(async () => { + const adopter = await request(app) + .post("/adopters") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") + .send({ + name: "Integration Adopter", + email: "integration@gmail.com", + }); + + adopterId = adopter.body.data.id; }); -}); + + it("POST /adoptions - should create adoption", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") + .send({ + adopter_id: adopterId, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("data.id"); + + adoptionId = res.body.data.id; + }); + + it("POST /adoptions - should return 400 when fob_id missing", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ + adopter_id: adopterId, + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(400); + }); + + it("GET /adoptions - should return list", async () => { + const res = await request(app) + .get("/adoptions?page=1&limit=10") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + }); + + it("GET /adoptions/:id - should return 404", async () => { + const res = await request(app) + .get("/adoptions/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(404); + }); + + it("PUT /adoptions/:id - should update adoption", async () => { + const res = await request(app) + .put(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ + fob_id: "NFC-UPDATED", + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("DELETE /adoptions/:id - should delete adoption", async () => { + const created = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ + adopter_id: adopterId, + fob_id: "NFC-DELETE", + adopted_at: "2026-05-14", + }); + + const res = await request(app) + .delete(`/adoptions/${created.body.data.id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + }); + + it("POST /adoptions - should return 401 when no token", async () => { + const res = await request(app) + .post("/adoptions") + .send({ + adopter_id: adopterId, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(401); + }); + + it("POST /adoptions - should return 403 for FARMER", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.FARMER}`) + .send({ + adopter_id: adopterId, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(403); + }); + + it("GET /adoptions - MANAGER should access list", async () => { + const res = await request(app) + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("DELETE cleanup created adoption", async () => { + const res = await request(app) + .delete(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + }); +}); \ No newline at end of file diff --git a/tests/unit/adoptions.test.ts b/tests/unit/adoptions.test.ts index 73e2381..d564cc0 100644 --- a/tests/unit/adoptions.test.ts +++ b/tests/unit/adoptions.test.ts @@ -1,5 +1,255 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +import { describe, expect, it, beforeEach, jest } from "@jest/globals"; +import { adoptionsService } from "../../src/modules/adoptions/adoptions.service"; +import { prisma } from "../../src/lib/prisma"; +import { AppError } from "../../src/middleware/errorHandler"; + +jest.mock("../../src/lib/prisma", () => ({ + prisma: { + adoption: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + adopter: { + findUnique: jest.fn(), + }, + }, +})); + +const mockedPrismaAdoption = prisma.adoption as { + create: jest.MockedFunction; + findMany: jest.MockedFunction; + findUnique: jest.MockedFunction; + update: jest.MockedFunction; + delete: jest.MockedFunction; + count: jest.MockedFunction; +}; + +const mockedPrismaAdopter = prisma.adopter as { + findUnique: jest.MockedFunction; +}; + +describe("AdoptionsService - Unit Tests", () => { + beforeEach(() => { + jest.clearAllMocks(); }); -}); + + describe("createAdoption", () => { + it("should create adoption successfully", async () => { + mockedPrismaAdopter.findUnique.mockResolvedValue({ + id: 1, + name: "Adam", + }); + + mockedPrismaAdoption.create.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + const result = await adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(result.id).toBe(1); + expect(result.fobId).toBe("NFC-001"); + expect(mockedPrismaAdopter.findUnique).toHaveBeenCalledTimes(1); + expect(mockedPrismaAdoption.create).toHaveBeenCalledTimes(1); + }); + + it("should throw 400 when fob_id is empty", async () => { + await expect( + adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "", + adopted_at: "2026-05-14", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 400 when adopted_at is invalid", async () => { + await expect( + adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "NFC-001", + adopted_at: "invalid-date", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 400 when adopted_at is in the future", async () => { + await expect( + adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "NFC-001", + adopted_at: "2099-01-01", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 404 when adopter does not exist", async () => { + mockedPrismaAdopter.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.createAdoption({ + adopter_id: 999, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }), + ).rejects.toThrow(AppError); + }); + }); + + describe("listAdoptions", () => { + it("should return paginated adoptions", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([ + { + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }, + ]); + + mockedPrismaAdoption.count.mockResolvedValue(1); + + const result = await adoptionsService.listAdoptions(1, 10); + + expect(result.data.length).toBe(1); + expect(result.meta.total).toBe(1); + expect(mockedPrismaAdoption.findMany).toHaveBeenCalled(); + }); + + it("should throw 400 for invalid pagination", async () => { + await expect(adoptionsService.listAdoptions(0, 10)).rejects.toThrow( + AppError, + ); + }); + }); + + describe("getAdoptionById", () => { + it("should return adoption when exists", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + const result = await adoptionsService.getAdoptionById(1); + + expect(result.id).toBe(1); + }); + + it("should throw 404 when adoption not found", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.getAdoptionById(999), + ).rejects.toThrow(AppError); + }); + + it("should throw 400 for invalid id", async () => { + await expect(adoptionsService.getAdoptionById(0)).rejects.toThrow( + AppError, + ); + }); + }); + + describe("updateAdoption", () => { + it("should update adoption successfully", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + mockedPrismaAdoption.update.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-UPDATED", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + const result = await adoptionsService.updateAdoption(1, { + fob_id: "NFC-UPDATED", + }); + + expect(result.fobId).toBe("NFC-UPDATED"); + expect(mockedPrismaAdoption.update).toHaveBeenCalledTimes(1); + }); + + it("should throw 400 when no fields provided", async () => { + await expect(adoptionsService.updateAdoption(1, {})).rejects.toThrow( + AppError, + ); + }); + + it("should throw 404 when updating non-existing adoption", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.updateAdoption(999, { + fob_id: "NFC-UPDATED", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 404 when updated adopter does not exist", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + mockedPrismaAdopter.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.updateAdoption(1, { + adopter_id: 999, + }), + ).rejects.toThrow(AppError); + }); + }); + + describe("deleteAdoption", () => { + it("should delete adoption successfully", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + }); + + mockedPrismaAdoption.delete.mockResolvedValue({ + id: 1, + }); + + const result = await adoptionsService.deleteAdoption(1); + + expect(result.message).toBe("Adoption deleted successfully"); + expect(mockedPrismaAdoption.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + }); + + it("should throw 404 when deleting non-existing adoption", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.deleteAdoption(999), + ).rejects.toThrow(AppError); + }); + }); +}); \ No newline at end of file From 23568d0c9d1546b09a50c60f687abfbdd4996c9a Mon Sep 17 00:00:00 2001 From: tinak250 Date: Sat, 16 May 2026 18:13:22 +1000 Subject: [PATCH 14/26] Format adoptions module --- src/modules/adoptions/adoptions.controller.ts | 2 +- src/modules/adoptions/adoptions.routes.ts | 2 +- src/modules/adoptions/adoptions.service.ts | 2 +- src/modules/adoptions/index.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/adoptions/adoptions.controller.ts b/src/modules/adoptions/adoptions.controller.ts index 12743b6..64da5bc 100644 --- a/src/modules/adoptions/adoptions.controller.ts +++ b/src/modules/adoptions/adoptions.controller.ts @@ -85,4 +85,4 @@ export class AdoptionsController { } } -export const adoptionsController = new AdoptionsController(); \ No newline at end of file +export const adoptionsController = new AdoptionsController(); diff --git a/src/modules/adoptions/adoptions.routes.ts b/src/modules/adoptions/adoptions.routes.ts index b332815..ca93637 100644 --- a/src/modules/adoptions/adoptions.routes.ts +++ b/src/modules/adoptions/adoptions.routes.ts @@ -63,4 +63,4 @@ router.delete( }, ); -export default router; \ No newline at end of file +export default router; diff --git a/src/modules/adoptions/adoptions.service.ts b/src/modules/adoptions/adoptions.service.ts index 5a314f0..6825135 100644 --- a/src/modules/adoptions/adoptions.service.ts +++ b/src/modules/adoptions/adoptions.service.ts @@ -195,4 +195,4 @@ export class AdoptionsService { export const adoptionsService = new AdoptionsService(); -export type { CreateAdoptionInput, UpdateAdoptionInput }; \ No newline at end of file +export type { CreateAdoptionInput, UpdateAdoptionInput }; diff --git a/src/modules/adoptions/index.ts b/src/modules/adoptions/index.ts index a6f0df2..2a0de59 100644 --- a/src/modules/adoptions/index.ts +++ b/src/modules/adoptions/index.ts @@ -1,3 +1,3 @@ export { default as adoptionsRoutes } from "./adoptions.routes"; export { adoptionsController } from "./adoptions.controller"; -export { adoptionsService } from "./adoptions.service"; \ No newline at end of file +export { adoptionsService } from "./adoptions.service"; From 1ed3e334272119f69f5b545e46b05bb6fcbce13c Mon Sep 17 00:00:00 2001 From: tinak250 Date: Sat, 16 May 2026 23:12:09 +1000 Subject: [PATCH 15/26] Implement adoptions API with filters and tests --- src/modules/adoptions/adoptions.controller.ts | 58 ++--- src/modules/adoptions/adoptions.service.ts | 139 ++++++++++-- tests/integration/adoptions.test.ts | 214 ++++++++++++++++-- tests/unit/adoptions.test.ts | 91 +++++++- 4 files changed, 427 insertions(+), 75 deletions(-) diff --git a/src/modules/adoptions/adoptions.controller.ts b/src/modules/adoptions/adoptions.controller.ts index 64da5bc..5742de1 100644 --- a/src/modules/adoptions/adoptions.controller.ts +++ b/src/modules/adoptions/adoptions.controller.ts @@ -3,19 +3,24 @@ import { adoptionsService, type CreateAdoptionInput, type UpdateAdoptionInput, + type ListAdoptionsFilters, } from "./adoptions.service"; +const parseOptionalNumber = (value: unknown): number | undefined => { + if (value === undefined) { + return undefined; + } + const parsed = Number(value); + return parsed; +}; + export class AdoptionsController { async createAdoption(req: Request, res: Response, next: NextFunction) { try { const payload = req.body as CreateAdoptionInput; - const result = await adoptionsService.createAdoption(payload); - return res.status(201).json({ - success: true, - data: result, - }); + return res.status(201).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -23,15 +28,22 @@ export class AdoptionsController { async listAdoptions(req: Request, res: Response, next: NextFunction) { try { - const page = Number(req.query.page) || 1; - const limit = Number(req.query.limit) || 10; - - const result = await adoptionsService.listAdoptions(page, limit); - - return res.status(200).json({ - success: true, - data: result, - }); + const filters: ListAdoptionsFilters = { + page: parseOptionalNumber(req.query.page) ?? 1, + limit: parseOptionalNumber(req.query.limit) ?? 10, + fob_id: + req.query.fob_id !== undefined ? String(req.query.fob_id) : undefined, + adopter_id: parseOptionalNumber(req.query.adopter_id), + adopter: + req.query.adopter !== undefined + ? String(req.query.adopter) + : undefined, + year: parseOptionalNumber(req.query.year), + }; + + const result = await adoptionsService.listAdoptions(filters); + + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -40,13 +52,9 @@ export class AdoptionsController { async getAdoptionById(req: Request, res: Response, next: NextFunction) { try { const id = Number(req.params.id); - const result = await adoptionsService.getAdoptionById(id); - return res.status(200).json({ - success: true, - data: result, - }); + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -55,15 +63,11 @@ export class AdoptionsController { async updateAdoption(req: Request, res: Response, next: NextFunction) { try { const id = Number(req.params.id); - const payload = req.body as UpdateAdoptionInput; const result = await adoptionsService.updateAdoption(id, payload); - return res.status(200).json({ - success: true, - data: result, - }); + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -72,13 +76,9 @@ export class AdoptionsController { async deleteAdoption(req: Request, res: Response, next: NextFunction) { try { const id = Number(req.params.id); - const result = await adoptionsService.deleteAdoption(id); - return res.status(200).json({ - success: true, - data: result, - }); + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } diff --git a/src/modules/adoptions/adoptions.service.ts b/src/modules/adoptions/adoptions.service.ts index 6825135..1a38e9e 100644 --- a/src/modules/adoptions/adoptions.service.ts +++ b/src/modules/adoptions/adoptions.service.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import { prisma } from "../../lib/prisma"; import { AppError } from "../../middleware/errorHandler"; import { ERROR_CODES } from "../../utils/errorCodes"; @@ -14,9 +15,18 @@ interface UpdateAdoptionInput { adopted_at?: string; } +interface ListAdoptionsFilters { + page?: number; + limit?: number; + fob_id?: string; + adopter_id?: number; + adopter?: string; + year?: number; +} + const assertValidId = (id: number) => { if (!Number.isInteger(id) || id <= 0) { - throw new AppError(400, ERROR_CODES.VAL_002, ERROR_CODES.VAL_002); + throw new AppError(400, "Invalid ID", ERROR_CODES.VAL_002); } }; @@ -35,18 +45,54 @@ const assertValidPagination = (page: number, limit: number) => { } }; -const assertValidDate = (date: string) => { - const parsedDate = new Date(date); +const assertValidYear = (year: number) => { + const currentYear = new Date().getFullYear(); + + if (!Number.isInteger(year) || year < 1900 || year > currentYear) { + throw new AppError(400, "Invalid year filter", ERROR_CODES.VAL_002); + } +}; + +const parseStrictDate = (date: string) => { + if (typeof date !== "string" || !date.trim()) { + throw new AppError(400, "adopted_at is required", ERROR_CODES.VAL_003); + } + + const trimmedDate = date.trim(); + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + + if (!dateRegex.test(trimmedDate)) { + throw new AppError( + 400, + "Adoption date must use YYYY-MM-DD format", + ERROR_CODES.VAL_002, + ); + } + + const parsedDate = new Date(`${trimmedDate}T00:00:00.000Z`); if (Number.isNaN(parsedDate.getTime())) { throw new AppError(400, "Invalid adoption date", ERROR_CODES.VAL_002); } - if (parsedDate > new Date()) { + const [year, month, day] = trimmedDate.split("-").map(Number); + + if ( + parsedDate.getUTCFullYear() !== year || + parsedDate.getUTCMonth() + 1 !== month || + parsedDate.getUTCDate() !== day + ) { + throw new AppError(400, "Invalid adoption date", ERROR_CODES.VAL_002); + } + + const today = new Date(); + today.setUTCHours(23, 59, 59, 999); + + if (parsedDate > today) { throw new AppError( 400, "Adoption date cannot be in the future", - ERROR_CODES.VAL_003, + ERROR_CODES.VAL_002, ); } @@ -54,17 +100,17 @@ const assertValidDate = (date: string) => { }; const assertCreatePayload = (data: CreateAdoptionInput) => { + if (data.adopter_id === undefined || data.adopter_id === null) { + throw new AppError(400, "adopter_id is required", ERROR_CODES.VAL_003); + } + assertValidId(Number(data.adopter_id)); if (!data.fob_id?.trim()) { - throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); - } - - if (!data.adopted_at) { - throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + throw new AppError(400, "fob_id is required", ERROR_CODES.VAL_003); } - assertValidDate(data.adopted_at); + parseStrictDate(data.adopted_at); }; const assertUpdatePayload = (data: UpdateAdoptionInput) => { @@ -85,23 +131,75 @@ const assertUpdatePayload = (data: UpdateAdoptionInput) => { } if (data.adopted_at !== undefined) { - assertValidDate(data.adopted_at); + parseStrictDate(data.adopted_at); } }; export class AdoptionsService { - async listAdoptions(page = 1, limit = 10) { + async listAdoptions(filters: ListAdoptionsFilters = {}) { + const page = filters.page === undefined ? 1 : Number(filters.page); + + const limit = filters.limit === undefined ? 10 : Number(filters.limit); + assertValidPagination(page, limit); const skip = (page - 1) * limit; + const where: Prisma.AdoptionWhereInput = {}; + + if (filters.fob_id !== undefined) { + if (!filters.fob_id.trim()) { + throw new AppError(400, "Invalid fob_id filter", ERROR_CODES.VAL_002); + } + + where.fobId = { + contains: filters.fob_id.trim(), + mode: "insensitive", + }; + } + + if (filters.adopter_id !== undefined) { + assertValidId(Number(filters.adopter_id)); + + where.adopterId = Number(filters.adopter_id); + } + + if (filters.adopter !== undefined) { + if (!filters.adopter.trim()) { + throw new AppError(400, "Invalid adopter filter", ERROR_CODES.VAL_002); + } + + where.adopter = { + name: { + contains: filters.adopter.trim(), + mode: "insensitive", + }, + }; + } + + if (filters.year !== undefined) { + const year = Number(filters.year); + + assertValidYear(year); + + where.adoptedAt = { + gte: new Date(`${year}-01-01T00:00:00.000Z`), + lte: new Date(`${year}-12-31T23:59:59.999Z`), + }; + } + const [data, total] = await Promise.all([ prisma.adoption.findMany({ + where, skip, take: limit, orderBy: { id: "desc" }, + include: { + adopter: true, + }, }), - prisma.adoption.count(), + + prisma.adoption.count({ where }), ]); return { @@ -129,7 +227,7 @@ export class AdoptionsService { data: { adopterId: Number(data.adopter_id), fobId: data.fob_id.trim(), - adoptedAt: assertValidDate(data.adopted_at), + adoptedAt: parseStrictDate(data.adopted_at), }, }); } @@ -139,6 +237,9 @@ export class AdoptionsService { const adoption = await prisma.adoption.findUnique({ where: { id }, + include: { + adopter: true, + }, }); if (!adoption) { @@ -150,6 +251,7 @@ export class AdoptionsService { async updateAdoption(id: number, data: UpdateAdoptionInput) { assertValidId(id); + assertUpdatePayload(data); await this.getAdoptionById(id); @@ -166,13 +268,16 @@ export class AdoptionsService { return prisma.adoption.update({ where: { id }, + data: { ...(data.adopter_id !== undefined ? { adopterId: Number(data.adopter_id) } : {}), + ...(data.fob_id !== undefined ? { fobId: data.fob_id.trim() } : {}), + ...(data.adopted_at !== undefined - ? { adoptedAt: assertValidDate(data.adopted_at) } + ? { adoptedAt: parseStrictDate(data.adopted_at) } : {}), }, }); @@ -195,4 +300,4 @@ export class AdoptionsService { export const adoptionsService = new AdoptionsService(); -export type { CreateAdoptionInput, UpdateAdoptionInput }; +export type { CreateAdoptionInput, UpdateAdoptionInput, ListAdoptionsFilters }; diff --git a/tests/integration/adoptions.test.ts b/tests/integration/adoptions.test.ts index b87c13d..c3bbf21 100644 --- a/tests/integration/adoptions.test.ts +++ b/tests/integration/adoptions.test.ts @@ -1,7 +1,7 @@ process.env.NODE_ENV = "development"; process.env.AUTH_DEV_MODE = "true"; -import { describe, expect, it, beforeAll } from "@jest/globals"; +import { describe, expect, it, beforeAll, afterAll } from "@jest/globals"; import request from "supertest"; import app from "../../src/app"; @@ -15,6 +15,7 @@ const TOKENS = { describe("Adoptions API Integration Tests", () => { let adopterId: number; let adoptionId: number; + const cleanupAdoptionIds: number[] = []; beforeAll(async () => { const adopter = await request(app) @@ -23,12 +24,29 @@ describe("Adoptions API Integration Tests", () => { .set("Content-Type", "application/json") .send({ name: "Integration Adopter", - email: "integration@gmail.com", + email: `integration-${Date.now()}@gmail.com`, }); + expect(adopter.status).toBe(201); + expect(adopter.body).toHaveProperty("data.id"); + adopterId = adopter.body.data.id; }); + afterAll(async () => { + for (const id of cleanupAdoptionIds) { + await request(app) + .delete(`/adoptions/${id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + } + + if (adopterId) { + await request(app) + .delete(`/adopters/${adopterId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + } + }); + it("POST /adoptions - should create adoption", async () => { const res = await request(app) .post("/adoptions") @@ -42,14 +60,17 @@ describe("Adoptions API Integration Tests", () => { expect(res.status).toBe(201); expect(res.body).toHaveProperty("data.id"); + expect(res.body.data.fobId).toBe("NFC-001"); adoptionId = res.body.data.id; + cleanupAdoptionIds.push(adoptionId); }); it("POST /adoptions - should return 400 when fob_id missing", async () => { const res = await request(app) .post("/adoptions") .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") .send({ adopter_id: adopterId, adopted_at: "2026-05-14", @@ -58,12 +79,82 @@ describe("Adoptions API Integration Tests", () => { expect(res.status).toBe(400); }); + it("POST /adoptions - should return 400 for invalid adopted_at format", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") + .send({ + adopter_id: adopterId, + fob_id: "NFC-BAD-DATE", + adopted_at: "14-05-2026", + }); + + expect(res.status).toBe(400); + }); + it("GET /adoptions - should return list", async () => { const res = await request(app) .get("/adoptions?page=1&limit=10") .set("Authorization", `Bearer ${TOKENS.ADMIN}`); expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty("data"); + expect(res.body.data).toHaveProperty("meta"); + }); + + it("GET /adoptions - should filter by fob_id", async () => { + const res = await request(app) + .get("/adoptions?fob_id=NFC-001") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should filter by adopter_id", async () => { + const res = await request(app) + .get(`/adoptions?adopter_id=${adopterId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should filter by adopter name", async () => { + const res = await request(app) + .get("/adoptions?adopter=Integration") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should filter by year", async () => { + const res = await request(app) + .get("/adoptions?year=2026") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should return 400 for invalid page query", async () => { + const res = await request(app) + .get("/adoptions?page=abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); + + it("GET /adoptions/:id - should return adoption by id", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.id).toBe(adoptionId); }); it("GET /adoptions/:id - should return 404", async () => { @@ -78,69 +169,156 @@ describe("Adoptions API Integration Tests", () => { const res = await request(app) .put(`/adoptions/${adoptionId}`) .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") .send({ fob_id: "NFC-UPDATED", }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); + expect(res.body.data.fobId).toBe("NFC-UPDATED"); }); it("DELETE /adoptions/:id - should delete adoption", async () => { const created = await request(app) .post("/adoptions") .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") .send({ adopter_id: adopterId, fob_id: "NFC-DELETE", adopted_at: "2026-05-14", }); + expect(created.status).toBe(201); + expect(created.body).toHaveProperty("data.id"); + const res = await request(app) .delete(`/adoptions/${created.body.data.id}`) .set("Authorization", `Bearer ${TOKENS.ADMIN}`); expect(res.status).toBe(200); + + const checkDeleted = await request(app) + .get(`/adoptions/${created.body.data.id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(checkDeleted.status).toBe(404); }); it("POST /adoptions - should return 401 when no token", async () => { + const res = await request(app).post("/adoptions").send({ + adopter_id: adopterId, + fob_id: "NFC-NO-TOKEN", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(401); + }); + + it("GET /adoptions - should return 401 when no token", async () => { + const res = await request(app).get("/adoptions"); + + expect(res.status).toBe(401); + }); + + it("GET /adoptions/:id - should return 401 when no token", async () => { + const res = await request(app).get(`/adoptions/${adoptionId}`); + + expect(res.status).toBe(401); + }); + + it("PUT /adoptions/:id - should return 401 when no token", async () => { + const res = await request(app).put(`/adoptions/${adoptionId}`).send({ + fob_id: "NFC-NO-TOKEN-UPDATE", + }); + + expect(res.status).toBe(401); + }); + + it("DELETE /adoptions/:id - should return 401 when no token", async () => { + const res = await request(app).delete(`/adoptions/${adoptionId}`); + + expect(res.status).toBe(401); + }); + + it("GET /adoptions - MANAGER should access list", async () => { + const res = await request(app) + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("GET /adoptions/:id - MANAGER should access details", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("POST /adoptions - MANAGER should return 403", async () => { const res = await request(app) .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) .send({ adopter_id: adopterId, - fob_id: "NFC-001", + fob_id: "NFC-MANAGER", adopted_at: "2026-05-14", }); - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); - it("POST /adoptions - should return 403 for FARMER", async () => { + it("PUT /adoptions/:id - MANAGER should return 403", async () => { const res = await request(app) - .post("/adoptions") - .set("Authorization", `Bearer ${TOKENS.FARMER}`) + .put(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) .send({ - adopter_id: adopterId, - fob_id: "NFC-001", - adopted_at: "2026-05-14", + fob_id: "NFC-MANAGER-UPDATE", }); expect(res.status).toBe(403); }); - it("GET /adoptions - MANAGER should access list", async () => { + it("DELETE /adoptions/:id - MANAGER should return 403", async () => { const res = await request(app) - .get("/adoptions") + .delete(`/adoptions/${adoptionId}`) .set("Authorization", `Bearer ${TOKENS.MANAGER}`); - expect(res.status).toBe(200); + expect(res.status).toBe(403); }); - it("DELETE cleanup created adoption", async () => { + it("GET /adoptions - INSPECTOR should return 403", async () => { const res = await request(app) - .delete(`/adoptions/${adoptionId}`) - .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); - expect(res.status).toBe(200); + expect(res.status).toBe(403); + }); + + it("GET /adoptions/:id - INSPECTOR should return 403", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("GET /adoptions - FARMER should return 403", async () => { + const res = await request(app) + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(res.status).toBe(403); + }); + + it("GET /adoptions/:id - FARMER should return 403", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(res.status).toBe(403); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/adoptions.test.ts b/tests/unit/adoptions.test.ts index d564cc0..72b6f03 100644 --- a/tests/unit/adoptions.test.ts +++ b/tests/unit/adoptions.test.ts @@ -121,18 +121,87 @@ describe("AdoptionsService - Unit Tests", () => { mockedPrismaAdoption.count.mockResolvedValue(1); - const result = await adoptionsService.listAdoptions(1, 10); + const result = await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + }); expect(result.data.length).toBe(1); expect(result.meta.total).toBe(1); expect(mockedPrismaAdoption.findMany).toHaveBeenCalled(); }); - it("should throw 400 for invalid pagination", async () => { - await expect(adoptionsService.listAdoptions(0, 10)).rejects.toThrow( - AppError, + it("should apply fob_id filter", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([]); + mockedPrismaAdoption.count.mockResolvedValue(0); + + await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + fob_id: "NFC-001", + }); + + expect(mockedPrismaAdoption.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + fobId: { + contains: "NFC-001", + mode: "insensitive", + }, + }), + }), ); }); + + it("should apply adopter_id filter", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([]); + mockedPrismaAdoption.count.mockResolvedValue(0); + + await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + adopter_id: 1, + }); + + expect(mockedPrismaAdoption.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + adopterId: 1, + }), + }), + ); + }); + + it("should apply year filter", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([]); + mockedPrismaAdoption.count.mockResolvedValue(0); + + await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + year: 2026, + }); + + expect(mockedPrismaAdoption.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + adoptedAt: expect.objectContaining({ + gte: expect.any(Date), + lte: expect.any(Date), + }), + }), + }), + ); + }); + + it("should throw 400 for invalid pagination", async () => { + await expect( + adoptionsService.listAdoptions({ + page: 0, + limit: 10, + }), + ).rejects.toThrow(AppError); + }); }); describe("getAdoptionById", () => { @@ -153,9 +222,9 @@ describe("AdoptionsService - Unit Tests", () => { it("should throw 404 when adoption not found", async () => { mockedPrismaAdoption.findUnique.mockResolvedValue(null); - await expect( - adoptionsService.getAdoptionById(999), - ).rejects.toThrow(AppError); + await expect(adoptionsService.getAdoptionById(999)).rejects.toThrow( + AppError, + ); }); it("should throw 400 for invalid id", async () => { @@ -247,9 +316,9 @@ describe("AdoptionsService - Unit Tests", () => { it("should throw 404 when deleting non-existing adoption", async () => { mockedPrismaAdoption.findUnique.mockResolvedValue(null); - await expect( - adoptionsService.deleteAdoption(999), - ).rejects.toThrow(AppError); + await expect(adoptionsService.deleteAdoption(999)).rejects.toThrow( + AppError, + ); }); }); -}); \ No newline at end of file +}); From 57a8e4793a3cd4194667a16aeb3ab966112aed0d Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sun, 17 May 2026 18:52:38 +1000 Subject: [PATCH 16/26] docs: add scan batches API documentation to API.md --- docs/API.md | 414 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 413 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 17eba12..b0d8e6a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3964,4 +3964,416 @@ The Adoptions API follows the TreeO2 backend engineering standard: - Prisma-backed data access - Swagger documentation - Automated tests -- Scalable structure for future enhancements \ No newline at end of file +- Scalable structure for future enhancements + +--- +## 17. Scan Batches API + +This module manages scan batch upload and retrieval operations across the TreeO2 platform. It provides scan batch creation, pagination, project-scoped access control, validation, deletion protection, Swagger documentation, and automated testing coverage. + +**Module Path:** `src/modules/scan-batches/` + +### Files +- `scanBatches.routes.ts` +- `scanBatches.controller.ts` +- `scanBatches.service.ts` +- `scan-batches.schema.ts` +- `scan-batches.constants.ts` +- `scan-batches.docs.ts` +- `index.ts` + +--- + +## 17.1 Purpose + +The Scan Batches API is responsible for managing grouped tree scan uploads in the system. + +Scan batches are operational upload containers used for: +- Grouping uploaded tree scans +- Managing inspector uploads +- Tracking project-based scan submissions +- Validating relationships between inspectors, projects, farmers, and species +- Enforcing project-scoped access control +- Preventing deletion of batches linked to existing tree scans + +--- + +## 17.2 Architecture Flow + +Every request follows the standard backend module structure: + +```text +Route → Validation Schema → Controller → Service → Prisma ORM → PostgreSQL → Response +``` + +### Responsibilities + +### Routes +- Define endpoints +- Apply authentication middleware +- Apply role-based authorization +- Register Swagger documentation + +### Controller +- Parse request params/query/body +- Validate authenticated user context +- Pass validated data to service layer +- Return structured HTTP responses + +### Service +- Perform business validation +- Validate project relationships +- Apply access control rules +- Execute Prisma database operations +- Handle transactional batch creation +- Prevent invalid delete operations +- Throw structured application errors + +### Schemas +- Validate request body +- Validate query parameters +- Validate path parameters +- Enforce numeric/date validation rules +- Validate coordinates and measurements + +--- + +## 17.3 Security + +All endpoints are protected using Bearer Token authentication. + +### Middleware Used +- `authMiddleware` +- `roleMiddleware` + +### Service-Level Access Control + +- `ADMIN` can access all scan batches +- `MANAGER` can access batches from assigned projects only +- `INSPECTOR` can upload and access only their own batches + +--- + +## 17.4 Access Control Matrix + +| Endpoint | ADMIN | MANAGER | INSPECTOR | FARMER | DEVELOPER | +|---|---|---|---|---|---| +| GET /scan-batches | Yes | Yes (assigned projects only) | Yes (own batches only) | No | No | +| GET /scan-batches/{id} | Yes | Yes (assigned projects only) | Yes (own batches only) | No | No | +| POST /scan-batches | No | No | Yes | No | No | +| DELETE /scan-batches/{id} | Yes | No | No | No | No | + +--- + +## 17.5 Endpoints + +### GET /scan-batches + +Retrieve paginated scan batches with optional filtering. + +#### Query Parameters + +| Name | Type | Required | +|---|---|---| +| page | integer | No | +| limit | integer | No | +| project_id | integer | No | +| inspector_id | integer | No | + +#### Response + +```json +{ + "success": true, + "message": "Scan batches fetched successfully", + "data": [ + { + "id": 1, + "inspectorId": 4, + "projectId": 1, + "uploadedAt": "2024-05-20T10:35:00.000Z" + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 1, + "totalPages": 1 + } +} +``` + +#### Status Codes +- `200` Success +- `400` Invalid query parameters +- `401` Authentication required +- `403` Insufficient permissions + +--- + +### GET /scan-batches/{id} + +Retrieve a single scan batch by ID. + +#### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +#### Response + +```json +{ + "success": true, + "message": "Scan batch fetched successfully", + "data": { + "id": 1, + "inspectorId": 4, + "projectId": 1, + "uploadedAt": "2024-05-20T10:35:00.000Z" + } +} +``` + +#### Status Codes +- `200` Success +- `400` Invalid scan batch ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Scan batch not found + +--- + +### POST /scan-batches + +Create a new scan batch and associate uploaded tree scans. + +#### Request Body + +```json +{ + "project_id": 1, + "uploaded_at": "2024-05-20T10:35:00.000Z", + "scans": [ + { + "fob_id": "SWAGGER-001", + "farmer_id": 16, + "species_id": 1, + "estimated_planted_year": 2024, + "estimated_planted_month": 5, + "planted_date": "2024-05-20", + "height_m": 2.5, + "circumference_cm": 45.3, + "diameter_cm": 14.4, + "latitude": -8.5569, + "longitude": 125.5603, + "device_id": "MOB-001" + } + ] +} +``` + +#### Required Fields +- `project_id` +- `scans` +- `fob_id` +- `farmer_id` +- `species_id` +- `estimated_planted_year` +- `estimated_planted_month` + +#### Response + +```json +{ + "success": true, + "message": "Scan batch uploaded successfully", + "data": { + "id": 1, + "inspectorId": 4, + "projectId": 1 + } +} +``` + +#### Status Codes +- `201` Created +- `400` Validation failed +- `401` Authentication required +- `403` Insufficient permissions +- `404` Related entity not found +- `422` Invalid project or measurement values + +--- + +### DELETE /scan-batches/{id} + +Delete a scan batch if it contains no related tree scans. + +#### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +#### Response + +```json +{ + "success": true, + "message": "Scan batch deleted successfully" +} +``` + +#### Status Codes +- `200` Success +- `401` Authentication required +- `403` Insufficient permissions +- `404` Scan batch not found +- `409` Scan batch contains related tree scans + +--- + +## 17.6 Validation Rules + +### Create Validation +- Project ID must be positive integer +- Inspector must exist +- Inspector must have INSPECTOR role +- Inspector account must be active +- Project must exist +- Project must be active +- Inspector must belong to project +- Farmer must exist +- Farmer must belong to project +- Species must exist +- Species must belong to project +- Height must not exceed allowed limit +- Diameter must not exceed allowed limit +- Circumference must not exceed allowed limit +- Coordinates must be valid +- Month must be between 1 and 12 + +### Access Control Validation +- Managers can access batches from assigned projects only +- Inspectors can access only their own batches +- Only inspectors can upload batches +- Only admins can delete batches + +### Delete Validation +- Batch must exist +- Batch cannot contain related tree scans + +--- + +## 17.7 Error Handling + +Uses centralized error middleware. + +### Standard Error Response + +```json +{ + "success": false, + "message": "Scan batch not found" +} +``` + +### Common Errors +- Authentication required +- Insufficient permissions +- Invalid scan batch ID +- Invalid coordinates +- Invalid measurement values +- Project inactive +- Inspector not assigned to project +- Farmer not assigned to project +- Species not assigned to project +- Scan batch not found +- Scan batch contains related tree scans + +--- + +## 17.8 Swagger Documentation + +All endpoints are documented in: + +```text +scan-batches.docs.ts +``` + +Available at: + +```text +http://localhost:3000/api-docs +``` + +### Swagger Supports +- Interactive endpoint testing +- Request examples +- Response schemas +- Security schemas +- Query parameter documentation + +--- + +## 17.9 Testing + +### Test Files +- `tests/unit/scan-batches.test.ts` +- `tests/integration/scan-batches.test.ts` + +### Covered Scenarios + +#### Authentication +- No token returns `401` + +#### Authorization +- Admin access validation +- Manager assigned-project restrictions +- Inspector own-batch restrictions +- Inspector-only upload validation +- Admin-only delete validation + +#### Read +- Get all scan batches +- Get scan batch by ID +- Pagination validation +- Filtering validation +- Missing batch returns `404` + +#### Create +- Valid scan batch upload +- Invalid role rejection +- Inactive inspector rejection +- Inactive project rejection +- Invalid coordinates rejection +- Invalid measurement rejection +- Unassigned farmer rejection +- Unassigned inspector rejection +- Unassigned species rejection +- Multi-scan validation + +#### Delete +- Valid delete succeeds +- Delete blocked when tree scans exist +- Missing scan batch rejected + +--- + +## 17.10 Summary + +The Scan Batches API follows the TreeO2 backend engineering standard: + +- Modular backend architecture +- Secure authentication +- Role-based access control +- Project-scoped authorization +- Strong validation rules +- Relationship integrity validation +- Protected delete operations +- Swagger documentation +- Automated unit testing +- Automated integration testing +- Scalable backend structure \ No newline at end of file From ba758bf51c0dd5a13e416af726ea6223f2186ff4 Mon Sep 17 00:00:00 2001 From: Alan maria George Date: Sun, 10 May 2026 16:05:59 +1000 Subject: [PATCH 17/26] Add organisation related Prisma models --- prisma/models/country.prisma | 1 + prisma/models/location.prisma | 1 + prisma/models/organisation_roles.prisma | 6 ++++++ prisma/models/organisations.prisma | 23 +++++++++++++++++++++++ prisma/models/user.prisma | 1 + prisma/models/user_organisations.prisma | 12 ++++++++++++ 6 files changed, 44 insertions(+) create mode 100644 prisma/models/organisation_roles.prisma create mode 100644 prisma/models/organisations.prisma create mode 100644 prisma/models/user_organisations.prisma diff --git a/prisma/models/country.prisma b/prisma/models/country.prisma index 26addfe..a20a8e5 100644 --- a/prisma/models/country.prisma +++ b/prisma/models/country.prisma @@ -8,6 +8,7 @@ model Country { administrativeLevels AdministrativeLevel[] users User[] @relation("UserCountry") projects Project[] @relation("ProjectCountry") + organisations Organisation[] @@map("countries") } \ No newline at end of file diff --git a/prisma/models/location.prisma b/prisma/models/location.prisma index 1b2f391..edfd73d 100644 --- a/prisma/models/location.prisma +++ b/prisma/models/location.prisma @@ -15,6 +15,7 @@ model Location { children Location[] @relation("LocationHierarchy") users User[] @relation("UserAdminLocation") projects Project[] @relation("ProjectAdminLocation") + organisations Organisation[] @@index([countryId]) @@index([parentId]) diff --git a/prisma/models/organisation_roles.prisma b/prisma/models/organisation_roles.prisma new file mode 100644 index 0000000..75192e0 --- /dev/null +++ b/prisma/models/organisation_roles.prisma @@ -0,0 +1,6 @@ +model OrganisationRole { + id Int @id @default(autoincrement()) + name String @unique @db.VarChar(100) + + @@map("organisation_roles") +} \ No newline at end of file diff --git a/prisma/models/organisations.prisma b/prisma/models/organisations.prisma new file mode 100644 index 0000000..a972c0e --- /dev/null +++ b/prisma/models/organisations.prisma @@ -0,0 +1,23 @@ +model Organisation { + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + contactEmail String? @unique @map("contact_email") @db.VarChar(300) + governmentId String? @map("government_id") @db.VarChar(80) + countryId Int? @map("country_id") + adminLocationId Int? @map("admin_location_id") + streetAddress String? @map("street_address") @db.VarChar(500) + logoId String? @map("logo_id") @db.VarChar(100) + description String? @db.Text + notes String? @db.Text + accountActive Boolean @default(true) @map("account_active") + dateJoined DateTime? @map("date_joined") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + country Country? @relation(fields: [countryId], references: [id]) + adminLocation Location? @relation(fields: [adminLocationId], references: [id]) + + userOrganisations UserOrganisation[] + + @@map("organisations") +} \ No newline at end of file diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index a56dc6f..e5a8fb8 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -36,6 +36,7 @@ model User { inspectorTreeScans TreeScan[] @relation("InspectorTreeScans") correctedTreeScans TreeScan[] @relation("CorrectedTreeScans") treeScanAudits TreeScanAudit[] @relation("TreeScanAuditChangedBy") + userOrganisations UserOrganisation[] @@map("users") diff --git a/prisma/models/user_organisations.prisma b/prisma/models/user_organisations.prisma new file mode 100644 index 0000000..d91d574 --- /dev/null +++ b/prisma/models/user_organisations.prisma @@ -0,0 +1,12 @@ +model UserOrganisation { + userId Int @map("user_id") + organisationId Int @map("organisation_id") + status String @default("active") @db.VarChar(30) + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Restrict) + + @@id([userId, organisationId]) + @@map("user_organisations") +} \ No newline at end of file From 4264fdbf9bbbb1463cc9ebb3a8ccbd0ffc0769fa Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 20:56:31 +1000 Subject: [PATCH 18/26] fix: enable prismaSchemaFolder so model files in prisma/models/ are picked up --- prisma/schema.prisma | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d37099d..360f1b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["prismaSchemaFolder"] } datasource db { From 4770eabb8e2adb75be25bb269106f67b92178aed Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 20:56:55 +1000 Subject: [PATCH 19/26] fix: strip projectIds from updateUser before Prisma call; add password validation and hashing to createUser --- .../user-management/userManagement.service.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/modules/user-management/userManagement.service.ts b/src/modules/user-management/userManagement.service.ts index 97a785c..f060bdd 100644 --- a/src/modules/user-management/userManagement.service.ts +++ b/src/modules/user-management/userManagement.service.ts @@ -2,6 +2,7 @@ import { prisma } from "../../lib/prisma"; import { ERROR_CODES } from "../../utils/errorCodes"; import { AppError } from "../../middleware/errorHandler"; import type { Prisma } from "@prisma/client"; +import { hashPassword } from "../../lib/bcrypt"; export type AuthUser = { id: number; @@ -12,6 +13,7 @@ export type AuthUser = { export type CreateUserInput = { name: string; email: string; + password?: string; roleId: number; projectIds?: number[]; }; @@ -135,6 +137,26 @@ export const UserManagementService = { throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); } + if (data.email.length > 300) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + + // Validate password complexity if provided + if (data.password) { + if (data.password.length < 8 || data.password.length > 72) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + if (!/[A-Z]/.test(data.password)) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + if (!/[0-9]/.test(data.password)) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + if (!/[^a-zA-Z0-9]/.test(data.password)) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + } + const role = await prisma.role.findUnique({ where: { id: data.roleId }, }); @@ -151,11 +173,17 @@ export const UserManagementService = { throw new AppError(409, ERROR_CODES.DATA_002, ERROR_CODES.DATA_002); } + // Hash password if provided + const passwordHash = data.password + ? await hashPassword(data.password) + : undefined; + return prisma.user.create({ data: { name: data.name.trim(), email: data.email.trim(), roleId: data.roleId, + ...(passwordHash && { passwordHash }), }, select: userSelect, }); @@ -199,9 +227,11 @@ export const UserManagementService = { } } + const { projectIds: _projectIds, ...dbData } = data; + return prisma.user.update({ where: { id: userId }, - data, + data: dbData, select: userSelect, }); }, From 67b52a07e4278cd0fe546180586245cbf847aea8 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 20:59:20 +1000 Subject: [PATCH 20/26] test: add deleteUser tests and RBAC coverage for getUsers --- tests/unit/user-management.test.ts | 64 +++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/unit/user-management.test.ts b/tests/unit/user-management.test.ts index e187e1e..05b1d59 100644 --- a/tests/unit/user-management.test.ts +++ b/tests/unit/user-management.test.ts @@ -95,7 +95,6 @@ describe("UserManagementService - UNIT TESTS (FIXED)", () => { email: "test@test.com", roleId: 1, }); - expect(result.id).toBe(1); }); @@ -183,5 +182,68 @@ describe("UserManagementService - UNIT TESTS (FIXED)", () => { code: expect.stringContaining("AUTH_004"), }); }); + + it("should block INSPECTOR from getUsers", async () => { + await expect( + UserManagementService.getUsers({ id: 1, role: "INSPECTOR" }), + ).rejects.toMatchObject({ + statusCode: 403, + code: expect.stringContaining("AUTH_004"), + }); + }); + + it("should block FARMER from getUsers", async () => { + await expect( + UserManagementService.getUsers({ id: 1, role: "FARMER" }), + ).rejects.toMatchObject({ + statusCode: 403, + code: expect.stringContaining("AUTH_004"), + }); + }); + }); + + // DELETE USER + describe("deleteUser", () => { + it("should soft-delete user", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.treeScan.findFirst.mockResolvedValue(null); + mockPrisma.user.update.mockResolvedValue({ id: 1 }); + + const result = await UserManagementService.deleteUser("1"); + + expect(result).toBe(true); + }); + + it("should throw not found", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + UserManagementService.deleteUser("999"), + ).rejects.toMatchObject({ + statusCode: 404, + code: expect.stringContaining("DATA_001"), + }); + }); + + it("should throw invalid id", async () => { + await expect( + UserManagementService.deleteUser("abc"), + ).rejects.toMatchObject({ + statusCode: 400, + code: expect.stringContaining("VAL_002"), + }); + }); + + it("should block delete when user has linked tree scans", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.treeScan.findFirst.mockResolvedValue({ id: 99 }); + + await expect( + UserManagementService.deleteUser("1"), + ).rejects.toMatchObject({ + statusCode: 409, + code: expect.stringContaining("VAL_001"), + }); + }); }); }); \ No newline at end of file From 35c1e68656762dd18fe00199621b4bacb2c4735c Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:18:11 +1000 Subject: [PATCH 21/26] config: add tsconfig.test.json and update jest to resolve jest globals in test files --- jest.config.js | 4 +++- tsconfig.test.json | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tsconfig.test.json diff --git a/jest.config.js b/jest.config.js index 3d14f5e..ec860b3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,11 @@ /** @type {import('jest').Config} */ module.exports = { - preset: 'ts-jest', testEnvironment: 'node', roots: ['/tests'], testMatch: ['**/*.test.ts'], collectCoverageFrom: ['src/**/*.ts', '!src/index.ts'], coverageDirectory: 'coverage', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }], + }, }; \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..9733f48 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-test", + "types": ["jest", "node"] + }, + "include": ["src/**/*", "tests/**/*"] +} \ No newline at end of file From ec57e4af0da3e681bb628fd3ac959414fab5c86f Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:18:24 +1000 Subject: [PATCH 22/26] test: rewrite user management integration tests with full endpoint and RBAC coverage --- tests/integration/user-management.test.ts | 360 ++++++++++++++++++---- 1 file changed, 296 insertions(+), 64 deletions(-) diff --git a/tests/integration/user-management.test.ts b/tests/integration/user-management.test.ts index c9b8948..fd06eeb 100644 --- a/tests/integration/user-management.test.ts +++ b/tests/integration/user-management.test.ts @@ -1,102 +1,334 @@ +import "dotenv/config"; import request from "supertest"; -import express from "express"; -import userRoutes from "../../src/modules/user-management/userManagement.routes"; -import { prisma } from "../../src/lib/prisma"; +import { PrismaClient } from "@prisma/client"; import jwt from "jsonwebtoken"; +import app from "../../src/app"; -const app = express(); -app.use(express.json()); -app.use("/users", userRoutes); +const prisma = new PrismaClient(); -const generateToken = (user: any) => - jwt.sign(user, process.env.JWT_SECRET as string); +const sign = (payload: object) => + jwt.sign(payload, process.env.JWT_SECRET as string); + +const TOKENS = { + ADMIN: sign({ id: 1, role: "ADMIN" }), + MANAGER: sign({ id: 2, role: "MANAGER", projectIds: [] }), + INSPECTOR: sign({ id: 3, role: "INSPECTOR" }), + FARMER: sign({ id: 4, role: "FARMER" }), +}; describe("User Management Integration Tests", () => { - let adminToken: string; + let roleId: number; + let userId: number; beforeEach(async () => { await prisma.treeScan.deleteMany(); await prisma.user.deleteMany(); await prisma.role.deleteMany(); - const role = await prisma.role.create({ - data: { id: 1, name: "ADMIN" }, - }); - - await prisma.user.create({ - data: { - id: 1, - name: "Admin", - email: "admin@test.com", - roleId: role.id, - }, - }); + const role = await prisma.role.create({ data: { name: "ADMIN" } }); + roleId = role.id; - adminToken = generateToken({ - id: 1, - role: "ADMIN", - projectIds: [], + const user = await prisma.user.create({ + data: { name: "Test Admin", email: "admin@test.com", roleId }, }); + userId = user.id; }); afterAll(async () => { + await prisma.treeScan.deleteMany(); + await prisma.user.deleteMany(); + await prisma.role.deleteMany(); await prisma.$disconnect(); }); - it("GET /users → 200", async () => { - const res = await request(app) - .get("/users") - .set("Authorization", `Bearer ${adminToken}`); + // ── GET /users ────────────────────────────────────────────────────────────── + describe("GET /users", () => { + it("should return 401 with no token", async () => { + const res = await request(app).get("/users"); + expect(res.status).toBe(401); + }); + + it("should return 200 for ADMIN", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it("should return 200 for MANAGER", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("should return 403 for FARMER", async () => { + const res = await request(app) + .get("/users") + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(res.status).toBe(403); + }); + + it("should return 400 for non-numeric project query param", async () => { + const res = await request(app) + .get("/users?project=abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); + + it("should return 200 when filtering by valid project id", async () => { + const res = await request(app) + .get("/users?project=1") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); - expect(res.status).toBe(200); + expect(res.status).toBe(200); + }); }); - it("POST /users → 201", async () => { - const res = await request(app) - .post("/users") - .set("Authorization", `Bearer ${adminToken}`) - .send({ - name: "New User", - email: "new@test.com", - roleId: 1, + // ── GET /users/:id ────────────────────────────────────────────────────────── + describe("GET /users/:id", () => { + it("should return 401 with no token", async () => { + const res = await request(app).get(`/users/${userId}`); + expect(res.status).toBe(401); + }); + + it("should return 200 and the user for ADMIN", async () => { + const res = await request(app) + .get(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(userId); + expect(res.body.email).toBe("admin@test.com"); + }); + + it("should return 404 when user does not exist", async () => { + const res = await request(app) + .get("/users/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(404); + }); + + it("should return 400 for non-numeric id", async () => { + const res = await request(app) + .get("/users/abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); + + it("should return 403 when INSPECTOR requests another user's profile", async () => { + const res = await request(app) + .get(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("should return 200 when INSPECTOR requests their own profile", async () => { + const inspector = await prisma.user.create({ + data: { name: "Inspector", email: "inspector@test.com", roleId }, }); - expect(res.status).toBe(201); + const token = sign({ id: inspector.id, role: "INSPECTOR" }); + + const res = await request(app) + .get(`/users/${inspector.id}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(inspector.id); + }); }); - it("PUT /users/:id → 200", async () => { - const res = await request(app) - .put("/users/1") - .set("Authorization", `Bearer ${adminToken}`) - .send({ name: "Updated" }); + // ── POST /users ───────────────────────────────────────────────────────────── + describe("POST /users", () => { + it("should return 401 with no token", async () => { + const res = await request(app) + .post("/users") + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(401); + }); + + it("should return 403 for MANAGER", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(403); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(403); + }); + + it("should return 201 and the created user for ADMIN", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "New User", email: "new@test.com", roleId }); + + expect(res.status).toBe(201); + expect(res.body.email).toBe("new@test.com"); + expect(res.body.name).toBe("New User"); + }); + + it("should return 400 for invalid email format", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "New User", email: "not-an-email", roleId }); + + expect(res.status).toBe(400); + }); + + it("should return 400 for missing name", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "", email: "new@test.com", roleId }); - expect(res.status).toBe(200); + expect(res.status).toBe(400); + }); + + it("should return 409 for duplicate email", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Duplicate", email: "admin@test.com", roleId }); + + expect(res.status).toBe(409); + }); + + it("should return 400 when roleId does not exist", async () => { + const res = await request(app) + .post("/users") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "New User", email: "new@test.com", roleId: 999999 }); + + expect(res.status).toBe(400); + }); }); - it("DELETE /users/:id → 200", async () => { - const res = await request(app) - .delete("/users/1") - .set("Authorization", `Bearer ${adminToken}`); + // ── PUT /users/:id ────────────────────────────────────────────────────────── + describe("PUT /users/:id", () => { + it("should return 401 with no token", async () => { + const res = await request(app) + .put(`/users/${userId}`) + .send({ name: "Updated" }); + + expect(res.status).toBe(401); + }); + + it("should return 200 and the updated user for ADMIN", async () => { + const res = await request(app) + .put(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Updated Name" }); + + expect(res.status).toBe(200); + expect(res.body.name).toBe("Updated Name"); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .put(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ name: "Updated" }); + + expect(res.status).toBe(403); + }); + + it("should return 404 when user does not exist", async () => { + const res = await request(app) + .put("/users/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Updated" }); + + expect(res.status).toBe(404); + }); + + it("should return 400 for non-numeric id", async () => { + const res = await request(app) + .put("/users/abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ name: "Updated" }); - expect(res.status).toBe(200); + expect(res.status).toBe(400); + }); }); - it("RBAC → inspector blocked", async () => { - const token = generateToken({ - id: 2, - role: "INSPECTOR", - projectIds: [], + // ── DELETE /users/:id ─────────────────────────────────────────────────────── + describe("DELETE /users/:id", () => { + it("should return 401 with no token", async () => { + const res = await request(app).delete(`/users/${userId}`); + expect(res.status).toBe(401); }); - const res = await request(app) - .post("/users") - .set("Authorization", `Bearer ${token}`) - .send({ - name: "Blocked", - email: "blocked@test.com", - roleId: 1, - }); + it("should return 200 and soft-delete the user for ADMIN", async () => { + const res = await request(app) + .delete(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("User deactivated successfully"); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + expect(user?.accountActive).toBe(false); + expect(user?.canSignIn).toBe(false); + }); - expect(res.status).toBe(403); + it("should return 403 for MANAGER", async () => { + const res = await request(app) + .delete(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(403); + }); + + it("should return 403 for INSPECTOR", async () => { + const res = await request(app) + .delete(`/users/${userId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("should return 404 when user does not exist", async () => { + const res = await request(app) + .delete("/users/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(404); + }); + + it("should return 400 for non-numeric id", async () => { + const res = await request(app) + .delete("/users/abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); }); }); \ No newline at end of file From f71b46fd56a611e8360ec8dd75cf20dfd8d4a6ca Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:32:24 +1000 Subject: [PATCH 23/26] chore: update package-lock.json --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e1688a5..fd30482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8660,4 +8660,4 @@ } } } -} \ No newline at end of file +} From ee4b05c6a8b240ce840f0c270acb5772e8eb5877 Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:54:02 +1000 Subject: [PATCH 24/26] fix: resolve lint error for unused variable in updateUser --- src/modules/user-management/userManagement.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/user-management/userManagement.service.ts b/src/modules/user-management/userManagement.service.ts index f060bdd..55deeac 100644 --- a/src/modules/user-management/userManagement.service.ts +++ b/src/modules/user-management/userManagement.service.ts @@ -227,7 +227,7 @@ export const UserManagementService = { } } - const { projectIds: _projectIds, ...dbData } = data; + const { projectIds: _, ...dbData } = data; return prisma.user.update({ where: { id: userId }, From d1b62025406c436018e082df8092b54d7441f00a Mon Sep 17 00:00:00 2001 From: Sneha Gopalappa Date: Fri, 15 May 2026 21:59:22 +1000 Subject: [PATCH 25/26] fix: resolve lint error for unused variable in updateUser --- src/modules/user-management/userManagement.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/user-management/userManagement.service.ts b/src/modules/user-management/userManagement.service.ts index 55deeac..33aa4b2 100644 --- a/src/modules/user-management/userManagement.service.ts +++ b/src/modules/user-management/userManagement.service.ts @@ -227,7 +227,8 @@ export const UserManagementService = { } } - const { projectIds: _, ...dbData } = data; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { projectIds, ...dbData } = data; return prisma.user.update({ where: { id: userId }, From 791e3b27a45068a663a45139151ab265a89570dc Mon Sep 17 00:00:00 2001 From: Hashini-Deshappriya Date: Fri, 22 May 2026 19:23:55 +1000 Subject: [PATCH 26/26] Adopters API updated --- docs/API.md | 438 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) diff --git a/docs/API.md b/docs/API.md index b0d8e6a..ea87f2a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3575,6 +3575,444 @@ The Tree Scans API follows the TreeO2 backend engineering standard: - Scalable backend structure --- +## 16. Adopters API + +This module manages adopter records used within the TreeO2 platform. It provides CRUD operations with validation, authentication, role-based access control, pagination support, and automated test coverage. + +**Module Path:** `src/modules/adopters/` + +### Files +- `adopters.routes.ts` +- `adopters.controller.ts` +- `adopters.service.ts` +- `index.ts` + +### 16.1 Purpose + +The Adopters API is responsible for managing adopter records in the system. + +Adopters represent individuals or organisations associated with tree adoption activities. + +The module currently supports: +- listing adopters with pagination +- retrieving a single adopter +- creating adopters +- updating adopters +- deleting adopters + +### 16.2 Architecture Flow + +Every request follows the standard backend module structure: + +```text +Route → Controller → Service → Prisma ORM → PostgreSQL → Response +``` + +#### Responsibilities + +#### Routes +- Define endpoints +- Apply authentication middleware +- Apply role-based authorization +- Contain Swagger documentation + +#### Controller +- Receive request data +- Read params/query/body +- Call service methods +- Return HTTP responses + +#### Service +- Apply validation and business rules +- Execute Prisma queries +- Throw structured AppError responses +- Prevent invalid operations + +### 16.3 Security + +All endpoints are protected using Bearer Token authentication. + +Middleware used: +- `authMiddleware` +- `roleMiddleware` + +### 16.4 Access Control Matrix + +| Endpoint | ADMIN | MANAGER | INSPECTOR | FARMER | DEVELOPER | +| --------------------- | ----- | ------- | --------- | ------ | --------- | +| GET /adopters | Yes | Yes | No | No | No | +| GET /adopters/{id} | Yes | Yes | No | No | No | +| POST /adopters | Yes | No | No | No | No | +| PUT /adopters/{id} | Yes | No | No | No | No | +| DELETE /adopters/{id} | Yes | No | No | No | No | + + +### 16.5 Endpoints + +#### GET /adopters + +Retrieve a paginated list of adopters. + +| Name | Type | Required | Default | +| ----- | ------- | -------- | ------- | +| page | integer | No | 1 | +| limit | integer | No | 10 | + +Example Request + +GET /adopters?page=1&limit=10 + +##### Response + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com", + "createdAt": "2026-05-12T10:00:00.000Z" + } + ], + "meta": { + "page": 1, + "limit": 10, + "total": 1 + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid pagination values +- `401` Authentication required +- `403` Insufficient permissions + +#### GET /adopters/{id} +Retrieve a single adopter by ID. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com", + "createdAt": "2026-05-12T10:00:00.000Z" + } +} +``` +Status Codes +- 200 Success +- 400 Invalid adopter ID +- 401 Authentication required +- 403 Insufficient permissions +- 404 Adopter not found + +#### POST /adopters + +Create a new adopter. + + +##### Request Body + +```json +{ + "name": "Hashini", + "email": "hashini@gmail.com" +} +``` +Required Fields +- name + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Hashini", + "email": "hashini@gmail.com" + } +} +``` +##### Status Codes +- `201` Created +- `400` Invalid payload +- `401` Authentication required +- `403` Insufficient permissions +- `409` Duplicate adopter + +#### PUT /adopters/{id} + +Update an existing adopter. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +##### Request Body +Any subset of fields may be provided. +```json +{ + "name": "Updated Name", + "email": "updated@gmail.com" +} +``` +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Updated Name", + "email": "updated@gmail.com" + } +} +``` +##### Status Codes +- `200` Success +- `400` Invalid request / empty payload / invalid ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +#### DELETE /adopters/{id} +Delete an adopter. + +##### Path Parameters + +| Name | Type | Required | +| ---- | ------- | -------- | +| id | integer | Yes | + + +``` +##### Response + +```json +{ + "success": true, + "data": { + "message": "Adopter deleted successfully" + } +} +``` +##### Status Codes +- `200` Success +- `400` Invalid request / empty payload / invalid ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +### 16.6 Validation Rules + +#### Pagination Validation +- `page` must be: numeric , integer , greater than 0 +- `limit` must be: numeric , integer , greater than 0 + +#### Create Validation + +Rules: +- name is required +- name must be non-empty after trim +- email must be a string if provided + +Accepted example: +```json +{ + "name": "Hashini", + "email": "hashini@gmail.com" +} +``` + +#### Update Validation + +Rules: +- at least one field must be provided +- partial updates are allowed +- name must not be empty if provided +- email must be a string if provided + +Accepted example: +```json +{ + "email": "updated@gmail.com" +} +``` +#### ID Validation + +id must be: +- numeric +- integer +- positive + +### 16.7 Error Handling + +Uses centralised error middleware with AppError and ERROR_CODES. + +#### Standard Error Response + +```json +{ + "success": false, + "message": "Adopter not found", + "code": "DATA_001" +} +``` + +#### Common Errors +- Authentication required (401) +- Insufficient permissions (403) +- Invalid pagination (400) +- Invalid adopter ID (400) +- Missing required fields (400) +- Empty update payload (400) +- Invalid email (400) +- Adopter not found (404) +- Internal server error (500) + +### 16.8 Swagger Documentation + +All endpoints are documented in: + +`adopters.routes.ts` + +Available at: + +`http://localhost:3000/api-docs` + +Swagger supports: +- Interactive testing +- Request examples +- Response definitions +- Security schemas + +### 16.9 Testing + +#### Test Files +- `tests/unit/adopters.test.ts` +- `tests/integration/adopters.test.ts` + +#### Unit Tests Covered +These tests exercise the service layer directly. + +#### Covered Scenarios + +##### listAdopters +- returns paginated adopters +- validates invalid page values +- validates invalid limit values + +##### getAdopterById +- returns adopter +- throws for invalid id +- throws when adopter missing + +##### createAdopter +- creates adopter successfully +- rejects missing name +- rejects empty name +- rejects invalid email type + +##### updateAdopter +- updates adopter successfully +- supports partial updates +- rejects empty payload +- rejects invalid name +- rejects invalid email +- throws when adopter missing + +##### deleteAdopter +- deletes adopter successfully +- throws when adopter missing +- rejects invalid id + +#### Integration Tests Covered +These tests exercise the full API flow: +route → middleware → controller → service → Prisma → response + +#### Covered scenarios: + +#### Authentication +- returns 401 when token missing +#### Authorization +- Manager can access GET routes +- non-admin roles blocked from mutations +- returns 403 for unauthorized roles + +#### GET /adopters +- returns paginated results +- validates pagination query params + +#### GET /adopters/{id} +- returns adopter +- returns 400 for invalid ID +- returns 404 when missing + +#### POST /adopters +- creates adopter successfully +- validates request body +- rejects invalid email + +#### PUT /adopters/{id} +- updates adopter +- supports partial update +- returns 404 when adopter missing + +#### DELETE /adopters/{id} +- deletes adopter +- returns 404 when adopter missing + +### 16.10 Test Strategy Used +Current test strategy for this module: + +- Jest is used as the test runner +- integration tests use supertest +- Prisma is mocked in unit tests +- integration tests use the real database flow +- auth behaviour uses the current development auth scaffold +- integration tests create and clean up their own data + +### 16.11 How To Run Adopter Tests +Run unit tests only: + +npm test -- tests/unit/adopters.test.ts + +Run integration tests only: + +npm test -- tests/integration/adopters.test.ts + +### 16.12 Summary +The Adopters API follows the TreeO2 backend engineering standard: + +- Modular architecture +- Secure authentication +- Role-based access control +- Clean separation of concerns +- Strong validation +- Relationship management between users and projects +- Swagger documentation +- Unit testing for service/business logic +- Integration testing for full API flow +- Scalable structure for future project-user access rules +--- + ## 16. Adoptions API This module manages adoption records in the TreeO2 platform. It provides full CRUD operations for recording, retrieving, updating, and deleting tree adoption records.