diff --git a/frontends/nextjs/src/lib/api/filtering.test.ts b/frontends/nextjs/src/lib/api/filtering.test.ts new file mode 100644 index 000000000..3d960c9e4 --- /dev/null +++ b/frontends/nextjs/src/lib/api/filtering.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for filtering and sorting utilities + */ + +import { describe, it, expect } from 'vitest' +import { + parseFilterString, + parseFilterObject, + buildPrismaWhere, + parseSortString, + buildPrismaOrderBy, + isValidFieldName, + validateFilters, + validateSort, +} from './filtering' + +describe('filtering and sorting utilities', () => { + describe('parseFilterString', () => { + it.each([ + { input: '', expected: [], description: 'empty string' }, + { input: 'name:John', expected: [{ field: 'name', operator: 'eq', value: 'John' }], description: 'simple equality' }, + { input: 'age:gt:18', expected: [{ field: 'age', operator: 'gt', value: 18 }], description: 'greater than' }, + { input: 'active:eq:true', expected: [{ field: 'active', operator: 'eq', value: true }], description: 'boolean true' }, + { input: 'deleted:eq:false', expected: [{ field: 'deleted', operator: 'eq', value: false }], description: 'boolean false' }, + { input: 'role:in:admin,user', expected: [{ field: 'role', operator: 'in', value: 'admin' }], description: 'in operator (first value)' }, + { input: 'name:contains:john', expected: [{ field: 'name', operator: 'contains', value: 'john' }], description: 'contains operator' }, + { input: 'email:startsWith:admin', expected: [{ field: 'email', operator: 'startsWith', value: 'admin' }], description: 'startsWith operator' }, + ])('should parse $description', ({ input, expected }) => { + const result = parseFilterString(input) + if (expected.length === 0) { + expect(result).toEqual(expected) + } else { + expect(result[0]).toEqual(expected[0]) + } + }) + + it('should parse multiple filters', () => { + const result = parseFilterString('name:John,age:gt:18,active:true') + + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ field: 'name', operator: 'eq', value: 'John' }) + expect(result[1]).toEqual({ field: 'age', operator: 'gt', value: 18 }) + expect(result[2]).toEqual({ field: 'active', operator: 'eq', value: true }) + }) + }) + + describe('parseFilterObject', () => { + it.each([ + { input: {}, expected: [], description: 'empty object' }, + { input: { name: 'John' }, expected: [{ field: 'name', operator: 'eq', value: 'John' }], description: 'simple equality' }, + { input: { age: 18 }, expected: [{ field: 'age', operator: 'eq', value: 18 }], description: 'numeric value' }, + { input: { active: true }, expected: [{ field: 'active', operator: 'eq', value: true }], description: 'boolean value' }, + { input: { name: null }, expected: [{ field: 'name', operator: 'eq', value: null }], description: 'null value' }, + ])('should parse $description', ({ input, expected }) => { + const result = parseFilterObject(input) + expect(result).toEqual(expected) + }) + + it('should parse operator objects', () => { + const result = parseFilterObject({ age: { $gt: 18, $lt: 65 } }) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ field: 'age', operator: 'gt', value: 18 }) + expect(result[1]).toEqual({ field: 'age', operator: 'lt', value: 65 }) + }) + + it('should parse multiple fields', () => { + const result = parseFilterObject({ name: 'John', age: 30, active: true }) + + expect(result).toHaveLength(3) + expect(result).toContainEqual({ field: 'name', operator: 'eq', value: 'John' }) + expect(result).toContainEqual({ field: 'age', operator: 'eq', value: 30 }) + expect(result).toContainEqual({ field: 'active', operator: 'eq', value: true }) + }) + }) + + describe('buildPrismaWhere', () => { + it.each([ + { + input: [{ field: 'name', operator: 'eq' as const, value: 'John' }], + expected: { name: 'John' }, + description: 'equality', + }, + { + input: [{ field: 'age', operator: 'gt' as const, value: 18 }], + expected: { age: { gt: 18 } }, + description: 'greater than', + }, + { + input: [{ field: 'age', operator: 'gte' as const, value: 18 }], + expected: { age: { gte: 18 } }, + description: 'greater than or equal', + }, + { + input: [{ field: 'age', operator: 'lt' as const, value: 65 }], + expected: { age: { lt: 65 } }, + description: 'less than', + }, + { + input: [{ field: 'age', operator: 'lte' as const, value: 65 }], + expected: { age: { lte: 65 } }, + description: 'less than or equal', + }, + { + input: [{ field: 'role', operator: 'in' as const, value: ['admin', 'user'] }], + expected: { role: { in: ['admin', 'user'] } }, + description: 'in array', + }, + { + input: [{ field: 'role', operator: 'notIn' as const, value: ['guest'] }], + expected: { role: { notIn: ['guest'] } }, + description: 'not in array', + }, + { + input: [{ field: 'name', operator: 'contains' as const, value: 'john' }], + expected: { name: { contains: 'john', mode: 'insensitive' } }, + description: 'contains', + }, + { + input: [{ field: 'email', operator: 'startsWith' as const, value: 'admin' }], + expected: { email: { startsWith: 'admin', mode: 'insensitive' } }, + description: 'startsWith', + }, + { + input: [{ field: 'name', operator: 'endsWith' as const, value: 'son' }], + expected: { name: { endsWith: 'son', mode: 'insensitive' } }, + description: 'endsWith', + }, + { + input: [{ field: 'deletedAt', operator: 'isNull' as const }], + expected: { deletedAt: null }, + description: 'is null', + }, + { + input: [{ field: 'deletedAt', operator: 'isNotNull' as const }], + expected: { deletedAt: { not: null } }, + description: 'is not null', + }, + ])('should build where clause for $description', ({ input, expected }) => { + expect(buildPrismaWhere(input)).toEqual(expected) + }) + + it('should build where clause with multiple conditions', () => { + const conditions = [ + { field: 'name', operator: 'eq' as const, value: 'John' }, + { field: 'age', operator: 'gt' as const, value: 18 }, + { field: 'active', operator: 'eq' as const, value: true }, + ] + + const result = buildPrismaWhere(conditions) + + expect(result).toEqual({ + name: 'John', + age: { gt: 18 }, + active: true, + }) + }) + }) + + describe('parseSortString', () => { + it.each([ + { input: '', expected: [], description: 'empty string' }, + { input: 'name', expected: [{ field: 'name', direction: 'asc' }], description: 'ascending' }, + { input: '-name', expected: [{ field: 'name', direction: 'desc' }], description: 'descending' }, + { input: 'name,-age', expected: [{ field: 'name', direction: 'asc' }, { field: 'age', direction: 'desc' }], description: 'multiple fields' }, + ])('should parse $description', ({ input, expected }) => { + expect(parseSortString(input)).toEqual(expected) + }) + }) + + describe('buildPrismaOrderBy', () => { + it.each([ + { + input: [{ field: 'name', direction: 'asc' as const }], + expected: [{ name: 'asc' }], + description: 'single ascending', + }, + { + input: [{ field: 'name', direction: 'desc' as const }], + expected: [{ name: 'desc' }], + description: 'single descending', + }, + { + input: [ + { field: 'name', direction: 'asc' as const }, + { field: 'age', direction: 'desc' as const }, + ], + expected: [{ name: 'asc' }, { age: 'desc' }], + description: 'multiple fields', + }, + ])('should build orderBy for $description', ({ input, expected }) => { + expect(buildPrismaOrderBy(input)).toEqual(expected) + }) + }) + + describe('isValidFieldName', () => { + it.each([ + { input: 'name', expected: true, description: 'simple field' }, + { input: 'user_name', expected: true, description: 'with underscore' }, + { input: 'user.name', expected: true, description: 'nested field' }, + { input: 'user.profile.name', expected: true, description: 'deeply nested' }, + { input: 'name123', expected: true, description: 'with numbers' }, + { input: 'name-field', expected: false, description: 'with hyphen' }, + { input: 'name field', expected: false, description: 'with space' }, + { input: 'name;DROP TABLE', expected: false, description: 'SQL injection attempt' }, + { input: '../../../etc/passwd', expected: false, description: 'path traversal' }, + ])('should validate $description', ({ input, expected }) => { + expect(isValidFieldName(input)).toBe(expected) + }) + }) + + describe('validateFilters', () => { + it('should validate valid filters', () => { + const filters = [ + { field: 'name', operator: 'eq' as const, value: 'John' }, + { field: 'age', operator: 'gt' as const, value: 18 }, + ] + + const result = validateFilters(filters) + + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it('should reject invalid field names', () => { + const filters = [ + { field: 'name;DROP TABLE', operator: 'eq' as const, value: 'John' }, + ] + + const result = validateFilters(filters) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Invalid field name: name;DROP TABLE') + }) + + it('should reject invalid operators', () => { + const filters = [ + { field: 'name', operator: 'invalid' as any, value: 'John' }, + ] + + const result = validateFilters(filters) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Invalid operator: invalid') + }) + + it('should reject missing values for operators that need them', () => { + const filters = [ + { field: 'name', operator: 'eq' as const, value: undefined }, + ] + + const result = validateFilters(filters) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Operator eq requires a value') + }) + }) + + describe('validateSort', () => { + it('should validate valid sort conditions', () => { + const sorts = [ + { field: 'name', direction: 'asc' as const }, + { field: 'age', direction: 'desc' as const }, + ] + + const result = validateSort(sorts) + + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it('should reject invalid field names', () => { + const sorts = [ + { field: 'name;DROP TABLE', direction: 'asc' as const }, + ] + + const result = validateSort(sorts) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Invalid field name: name;DROP TABLE') + }) + + it('should reject invalid directions', () => { + const sorts = [ + { field: 'name', direction: 'invalid' as any }, + ] + + const result = validateSort(sorts) + + expect(result.valid).toBe(false) + expect(result.errors).toContain('Invalid sort direction: invalid') + }) + }) +}) diff --git a/frontends/nextjs/src/lib/api/filtering.ts b/frontends/nextjs/src/lib/api/filtering.ts new file mode 100644 index 000000000..c727e05f3 --- /dev/null +++ b/frontends/nextjs/src/lib/api/filtering.ts @@ -0,0 +1,264 @@ +/** + * Filtering and sorting utilities for API requests + * + * Provides utilities to build filter and sort queries + */ + +export type FilterOperator = + | 'eq' // Equal + | 'ne' // Not equal + | 'gt' // Greater than + | 'gte' // Greater than or equal + | 'lt' // Less than + | 'lte' // Less than or equal + | 'in' // In array + | 'notIn' // Not in array + | 'contains' // Contains substring + | 'startsWith' // Starts with + | 'endsWith' // Ends with + | 'isNull' // Is null + | 'isNotNull' // Is not null + +export interface FilterCondition { + field: string + operator: FilterOperator + value?: unknown +} + +export type SortDirection = 'asc' | 'desc' + +export interface SortCondition { + field: string + direction: SortDirection +} + +/** + * Parse filter string to filter conditions + * Format: field:operator:value or field:value (defaults to eq) + */ +export function parseFilterString(filterStr: string): FilterCondition[] { + if (!filterStr.trim()) { + return [] + } + + const filters: FilterCondition[] = [] + const parts = filterStr.split(',') + + for (const part of parts) { + const segments = part.trim().split(':') + + if (segments.length === 2) { + // field:value (default to eq) + filters.push({ + field: segments[0], + operator: 'eq', + value: parseValue(segments[1]), + }) + } else if (segments.length === 3) { + // field:operator:value + filters.push({ + field: segments[0], + operator: segments[1] as FilterOperator, + value: parseValue(segments[2]), + }) + } + } + + return filters +} + +/** + * Parse filter object to filter conditions + */ +export function parseFilterObject(filter: Record): FilterCondition[] { + const conditions: FilterCondition[] = [] + + for (const [field, value] of Object.entries(filter)) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Handle operator objects: { field: { $gt: 5 } } + for (const [op, val] of Object.entries(value)) { + const operator = op.startsWith('$') ? op.slice(1) : op + conditions.push({ + field, + operator: operator as FilterOperator, + value: val, + }) + } + } else { + // Simple equality: { field: value } + conditions.push({ + field, + operator: 'eq', + value, + }) + } + } + + return conditions +} + +/** + * Parse value from string (handles numbers, booleans, null) + */ +function parseValue(value: string): unknown { + if (value === 'null') return null + if (value === 'true') return true + if (value === 'false') return false + + const num = Number(value) + if (!isNaN(num)) return num + + return value +} + +/** + * Build Prisma where clause from filter conditions + */ +export function buildPrismaWhere(conditions: FilterCondition[]): Record { + const where: Record = {} + + for (const condition of conditions) { + const { field, operator, value } = condition + + switch (operator) { + case 'eq': + where[field] = value + break + case 'ne': + where[field] = { not: value } + break + case 'gt': + where[field] = { gt: value } + break + case 'gte': + where[field] = { gte: value } + break + case 'lt': + where[field] = { lt: value } + break + case 'lte': + where[field] = { lte: value } + break + case 'in': + where[field] = { in: Array.isArray(value) ? value : [value] } + break + case 'notIn': + where[field] = { notIn: Array.isArray(value) ? value : [value] } + break + case 'contains': + where[field] = { contains: String(value), mode: 'insensitive' } + break + case 'startsWith': + where[field] = { startsWith: String(value), mode: 'insensitive' } + break + case 'endsWith': + where[field] = { endsWith: String(value), mode: 'insensitive' } + break + case 'isNull': + where[field] = null + break + case 'isNotNull': + where[field] = { not: null } + break + } + } + + return where +} + +/** + * Parse sort string to sort conditions + * Format: field or -field (- prefix for desc) or field1,-field2 + */ +export function parseSortString(sortStr: string): SortCondition[] { + if (!sortStr.trim()) { + return [] + } + + const sorts: SortCondition[] = [] + const parts = sortStr.split(',') + + for (const part of parts) { + const trimmed = part.trim() + if (trimmed.startsWith('-')) { + sorts.push({ + field: trimmed.slice(1), + direction: 'desc', + }) + } else { + sorts.push({ + field: trimmed, + direction: 'asc', + }) + } + } + + return sorts +} + +/** + * Build Prisma orderBy from sort conditions + */ +export function buildPrismaOrderBy(conditions: SortCondition[]): Record[] { + return conditions.map(condition => ({ + [condition.field]: condition.direction, + })) +} + +/** + * Validate field name (prevents SQL injection) + */ +export function isValidFieldName(field: string): boolean { + // Allow alphanumeric, underscore, and dot (for nested fields) + return /^[a-zA-Z0-9_.]+$/.test(field) +} + +/** + * Validate filter conditions + */ +export function validateFilters(conditions: FilterCondition[]): { valid: boolean; errors: string[] } { + const errors: string[] = [] + + for (const condition of conditions) { + if (!isValidFieldName(condition.field)) { + errors.push(`Invalid field name: ${condition.field}`) + } + + const validOperators: FilterOperator[] = [ + 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', + 'in', 'notIn', 'contains', 'startsWith', 'endsWith', + 'isNull', 'isNotNull', + ] + + if (!validOperators.includes(condition.operator)) { + errors.push(`Invalid operator: ${condition.operator}`) + } + + // Validate value is provided for operators that need it + const operatorsNeedingValue = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'contains', 'startsWith', 'endsWith'] + if (operatorsNeedingValue.includes(condition.operator) && condition.value === undefined) { + errors.push(`Operator ${condition.operator} requires a value`) + } + } + + return { valid: errors.length === 0, errors } +} + +/** + * Validate sort conditions + */ +export function validateSort(conditions: SortCondition[]): { valid: boolean; errors: string[] } { + const errors: string[] = [] + + for (const condition of conditions) { + if (!isValidFieldName(condition.field)) { + errors.push(`Invalid field name: ${condition.field}`) + } + + if (!['asc', 'desc'].includes(condition.direction)) { + errors.push(`Invalid sort direction: ${condition.direction}`) + } + } + + return { valid: errors.length === 0, errors } +} diff --git a/frontends/nextjs/src/lib/api/pagination.test.ts b/frontends/nextjs/src/lib/api/pagination.test.ts new file mode 100644 index 000000000..e6805b3e5 --- /dev/null +++ b/frontends/nextjs/src/lib/api/pagination.test.ts @@ -0,0 +1,202 @@ +/** + * Tests for pagination utilities + */ + +import { describe, it, expect } from 'vitest' +import { + normalizePaginationParams, + calculatePaginationMetadata, + calculateOffset, + createPaginationResponse, + normalizeCursorPaginationParams, + calculateCursorPaginationMetadata, + createCursorPaginationResponse, + encodeCursor, + decodeCursor, + getPageNumbers, +} from './pagination' + +describe('pagination utilities', () => { + describe('normalizePaginationParams', () => { + it.each([ + { input: {}, expected: { page: 1, limit: 20 }, description: 'default values' }, + { input: { page: 2 }, expected: { page: 2, limit: 20 }, description: 'custom page' }, + { input: { limit: 50 }, expected: { page: 1, limit: 50 }, description: 'custom limit' }, + { input: { page: 3, limit: 30 }, expected: { page: 3, limit: 30 }, description: 'custom page and limit' }, + { input: { page: 0 }, expected: { page: 1, limit: 20 }, description: 'negative page to 1' }, + { input: { page: -5 }, expected: { page: 1, limit: 20 }, description: 'negative page to 1' }, + { input: { limit: 0 }, expected: { page: 1, limit: 1 }, description: 'zero limit to 1' }, + { input: { limit: -10 }, expected: { page: 1, limit: 1 }, description: 'negative limit to 1' }, + { input: { limit: 200 }, expected: { page: 1, limit: 100 }, description: 'limit capped at 100' }, + ])('should normalize $description', ({ input, expected }) => { + expect(normalizePaginationParams(input)).toEqual(expected) + }) + }) + + describe('calculatePaginationMetadata', () => { + it.each([ + { + params: { page: 1, limit: 20 }, + total: 100, + expected: { page: 1, limit: 20, total: 100, totalPages: 5, hasNextPage: true, hasPreviousPage: false }, + description: 'first page', + }, + { + params: { page: 3, limit: 20 }, + total: 100, + expected: { page: 3, limit: 20, total: 100, totalPages: 5, hasNextPage: true, hasPreviousPage: true }, + description: 'middle page', + }, + { + params: { page: 5, limit: 20 }, + total: 100, + expected: { page: 5, limit: 20, total: 100, totalPages: 5, hasNextPage: false, hasPreviousPage: true }, + description: 'last page', + }, + { + params: { page: 1, limit: 20 }, + total: 0, + expected: { page: 1, limit: 20, total: 0, totalPages: 0, hasNextPage: false, hasPreviousPage: false }, + description: 'empty result set', + }, + { + params: { page: 1, limit: 20 }, + total: 15, + expected: { page: 1, limit: 20, total: 15, totalPages: 1, hasNextPage: false, hasPreviousPage: false }, + description: 'single page result', + }, + ])('should calculate metadata for $description', ({ params, total, expected }) => { + expect(calculatePaginationMetadata(params, total)).toEqual(expected) + }) + }) + + describe('calculateOffset', () => { + it.each([ + { page: 1, limit: 20, expected: 0, description: 'first page' }, + { page: 2, limit: 20, expected: 20, description: 'second page' }, + { page: 3, limit: 10, expected: 20, description: 'third page with limit 10' }, + { page: 5, limit: 25, expected: 100, description: 'fifth page with limit 25' }, + ])('should calculate offset for $description', ({ page, limit, expected }) => { + expect(calculateOffset(page, limit)).toBe(expected) + }) + }) + + describe('createPaginationResponse', () => { + it('should create complete pagination response', () => { + const data = Array.from({ length: 20 }, (_, i) => ({ id: i + 1 })) + const params = { page: 2, limit: 20 } + const total = 100 + + const response = createPaginationResponse(data, params, total) + + expect(response.data).toEqual(data) + expect(response.meta).toEqual({ + page: 2, + limit: 20, + total: 100, + totalPages: 5, + hasNextPage: true, + hasPreviousPage: true, + }) + }) + }) + + describe('normalizeCursorPaginationParams', () => { + it.each([ + { input: {}, expected: { limit: 20, after: undefined, before: undefined }, description: 'default values' }, + { input: { limit: 50 }, expected: { limit: 50, after: undefined, before: undefined }, description: 'custom limit' }, + { input: { after: 'cursor1' }, expected: { limit: 20, after: 'cursor1', before: undefined }, description: 'with after cursor' }, + { input: { before: 'cursor2' }, expected: { limit: 20, after: undefined, before: 'cursor2' }, description: 'with before cursor' }, + { input: { limit: 200 }, expected: { limit: 100, after: undefined, before: undefined }, description: 'limit capped at 100' }, + ])('should normalize cursor params for $description', ({ input, expected }) => { + expect(normalizeCursorPaginationParams(input)).toEqual(expected) + }) + }) + + describe('calculateCursorPaginationMetadata', () => { + it('should calculate metadata with items', () => { + const items = [ + { id: 'id1', name: 'Item 1' }, + { id: 'id2', name: 'Item 2' }, + { id: 'id3', name: 'Item 3' }, + ] + + const meta = calculateCursorPaginationMetadata(items, 3, true) + + expect(meta).toEqual({ + limit: 3, + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'id1', + endCursor: 'id3', + }) + }) + + it('should calculate metadata with empty items', () => { + const items: { id: string }[] = [] + + const meta = calculateCursorPaginationMetadata(items, 20, false) + + expect(meta).toEqual({ + limit: 20, + hasNextPage: false, + hasPreviousPage: false, + startCursor: undefined, + endCursor: undefined, + }) + }) + }) + + describe('createCursorPaginationResponse', () => { + it('should create cursor pagination response', () => { + const data = [ + { id: 'id1', name: 'Item 1' }, + { id: 'id2', name: 'Item 2' }, + ] + + const response = createCursorPaginationResponse(data, 2, true) + + expect(response.data).toEqual(data) + expect(response.meta).toEqual({ + limit: 2, + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'id1', + endCursor: 'id2', + }) + }) + }) + + describe('cursor encoding/decoding', () => { + it.each([ + { id: 'simple-id', description: 'simple ID' }, + { id: 'uuid-v4-1234-5678', description: 'UUID' }, + { id: '12345', description: 'numeric ID' }, + { id: 'special!@#$%', description: 'special characters' }, + ])('should encode and decode $description', ({ id }) => { + const encoded = encodeCursor(id) + const decoded = decodeCursor(encoded) + + expect(decoded).toBe(id) + expect(encoded).not.toBe(id) + }) + }) + + describe('getPageNumbers', () => { + it.each([ + { currentPage: 1, totalPages: 5, expected: [1, 2, 3, 4, 5], description: 'all pages visible' }, + { currentPage: 1, totalPages: 10, expected: [1, 2, 3, 4, 5, 6, 7], description: 'first page of many' }, + { currentPage: 5, totalPages: 10, expected: [2, 3, 4, 5, 6, 7, 8], description: 'middle page' }, + { currentPage: 10, totalPages: 10, expected: [4, 5, 6, 7, 8, 9, 10], description: 'last page' }, + { currentPage: 1, totalPages: 1, expected: [1], description: 'single page' }, + { currentPage: 2, totalPages: 3, expected: [1, 2, 3], description: 'few pages' }, + ])('should get page numbers for $description', ({ currentPage, totalPages, expected }) => { + expect(getPageNumbers(currentPage, totalPages)).toEqual(expected) + }) + + it('should respect maxVisible parameter', () => { + expect(getPageNumbers(5, 20, 5)).toEqual([3, 4, 5, 6, 7]) + expect(getPageNumbers(10, 20, 3)).toEqual([9, 10, 11]) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/api/pagination.ts b/frontends/nextjs/src/lib/api/pagination.ts new file mode 100644 index 000000000..d7712e83e --- /dev/null +++ b/frontends/nextjs/src/lib/api/pagination.ts @@ -0,0 +1,172 @@ +/** + * Pagination utilities for API requests and UI components + * + * Provides utilities for both offset-based and cursor-based pagination + */ + +export interface PaginationMetadata { + page: number + limit: number + total: number + totalPages: number + hasNextPage: boolean + hasPreviousPage: boolean +} + +export interface CursorPaginationMetadata { + limit: number + hasNextPage: boolean + hasPreviousPage: boolean + startCursor?: string + endCursor?: string +} + +export interface PaginationParams { + page?: number + limit?: number +} + +export interface CursorPaginationParams { + limit?: number + after?: string + before?: string +} + +const DEFAULT_PAGE = 1 +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 100 + +/** + * Normalize pagination parameters + */ +export function normalizePaginationParams(params: PaginationParams): Required { + const page = Math.max(1, params.page ?? DEFAULT_PAGE) + const limit = Math.min(MAX_LIMIT, Math.max(1, params.limit ?? DEFAULT_LIMIT)) + + return { page, limit } +} + +/** + * Calculate pagination metadata + */ +export function calculatePaginationMetadata( + params: Required, + total: number +): PaginationMetadata { + const { page, limit } = params + const totalPages = Math.ceil(total / limit) + + return { + page, + limit, + total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + } +} + +/** + * Calculate offset for database query + */ +export function calculateOffset(page: number, limit: number): number { + return (page - 1) * limit +} + +/** + * Create pagination response + */ +export function createPaginationResponse( + data: T[], + params: PaginationParams, + total: number +): { data: T[]; meta: PaginationMetadata } { + const normalizedParams = normalizePaginationParams(params) + const meta = calculatePaginationMetadata(normalizedParams, total) + + return { data, meta } +} + +/** + * Normalize cursor pagination parameters + */ +export function normalizeCursorPaginationParams( + params: CursorPaginationParams +): Required> & Pick { + const limit = Math.min(MAX_LIMIT, Math.max(1, params.limit ?? DEFAULT_LIMIT)) + + return { + limit, + after: params.after, + before: params.before, + } +} + +/** + * Calculate cursor pagination metadata + */ +export function calculateCursorPaginationMetadata( + items: T[], + limit: number, + hasMore: boolean +): CursorPaginationMetadata { + const startCursor = items.length > 0 ? items[0].id : undefined + const endCursor = items.length > 0 ? items[items.length - 1].id : undefined + + return { + limit, + hasNextPage: hasMore, + hasPreviousPage: !!startCursor, + startCursor, + endCursor, + } +} + +/** + * Create cursor pagination response + */ +export function createCursorPaginationResponse( + data: T[], + limit: number, + hasMore: boolean +): { data: T[]; meta: CursorPaginationMetadata } { + const meta = calculateCursorPaginationMetadata(data, limit, hasMore) + + return { data, meta } +} + +/** + * Encode cursor (base64 encode the ID) + */ +export function encodeCursor(id: string): string { + return Buffer.from(id).toString('base64') +} + +/** + * Decode cursor (base64 decode to get ID) + */ +export function decodeCursor(cursor: string): string { + return Buffer.from(cursor, 'base64').toString('utf-8') +} + +/** + * Get page numbers for pagination UI + */ +export function getPageNumbers(currentPage: number, totalPages: number, maxVisible: number = 7): number[] { + if (totalPages <= maxVisible) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + const halfVisible = Math.floor(maxVisible / 2) + let startPage = Math.max(1, currentPage - halfVisible) + let endPage = Math.min(totalPages, currentPage + halfVisible) + + // Adjust if we're near the start or end + if (currentPage <= halfVisible) { + endPage = maxVisible + } else if (currentPage >= totalPages - halfVisible) { + startPage = totalPages - maxVisible + 1 + } + + return Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i) +} diff --git a/frontends/nextjs/src/lib/api/retry.test.ts b/frontends/nextjs/src/lib/api/retry.test.ts new file mode 100644 index 000000000..b50d61682 --- /dev/null +++ b/frontends/nextjs/src/lib/api/retry.test.ts @@ -0,0 +1,223 @@ +/** + * Tests for retry utilities + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { retryFetch, retry } from './retry' + +describe('retry utilities', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + describe('retryFetch', () => { + it.each([ + { statusCode: 200, shouldRetry: false, description: 'successful request' }, + { statusCode: 404, shouldRetry: false, description: 'client error (not retryable)' }, + { statusCode: 500, shouldRetry: true, description: 'server error (retryable)' }, + { statusCode: 502, shouldRetry: true, description: 'bad gateway (retryable)' }, + { statusCode: 503, shouldRetry: true, description: 'service unavailable (retryable)' }, + { statusCode: 429, shouldRetry: true, description: 'rate limited (retryable)' }, + ])('should handle $description correctly', async ({ statusCode, shouldRetry }) => { + let callCount = 0 + const mockFetch = vi.fn(async () => { + callCount++ + return new Response(JSON.stringify({ test: 'data' }), { + status: callCount === 1 && shouldRetry ? statusCode : (callCount === 1 ? statusCode : 200), + }) + }) + + let responsePromise: Promise + if (shouldRetry) { + responsePromise = retryFetch(mockFetch, { maxRetries: 2, initialDelayMs: 10 }) + // Fast-forward through retry delays + await vi.advanceTimersByTimeAsync(100) + } else { + responsePromise = retryFetch(mockFetch, { maxRetries: 2, initialDelayMs: 10 }) + } + + const response = await responsePromise + + if (shouldRetry) { + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(response.status).toBe(200) + } else { + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(response.status).toBe(statusCode) + } + }) + + it('should retry up to maxRetries times', async () => { + let callCount = 0 + const mockFetch = vi.fn(async () => { + callCount++ + return new Response(JSON.stringify({ error: 'Server error' }), { status: 500 }) + }) + + const promise = retryFetch(mockFetch, { maxRetries: 3, initialDelayMs: 10 }) + + // Fast-forward through all retry delays + await vi.advanceTimersByTimeAsync(1000) + + const response = await promise + + expect(mockFetch).toHaveBeenCalledTimes(4) // initial + 3 retries + expect(response.status).toBe(500) + }) + + it('should use exponential backoff', async () => { + const delays: number[] = [] + let callCount = 0 + + const mockFetch = vi.fn(async () => { + callCount++ + return new Response(JSON.stringify({ error: 'Server error' }), { status: 500 }) + }) + + const promise = retryFetch(mockFetch, { + maxRetries: 3, + initialDelayMs: 100, + backoffMultiplier: 2, + }) + + // Capture delays by advancing timers incrementally + for (let i = 0; i < 3; i++) { + await vi.advanceTimersByTimeAsync(1) + const delay = 100 * Math.pow(2, i) + await vi.advanceTimersByTimeAsync(delay) + } + + await promise + + // Verify exponential backoff: 100ms, 200ms, 400ms + expect(mockFetch).toHaveBeenCalledTimes(4) + }) + + it('should handle network errors with retries', async () => { + let callCount = 0 + const mockFetch = vi.fn(async () => { + callCount++ + if (callCount < 3) { + throw new Error('Network error') + } + return new Response(JSON.stringify({ success: true }), { status: 200 }) + }) + + const promise = retryFetch(mockFetch, { maxRetries: 3, initialDelayMs: 10 }) + + // Fast-forward through retry delays + await vi.advanceTimersByTimeAsync(500) + + const response = await promise + + expect(mockFetch).toHaveBeenCalledTimes(3) + expect(response.status).toBe(200) + }) + + it('should throw error after max retries exceeded', async () => { + const mockFetch = vi.fn(async () => { + throw new Error('Network error') + }) + + const promise = retryFetch(mockFetch, { maxRetries: 2, initialDelayMs: 10 }) + + // Fast-forward through all retry delays + vi.advanceTimersByTimeAsync(500) + + await expect(promise).rejects.toThrow('Network error') + expect(mockFetch).toHaveBeenCalledTimes(3) // initial + 2 retries + }) + + it('should respect maxDelayMs', async () => { + let callCount = 0 + const mockFetch = vi.fn(async () => { + callCount++ + return new Response(JSON.stringify({ error: 'Server error' }), { status: 500 }) + }) + + const promise = retryFetch(mockFetch, { + maxRetries: 5, + initialDelayMs: 1000, + maxDelayMs: 2000, + backoffMultiplier: 2, + }) + + // The delays would be: 1000, 2000, 2000 (capped), 2000 (capped), 2000 (capped) + await vi.advanceTimersByTimeAsync(10000) + + await promise + + expect(mockFetch).toHaveBeenCalledTimes(6) + }) + }) + + describe('retry', () => { + it('should retry async function on failure', async () => { + let callCount = 0 + const mockFn = vi.fn(async () => { + callCount++ + if (callCount < 2) { + throw new Error('Temporary error') + } + return 'success' + }) + + const promise = retry(mockFn, { maxRetries: 2, initialDelayMs: 10 }) + + await vi.advanceTimersByTimeAsync(500) + + const result = await promise + + expect(mockFn).toHaveBeenCalledTimes(2) + expect(result).toBe('success') + }) + + it('should return result on first success', async () => { + const mockFn = vi.fn(async () => 'success') + + const result = await retry(mockFn, { maxRetries: 3, initialDelayMs: 10 }) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(result).toBe('success') + }) + + it('should throw after max retries', async () => { + const mockFn = vi.fn(async () => { + throw new Error('Persistent error') + }) + + const promise = retry(mockFn, { maxRetries: 2, initialDelayMs: 10 }) + + vi.advanceTimersByTimeAsync(500) + + await expect(promise).rejects.toThrow('Persistent error') + expect(mockFn).toHaveBeenCalledTimes(3) + }) + + it('should use exponential backoff', async () => { + let callCount = 0 + const mockFn = vi.fn(async () => { + callCount++ + if (callCount < 4) { + throw new Error('Temporary error') + } + return 'success' + }) + + const promise = retry(mockFn, { + maxRetries: 3, + initialDelayMs: 100, + backoffMultiplier: 2, + }) + + // Advance through all delays: 100ms, 200ms, 400ms + await vi.advanceTimersByTimeAsync(800) + + const result = await promise + + expect(mockFn).toHaveBeenCalledTimes(4) + expect(result).toBe('success') + }) + }) +}) diff --git a/frontends/nextjs/src/lib/api/retry.ts b/frontends/nextjs/src/lib/api/retry.ts new file mode 100644 index 000000000..322c55bef --- /dev/null +++ b/frontends/nextjs/src/lib/api/retry.ts @@ -0,0 +1,127 @@ +/** + * Utility for retrying failed API requests + * + * Provides exponential backoff retry logic for transient failures + */ + +export interface RetryOptions { + maxRetries?: number + initialDelayMs?: number + maxDelayMs?: number + backoffMultiplier?: number + retryableStatusCodes?: number[] +} + +const DEFAULT_OPTIONS: Required = { + maxRetries: 3, + initialDelayMs: 100, + maxDelayMs: 5000, + backoffMultiplier: 2, + retryableStatusCodes: [408, 429, 500, 502, 503, 504], +} + +/** + * Sleep for specified milliseconds + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Calculate delay with exponential backoff + */ +function calculateDelay(attempt: number, options: Required): number { + const delay = options.initialDelayMs * Math.pow(options.backoffMultiplier, attempt) + return Math.min(delay, options.maxDelayMs) +} + +/** + * Check if status code is retryable + */ +function isRetryable(statusCode: number, retryableStatusCodes: number[]): boolean { + return retryableStatusCodes.includes(statusCode) +} + +/** + * Retry a fetch request with exponential backoff + * + * @param fn - Function that returns a fetch promise + * @param options - Retry options + * @returns Promise that resolves with the response or rejects after all retries + */ +export async function retryFetch( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options } + let lastError: Error | null = null + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + const response = await fn() + + // If response is ok or not retryable, return it + if (response.ok || !isRetryable(response.status, opts.retryableStatusCodes)) { + return response + } + + // If this was the last attempt, return the failed response + if (attempt === opts.maxRetries) { + return response + } + + // Wait before retrying + const delay = calculateDelay(attempt, opts) + await sleep(delay) + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error') + + // If this was the last attempt, throw the error + if (attempt === opts.maxRetries) { + throw lastError + } + + // Wait before retrying + const delay = calculateDelay(attempt, opts) + await sleep(delay) + } + } + + // Should never reach here, but TypeScript requires it + throw lastError ?? new Error('Retry failed') +} + +/** + * Retry an async function with exponential backoff + * + * @param fn - Async function to retry + * @param options - Retry options + * @returns Promise that resolves with the result or rejects after all retries + */ +export async function retry( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options } + let lastError: Error | null = null + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error') + + // If this was the last attempt, throw the error + if (attempt === opts.maxRetries) { + throw lastError + } + + // Wait before retrying + const delay = calculateDelay(attempt, opts) + await sleep(delay) + } + } + + // Should never reach here, but TypeScript requires it + throw lastError ?? new Error('Retry failed') +}