From c408e0c69b4367fcbda841ae317fc91544aaf73b Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:53:44 +1000 Subject: [PATCH 1/9] 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 9ab74df6deb3c6915af5ece175760a2641fef780 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:53:55 +1000 Subject: [PATCH 2/9] 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 2414f49b6a59f53518eb3e03e56fc17fbeb627fe Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:54:08 +1000 Subject: [PATCH 3/9] 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 2f5cf12839ee6f2ef210cb546ab8506552821e97 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:54:26 +1000 Subject: [PATCH 4/9] 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 bb4335a9ae78690b1ec4984ff1ceb3fc1c80a4e0 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 02:54:40 +1000 Subject: [PATCH 5/9] 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 1c2105d0f59cdcf04612d95dc48aaad386c7b3b5 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 23:50:14 +1000 Subject: [PATCH 6/9] 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 a44ff79b1d167b4a05b18c43182266153eb01c4c Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 23:50:53 +1000 Subject: [PATCH 7/9] 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 67d906633c544f5a0b64f5bd522e8215bd35b935 Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sat, 16 May 2026 23:51:05 +1000 Subject: [PATCH 8/9] 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 2bebcb7b6bfe76fe4d63aa0c36933c281b9e780e Mon Sep 17 00:00:00 2001 From: Jaykumar677 Date: Sun, 17 May 2026 18:52:38 +1000 Subject: [PATCH 9/9] 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 b7f83c1..9b9c4b2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3489,4 +3489,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