From 56fea77d6d72e689a488e77dc1134ad1d25efeb7 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 15:40:39 -0400 Subject: [PATCH 01/14] feat(api): validate employee list pagination and sorting --- backend/src/schemas/employeeSchema.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/schemas/employeeSchema.ts b/backend/src/schemas/employeeSchema.ts index 93b049d8..fde5e88e 100644 --- a/backend/src/schemas/employeeSchema.ts +++ b/backend/src/schemas/employeeSchema.ts @@ -16,12 +16,16 @@ export const createEmployeeSchema = z.object({ export const updateEmployeeSchema = createEmployeeSchema.partial().omit({ organization_id: true }); export const employeeQuerySchema = z.object({ - page: z.string().regex(/^\d+$/).transform(Number).optional().default('1' as any), // Fix: mismatch between number and string default - limit: z.string().regex(/^\d+$/).transform(Number).optional().default('10' as any), // Fix: mismatch between number and string default + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), search: z.string().optional(), status: z.enum(['active', 'inactive', 'pending']).optional(), department: z.string().optional(), organization_id: z.string().regex(/^\d+$/).transform(Number).optional(), + sort: z + .enum(['created_at', 'first_name', 'last_name', 'email', 'department', 'status']) + .default('created_at'), + order: z.enum(['asc', 'desc']).default('desc'), }); export type CreateEmployeeInput = z.infer; From a5b321702279d520fff4105b7e774dc70c4e7542 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 15:40:53 -0400 Subject: [PATCH 02/14] feat(api): apply allowlisted employee sorting --- backend/src/services/employeeService.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index ba2a7489..3375f722 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -47,8 +47,26 @@ export class EmployeeService { } async findAll(organization_id: number, params: EmployeeQueryInput) { - const { page = 1, limit = 10, search, status, department } = params; + const { + page = 1, + limit = 20, + search, + status, + department, + sort = 'created_at', + order = 'desc', + } = params; const offset = (page - 1) * limit; + const sortColumns = { + created_at: 'created_at', + first_name: 'first_name', + last_name: 'last_name', + email: 'email', + department: 'department', + status: 'status', + } as const; + const sortColumn = sortColumns[sort]; + const sortDirection = order === 'asc' ? 'ASC' : 'DESC'; let query = ` SELECT *, count(*) OVER() as total_count @@ -85,7 +103,7 @@ export class EmployeeService { paramIndex++; } - query += ` ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; + query += ` ORDER BY ${sortColumn} ${sortDirection} LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; values.push(limit, offset); const result = await pool.query(query, values); From 3c039e672fa745bdd4006ea75998dfda0131293f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 15:41:05 -0400 Subject: [PATCH 03/14] feat(api): validate audit pagination and sorting --- .../controllers/transactionAuditController.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/src/controllers/transactionAuditController.ts b/backend/src/controllers/transactionAuditController.ts index e61f0f18..b20c3d7c 100644 --- a/backend/src/controllers/transactionAuditController.ts +++ b/backend/src/controllers/transactionAuditController.ts @@ -12,6 +12,10 @@ const listQuerySchema = z.object({ employeeId: z.string().optional(), asset: z.string().optional(), type: z.enum(['all', 'transaction', 'contract_event']).default('all'), + sort: z + .enum(['created_at', 'stellar_created_at', 'source_account', 'fee_charged', 'operation_count']) + .default('created_at'), + order: z.enum(['asc', 'desc']).default('desc'), }); export class TransactionAuditController { @@ -63,19 +67,31 @@ export class TransactionAuditController { */ static async listAuditRecords(req: Request, res: Response) { try { - const { page, limit, sourceAccount, dateStart, dateEnd, status, employeeId, asset, type } = - listQuerySchema.parse(req.query); + const { + page, + limit, + sourceAccount, + dateStart, + dateEnd, + status, + employeeId, + asset, + type, + sort, + order, + } = listQuerySchema.parse(req.query); const result = await TransactionAuditService.list( page, limit, sourceAccount as string | undefined, - { dateStart, dateEnd, status, employeeId, asset, type } + { dateStart, dateEnd, status, employeeId, asset, type, sort, order } ); res.json({ data: result.data, total: result.total, page, + limit, totalPages: Math.ceil(result.total / limit), }); } catch (error) { From b9a83ae1c2ea6bb167d879493c2c0ab96b9ba409 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 15:41:51 -0400 Subject: [PATCH 04/14] feat(api): apply allowlisted audit-log sorting --- .../src/services/transactionAuditService.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/src/services/transactionAuditService.ts b/backend/src/services/transactionAuditService.ts index 82bf96c3..04f1de0b 100644 --- a/backend/src/services/transactionAuditService.ts +++ b/backend/src/services/transactionAuditService.ts @@ -86,13 +86,30 @@ export class TransactionAuditService { employeeId?: string | undefined; asset?: string | undefined; type?: 'all' | 'transaction' | 'contract_event' | undefined; + sort?: + | 'created_at' + | 'stellar_created_at' + | 'source_account' + | 'fee_charged' + | 'operation_count' + | undefined; + order?: 'asc' | 'desc' | undefined; } ): Promise<{ data: AuditRecord[]; total: number }> { const offset = (page - 1) * limit; + const sortColumns = { + created_at: 'tal.created_at', + stellar_created_at: 'tal.stellar_created_at', + source_account: 'tal.source_account', + fee_charged: 'tal.fee_charged', + operation_count: 'tal.operation_count', + } as const; + const sortColumn = sortColumns[filters?.sort ?? 'created_at']; + const sortDirection = filters?.order === 'asc' ? 'ASC' : 'DESC'; const values: (string | number)[] = []; let paramIdx = 1; - let whereClauses: string[] = []; + const whereClauses: string[] = []; if (sourceAccount) { whereClauses.push(`tal.source_account = $${paramIdx++}`); values.push(sourceAccount); @@ -148,7 +165,7 @@ export class TransactionAuditService { ) pal ON tal.tx_hash = pal.tx_hash LEFT JOIN employees e ON pal.employee_id = e.id ${where} - ORDER BY tal.created_at DESC + ORDER BY ${sortColumn} ${sortDirection} LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, values ); From 0b0f37206aaf65352ec228e406bfbdbb7c21651c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 15:42:40 -0400 Subject: [PATCH 05/14] feat(api): validate payroll transaction pagination --- backend/src/routes/payroll.routes.ts | 57 +++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index 47ee768f..bbdfb62e 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -1,4 +1,5 @@ import { Request, Response, Router } from 'express'; +import { z } from 'zod'; import { payrollQueryService } from '../services/payroll-query.service.js'; import logger from '../utils/logger.js'; import { authenticateJWT } from '../middlewares/auth.js'; @@ -17,6 +18,15 @@ function asString(value: unknown): string | undefined { return undefined; } +const payrollTransactionsQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + sort: z.enum(['timestamp', 'amount', 'employeeId']).optional(), + order: z.enum(['asc', 'desc']).optional(), + sortBy: z.enum(['timestamp', 'amount', 'employeeId']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), +}); + // Apply authentication to all payroll routes router.use(authenticateJWT); @@ -40,9 +50,10 @@ router.use(isolateOrganization); * - startDate: Start date (ISO 8601) * - endDate: End date (ISO 8601) * - page: Page number (default: 1) - * - limit: Records per page (default: 50, max: 500) - * - sortBy: Sort field (timestamp, amount, employeeId) - * - sortOrder: Sort order (asc, desc) + * - limit: Records per page (default: 20, max: 100) + * - sort: Sort field (timestamp, amount, employeeId) + * - order: Sort order (asc, desc) + * - sortBy/sortOrder: Backwards-compatible aliases for sort/order */ router.get('/transactions', async (req: Request, res: Response) => { try { @@ -56,6 +67,8 @@ router.get('/transactions', async (req: Request, res: Response) => { endDate, page, limit, + sort, + order, sortBy, sortOrder, } = req.query; @@ -67,6 +80,24 @@ router.get('/transactions', async (req: Request, res: Response) => { }); } + const paginationResult = payrollTransactionsQuerySchema.safeParse({ + page: asString(page), + limit: asString(limit), + sort: asString(sort), + order: asString(order), + sortBy: asString(sortBy), + sortOrder: asString(sortOrder), + }); + if (!paginationResult.success) { + return res.status(400).json({ + error: 'Validation Error', + details: paginationResult.error.issues, + }); + } + + const pagination = paginationResult.data; + const selectedSort = pagination.sort ?? pagination.sortBy ?? 'timestamp'; + const selectedOrder = pagination.order ?? pagination.sortOrder ?? 'desc'; const query = { organizationPublicKey: orgPublicKeyStr, employeeId: asString(employeeId), @@ -77,15 +108,23 @@ router.get('/transactions', async (req: Request, res: Response) => { endDate: asString(endDate) ? new Date(asString(endDate)!) : undefined, }; - const result = await payrollQueryService.queryPayroll(query, Number(page), Number(limit), { - enrichPayrollData: true, - sortBy: (sortBy as any) || 'timestamp', - sortOrder: (sortOrder as any) || 'desc', - }); + const result = await payrollQueryService.queryPayroll( + query, + pagination.page, + pagination.limit, + { + enrichPayrollData: true, + sortBy: selectedSort, + sortOrder: selectedOrder, + } + ); res.json({ success: true, - data: result, + data: { + ...result, + totalPages: result.pageCount, + }, }); } catch (error) { logger.error('GET /api/payroll/transactions failed', error); From fa7a26c79e40bd821a5ab40e75746d06cf9497d6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 14:13:11 -0400 Subject: [PATCH 06/14] fix(api): propagate bounded list sorting --- backend/src/services/payroll-query.service.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/backend/src/services/payroll-query.service.ts b/backend/src/services/payroll-query.service.ts index 64c416d1..d97c9c69 100644 --- a/backend/src/services/payroll-query.service.ts +++ b/backend/src/services/payroll-query.service.ts @@ -97,7 +97,9 @@ export class PayrollQueryService { startDate?: Date, endDate?: Date, page?: number, - limit?: number + limit?: number, + sortBy?: 'timestamp' | 'amount' | 'employeeId', + sortOrder?: 'asc' | 'desc' ): Promise> { const query: PayrollIndexQuery = { organizationPublicKey, @@ -108,8 +110,8 @@ export class PayrollQueryService { return this.queryPayroll(query, page, limit, { enrichPayrollData: true, - sortBy: 'timestamp', - sortOrder: 'desc', + sortBy: sortBy ?? 'timestamp', + sortOrder: sortOrder ?? 'desc', }); } @@ -120,7 +122,9 @@ export class PayrollQueryService { organizationPublicKey: string, batchId: string, page?: number, - limit?: number + limit?: number, + sortBy?: 'timestamp' | 'amount' | 'employeeId', + sortOrder?: 'asc' | 'desc' ): Promise> { const query: PayrollIndexQuery = { organizationPublicKey, @@ -129,8 +133,8 @@ export class PayrollQueryService { return this.queryPayroll(query, page, limit, { enrichPayrollData: true, - sortBy: 'timestamp', - sortOrder: 'desc', + sortBy: sortBy ?? 'timestamp', + sortOrder: sortOrder ?? 'desc', }); } @@ -228,7 +232,9 @@ export class PayrollQueryService { organizationPublicKey: string, memoPattern: string, page?: number, - limit?: number + limit?: number, + sortBy?: 'timestamp' | 'amount' | 'employeeId', + sortOrder?: 'asc' | 'desc' ): Promise> { const pagination = parsePaginationParams(page, limit); @@ -244,7 +250,12 @@ export class PayrollQueryService { ); const enriched = payrollIndexingService.enrichTransactions(rawTransactions); - return payrollIndexingService.paginateTransactions(enriched, pagination); + const sorted = payrollIndexingService.sortTransactions( + enriched, + sortBy ?? 'timestamp', + sortOrder ?? 'desc' + ); + return payrollIndexingService.paginateTransactions(sorted, pagination); } catch (error) { logger.error(`Failed to search by memo pattern ${memoPattern}`, error); throw error; From 0aafb7f92537f3ed8c5ac5f00dfd1eec042e9f94 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 14:13:37 -0400 Subject: [PATCH 07/14] fix(api): validate remaining payroll list pagination --- backend/src/routes/payroll.routes.ts | 88 ++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index bbdfb62e..979eaa7b 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -27,6 +27,13 @@ const payrollTransactionsQuerySchema = z.object({ sortOrder: z.enum(['asc', 'desc']).optional(), }); +const payrollListQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + sort: z.enum(['timestamp', 'amount', 'employeeId']).default('timestamp'), + order: z.enum(['asc', 'desc']).default('desc'), +}); + // Apply authentication to all payroll routes router.use(authenticateJWT); @@ -142,7 +149,7 @@ router.get('/transactions', async (req: Request, res: Response) => { router.get('/employees/:employeeId', async (req: Request, res: Response) => { try { const { employeeId } = req.params; - const { orgPublicKey, startDate, endDate, page, limit } = req.query; + const { orgPublicKey, startDate, endDate, page, limit, sort, order } = req.query; const orgPublicKeyStr = asString(orgPublicKey); if (!orgPublicKeyStr) { @@ -151,18 +158,37 @@ router.get('/employees/:employeeId', async (req: Request, res: Response) => { }); } + const paginationResult = payrollListQuerySchema.safeParse({ + page: asString(page), + limit: asString(limit), + sort: asString(sort), + order: asString(order), + }); + if (!paginationResult.success) { + return res.status(400).json({ + error: 'Validation Error', + details: paginationResult.error.issues, + }); + } + + const pagination = paginationResult.data; const result = await payrollQueryService.getEmployeePayroll( orgPublicKeyStr, employeeId as string, asString(startDate) ? new Date(asString(startDate)!) : undefined, asString(endDate) ? new Date(asString(endDate)!) : undefined, - Number(page), - Number(limit) + pagination.page, + pagination.limit, + pagination.sort, + pagination.order ); res.json({ success: true, - data: result, + data: { + ...result, + totalPages: result.pageCount, + }, }); } catch (error) { logger.error(`GET /api/payroll/employees/${req.params.employeeId} failed`, error); @@ -216,7 +242,7 @@ router.get('/employees/:employeeId/summary', async (req: Request, res: Response) router.get('/batches/:batchId', async (req: Request, res: Response) => { try { const { batchId } = req.params; - const { orgPublicKey, page, limit } = req.query; + const { orgPublicKey, page, limit, sort, order } = req.query; const orgPublicKeyStr = asString(orgPublicKey); if (!orgPublicKeyStr) { @@ -225,16 +251,35 @@ router.get('/batches/:batchId', async (req: Request, res: Response) => { }); } + const paginationResult = payrollListQuerySchema.safeParse({ + page: asString(page), + limit: asString(limit), + sort: asString(sort), + order: asString(order), + }); + if (!paginationResult.success) { + return res.status(400).json({ + error: 'Validation Error', + details: paginationResult.error.issues, + }); + } + + const pagination = paginationResult.data; const result = await payrollQueryService.getPayrollBatch( orgPublicKeyStr, batchId as string, - Number(page), - Number(limit) + pagination.page, + pagination.limit, + pagination.sort, + pagination.order ); res.json({ success: true, - data: result, + data: { + ...result, + totalPages: result.pageCount, + }, }); } catch (error) { logger.error(`GET /api/payroll/batches/${req.params.batchId} failed`, error); @@ -321,7 +366,7 @@ router.get('/audit', async (req: Request, res: Response) => { */ router.get('/search/memo', async (req: Request, res: Response) => { try { - const { orgPublicKey, pattern, page, limit } = req.query; + const { orgPublicKey, pattern, page, limit, sort, order } = req.query; const orgPublicKeyStr = asString(orgPublicKey); const patternStr = asString(pattern); @@ -331,16 +376,35 @@ router.get('/search/memo', async (req: Request, res: Response) => { }); } + const paginationResult = payrollListQuerySchema.safeParse({ + page: asString(page), + limit: asString(limit), + sort: asString(sort), + order: asString(order), + }); + if (!paginationResult.success) { + return res.status(400).json({ + error: 'Validation Error', + details: paginationResult.error.issues, + }); + } + + const pagination = paginationResult.data; const result = await payrollQueryService.searchByMemoPattern( orgPublicKeyStr, patternStr, - Number(page), - Number(limit) + pagination.page, + pagination.limit, + pagination.sort, + pagination.order ); res.json({ success: true, - data: result, + data: { + ...result, + totalPages: result.pageCount, + }, }); } catch (error) { logger.error('GET /api/payroll/search/memo failed', error); From 5427b5213f61eff6859e0d9f69c44bc7ff61ec7e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 03:04:24 -0400 Subject: [PATCH 08/14] fix(pagination): preserve real employee total beyond last page (#546) --- backend/src/services/employeeService.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index 3375f722..80a75dcb 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -103,12 +103,24 @@ export class EmployeeService { paramIndex++; } - query += ` ORDER BY ${sortColumn} ${sortDirection} LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; + // Window totals exist only on rows actually returned. Preserve the same + // tenant/search/status/department filters for an empty out-of-range page + // so a page past the end cannot falsely report total=0. + const countQuery = query.replace( + 'SELECT *, count(*) OVER() as total_count', + 'SELECT COUNT(*) AS total_count' + ); + const countValues = values.slice(); + query += ` ORDER BY ${sortColumn} ${sortDirection} LIMIT ${paramIndex++} OFFSET ${paramIndex++}`; values.push(limit, offset); const result = await pool.query(query, values); - const total = result.rows.length > 0 ? parseInt(result.rows[0].total_count) : 0; + let total = result.rows.length > 0 ? parseInt(result.rows[0].total_count, 10) : 0; + if (result.rows.length === 0 && offset > 0) { + const countResult = await pool.query(countQuery, countValues); + total = Number.parseInt(String(countResult.rows[0]?.total_count ?? 0), 10); + } const employees = result.rows.map((row) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { total_count, ...employee } = row; From cfe23c990c92b7abacbe28831441e9a5782776ec Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 03:04:41 -0400 Subject: [PATCH 09/14] fix(pagination): align filtered audit counts and page details (#546) --- .../src/services/transactionAuditService.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/backend/src/services/transactionAuditService.ts b/backend/src/services/transactionAuditService.ts index 04f1de0b..21fc0f97 100644 --- a/backend/src/services/transactionAuditService.ts +++ b/backend/src/services/transactionAuditService.ts @@ -110,6 +110,10 @@ export class TransactionAuditService { let paramIdx = 1; const whereClauses: string[] = []; + // Use the same detail-row predicates for totals and paginated results. + // Per-transaction MAX(employee_id)/MAX(asset_code) must not decide which + // transactions match a filter (the matching detail may not be the MAX). + const payrollWhere: string[] = []; if (sourceAccount) { whereClauses.push(`tal.source_account = $${paramIdx++}`); values.push(sourceAccount); @@ -130,21 +134,25 @@ export class TransactionAuditService { else if (filters.status === 'Failed') whereClauses.push(`tal.successful = false`); } if (filters.employeeId) { - whereClauses.push(`pal.employee_id = $${paramIdx++}`); + payrollWhere.push(`pf.employee_id = ${paramIdx++}`); values.push(filters.employeeId); } if (filters.asset) { - whereClauses.push(`pal.asset_code = $${paramIdx++}`); + payrollWhere.push(`pf.asset_code = ${paramIdx++}`); values.push(filters.asset); } } + if (payrollWhere.length > 0) { + whereClauses.push( + `EXISTS (SELECT 1 FROM payroll_audit_logs pf WHERE pf.tx_hash = tal.tx_hash AND ${payrollWhere.join(' AND ')})` + ); + } const where = whereClauses.length > 0 ? `WHERE ` + whereClauses.join(' AND ') : ''; + const aggregateWhere = payrollWhere.length > 0 ? `WHERE ${payrollWhere.join(' AND ')}` : ''; const countResult = await pool.query( - `SELECT COUNT(DISTINCT tal.id) FROM transaction_audit_logs tal - LEFT JOIN payroll_audit_logs pal ON tal.tx_hash = pal.tx_hash - LEFT JOIN employees e ON pal.employee_id = e.id + `SELECT COUNT(*) FROM transaction_audit_logs tal ${where}`, values.slice() ); @@ -161,7 +169,7 @@ export class TransactionAuditService { FROM transaction_audit_logs tal LEFT JOIN ( SELECT tx_hash, MAX(employee_id) as employee_id, MAX(asset_code) as asset_code, SUM(amount) as amount - FROM payroll_audit_logs GROUP BY tx_hash + FROM payroll_audit_logs pf ${aggregateWhere} GROUP BY tx_hash ) pal ON tal.tx_hash = pal.tx_hash LEFT JOIN employees e ON pal.employee_id = e.id ${where} From a42d034940dcbf7cee0f181f93af23d4082f7276 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 03:05:10 -0400 Subject: [PATCH 10/14] test(pagination): empty employee page retains total and filter scope (#546) --- .../__tests__/employeeService.test.ts | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/backend/src/services/__tests__/employeeService.test.ts b/backend/src/services/__tests__/employeeService.test.ts index 676a7be4..3e78b3cf 100644 --- a/backend/src/services/__tests__/employeeService.test.ts +++ b/backend/src/services/__tests__/employeeService.test.ts @@ -69,6 +69,212 @@ describe('EmployeeService', () => { ); }); + it('preserves total and filters when a requested employee page is past the end', async () => { + (mockPool.query as jest.Mock) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ total_count: '17' }] }); + + const result = await employeeService.findAll( + 42, + { page: 5, limit: 10, department: 'IT', search: 'Jane' } as any + ); + + expect(result.data).toEqual([]); + expect(result.pagination).toEqual({ total: 17, page: 5, limit: 10, totalPages: 2 }); + expect(mockPool.query).toHaveBeenCalledTimes(2); + + const [pageSql, pageArgs] = (mockPool.query as jest.Mock).mock.calls[0]; + const [countSql, countArgs] = (mockPool.query as jest.Mock).mock.calls[1]; + expect(pageSql).toContain('count(*) OVER() as total_count'); + expect(countSql).toContain('SELECT COUNT(*) AS total_count'); + expect(countSql).not.toContain('LIMIT'); + expect(countSql).toContain('organization_id = + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [], + }); + + await employeeService.findAll({ department: 'IT', page: 1, limit: 10 }); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('department = $'), + expect.arrayContaining(['IT']) + ); + }); + }); + + describe('update', () => { + it('should update employee successfully', async () => { + const updateData = { first_name: 'Johnny' }; + const mockUpdatedEmployee = { id: 1, first_name: 'Johnny' }; + + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockUpdatedEmployee], + }); + + const result = await employeeService.update(1, updateData); + + expect(result).toEqual(mockUpdatedEmployee); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE employees'), + expect.arrayContaining(['Johnny', 1]) + ); + }); + + it('should return null if employee not found', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [], + }); + + const result = await employeeService.update(999, { first_name: 'Test' }); + expect(result).toBeNull(); + }); + }); + + describe('delete', () => { + it('should soft delete employee', async () => { + const mockDeletedEmployee = { id: 1, deleted_at: new Date() }; + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockDeletedEmployee], + rowCount: 1, + }); + + const result = await employeeService.delete(1); + + expect(result).toEqual(mockDeletedEmployee); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringMatching(/UPDATE employees\s+SET deleted_at = NOW()/), + [1] + ); + }); + }); +}); +); + expect(countSql).toContain('department = + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [], + }); + + await employeeService.findAll({ department: 'IT', page: 1, limit: 10 }); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('department = $'), + expect.arrayContaining(['IT']) + ); + }); + }); + + describe('update', () => { + it('should update employee successfully', async () => { + const updateData = { first_name: 'Johnny' }; + const mockUpdatedEmployee = { id: 1, first_name: 'Johnny' }; + + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockUpdatedEmployee], + }); + + const result = await employeeService.update(1, updateData); + + expect(result).toEqual(mockUpdatedEmployee); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE employees'), + expect.arrayContaining(['Johnny', 1]) + ); + }); + + it('should return null if employee not found', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [], + }); + + const result = await employeeService.update(999, { first_name: 'Test' }); + expect(result).toBeNull(); + }); + }); + + describe('delete', () => { + it('should soft delete employee', async () => { + const mockDeletedEmployee = { id: 1, deleted_at: new Date() }; + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockDeletedEmployee], + rowCount: 1, + }); + + const result = await employeeService.delete(1); + + expect(result).toEqual(mockDeletedEmployee); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringMatching(/UPDATE employees\s+SET deleted_at = NOW()/), + [1] + ); + }); + }); +}); +); + expect(countSql).toContain('first_name ILIKE + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [], + }); + + await employeeService.findAll({ department: 'IT', page: 1, limit: 10 }); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('department = $'), + expect.arrayContaining(['IT']) + ); + }); + }); + + describe('update', () => { + it('should update employee successfully', async () => { + const updateData = { first_name: 'Johnny' }; + const mockUpdatedEmployee = { id: 1, first_name: 'Johnny' }; + + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockUpdatedEmployee], + }); + + const result = await employeeService.update(1, updateData); + + expect(result).toEqual(mockUpdatedEmployee); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE employees'), + expect.arrayContaining(['Johnny', 1]) + ); + }); + + it('should return null if employee not found', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [], + }); + + const result = await employeeService.update(999, { first_name: 'Test' }); + expect(result).toBeNull(); + }); + }); + + describe('delete', () => { + it('should soft delete employee', async () => { + const mockDeletedEmployee = { id: 1, deleted_at: new Date() }; + (mockPool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockDeletedEmployee], + rowCount: 1, + }); + + const result = await employeeService.delete(1); + + expect(result).toEqual(mockDeletedEmployee); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringMatching(/UPDATE employees\s+SET deleted_at = NOW()/), + [1] + ); + }); + }); +}); +); + expect(countArgs).toEqual([42, 'IT', '%Jane%']); + expect(pageArgs).toEqual([42, 'IT', '%Jane%', 10, 40]); + }); + it('should filter by department', async () => { (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [], From 5ff024be3f79e77f717b3d8d3c90111dd4bc3bb6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 03:05:58 -0400 Subject: [PATCH 11/14] test(pagination): audit totals and page data use identical detail filters (#546) --- .../__tests__/transactionAuditService.test.ts | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/backend/src/services/__tests__/transactionAuditService.test.ts b/backend/src/services/__tests__/transactionAuditService.test.ts index 44c7b65b..29e81704 100644 --- a/backend/src/services/__tests__/transactionAuditService.test.ts +++ b/backend/src/services/__tests__/transactionAuditService.test.ts @@ -79,7 +79,73 @@ describe('TransactionAuditService', () => { }); const countQuery = (mockPool.query as jest.Mock).mock.calls[0]; - expect(countQuery[0]).toContain('pal.employee_id = $'); + expect(countQuery[0]).toContain('pf.employee_id = + expect(countQuery[1]).toContain('emp-123'); + }); + + it('uses matching payroll detail predicates for totals and page rows', async () => { + // The matching employee and asset may NOT have the maximum values + // in a multi-line payroll transaction. + (mockPool.query as jest.Mock) + .mockResolvedValueOnce({ rows: [{ count: '1' }] }) + .mockResolvedValueOnce({ rows: [{ id: 17, employee_name: 'Jane Doe', asset: 'USDC' }] }); + + const result = await TransactionAuditService.list(1, 20, undefined, { + employeeId: '3', asset: 'USDC', + }); + expect(result.total).toBe(1); + expect(result.data).toHaveLength(1); + + const [countSql, countArgs] = (mockPool.query as jest.Mock).mock.calls[0]; + const [dataSql, dataArgs] = (mockPool.query as jest.Mock).mock.calls[1]; + expect(countSql).toContain('EXISTS (SELECT 1 FROM payroll_audit_logs pf'); + expect(dataSql).toContain('EXISTS (SELECT 1 FROM payroll_audit_logs pf'); + expect(countSql).toContain('pf.employee_id = $1'); + expect(dataSql).toContain('pf.asset_code = $2'); + expect(countSql).toContain('COUNT(*) FROM transaction_audit_logs tal'); + expect(dataSql).toContain('FROM payroll_audit_logs pf WHERE pf.employee_id = $1 AND pf.asset_code = $2'); + expect(countArgs).toEqual(['3', 'USDC']); + expect(dataArgs).toEqual(['3', 'USDC', 20, 0]); + }); + + it('should apply asset filter correctly', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '0' }] }); + (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] }); + + await TransactionAuditService.list(1, 20, undefined, { + asset: 'USDC' + }); + + const countQuery = (mockPool.query as jest.Mock).mock.calls[0]; + expect(countQuery[0]).toContain('pf.asset_code = + expect(countQuery[1]).toContain('USDC'); + }); + }); +}); +); + expect(countQuery[1]).toContain('emp-123'); + }); + + it('should apply asset filter correctly', async () => { + (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '0' }] }); + (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] }); + + await TransactionAuditService.list(1, 20, undefined, { + asset: 'USDC' + }); + + const countQuery = (mockPool.query as jest.Mock).mock.calls[0]; + expect(countQuery[0]).toContain('pal.asset_code = $'); + expect(countQuery[1]).toContain('USDC'); + }); + }); +}); +); + expect(countQuery[1]).toContain('USDC'); + }); + }); +}); +); expect(countQuery[1]).toContain('emp-123'); }); From e2677272e0622286b434903fa1a70e786422cbb6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 03:06:48 -0400 Subject: [PATCH 12/14] fix(test): repair employee page regression insertion without duplicate source (#546) --- .../__tests__/employeeService.test.ts | 186 +----------------- 1 file changed, 3 insertions(+), 183 deletions(-) diff --git a/backend/src/services/__tests__/employeeService.test.ts b/backend/src/services/__tests__/employeeService.test.ts index 3e78b3cf..f81aea3e 100644 --- a/backend/src/services/__tests__/employeeService.test.ts +++ b/backend/src/services/__tests__/employeeService.test.ts @@ -88,189 +88,9 @@ describe('EmployeeService', () => { expect(pageSql).toContain('count(*) OVER() as total_count'); expect(countSql).toContain('SELECT COUNT(*) AS total_count'); expect(countSql).not.toContain('LIMIT'); - expect(countSql).toContain('organization_id = - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [], - }); - - await employeeService.findAll({ department: 'IT', page: 1, limit: 10 }); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('department = $'), - expect.arrayContaining(['IT']) - ); - }); - }); - - describe('update', () => { - it('should update employee successfully', async () => { - const updateData = { first_name: 'Johnny' }; - const mockUpdatedEmployee = { id: 1, first_name: 'Johnny' }; - - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [mockUpdatedEmployee], - }); - - const result = await employeeService.update(1, updateData); - - expect(result).toEqual(mockUpdatedEmployee); - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('UPDATE employees'), - expect.arrayContaining(['Johnny', 1]) - ); - }); - - it('should return null if employee not found', async () => { - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [], - }); - - const result = await employeeService.update(999, { first_name: 'Test' }); - expect(result).toBeNull(); - }); - }); - - describe('delete', () => { - it('should soft delete employee', async () => { - const mockDeletedEmployee = { id: 1, deleted_at: new Date() }; - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [mockDeletedEmployee], - rowCount: 1, - }); - - const result = await employeeService.delete(1); - - expect(result).toEqual(mockDeletedEmployee); - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringMatching(/UPDATE employees\s+SET deleted_at = NOW()/), - [1] - ); - }); - }); -}); -); - expect(countSql).toContain('department = - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [], - }); - - await employeeService.findAll({ department: 'IT', page: 1, limit: 10 }); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('department = $'), - expect.arrayContaining(['IT']) - ); - }); - }); - - describe('update', () => { - it('should update employee successfully', async () => { - const updateData = { first_name: 'Johnny' }; - const mockUpdatedEmployee = { id: 1, first_name: 'Johnny' }; - - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [mockUpdatedEmployee], - }); - - const result = await employeeService.update(1, updateData); - - expect(result).toEqual(mockUpdatedEmployee); - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('UPDATE employees'), - expect.arrayContaining(['Johnny', 1]) - ); - }); - - it('should return null if employee not found', async () => { - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [], - }); - - const result = await employeeService.update(999, { first_name: 'Test' }); - expect(result).toBeNull(); - }); - }); - - describe('delete', () => { - it('should soft delete employee', async () => { - const mockDeletedEmployee = { id: 1, deleted_at: new Date() }; - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [mockDeletedEmployee], - rowCount: 1, - }); - - const result = await employeeService.delete(1); - - expect(result).toEqual(mockDeletedEmployee); - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringMatching(/UPDATE employees\s+SET deleted_at = NOW()/), - [1] - ); - }); - }); -}); -); - expect(countSql).toContain('first_name ILIKE - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [], - }); - - await employeeService.findAll({ department: 'IT', page: 1, limit: 10 }); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('department = $'), - expect.arrayContaining(['IT']) - ); - }); - }); - - describe('update', () => { - it('should update employee successfully', async () => { - const updateData = { first_name: 'Johnny' }; - const mockUpdatedEmployee = { id: 1, first_name: 'Johnny' }; - - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [mockUpdatedEmployee], - }); - - const result = await employeeService.update(1, updateData); - - expect(result).toEqual(mockUpdatedEmployee); - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('UPDATE employees'), - expect.arrayContaining(['Johnny', 1]) - ); - }); - - it('should return null if employee not found', async () => { - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [], - }); - - const result = await employeeService.update(999, { first_name: 'Test' }); - expect(result).toBeNull(); - }); - }); - - describe('delete', () => { - it('should soft delete employee', async () => { - const mockDeletedEmployee = { id: 1, deleted_at: new Date() }; - (mockPool.query as jest.Mock).mockResolvedValueOnce({ - rows: [mockDeletedEmployee], - rowCount: 1, - }); - - const result = await employeeService.delete(1); - - expect(result).toEqual(mockDeletedEmployee); - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringMatching(/UPDATE employees\s+SET deleted_at = NOW()/), - [1] - ); - }); - }); -}); -); + expect(countSql).toContain('organization_id = $'); + expect(countSql).toContain('department = $'); + expect(countSql).toContain('first_name ILIKE $'); expect(countArgs).toEqual([42, 'IT', '%Jane%']); expect(pageArgs).toEqual([42, 'IT', '%Jane%', 10, 40]); }); From e067b8697b5b6eb5ba6b35324447cf58c12b1959 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 03:07:09 -0400 Subject: [PATCH 13/14] fix(test): preserve existing audit cases and scoped detail-regression (#546) --- .../__tests__/transactionAuditService.test.ts | 50 ++----------------- 1 file changed, 4 insertions(+), 46 deletions(-) diff --git a/backend/src/services/__tests__/transactionAuditService.test.ts b/backend/src/services/__tests__/transactionAuditService.test.ts index 29e81704..66e967c0 100644 --- a/backend/src/services/__tests__/transactionAuditService.test.ts +++ b/backend/src/services/__tests__/transactionAuditService.test.ts @@ -79,13 +79,13 @@ describe('TransactionAuditService', () => { }); const countQuery = (mockPool.query as jest.Mock).mock.calls[0]; - expect(countQuery[0]).toContain('pf.employee_id = + expect(countQuery[0]).toContain('pf.employee_id = $'); expect(countQuery[1]).toContain('emp-123'); }); it('uses matching payroll detail predicates for totals and page rows', async () => { - // The matching employee and asset may NOT have the maximum values - // in a multi-line payroll transaction. + // Transactions with multiple payroll detail rows must preserve a match + // even if the selected employee/asset is not the aggregate maximum. (mockPool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '1' }] }) .mockResolvedValueOnce({ rows: [{ id: 17, employee_name: 'Jane Doe', asset: 'USDC' }] }); @@ -95,7 +95,6 @@ describe('TransactionAuditService', () => { }); expect(result.total).toBe(1); expect(result.data).toHaveLength(1); - const [countSql, countArgs] = (mockPool.query as jest.Mock).mock.calls[0]; const [dataSql, dataArgs] = (mockPool.query as jest.Mock).mock.calls[1]; expect(countSql).toContain('EXISTS (SELECT 1 FROM payroll_audit_logs pf'); @@ -117,48 +116,7 @@ describe('TransactionAuditService', () => { }); const countQuery = (mockPool.query as jest.Mock).mock.calls[0]; - expect(countQuery[0]).toContain('pf.asset_code = - expect(countQuery[1]).toContain('USDC'); - }); - }); -}); -); - expect(countQuery[1]).toContain('emp-123'); - }); - - it('should apply asset filter correctly', async () => { - (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '0' }] }); - (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] }); - - await TransactionAuditService.list(1, 20, undefined, { - asset: 'USDC' - }); - - const countQuery = (mockPool.query as jest.Mock).mock.calls[0]; - expect(countQuery[0]).toContain('pal.asset_code = $'); - expect(countQuery[1]).toContain('USDC'); - }); - }); -}); -); - expect(countQuery[1]).toContain('USDC'); - }); - }); -}); -); - expect(countQuery[1]).toContain('emp-123'); - }); - - it('should apply asset filter correctly', async () => { - (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '0' }] }); - (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] }); - - await TransactionAuditService.list(1, 20, undefined, { - asset: 'USDC' - }); - - const countQuery = (mockPool.query as jest.Mock).mock.calls[0]; - expect(countQuery[0]).toContain('pal.asset_code = $'); + expect(countQuery[0]).toContain('pf.asset_code = $'); expect(countQuery[1]).toContain('USDC'); }); }); From d2c447736d28cb5bd4d5602458c8011be5f54218 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 04:59:04 -0400 Subject: [PATCH 14/14] fix(api): restore bound pagination and audit filter parameters --- backend/src/services/__tests__/employeeService.test.ts | 1 + .../src/services/__tests__/transactionAuditService.test.ts | 1 + backend/src/services/employeeService.ts | 2 +- backend/src/services/transactionAuditService.ts | 4 ++-- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/services/__tests__/employeeService.test.ts b/backend/src/services/__tests__/employeeService.test.ts index f81aea3e..2b7d4d92 100644 --- a/backend/src/services/__tests__/employeeService.test.ts +++ b/backend/src/services/__tests__/employeeService.test.ts @@ -92,6 +92,7 @@ describe('EmployeeService', () => { expect(countSql).toContain('department = $'); expect(countSql).toContain('first_name ILIKE $'); expect(countArgs).toEqual([42, 'IT', '%Jane%']); + expect(pageSql).toContain('LIMIT $4 OFFSET $5'); expect(pageArgs).toEqual([42, 'IT', '%Jane%', 10, 40]); }); diff --git a/backend/src/services/__tests__/transactionAuditService.test.ts b/backend/src/services/__tests__/transactionAuditService.test.ts index 66e967c0..a3c9ffcb 100644 --- a/backend/src/services/__tests__/transactionAuditService.test.ts +++ b/backend/src/services/__tests__/transactionAuditService.test.ts @@ -104,6 +104,7 @@ describe('TransactionAuditService', () => { expect(countSql).toContain('COUNT(*) FROM transaction_audit_logs tal'); expect(dataSql).toContain('FROM payroll_audit_logs pf WHERE pf.employee_id = $1 AND pf.asset_code = $2'); expect(countArgs).toEqual(['3', 'USDC']); + expect(dataSql).toContain('LIMIT $3 OFFSET $4'); expect(dataArgs).toEqual(['3', 'USDC', 20, 0]); }); diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index 80a75dcb..8e338392 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -111,7 +111,7 @@ export class EmployeeService { 'SELECT COUNT(*) AS total_count' ); const countValues = values.slice(); - query += ` ORDER BY ${sortColumn} ${sortDirection} LIMIT ${paramIndex++} OFFSET ${paramIndex++}`; + query += ` ORDER BY ${sortColumn} ${sortDirection} LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; values.push(limit, offset); const result = await pool.query(query, values); diff --git a/backend/src/services/transactionAuditService.ts b/backend/src/services/transactionAuditService.ts index 21fc0f97..84ef1d68 100644 --- a/backend/src/services/transactionAuditService.ts +++ b/backend/src/services/transactionAuditService.ts @@ -134,11 +134,11 @@ export class TransactionAuditService { else if (filters.status === 'Failed') whereClauses.push(`tal.successful = false`); } if (filters.employeeId) { - payrollWhere.push(`pf.employee_id = ${paramIdx++}`); + payrollWhere.push(`pf.employee_id = $${paramIdx++}`); values.push(filters.employeeId); } if (filters.asset) { - payrollWhere.push(`pf.asset_code = ${paramIdx++}`); + payrollWhere.push(`pf.asset_code = $${paramIdx++}`); values.push(filters.asset); } }