diff --git a/backend/.env.example b/backend/.env.example index 4391e10..b69d729 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -50,3 +50,4 @@ UEX_BACKOFF_BASE_MS=1000 UEX_RATE_LIMIT_PAUSE_MS=2000 UEX_ENDPOINTS_PAUSE_MS=2000 UEX_API_KEY= + diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 259de13..bee2c17 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -20,6 +20,6 @@ module.exports = { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'error', }, }; diff --git a/backend/data-source.js b/backend/data-source.js index c6cc9a4..6a84687 100644 --- a/backend/data-source.js +++ b/backend/data-source.js @@ -1,24 +1,24 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); exports.AppDataSource = void 0; -require("dotenv/config"); -const typeorm_1 = require("typeorm"); -const user_entity_1 = require("./src/modules/users/user.entity"); +require('dotenv/config'); +const typeorm_1 = require('typeorm'); +const user_entity_1 = require('./src/modules/users/user.entity'); exports.AppDataSource = new typeorm_1.DataSource({ - type: 'postgres', - host: process.env.DATABASE_HOST, - port: parseInt(process.env.DATABASE_PORT || '0'), - username: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - entities: [user_entity_1.User], - migrations: ['src/migrations/*.ts'], - synchronize: false, + type: 'postgres', + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT || '0'), + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + entities: [user_entity_1.User], + migrations: ['src/migrations/*.ts'], + synchronize: false, }); exports.AppDataSource.initialize() - .then(() => { + .then(() => { console.log('Data Source has been initialized!'); -}) - .catch((err) => { + }) + .catch((err) => { console.error('Error during Data Source initialization:', err); -}); + }); diff --git a/backend/eslint.config.js b/backend/eslint.config.js index 5aeba67..561b499 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -10,7 +10,7 @@ const compat = new FlatCompat({ module.exports = [ ...compat.extends( 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended' + 'plugin:prettier/recommended', ), { files: ['src/**/*.ts'], @@ -26,11 +26,14 @@ module.exports = [ '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], }, }, { @@ -45,11 +48,14 @@ module.exports = [ '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], }, }, ]; diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c42646a..3d33418 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -92,10 +92,12 @@ if (!isTest) { }); console.log('✅ Redis cache connected successfully'); return { store }; - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); console.warn( '⚠️ Redis connection failed, using in-memory cache:', - error?.message || error, + errorMessage, ); // Fall back to in-memory cache if Redis is not available return { diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 8273540..3c9423a 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -7,6 +7,12 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; +interface HttpExceptionResponse { + message?: string | string[]; + errors?: unknown; + [key: string]: unknown; +} + @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { @@ -16,21 +22,28 @@ export class HttpExceptionFilter implements ExceptionFilter { const status = exception.getStatus(); const exceptionResponse = exception.getResponse(); + let message: string | string[] = 'An error occurred'; + let errors: unknown = undefined; + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if ( + typeof exceptionResponse === 'object' && + exceptionResponse !== null + ) { + const typedResponse = exceptionResponse as HttpExceptionResponse; + message = typedResponse.message || 'An error occurred'; + errors = typedResponse.errors; + } + const errorResponse = { success: false, statusCode: status, timestamp: new Date().toISOString(), path: request.url, method: request.method, - message: - typeof exceptionResponse === 'string' - ? exceptionResponse - : (exceptionResponse as any).message || 'An error occurred', - errors: - typeof exceptionResponse === 'object' && - (exceptionResponse as any).errors - ? (exceptionResponse as any).errors - : undefined, + message, + errors, }; response.status(status).json(errorResponse); diff --git a/backend/src/common/guards/throttler.guard.ts b/backend/src/common/guards/throttler.guard.ts index 5570d4a..b6ec743 100644 --- a/backend/src/common/guards/throttler.guard.ts +++ b/backend/src/common/guards/throttler.guard.ts @@ -16,8 +16,8 @@ import { Request } from 'express'; */ @Injectable() export class CustomThrottlerGuard extends ThrottlerGuard { - protected async getTracker(req: Record): Promise { - const request = req as Request; + protected async getTracker(req: Record): Promise { + const request = req as unknown as Request; // req.ips is populated by Express only when trust proxy is configured; // it contains the full chain of forwarded IPs with spoofed entries stripped. // Fall back to req.ip (the direct connection address) when not behind a proxy. diff --git a/backend/src/common/interceptors/audit-log.interceptor.ts b/backend/src/common/interceptors/audit-log.interceptor.ts index 72dc9d2..7a9789f 100644 --- a/backend/src/common/interceptors/audit-log.interceptor.ts +++ b/backend/src/common/interceptors/audit-log.interceptor.ts @@ -13,6 +13,24 @@ import { AuditLogMetadata, } from '../decorators/audit-log.decorator'; +interface AuditLogResponse { + id?: number | string; + [key: string]: unknown; +} + +interface AuditLogRequest { + user?: { + userId?: number; + username?: string; + }; + params?: Record; + query?: Record; + method: string; + url: string; + ip: string; + headers: Record; +} + @Injectable() export class AuditLogInterceptor implements NestInterceptor { constructor( @@ -20,7 +38,7 @@ export class AuditLogInterceptor implements NestInterceptor { private auditLogsService: AuditLogsService, ) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept(context: ExecutionContext, next: CallHandler): Observable { const auditMetadata = this.reflector.getAllAndOverride( AUDIT_LOG_KEY, [context.getHandler(), context.getClass()], @@ -30,35 +48,64 @@ export class AuditLogInterceptor implements NestInterceptor { return next.handle(); } - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest() as AuditLogRequest; const user = request.user; const { action, entityType } = auditMetadata; return next.handle().pipe( - tap(async (response) => { + tap((response: unknown) => { + const isPlainObject = + typeof response === 'object' && + response !== null && + !Array.isArray(response); + const typedResponse = isPlainObject + ? (response as AuditLogResponse) + : undefined; + // Extract entity ID from response or params - const entityId = - response?.id || + let entityId: number | undefined; + const rawEntityId = + typedResponse?.id || request.params?.id || request.params?.organizationId || request.params?.roleId; - await this.auditLogsService.log({ - userId: user?.userId, - username: user?.username, - action, - entityType, - entityId: entityId ? parseInt(entityId, 10) : undefined, - metadata: { - method: request.method, - url: request.url, - params: request.params, - query: request.query, - }, - newValues: response, - ipAddress: request.ip, - userAgent: request.headers['user-agent'], - }); + if (rawEntityId !== undefined) { + if (typeof rawEntityId === 'number') { + entityId = Number.isNaN(rawEntityId) ? undefined : rawEntityId; + } else if ( + typeof rawEntityId === 'string' && + /^\d+$/.test(rawEntityId) + ) { + entityId = parseInt(rawEntityId, 10); + } + // Non-numeric IDs (e.g. UUIDs) are intentionally left as undefined + // to avoid silent truncation like parseInt('1e3...') → 1. + } + + this.auditLogsService + .log({ + userId: user?.userId, + username: user?.username, + action, + entityType, + entityId, + metadata: { + method: request.method, + url: request.url, + params: request.params, + query: request.query, + }, + newValues: typedResponse, + ipAddress: request.ip, + userAgent: + typeof request.headers['user-agent'] === 'string' + ? request.headers['user-agent'] + : undefined, + }) + .catch(() => { + // Audit log failures must not affect the response pipeline + }); }), ); } diff --git a/backend/src/common/types/query-params.type.ts b/backend/src/common/types/query-params.type.ts new file mode 100644 index 0000000..855736c --- /dev/null +++ b/backend/src/common/types/query-params.type.ts @@ -0,0 +1,8 @@ +export interface QueryParams { + [key: string]: string | string[] | QueryParams | QueryParams[] | undefined; +} + +/** Narrows a query param value to string | undefined, ignoring arrays/objects. */ +export function asString(value: QueryParams[string]): string | undefined { + return typeof value === 'string' ? value : undefined; +} diff --git a/backend/src/database/seeds/database-seeder.service.spec.ts b/backend/src/database/seeds/database-seeder.service.spec.ts index a589a37..04f0e9d 100644 --- a/backend/src/database/seeds/database-seeder.service.spec.ts +++ b/backend/src/database/seeds/database-seeder.service.spec.ts @@ -155,49 +155,67 @@ describe('DatabaseSeederService', () => { .spyOn(gamesRepository, 'findOne') .mockResolvedValueOnce(null) // First game check (sc) .mockResolvedValueOnce(null) // Second game check (sq42) - .mockResolvedValueOnce(mockGame as any) // Get sc game for org creation + .mockResolvedValueOnce(mockGame as unknown as Game) // Get sc game for org creation .mockResolvedValueOnce(null); // Org check - jest.spyOn(gamesRepository, 'create').mockReturnValue(mockGame as any); - jest.spyOn(gamesRepository, 'save').mockResolvedValue(mockGame as any); + jest + .spyOn(gamesRepository, 'create') + .mockReturnValue(mockGame as unknown as Game); + jest + .spyOn(gamesRepository, 'save') + .mockResolvedValue(mockGame as unknown as Game); jest.spyOn(rolesRepository, 'findOne').mockResolvedValue(null); - jest.spyOn(rolesRepository, 'create').mockReturnValue(mockRole as any); - jest.spyOn(rolesRepository, 'save').mockResolvedValue(mockRole as any); + jest + .spyOn(rolesRepository, 'create') + .mockReturnValue(mockRole as unknown as Role); + jest + .spyOn(rolesRepository, 'save') + .mockResolvedValue(mockRole as unknown as Role); jest.spyOn(organizationsRepository, 'findOne').mockResolvedValue(null); jest .spyOn(organizationsRepository, 'create') - .mockReturnValue(mockOrganization as any); + .mockReturnValue(mockOrganization as unknown as Organization); jest .spyOn(organizationsRepository, 'save') - .mockResolvedValue(mockOrganization as any); + .mockResolvedValue(mockOrganization as unknown as Organization); jest.spyOn(usersRepository, 'findOne').mockResolvedValue(null); - jest.spyOn(usersRepository, 'create').mockReturnValue(mockUser as any); - jest.spyOn(usersRepository, 'save').mockResolvedValue(mockUser as any); + jest + .spyOn(usersRepository, 'create') + .mockReturnValue(mockUser as unknown as User); + jest + .spyOn(usersRepository, 'save') + .mockResolvedValue(mockUser as unknown as User); jest.spyOn(userOrgRolesRepository, 'findOne').mockResolvedValue(null); jest .spyOn(userOrgRolesRepository, 'create') - .mockReturnValue(mockUserOrgRole as any); + .mockReturnValue(mockUserOrgRole as unknown as UserOrganizationRole); jest .spyOn(userOrgRolesRepository, 'save') - .mockResolvedValue(mockUserOrgRole as any); + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); await expect(service.seedAll()).resolves.toBeUndefined(); }); it('should handle existing data gracefully', async () => { - jest.spyOn(gamesRepository, 'findOne').mockResolvedValue(mockGame as any); - jest.spyOn(rolesRepository, 'findOne').mockResolvedValue(mockRole as any); + jest + .spyOn(gamesRepository, 'findOne') + .mockResolvedValue(mockGame as unknown as Game); + jest + .spyOn(rolesRepository, 'findOne') + .mockResolvedValue(mockRole as unknown as Role); jest .spyOn(organizationsRepository, 'findOne') - .mockResolvedValue(mockOrganization as any); - jest.spyOn(usersRepository, 'findOne').mockResolvedValue(mockUser as any); + .mockResolvedValue(mockOrganization as unknown as Organization); + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(mockUser as unknown as User); jest .spyOn(userOrgRolesRepository, 'findOne') - .mockResolvedValue(mockUserOrgRole as any); + .mockResolvedValue(mockUserOrgRole as unknown as UserOrganizationRole); await expect(service.seedAll()).resolves.toBeUndefined(); diff --git a/backend/src/modules/audit-logs/audit-log.entity.ts b/backend/src/modules/audit-logs/audit-log.entity.ts index 280b62f..ad0eaa4 100644 --- a/backend/src/modules/audit-logs/audit-log.entity.ts +++ b/backend/src/modules/audit-logs/audit-log.entity.ts @@ -56,13 +56,13 @@ export class AuditLog { entityId?: number; @Column('jsonb', { nullable: true }) - metadata?: Record; + metadata?: Record; @Column('jsonb', { nullable: true }) - oldValues?: Record; + oldValues?: Record; @Column('jsonb', { nullable: true }) - newValues?: Record; + newValues?: Record; @Column({ nullable: true }) ipAddress?: string; diff --git a/backend/src/modules/audit-logs/audit-logs.service.spec.ts b/backend/src/modules/audit-logs/audit-logs.service.spec.ts index f48e6d9..6493702 100644 --- a/backend/src/modules/audit-logs/audit-logs.service.spec.ts +++ b/backend/src/modules/audit-logs/audit-logs.service.spec.ts @@ -72,8 +72,8 @@ describe('AuditLogsService', () => { metadata: { test: 'data' }, }; - jest.spyOn(repository, 'create').mockReturnValue(mockAuditLog as any); - jest.spyOn(repository, 'save').mockResolvedValue(mockAuditLog as any); + jest.spyOn(repository, 'create').mockReturnValue(mockAuditLog); + jest.spyOn(repository, 'save').mockResolvedValue(mockAuditLog); const result = await service.log(dto); diff --git a/backend/src/modules/audit-logs/audit-logs.service.ts b/backend/src/modules/audit-logs/audit-logs.service.ts index 570420b..82e780a 100644 --- a/backend/src/modules/audit-logs/audit-logs.service.ts +++ b/backend/src/modules/audit-logs/audit-logs.service.ts @@ -9,9 +9,9 @@ export interface CreateAuditLogDto { action: AuditAction; entityType: AuditEntityType; entityId?: number; - metadata?: Record; - oldValues?: Record; - newValues?: Record; + metadata?: Record; + oldValues?: Record; + newValues?: Record; ipAddress?: string; userAgent?: string; } diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 2121759..1178212 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -3,6 +3,7 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { AuthenticatedRequest } from './interfaces/authenticated-request.interface'; describe('AuthController - Password Reset', () => { let controller: AuthController; @@ -121,8 +122,8 @@ describe('AuthController - Password Reset', () => { describe('changePassword', () => { it('should call authService.changePassword with userId and passwords', async () => { const mockRequest = { - user: { userId: 1 }, - }; + user: { userId: 1, username: 'testuser' }, + } as unknown as AuthenticatedRequest; const currentPassword = 'oldPassword123'; const newPassword = 'newSecurePassword123'; const expectedResponse = { message: 'Password changed successfully' }; @@ -144,8 +145,8 @@ describe('AuthController - Password Reset', () => { it('should handle incorrect current password error', async () => { const mockRequest = { - user: { userId: 1 }, - }; + user: { userId: 1, username: 'testuser' }, + } as AuthenticatedRequest; const currentPassword = 'wrongPassword'; const newPassword = 'newPassword123'; @@ -163,8 +164,8 @@ describe('AuthController - Password Reset', () => { it('should extract userId from authenticated request', async () => { const mockRequest = { - user: { userId: 42 }, - }; + user: { userId: 42, username: 'testuser' }, + } as unknown as AuthenticatedRequest; const currentPassword = 'oldPassword123'; const newPassword = 'newPassword123'; diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index da7015c..cab523d 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -23,13 +23,15 @@ import { LocalAuthGuard } from './local-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard'; import { RefreshTokenAuthGuard } from './refresh-token-auth.guard'; import { UserDto } from '../users/dto/user.dto'; -import { User } from '../users/user.entity'; import { Request as ExpressRequest, Response } from 'express'; import { ChangePasswordDto, ForgotPasswordDto, ResetPasswordDto, } from './dto/password-reset.dto'; +import { AuthenticatedRequest } from './interfaces/authenticated-request.interface'; +import { RefreshTokenRequest } from './interfaces/refresh-token-request.interface'; +import { ValidatedUser } from './interfaces/validated-user.interface'; // Parse throttle config once at module load time. // Number() handles numeric strings and NaN from non-numeric input; the @@ -97,11 +99,10 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Post('login') async login( - @Request() req: ExpressRequest, + @Request() req: ExpressRequest & { user: ValidatedUser }, @Res({ passthrough: true }) res: Response, ) { - const user = req.user as Omit; - const tokens = await this.authService.login(user); + const tokens = await this.authService.login(req.user); res.cookie( 'access_token', tokens.accessToken, @@ -112,7 +113,7 @@ export class AuthController { tokens.refreshToken, this.cookieOptions(7 * 24 * 60 * 60 * 1000), ); - return { message: 'Login successful', username: user.username }; + return { message: 'Login successful', username: req.user.username }; } @ApiOperation({ summary: 'Register new user' }) @@ -130,8 +131,8 @@ export class AuthController { @ApiResponse({ status: 401, description: 'Unauthorized' }) @UseGuards(JwtAuthGuard) @Get('me') - me(@Request() req: any) { - return { id: req.user.userId, username: req.user.username }; + me(@Request() req: AuthenticatedRequest) { + return { userId: req.user.userId, username: req.user.username }; } @ApiOperation({ summary: 'Refresh access token using refresh token cookie' }) @@ -141,7 +142,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Post('refresh') async refresh( - @Request() req: any, + @Request() req: RefreshTokenRequest, @Res({ passthrough: true }) res: Response, ) { const tokens = await this.authService.refreshAccessToken( @@ -166,7 +167,10 @@ export class AuthController { @UseGuards(RefreshTokenAuthGuard) @HttpCode(HttpStatus.OK) @Post('logout') - async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { + async logout( + @Request() req: RefreshTokenRequest, + @Res({ passthrough: true }) res: Response, + ) { await this.authService.revokeRefreshToken(req.user.refreshToken); const { maxAge: _maxAge, ...clearOpts } = this.cookieOptions(0); res.clearCookie('access_token', clearOpts); @@ -209,7 +213,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Post('change-password') async changePassword( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Body() changePasswordDto: ChangePasswordDto, ) { const userId = req.user.userId; diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index b34ebc3..3695a00 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -135,17 +135,19 @@ describe('AuthService', () => { it('should generate token that expires in 1 hour', async () => { mockUsersService.findByEmail.mockResolvedValue(mockUser); const now = new Date(); - let savedToken: any; + let savedToken: { expiresAt: Date } | undefined; - mockPasswordResetRepository.save.mockImplementation((token) => { - savedToken = token; - return Promise.resolve(token); - }); + mockPasswordResetRepository.save.mockImplementation( + (token: { expiresAt: Date }) => { + savedToken = token; + return Promise.resolve(token); + }, + ); await service.requestPasswordReset(mockUser.email); expect(savedToken).toBeDefined(); - const expiryTime = new Date(savedToken.expiresAt).getTime(); + const expiryTime = new Date(savedToken!.expiresAt).getTime(); const expectedExpiry = now.getTime() + 60 * 60 * 1000; // 1 hour expect(Math.abs(expiryTime - expectedExpiry)).toBeLessThan(1000); // Within 1 second }); @@ -364,25 +366,29 @@ describe('AuthService', () => { }); it('should persist the SHA-256 hash, not the raw token', async () => { - let savedData: any; - mockRefreshTokenRepository.save.mockImplementation((data) => { - savedData = data; - return Promise.resolve(data); - }); + let savedData: { token: string; expiresAt: Date } | undefined; + mockRefreshTokenRepository.save.mockImplementation( + (data: { token: string; expiresAt: Date }) => { + savedData = data; + return Promise.resolve(data); + }, + ); const raw = await service.generateRefreshToken(mockUser.id); - expect(savedData.token).toBe(sha256(raw)); - expect(savedData.token).not.toBe(raw); + expect(savedData!.token).toBe(sha256(raw)); + expect(savedData!.token).not.toBe(raw); }); it('should set expiry 7 calendar days from now', async () => { const now = new Date(); - let savedData: any; - mockRefreshTokenRepository.save.mockImplementation((data) => { - savedData = data; - return Promise.resolve(data); - }); + let savedData: { token: string; expiresAt: Date } | undefined; + mockRefreshTokenRepository.save.mockImplementation( + (data: { token: string; expiresAt: Date }) => { + savedData = data; + return Promise.resolve(data); + }, + ); await service.generateRefreshToken(mockUser.id); @@ -391,7 +397,7 @@ describe('AuthService', () => { const expected = new Date(now); expected.setDate(expected.getDate() + 7); expect( - Math.abs(savedData.expiresAt.getTime() - expected.getTime()), + Math.abs(savedData!.expiresAt.getTime() - expected.getTime()), ).toBeLessThan(1000); }); }); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index edfaf37..904273f 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -17,6 +17,8 @@ import { RefreshToken } from './refresh-token.entity'; import { PasswordReset } from './password-reset.entity'; import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { ValidatedUser } from './interfaces/validated-user.interface'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; @Injectable() export class AuthService { @@ -35,7 +37,10 @@ export class AuthService { private passwordResetRepository: Repository, ) {} - async validateUser(username: string, pass: string): Promise { + async validateUser( + username: string, + pass: string, + ): Promise { const user = await this.usersService.findOne(username); const trimmedPass = pass.trim(); @@ -60,9 +65,9 @@ export class AuthService { } async login( - user: Omit, + user: ValidatedUser, ): Promise<{ accessToken: string; refreshToken: string }> { - const payload = { username: user.username, sub: user.id }; + const payload: JwtPayload = { username: user.username, sub: user.id }; const accessToken = this.jwtService.sign(payload); const refreshToken = await this.generateRefreshToken(user.id); diff --git a/backend/src/modules/auth/interfaces/authenticated-request.interface.ts b/backend/src/modules/auth/interfaces/authenticated-request.interface.ts new file mode 100644 index 0000000..78b037f --- /dev/null +++ b/backend/src/modules/auth/interfaces/authenticated-request.interface.ts @@ -0,0 +1,17 @@ +import { Request } from 'express'; + +/** + * User object attached to request after JWT authentication + */ +export interface AuthenticatedUser { + userId: number; + username: string; +} + +/** + * Express request with authenticated user + * Used after JwtAuthGuard validates the request + */ +export interface AuthenticatedRequest extends Request { + user: AuthenticatedUser; +} diff --git a/backend/src/modules/auth/interfaces/jwt-payload.interface.ts b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..f62892a --- /dev/null +++ b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts @@ -0,0 +1,13 @@ +/** + * JWT payload structure for access tokens + */ +export interface JwtPayload { + /** User ID (subject) */ + sub: number; + /** Username */ + username: string; + /** Issued at timestamp (optional, added by JWT) */ + iat?: number; + /** Expiration timestamp (optional, added by JWT) */ + exp?: number; +} diff --git a/backend/src/modules/auth/interfaces/refresh-token-request.interface.ts b/backend/src/modules/auth/interfaces/refresh-token-request.interface.ts new file mode 100644 index 0000000..b70dc36 --- /dev/null +++ b/backend/src/modules/auth/interfaces/refresh-token-request.interface.ts @@ -0,0 +1,16 @@ +import { Request } from 'express'; + +/** + * User object attached to request after refresh token authentication + */ +export interface RefreshTokenUser { + refreshToken: string; +} + +/** + * Express request with refresh token + * Used after RefreshTokenAuthGuard validates the request + */ +export interface RefreshTokenRequest extends Request { + user: RefreshTokenUser; +} diff --git a/backend/src/modules/auth/interfaces/validated-user.interface.ts b/backend/src/modules/auth/interfaces/validated-user.interface.ts new file mode 100644 index 0000000..f0296c5 --- /dev/null +++ b/backend/src/modules/auth/interfaces/validated-user.interface.ts @@ -0,0 +1,6 @@ +import { User } from '../../users/user.entity'; + +/** + * User returned from validateUser (without password) + */ +export type ValidatedUser = Omit; diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 97a4646..7712afc 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -3,6 +3,8 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; +import { AuthenticatedUser } from './interfaces/authenticated-request.interface'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -19,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: { sub: number; username: string }) { + async validate(payload: JwtPayload): Promise { return { userId: payload.sub, username: payload.username }; } } diff --git a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts index b85d579..005e868 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { OrgInventoryController } from './org-inventory.controller'; import { OrgInventoryService } from './org-inventory.service'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; describe('OrgInventoryController', () => { let controller: OrgInventoryController; @@ -22,16 +23,22 @@ describe('OrgInventoryController', () => { }); it('accepts camelCase query aliases for org inventory filters', async () => { - await controller.list({ user: { userId: 7 } }, 42, { - gameId: '1', - categoryId: '5', - uexItemId: '9', - locationId: '12', - minQuantity: '0.25', - maxQuantity: '10.5', - limit: '25', - offset: '50', - }); + await controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1', + categoryId: '5', + uexItemId: '9', + locationId: '12', + minQuantity: '0.25', + maxQuantity: '10.5', + limit: '25', + offset: '50', + }, + ); expect(orgInventoryService.search).toHaveBeenCalledWith(7, { orgId: 42, @@ -52,26 +59,44 @@ describe('OrgInventoryController', () => { it('throws a bad request for invalid numeric pagination params', async () => { await expect( - controller.list({ user: { userId: 7 } }, 42, { - gameId: '1', - limit: 'abc', - }), + controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1', + limit: 'abc', + }, + ), ).rejects.toThrow(new BadRequestException('limit must be a number')); }); it('throws a bad request for non-integer or out-of-range pagination params', async () => { await expect( - controller.list({ user: { userId: 7 } }, 42, { - gameId: '1', - limit: '10.5', - }), + controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1', + limit: '10.5', + }, + ), ).rejects.toThrow(new BadRequestException('limit must be an integer')); await expect( - controller.list({ user: { userId: 7 } }, 42, { - gameId: '1', - offset: '-1', - }), + controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1', + offset: '-1', + }, + ), ).rejects.toThrow( new BadRequestException('offset must be greater than or equal to 0'), ); @@ -79,16 +104,28 @@ describe('OrgInventoryController', () => { it('throws a bad request for non-integer id-like filters', async () => { await expect( - controller.list({ user: { userId: 7 } }, 42, { - gameId: '1.5', - }), + controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1.5', + }, + ), ).rejects.toThrow(new BadRequestException('game_id must be an integer')); await expect( - controller.list({ user: { userId: 7 } }, 42, { - gameId: '1', - locationId: '2.5', - }), + controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1', + locationId: '2.5', + }, + ), ).rejects.toThrow( new BadRequestException('location_id must be an integer'), ); @@ -96,10 +133,16 @@ describe('OrgInventoryController', () => { it('throws a bad request for negative quantity filters', async () => { await expect( - controller.list({ user: { userId: 7 } }, 42, { - gameId: '1', - minQuantity: '-0.25', - }), + controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1', + minQuantity: '-0.25', + }, + ), ).rejects.toThrow( new BadRequestException( 'min_quantity must be greater than or equal to 0', @@ -107,10 +150,16 @@ describe('OrgInventoryController', () => { ); await expect( - controller.list({ user: { userId: 7 } }, 42, { - gameId: '1', - maxQuantity: '-1', - }), + controller.list( + { + user: { userId: 7, username: 'testuser' }, + } as unknown as AuthenticatedRequest, + 42, + { + gameId: '1', + maxQuantity: '-1', + }, + ), ).rejects.toThrow( new BadRequestException( 'max_quantity must be greater than or equal to 0', diff --git a/backend/src/modules/org-inventory/org-inventory.controller.ts b/backend/src/modules/org-inventory/org-inventory.controller.ts index 0f2eb68..4c3a0a8 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.ts @@ -31,6 +31,8 @@ import { ApiResponse, ApiParam, } from '@nestjs/swagger'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; +import { QueryParams, asString } from '../../common/types/query-params.type'; @ApiTags('Organization Inventory') @ApiBearerAuth() @@ -40,7 +42,7 @@ export class OrgInventoryController { constructor(private readonly orgInventoryService: OrgInventoryService) {} private readOptionalNumber( - query: Record, + query: QueryParams, keys: string[], fieldName: string, options?: { @@ -87,9 +89,9 @@ export class OrgInventoryController { type: [OrgInventoryItemDto], }) async list( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Param('orgId', ParseIntPipe) orgId: number, - @Query() query: Record, + @Query() query: QueryParams, ): Promise<{ items: OrgInventoryItemDto[]; total: number; @@ -137,7 +139,7 @@ export class OrgInventoryController { min: 1, }, ), - search: query.search, + search: asString(query.search), limit: this.readOptionalNumber(query, ['limit'], 'limit', { integer: true, min: 1, @@ -146,13 +148,19 @@ export class OrgInventoryController { integer: true, min: 0, }), - sort: query.sort, - order: query.order, + sort: asString(query.sort) as + | 'name' + | 'quantity' + | 'location' + | 'date_added' + | 'date_modified' + | undefined, + order: asString(query.order) as 'asc' | 'desc' | undefined, activeOnly: query.active_only !== undefined - ? query.active_only === 'true' || query.active_only === true + ? query.active_only === 'true' : query.activeOnly !== undefined - ? query.activeOnly === 'true' || query.activeOnly === true + ? query.activeOnly === 'true' : undefined, minQuantity: this.readOptionalNumber( query, @@ -196,7 +204,7 @@ export class OrgInventoryController { @ApiResponse({ status: 403, description: 'Permission denied' }) @ApiResponse({ status: 409, description: 'Inventory item already exists' }) async create( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Param('orgId', ParseIntPipe) orgId: number, @Body() createDto: CreateOrgInventoryItemDto, ): Promise { @@ -219,7 +227,7 @@ export class OrgInventoryController { type: OrgInventorySummaryDto, }) async getSummary( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Param('orgId', ParseIntPipe) orgId: number, @Query('gameId', ParseIntPipe) gameId: number, ): Promise { @@ -241,7 +249,7 @@ export class OrgInventoryController { }) @ApiResponse({ status: 404, description: 'Item not found' }) async findById( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseUUIDPipe) id: string, ): Promise { @@ -264,7 +272,7 @@ export class OrgInventoryController { @ApiResponse({ status: 404, description: 'Item not found' }) @ApiResponse({ status: 403, description: 'Permission denied' }) async update( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseUUIDPipe) id: string, @Body() updateDto: UpdateOrgInventoryItemDto, @@ -290,7 +298,7 @@ export class OrgInventoryController { @ApiResponse({ status: 404, description: 'Item not found' }) @ApiResponse({ status: 403, description: 'Permission denied' }) async delete( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Param('orgId', ParseIntPipe) orgId: number, @Param('id', ParseUUIDPipe) id: string, ): Promise { diff --git a/backend/src/modules/uex-sync/clients/uex-categories.client.ts b/backend/src/modules/uex-sync/clients/uex-categories.client.ts index 5bbadfb..4bf4c2e 100644 --- a/backend/src/modules/uex-sync/clients/uex-categories.client.ts +++ b/backend/src/modules/uex-sync/clients/uex-categories.client.ts @@ -80,19 +80,20 @@ export class UEXCategoriesClient { this.logger.log(`Fetched ${categories.length} categories from UEX API`); return categories; - } catch (error: any) { + } catch (error: unknown) { if (error instanceof RateLimitException) { throw error; } - if (error.response?.status >= 500) { + const errorResponse = error as { response?: { status?: number } }; + if (error && (errorResponse.response?.status ?? 0) >= 500) { throw new UEXServerException( - `UEX server error: ${error.message || 'Unknown error'}`, + `UEX server error: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } throw new UEXClientException( - `Failed to fetch categories: ${error.message || 'Unknown error'}`, + `Failed to fetch categories: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } } diff --git a/backend/src/modules/uex-sync/clients/uex-companies.client.ts b/backend/src/modules/uex-sync/clients/uex-companies.client.ts index 256ff47..419389a 100644 --- a/backend/src/modules/uex-sync/clients/uex-companies.client.ts +++ b/backend/src/modules/uex-sync/clients/uex-companies.client.ts @@ -81,19 +81,20 @@ export class UEXCompaniesClient { this.logger.log(`Fetched ${companies.length} companies from UEX API`); return companies; - } catch (error: any) { + } catch (error: unknown) { if (error instanceof RateLimitException) { throw error; } - if (error.response?.status >= 500) { + const errorResponse = error as { response?: { status?: number } }; + if (error && (errorResponse.response?.status ?? 0) >= 500) { throw new UEXServerException( - `UEX server error: ${error.message || 'Unknown error'}`, + `UEX server error: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } throw new UEXClientException( - `Failed to fetch companies: ${error.message || 'Unknown error'}`, + `Failed to fetch companies: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } } diff --git a/backend/src/modules/uex-sync/clients/uex-items.client.ts b/backend/src/modules/uex-sync/clients/uex-items.client.ts index 43f4ee7..8569b9c 100644 --- a/backend/src/modules/uex-sync/clients/uex-items.client.ts +++ b/backend/src/modules/uex-sync/clients/uex-items.client.ts @@ -89,19 +89,20 @@ export class UEXItemsClient { ); return items; - } catch (error: any) { + } catch (error: unknown) { if (error instanceof RateLimitException) { throw error; } - if (error.response?.status >= 500) { + const errorResponse = error as { response?: { status?: number } }; + if (error && (errorResponse.response?.status ?? 0) >= 500) { throw new UEXServerException( - `UEX server error: ${error.message || 'Unknown error'}`, + `UEX server error: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } throw new UEXClientException( - `Failed to fetch items for category ${categoryId}: ${error.message || 'Unknown error'}`, + `Failed to fetch items for category ${categoryId}: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } } @@ -144,19 +145,20 @@ export class UEXItemsClient { this.logger.log(`Fetched ${items.length} items from UEX API`); return items; - } catch (error: any) { + } catch (error: unknown) { if (error instanceof RateLimitException) { throw error; } - if (error.response?.status >= 500) { + const errorResponse = error as { response?: { status?: number } }; + if (error && (errorResponse.response?.status ?? 0) >= 500) { throw new UEXServerException( - `UEX server error: ${error.message || 'Unknown error'}`, + `UEX server error: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } throw new UEXClientException( - `Failed to fetch items: ${error.message || 'Unknown error'}`, + `Failed to fetch items: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } } diff --git a/backend/src/modules/uex-sync/clients/uex-locations.client.ts b/backend/src/modules/uex-sync/clients/uex-locations.client.ts index c97b627..e713c7c 100644 --- a/backend/src/modules/uex-sync/clients/uex-locations.client.ts +++ b/backend/src/modules/uex-sync/clients/uex-locations.client.ts @@ -197,19 +197,20 @@ export class UEXLocationsClient { this.logger.log(`Fetched ${locations.length} ${endpoint} from UEX API`); return locations; - } catch (error: any) { + } catch (error: unknown) { if (error instanceof RateLimitException) { throw error; } - if (error.response?.status >= 500) { + const errorResponse = error as { response?: { status?: number } }; + if (error && (errorResponse.response?.status ?? 0) >= 500) { throw new UEXServerException( - `UEX server error: ${error.message || 'Unknown error'}`, + `UEX server error: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } throw new UEXClientException( - `Failed to fetch ${endpoint}: ${error.message || 'Unknown error'}`, + `Failed to fetch ${endpoint}: ${(error instanceof Error ? error.message : 'Unknown error') || 'Unknown error'}`, ); } } diff --git a/backend/src/modules/uex-sync/schedulers/uex-sync.scheduler.ts b/backend/src/modules/uex-sync/schedulers/uex-sync.scheduler.ts index bc1c435..22ea629 100644 --- a/backend/src/modules/uex-sync/schedulers/uex-sync.scheduler.ts +++ b/backend/src/modules/uex-sync/schedulers/uex-sync.scheduler.ts @@ -44,10 +44,14 @@ export class UEXSyncScheduler { `created: ${result.created}, updated: ${result.updated}, ` + `deleted: ${result.deleted}, duration: ${result.durationMs}ms`, ); - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error( - `Scheduled categories sync failed: ${error.message}`, - error.stack, + `Scheduled categories sync failed: ${errorMessage}`, + errorStack, ); // Error already recorded in sync state by service // Add alerting here if needed @@ -82,10 +86,14 @@ export class UEXSyncScheduler { `created: ${result.created}, updated: ${result.updated}, ` + `deleted: ${result.deleted}, duration: ${result.durationMs}ms`, ); - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error( - `Scheduled items sync failed: ${error.message}`, - error.stack, + `Scheduled items sync failed: ${errorMessage}`, + errorStack, ); // Error already recorded in sync state by service // Add alerting here if needed @@ -120,10 +128,14 @@ export class UEXSyncScheduler { `total created: ${result.totalCreated}, updated: ${result.totalUpdated}, ` + `deleted: ${result.totalDeleted}, duration: ${result.totalDurationMs}ms`, ); - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error( - `Scheduled locations sync failed: ${error.message}`, - error.stack, + `Scheduled locations sync failed: ${errorMessage}`, + errorStack, ); // Error already recorded in sync state by service // Add alerting here if needed diff --git a/backend/src/modules/uex-sync/services/categories-sync.service.spec.ts b/backend/src/modules/uex-sync/services/categories-sync.service.spec.ts index 535c01a..ae1c058 100644 --- a/backend/src/modules/uex-sync/services/categories-sync.service.spec.ts +++ b/backend/src/modules/uex-sync/services/categories-sync.service.spec.ts @@ -13,10 +13,15 @@ import { describe('CategoriesSyncService', () => { let service: CategoriesSyncService; - let mockCategoryRepository: any; - let mockUexClient: any; - let mockSyncService: any; - let mockSystemUserService: any; + let mockCategoryRepository: { + findOne: jest.Mock; + save: jest.Mock; + update: jest.Mock; + manager: { transaction: jest.Mock }; + }; + let mockUexClient: Record; + let mockSyncService: Record; + let mockSystemUserService: Record; beforeEach(async () => { mockCategoryRepository = { @@ -66,7 +71,7 @@ describe('CategoriesSyncService', () => { { provide: ConfigService, useValue: { - get: jest.fn((key: string, defaultValue: any) => defaultValue), + get: jest.fn((key: string, defaultValue: unknown) => defaultValue), }, }, ], @@ -105,7 +110,9 @@ describe('CategoriesSyncService', () => { mockUexClient.fetchCategories.mockResolvedValue(mockCategories); mockCategoryRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), @@ -157,7 +164,9 @@ describe('CategoriesSyncService', () => { mockUexClient.fetchCategories.mockResolvedValue(mockCategories); mockCategoryRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest .fn() @@ -218,7 +227,9 @@ describe('CategoriesSyncService', () => { ]); mockCategoryRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), @@ -258,7 +269,9 @@ describe('CategoriesSyncService', () => { mockUexClient.fetchCategories.mockResolvedValue(mockCategories); mockCategoryRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), @@ -299,7 +312,9 @@ describe('CategoriesSyncService', () => { mockUexClient.fetchCategories.mockResolvedValue(mockCategories); mockCategoryRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), diff --git a/backend/src/modules/uex-sync/services/categories-sync.service.ts b/backend/src/modules/uex-sync/services/categories-sync.service.ts index 5eab9f5..39d584c 100644 --- a/backend/src/modules/uex-sync/services/categories-sync.service.ts +++ b/backend/src/modules/uex-sync/services/categories-sync.service.ts @@ -55,7 +55,7 @@ export class CategoriesSyncService { // Determine sync mode (delta vs full) const syncDecision = await this.syncService.shouldUseDeltaSync(endpoint); const useDelta = !forceFull && syncDecision.useDelta; - const filters: any = { type: 'item' }; // MVP: only item categories + const filters: Record = { type: 'item' }; // MVP: only item categories if (useDelta && syncDecision.lastSyncAt) { filters.date_modified = syncDecision.lastSyncAt; @@ -93,23 +93,27 @@ export class CategoriesSyncService { ); return { ...result, durationMs, syncMode: useDelta ? 'delta' : 'full' }; - } catch (error: any) { + } catch (error: unknown) { const durationMs = Date.now() - startTime; - await this.syncService.recordSyncFailure(endpoint, error, durationMs); + const syncError = + error instanceof Error ? error : new Error(String(error)); + await this.syncService.recordSyncFailure(endpoint, syncError, durationMs); throw error; } finally { await this.syncService.releaseSyncLock(endpoint); } } - private async fetchWithRetry(filters: any): Promise { + private async fetchWithRetry( + filters: Record, + ): Promise { let lastError: Error | undefined; for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { return await this.uexClient.fetchCategories(filters); - } catch (error: any) { - lastError = error; + } catch (error: unknown) { + lastError = error instanceof Error ? error : new Error(String(error)); // Don't retry rate limits if (error instanceof RateLimitException) { diff --git a/backend/src/modules/uex-sync/services/companies-sync.service.ts b/backend/src/modules/uex-sync/services/companies-sync.service.ts index e5d5469..0a947e7 100644 --- a/backend/src/modules/uex-sync/services/companies-sync.service.ts +++ b/backend/src/modules/uex-sync/services/companies-sync.service.ts @@ -78,9 +78,11 @@ export class CompaniesSyncService { durationMs, syncMode: 'full', }; - } catch (error: any) { + } catch (error: unknown) { const durationMs = Date.now() - startTime; - await this.syncService.recordSyncFailure(endpoint, error, durationMs); + const syncError = + error instanceof Error ? error : new Error(String(error)); + await this.syncService.recordSyncFailure(endpoint, syncError, durationMs); throw error; } finally { await this.syncService.releaseSyncLock(endpoint); diff --git a/backend/src/modules/uex-sync/services/items-sync.service.spec.ts b/backend/src/modules/uex-sync/services/items-sync.service.spec.ts index ecebcfc..40384bd 100644 --- a/backend/src/modules/uex-sync/services/items-sync.service.spec.ts +++ b/backend/src/modules/uex-sync/services/items-sync.service.spec.ts @@ -15,12 +15,17 @@ import { describe('ItemsSyncService', () => { let service: ItemsSyncService; - let mockItemRepository: any; - let mockCategoryRepository: any; - let mockCompanyRepository: any; - let mockUexClient: any; - let mockSyncService: any; - let mockSystemUserService: any; + let mockItemRepository: { + findOne: jest.Mock; + save: jest.Mock; + update: jest.Mock; + manager: { transaction: jest.Mock }; + }; + let mockCategoryRepository: Record; + let mockCompanyRepository: Record; + let mockUexClient: Record; + let mockSyncService: Record; + let mockSystemUserService: Record; beforeEach(async () => { mockItemRepository = { @@ -88,7 +93,7 @@ describe('ItemsSyncService', () => { { provide: ConfigService, useValue: { - get: jest.fn((key: string, defaultValue: any) => defaultValue), + get: jest.fn((key: string, defaultValue: unknown) => defaultValue), }, }, ], @@ -141,7 +146,9 @@ describe('ItemsSyncService', () => { mockUexClient.fetchItemsByCategory.mockResolvedValue(mockItems); mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), @@ -182,7 +189,9 @@ describe('ItemsSyncService', () => { mockUexClient.fetchItemsByCategory.mockResolvedValue(mockItems); mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue({ id: 1, uexId: 100 }), save: jest.fn(), @@ -266,7 +275,9 @@ describe('ItemsSyncService', () => { .mockResolvedValueOnce(mockItems); mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), @@ -303,7 +314,9 @@ describe('ItemsSyncService', () => { let transactionCallCount = 0; mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { transactionCallCount++; const mockManager = { findOne: jest.fn().mockResolvedValue(null), @@ -347,7 +360,9 @@ describe('ItemsSyncService', () => { mockUexClient.fetchItemsByCategory.mockResolvedValue(mockItems); mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), @@ -392,7 +407,9 @@ describe('ItemsSyncService', () => { .mockResolvedValueOnce(mockItems); // Category 3 mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({ id: 1 }), @@ -434,16 +451,22 @@ describe('ItemsSyncService', () => { mockCategoryRepository.find.mockResolvedValue(mockCategories); mockUexClient.fetchItemsByCategory.mockResolvedValue(mockItems); - const savedItems: any[] = []; + const savedItems: Record[] = []; mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), - save: jest.fn().mockImplementation((entity: any, data: any) => { - savedItems.push(data); - return { id: 1 }; - }), + save: jest + .fn() + .mockImplementation( + (_entity: unknown, data: Record) => { + savedItems.push(data); + return { id: 1 }; + }, + ), update: jest.fn(), }; return await callback(mockManager); @@ -482,16 +505,22 @@ describe('ItemsSyncService', () => { mockCategoryRepository.find.mockResolvedValue(mockCategories); mockUexClient.fetchItemsByCategory.mockResolvedValue(mockItems); - const savedItems: any[] = []; + const savedItems: Record[] = []; mockItemRepository.manager.transaction.mockImplementation( - async (callback: any) => { + async ( + callback: (manager: Record) => Promise, + ) => { const mockManager = { findOne: jest.fn().mockResolvedValue(null), - save: jest.fn().mockImplementation((entity: any, data: any) => { - savedItems.push(data); - return { id: 1 }; - }), + save: jest + .fn() + .mockImplementation( + (_entity: unknown, data: Record) => { + savedItems.push(data); + return { id: 1 }; + }, + ), update: jest.fn(), }; return await callback(mockManager); diff --git a/backend/src/modules/uex-sync/services/items-sync.service.ts b/backend/src/modules/uex-sync/services/items-sync.service.ts index 921deae..6d48b31 100644 --- a/backend/src/modules/uex-sync/services/items-sync.service.ts +++ b/backend/src/modules/uex-sync/services/items-sync.service.ts @@ -156,9 +156,11 @@ export class ItemsSyncService { durationMs, syncMode: useDelta ? 'delta' : 'full', }; - } catch (error: any) { + } catch (error: unknown) { const durationMs = Date.now() - startTime; - await this.syncService.recordSyncFailure(endpoint, error, durationMs); + const syncError = + error instanceof Error ? error : new Error(String(error)); + await this.syncService.recordSyncFailure(endpoint, syncError, durationMs); throw error; } finally { await this.syncService.releaseSyncLock(endpoint); @@ -224,7 +226,7 @@ export class ItemsSyncService { `Syncing items for category: ${category.name} (${category.uexId})`, ); - const filters: any = {}; + const filters: Record = {}; if (lastSyncAt) { filters.date_modified = lastSyncAt; } @@ -266,15 +268,15 @@ export class ItemsSyncService { private async fetchWithRetry( categoryId: number, - filters: any, + filters: Record, ): Promise { let lastError: Error | undefined; for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { return await this.uexClient.fetchItemsByCategory(categoryId, filters); - } catch (error: any) { - lastError = error; + } catch (error: unknown) { + lastError = error instanceof Error ? error : new Error(String(error)); // Don't retry rate limits if (error instanceof RateLimitException) { diff --git a/backend/src/modules/uex-sync/services/locations-sync.service.spec.ts b/backend/src/modules/uex-sync/services/locations-sync.service.spec.ts index 173feba..7a1fd46 100644 --- a/backend/src/modules/uex-sync/services/locations-sync.service.spec.ts +++ b/backend/src/modules/uex-sync/services/locations-sync.service.spec.ts @@ -15,16 +15,16 @@ import { UEXLocationsClient } from '../clients/uex-locations.client'; describe('LocationsSyncService', () => { let service: LocationsSyncService; - let mockStarSystemRepository: any; - let mockPlanetRepository: any; - let mockMoonRepository: any; - let mockCityRepository: any; - let mockSpaceStationRepository: any; - let mockOutpostRepository: any; - let mockPoiRepository: any; - let mockUexClient: any; - let mockSyncService: any; - let mockSystemUserService: any; + let mockStarSystemRepository: Record; + let mockPlanetRepository: Record; + let mockMoonRepository: Record; + let mockCityRepository: Record; + let mockSpaceStationRepository: Record; + let mockOutpostRepository: Record; + let mockPoiRepository: Record; + let mockUexClient: Record; + let mockSyncService: Record; + let mockSystemUserService: Record; beforeEach(async () => { const createMockRepository = () => ({ @@ -109,7 +109,7 @@ describe('LocationsSyncService', () => { { provide: ConfigService, useValue: { - get: jest.fn((key: string, defaultValue: any) => defaultValue), + get: jest.fn((key: string, defaultValue: unknown) => defaultValue), }, }, ], diff --git a/backend/src/modules/uex-sync/services/locations-sync.service.ts b/backend/src/modules/uex-sync/services/locations-sync.service.ts index 43bd8a4..21f8c0f 100644 --- a/backend/src/modules/uex-sync/services/locations-sync.service.ts +++ b/backend/src/modules/uex-sync/services/locations-sync.service.ts @@ -129,8 +129,10 @@ export class LocationsSyncService { if (i < this.syncOrder.length - 1) { await this.sleep(this.pauseBetweenEndpointsMs); } - } catch (error: any) { - this.logger.error(`Failed to sync ${endpoint}: ${error.message}`); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to sync ${endpoint}: ${errorMessage}`); throw error; } } @@ -164,7 +166,7 @@ export class LocationsSyncService { const syncDecision = await this.syncService.shouldUseDeltaSync(endpoint); const useDelta = !forceFull && syncDecision.useDelta; - const filters: any = {}; + const filters: Record = {}; if (useDelta && syncDecision.lastSyncAt) { filters.date_modified = syncDecision.lastSyncAt; @@ -199,16 +201,21 @@ export class LocationsSyncService { }); return { ...result, durationMs }; - } catch (error: any) { + } catch (error: unknown) { const durationMs = Date.now() - startTime; - await this.syncService.recordSyncFailure(endpoint, error, durationMs); + const syncError = + error instanceof Error ? error : new Error(String(error)); + await this.syncService.recordSyncFailure(endpoint, syncError, durationMs); throw error; } finally { await this.syncService.releaseSyncLock(endpoint); } } - private async fetchWithRetry(endpoint: string, filters: any): Promise { + private async fetchWithRetry( + endpoint: string, + filters: Record, + ): Promise { let lastError: Error | undefined; for (let attempt = 0; attempt < this.maxRetries; attempt++) { @@ -231,8 +238,8 @@ export class LocationsSyncService { default: throw new Error(`Unknown endpoint: ${endpoint}`); } - } catch (error: any) { - lastError = error; + } catch (error: unknown) { + lastError = error instanceof Error ? error : new Error(String(error)); // Don't retry rate limits if (error instanceof RateLimitException) { @@ -263,24 +270,30 @@ export class LocationsSyncService { private async processLocations( endpoint: string, - locations: any[], + locations: unknown[], syncMode: 'delta' | 'full', ): Promise> { switch (endpoint) { case 'star_systems': - return this.syncStarSystems(locations, syncMode); + return this.syncStarSystems( + locations as UEXStarSystemResponse[], + syncMode, + ); case 'planets': - return this.syncPlanets(locations, syncMode); + return this.syncPlanets(locations as UEXPlanetResponse[], syncMode); case 'moons': - return this.syncMoons(locations, syncMode); + return this.syncMoons(locations as UEXMoonResponse[], syncMode); case 'cities': - return this.syncCities(locations, syncMode); + return this.syncCities(locations as UEXCityResponse[], syncMode); case 'space_stations': - return this.syncSpaceStations(locations, syncMode); + return this.syncSpaceStations( + locations as UEXSpaceStationResponse[], + syncMode, + ); case 'outposts': - return this.syncOutposts(locations, syncMode); + return this.syncOutposts(locations as UEXOutpostResponse[], syncMode); case 'poi': - return this.syncPOI(locations, syncMode); + return this.syncPOI(locations as UEXPOIResponse[], syncMode); default: throw new Error(`Unknown endpoint: ${endpoint}`); } diff --git a/backend/src/modules/uex-sync/uex-sync.controller.ts b/backend/src/modules/uex-sync/uex-sync.controller.ts index 60836e9..3a742ec 100644 --- a/backend/src/modules/uex-sync/uex-sync.controller.ts +++ b/backend/src/modules/uex-sync/uex-sync.controller.ts @@ -196,7 +196,9 @@ export class UexSyncController { deleted, durationMs, }); - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'Sync failed'; results.push({ endpoint, status: SyncStatus.FAILED, @@ -205,7 +207,7 @@ export class UexSyncController { updated: 0, deleted: 0, durationMs: Date.now() - start, - errorMessage: error?.message || 'Sync failed', + errorMessage, }); } } diff --git a/backend/src/modules/uex-sync/uex-sync.service.spec.ts b/backend/src/modules/uex-sync/uex-sync.service.spec.ts index 435c48b..c286dc4 100644 --- a/backend/src/modules/uex-sync/uex-sync.service.spec.ts +++ b/backend/src/modules/uex-sync/uex-sync.service.spec.ts @@ -7,8 +7,8 @@ import { UexSyncConfig } from './uex-sync-config.entity'; describe('UexSyncService', () => { let service: UexSyncService; - let mockSyncStateRepository: any; - let mockSyncConfigRepository: any; + let mockSyncStateRepository: Record; + let mockSyncConfigRepository: Record; beforeEach(async () => { mockSyncStateRepository = { diff --git a/backend/src/modules/uex-sync/uex-sync.service.ts b/backend/src/modules/uex-sync/uex-sync.service.ts index 8adaf77..2c16393 100644 --- a/backend/src/modules/uex-sync/uex-sync.service.ts +++ b/backend/src/modules/uex-sync/uex-sync.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, MoreThan } from 'typeorm'; +import { Repository, MoreThan, IsNull } from 'typeorm'; import { UexSyncState, SyncStatus } from './uex-sync-state.entity'; import { UexSyncConfig } from './uex-sync-config.entity'; @@ -257,7 +257,7 @@ export class UexSyncService { lastSuccessfulSyncAt: MoreThan(thresholdDate), }, { - lastSuccessfulSyncAt: null as any, + lastSuccessfulSyncAt: IsNull(), }, ], order: { lastSuccessfulSyncAt: 'ASC' }, diff --git a/backend/src/modules/uex/repositories/base-uex.repository.spec.ts b/backend/src/modules/uex/repositories/base-uex.repository.spec.ts index 138257d..245ed1d 100644 --- a/backend/src/modules/uex/repositories/base-uex.repository.spec.ts +++ b/backend/src/modules/uex/repositories/base-uex.repository.spec.ts @@ -1,7 +1,9 @@ +import { FindOptionsWhere, UpdateResult } from 'typeorm'; import { BaseUexRepository } from './base-uex.repository'; +import { BaseUexEntity } from '../entities/base-uex.entity'; describe('BaseUexRepository', () => { - let repository: any; + let repository: BaseUexRepository; // Mock data const mockCategory = { @@ -38,7 +40,10 @@ describe('BaseUexRepository', () => { it('should find all non-deleted records', async () => { const findSpy = jest .spyOn(repository, 'find') - .mockResolvedValue([mockCategory, mockInactiveCategory] as any); + .mockResolvedValue([ + mockCategory, + mockInactiveCategory, + ] as unknown as BaseUexEntity[]); const result = await repository.findAllActive(); @@ -54,7 +59,7 @@ describe('BaseUexRepository', () => { const findSpy = jest.spyOn(repository, 'find').mockResolvedValue([]); await repository.findAllActive({ - where: { type: 'item' } as any, + where: { type: 'item' } as unknown as FindOptionsWhere, }); expect(findSpy).toHaveBeenCalledWith( @@ -69,7 +74,7 @@ describe('BaseUexRepository', () => { it('should find all active and non-deleted records', async () => { const findSpy = jest .spyOn(repository, 'find') - .mockResolvedValue([mockCategory] as any); + .mockResolvedValue([mockCategory] as unknown as BaseUexEntity[]); const result = await repository.findActive(); @@ -86,10 +91,10 @@ describe('BaseUexRepository', () => { it('should find one non-deleted record', async () => { const findOneSpy = jest .spyOn(repository, 'findOne') - .mockResolvedValue(mockCategory as any); + .mockResolvedValue(mockCategory as unknown as BaseUexEntity); const result = await repository.findOneActive({ - where: { uexId: 100 } as any, + where: { uexId: 100 } as unknown as FindOptionsWhere, }); expect(findOneSpy).toHaveBeenCalledWith( @@ -105,7 +110,7 @@ describe('BaseUexRepository', () => { jest.spyOn(repository, 'findOne').mockResolvedValue(null); const result = await repository.findOneActive({ - where: { uexId: 999 } as any, + where: { uexId: 999 } as unknown as FindOptionsWhere, }); expect(result).toBeNull(); @@ -116,10 +121,10 @@ describe('BaseUexRepository', () => { it('should find one active and non-deleted record', async () => { const findOneSpy = jest .spyOn(repository, 'findOne') - .mockResolvedValue(mockCategory as any); + .mockResolvedValue(mockCategory as unknown as BaseUexEntity); const result = await repository.findOneActiveOnly({ - where: { uexId: 100 } as any, + where: { uexId: 100 } as unknown as FindOptionsWhere, }); expect(findOneSpy).toHaveBeenCalledWith( @@ -135,7 +140,7 @@ describe('BaseUexRepository', () => { it('should find a record by uexId', async () => { const findOneSpy = jest .spyOn(repository, 'findOne') - .mockResolvedValue(mockCategory as any); + .mockResolvedValue(mockCategory as unknown as BaseUexEntity); const result = await repository.findByUexId(100); @@ -149,7 +154,7 @@ describe('BaseUexRepository', () => { it('should soft delete a record by id', async () => { const updateSpy = jest .spyOn(repository, 'update') - .mockResolvedValue({} as any); + .mockResolvedValue({ affected: 1 } as unknown as UpdateResult); await repository.markAsDeleted(1, 999); @@ -164,7 +169,7 @@ describe('BaseUexRepository', () => { it('should soft delete a record by uexId', async () => { const updateSpy = jest .spyOn(repository, 'update') - .mockResolvedValue({} as any); + .mockResolvedValue({ affected: 1 } as unknown as UpdateResult); await repository.markAsDeletedByUexId(100, 999); @@ -182,7 +187,7 @@ describe('BaseUexRepository', () => { it('should mark a record as inactive', async () => { const updateSpy = jest .spyOn(repository, 'update') - .mockResolvedValue({} as any); + .mockResolvedValue({ affected: 1 } as unknown as UpdateResult); await repository.deactivate(1, 999); @@ -197,7 +202,7 @@ describe('BaseUexRepository', () => { it('should mark a record as active', async () => { const updateSpy = jest .spyOn(repository, 'update') - .mockResolvedValue({} as any); + .mockResolvedValue({ affected: 1 } as unknown as UpdateResult); await repository.activate(1, 999); diff --git a/backend/src/modules/uex/repositories/base-uex.repository.ts b/backend/src/modules/uex/repositories/base-uex.repository.ts index 98ab772..b9e86a2 100644 --- a/backend/src/modules/uex/repositories/base-uex.repository.ts +++ b/backend/src/modules/uex/repositories/base-uex.repository.ts @@ -1,6 +1,14 @@ -import { FindManyOptions, FindOneOptions, Repository } from 'typeorm'; +import { + FindManyOptions, + FindOneOptions, + FindOptionsWhere, + Repository, +} from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { BaseUexEntity } from '../entities/base-uex.entity'; +type BaseUexUpdate = QueryDeepPartialEntity; + /** * Base repository class for UEX entities * Automatically filters out soft-deleted records in all queries @@ -15,7 +23,7 @@ export class BaseUexRepository extends Repository { where: { ...(options?.where || {}), deleted: false, - } as any, + } as FindOptionsWhere, }); } @@ -29,7 +37,7 @@ export class BaseUexRepository extends Repository { ...(options?.where || {}), deleted: false, active: true, - } as any, + } as FindOptionsWhere, }); } @@ -42,7 +50,7 @@ export class BaseUexRepository extends Repository { where: { ...(options.where || {}), deleted: false, - } as any, + } as FindOptionsWhere, }); } @@ -56,7 +64,7 @@ export class BaseUexRepository extends Repository { ...(options.where || {}), deleted: false, active: true, - } as any, + } as FindOptionsWhere, }); } @@ -65,7 +73,7 @@ export class BaseUexRepository extends Repository { */ async findByUexId(uexId: number): Promise { return this.findOneActive({ - where: { uexId } as any, + where: { uexId } as FindOptionsWhere, }); } @@ -73,22 +81,18 @@ export class BaseUexRepository extends Repository { * Mark record as soft deleted */ async markAsDeleted(id: number, modifiedBy: number): Promise { - await this.update(id, { - deleted: true, - modifiedById: modifiedBy, - } as any); + const update: BaseUexUpdate = { deleted: true, modifiedById: modifiedBy }; + await this.update(id, update as QueryDeepPartialEntity); } /** * Mark record as soft deleted by UEX ID */ async markAsDeletedByUexId(uexId: number, modifiedBy: number): Promise { + const update: BaseUexUpdate = { deleted: true, modifiedById: modifiedBy }; await this.update( - { uexId } as any, - { - deleted: true, - modifiedById: modifiedBy, - } as any, + { uexId } as FindOptionsWhere, + update as QueryDeepPartialEntity, ); } @@ -96,19 +100,15 @@ export class BaseUexRepository extends Repository { * Mark record as inactive */ async deactivate(id: number, modifiedBy: number): Promise { - await this.update(id, { - active: false, - modifiedById: modifiedBy, - } as any); + const update: BaseUexUpdate = { active: false, modifiedById: modifiedBy }; + await this.update(id, update as QueryDeepPartialEntity); } /** * Mark record as active */ async activate(id: number, modifiedBy: number): Promise { - await this.update(id, { - active: true, - modifiedById: modifiedBy, - } as any); + const update: BaseUexUpdate = { active: true, modifiedById: modifiedBy }; + await this.update(id, update as QueryDeepPartialEntity); } } diff --git a/backend/src/modules/uex/uex.service.spec.ts b/backend/src/modules/uex/uex.service.spec.ts index 3347c7d..09d78a8 100644 --- a/backend/src/modules/uex/uex.service.spec.ts +++ b/backend/src/modules/uex/uex.service.spec.ts @@ -27,7 +27,7 @@ describe('UexService', () => { } as unknown as UexItem; const createQueryBuilder = () => { - const qb: any = { + const qb: Record = { leftJoinAndSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -123,7 +123,7 @@ describe('UexService', () => { }); it('should return active star systems by default', async () => { - const mockQueryBuilder: any = { + const mockQueryBuilder: Record = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), @@ -138,14 +138,15 @@ describe('UexService', () => { ]), }; - (mockStarSystemRepository as any).createQueryBuilder = jest - .fn() - .mockReturnValue(mockQueryBuilder); + ( + mockStarSystemRepository as unknown as Record + ).createQueryBuilder = jest.fn().mockReturnValue(mockQueryBuilder); const systems = await service.getStarSystems({}); expect( - (mockStarSystemRepository as any).createQueryBuilder, + (mockStarSystemRepository as unknown as Record) + .createQueryBuilder, ).toHaveBeenCalledWith('system'); expect(mockQueryBuilder.where).toHaveBeenCalledWith( 'system.deleted = FALSE', @@ -164,7 +165,7 @@ describe('UexService', () => { }); it('should allow including inactive systems when filters disable flags', async () => { - const mockQueryBuilder: any = { + const mockQueryBuilder: Record = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), @@ -179,9 +180,9 @@ describe('UexService', () => { ]), }; - (mockStarSystemRepository as any).createQueryBuilder = jest - .fn() - .mockReturnValue(mockQueryBuilder); + ( + mockStarSystemRepository as unknown as Record + ).createQueryBuilder = jest.fn().mockReturnValue(mockQueryBuilder); const systems = await service.getStarSystems({ activeOnly: false, diff --git a/backend/src/modules/user-inventory/entities/inventory-audit-log.entity.ts b/backend/src/modules/user-inventory/entities/inventory-audit-log.entity.ts index 0126445..cd07400 100644 --- a/backend/src/modules/user-inventory/entities/inventory-audit-log.entity.ts +++ b/backend/src/modules/user-inventory/entities/inventory-audit-log.entity.ts @@ -53,7 +53,7 @@ export class InventoryAuditLog { reason?: string; @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; + metadata?: Record; @CreateDateColumn({ name: 'date_created', type: 'timestamptz' }) dateCreated!: Date; diff --git a/backend/src/modules/user-inventory/user-inventory.controller.ts b/backend/src/modules/user-inventory/user-inventory.controller.ts index ebce157..37c6962 100644 --- a/backend/src/modules/user-inventory/user-inventory.controller.ts +++ b/backend/src/modules/user-inventory/user-inventory.controller.ts @@ -27,6 +27,8 @@ import { UpdateUserInventoryItemDto, UserInventorySearchDto, } from './dto/user-inventory-item.dto'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; +import { QueryParams, asString } from '../../common/types/query-params.type'; @Controller('api/inventory') @UseGuards(JwtAuthGuard) @@ -36,45 +38,111 @@ export class UserInventoryController { private readonly userInventoryService: UserInventoryService, ) {} + private readOptionalNumber( + query: QueryParams, + keys: string[], + fieldName: string, + options?: { integer?: boolean; min?: number }, + ): number | undefined { + const rawValue = keys + .map((key) => query[key]) + .find((value) => value !== undefined); + + if (rawValue === undefined || rawValue === '') { + return undefined; + } + + const parsed = Number(rawValue); + if (Number.isNaN(parsed)) { + throw new BadRequestException(`${fieldName} must be a number`); + } + if (options?.integer && !Number.isInteger(parsed)) { + throw new BadRequestException(`${fieldName} must be an integer`); + } + if (options?.min !== undefined && parsed < options.min) { + throw new BadRequestException( + `${fieldName} must be greater than or equal to ${options.min}`, + ); + } + return parsed; + } + @Get() - async list(@Query() query: Record, @Request() req: any) { + async list( + @Query() query: QueryParams, + @Request() req: AuthenticatedRequest, + ) { const userId = req.user.userId; - const parsedMinQuantity = Number( - query.min_quantity ?? query.minQuantity ?? Number.NaN, - ); - const parsedMaxQuantity = Number( - query.max_quantity ?? query.maxQuantity ?? Number.NaN, + + const gameId = this.readOptionalNumber( + query, + ['game_id', 'gameId'], + 'game_id', + { integer: true }, ); + if (!gameId) { + throw new BadRequestException('game_id is required'); + } const searchDto: UserInventorySearchDto = { - gameId: Number(query.game_id ?? query.gameId), - categoryId: query.category_id ? Number(query.category_id) : undefined, - uexItemId: query.uex_item_id ? Number(query.uex_item_id) : undefined, - locationId: query.location_id ? Number(query.location_id) : undefined, - sharedOrgId: query.shared_org_id - ? Number(query.shared_org_id) - : undefined, - search: query.search, - limit: query.limit ? Number(query.limit) : undefined, - offset: query.offset ? Number(query.offset) : undefined, - sort: query.sort, - order: query.order, + gameId, + categoryId: this.readOptionalNumber( + query, + ['category_id', 'categoryId'], + 'category_id', + { integer: true }, + ), + uexItemId: this.readOptionalNumber( + query, + ['uex_item_id', 'uexItemId'], + 'uex_item_id', + { integer: true }, + ), + locationId: this.readOptionalNumber( + query, + ['location_id', 'locationId'], + 'location_id', + { integer: true }, + ), + sharedOrgId: this.readOptionalNumber( + query, + ['shared_org_id', 'sharedOrgId'], + 'shared_org_id', + { integer: true }, + ), + search: asString(query.search), + limit: this.readOptionalNumber(query, ['limit'], 'limit', { + integer: true, + min: 1, + }), + offset: this.readOptionalNumber(query, ['offset'], 'offset', { + integer: true, + min: 0, + }), + sort: asString(query.sort) as + | 'name' + | 'quantity' + | 'location' + | 'date_added' + | 'date_modified' + | undefined, + order: asString(query.order) as 'asc' | 'desc' | undefined, sharedOnly: query.shared_only !== undefined - ? query.shared_only === 'true' || query.shared_only === true + ? query.shared_only === 'true' : undefined, - minQuantity: Number.isNaN(parsedMinQuantity) - ? undefined - : parsedMinQuantity, - maxQuantity: Number.isNaN(parsedMaxQuantity) - ? undefined - : parsedMaxQuantity, + minQuantity: this.readOptionalNumber( + query, + ['min_quantity', 'minQuantity'], + 'min_quantity', + ), + maxQuantity: this.readOptionalNumber( + query, + ['max_quantity', 'maxQuantity'], + 'max_quantity', + ), }; - if (!searchDto.gameId) { - throw new BadRequestException('game_id is required'); - } - return this.userInventoryService.findAll(userId, searchDto); } @@ -82,7 +150,7 @@ export class UserInventoryController { @HttpCode(HttpStatus.CREATED) async create( @Body() createDto: CreateUserInventoryItemDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const userId = req.user.userId; return this.userInventoryService.create(userId, createDto); @@ -92,7 +160,7 @@ export class UserInventoryController { async update( @Param('id', ParseUUIDPipe) id: string, @Body() updateDto: UpdateUserInventoryItemDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const userId = req.user.userId; return this.userInventoryService.update(id, userId, updateDto); @@ -100,7 +168,10 @@ export class UserInventoryController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - async delete(@Param('id', ParseUUIDPipe) id: string, @Request() req: any) { + async delete( + @Param('id', ParseUUIDPipe) id: string, + @Request() req: AuthenticatedRequest, + ) { const userId = req.user.userId; await this.userInventoryService.delete(id, userId); } @@ -109,7 +180,7 @@ export class UserInventoryController { async shareItem( @Param('itemId', ParseUUIDPipe) itemId: string, @Body() shareDto: ShareItemDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ): Promise<{ message: string }> { const userId = req.user.userId; await this.inventorySharingService.shareItemWithOrg( @@ -123,7 +194,7 @@ export class UserInventoryController { @Delete(':itemId/share') async unshareItem( @Param('itemId', ParseUUIDPipe) itemId: string, - @Request() req: any, + @Request() req: AuthenticatedRequest, ): Promise<{ message: string }> { const userId = req.user.userId; await this.inventorySharingService.unshareItemFromOrg(userId, itemId); @@ -135,7 +206,7 @@ export class UserInventoryController { @RequirePermission(OrgPermission.CAN_VIEW_MEMBER_SHARED_ITEMS) async getSharedItems( @Query('orgId', ParseIntPipe) orgId: number, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const userId = req.user.userId; return this.inventorySharingService.findUserSharedItems(userId, orgId); @@ -145,7 +216,7 @@ export class UserInventoryController { @UseGuards(PermissionsGuard) @RequirePermission(OrgPermission.CAN_ADMIN_ORG_INVENTORY) async getAuditLog( - @Request() req: any, + @Request() req: AuthenticatedRequest, @Query('userId') userId?: number, @Query('orgId') orgId?: number, @Query('limit') limit?: number, diff --git a/backend/src/modules/user-inventory/user-inventory.service.spec.ts b/backend/src/modules/user-inventory/user-inventory.service.spec.ts index d42490c..8642c5d 100644 --- a/backend/src/modules/user-inventory/user-inventory.service.spec.ts +++ b/backend/src/modules/user-inventory/user-inventory.service.spec.ts @@ -40,16 +40,18 @@ describe('UserInventoryService', () => { dateModified: new Date(), addedBy: 1, modifiedBy: 1, - user: undefined as any, - game: undefined as any, - item: { name: 'Test Item' } as any, - location: { displayName: 'Test Location' } as any, + user: undefined as unknown as UserInventoryItem['user'], + game: undefined as unknown as UserInventoryItem['game'], + item: { name: 'Test Item' } as unknown as UserInventoryItem['item'], + location: { + displayName: 'Test Location', + } as unknown as UserInventoryItem['location'], sharedOrg: undefined, - addedByUser: undefined as any, - modifiedByUser: undefined as any, + addedByUser: undefined as unknown as UserInventoryItem['addedByUser'], + modifiedByUser: undefined as unknown as UserInventoryItem['modifiedByUser'], }; - const mockQueryBuilder: any = { + const mockQueryBuilder: Record = { createQueryBuilder: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -74,9 +76,7 @@ describe('UserInventoryService', () => { transactionRepository = { create: jest.fn(), save: jest.fn(), - createQueryBuilder: jest - .fn() - .mockReturnValue(transactionQueryBuilder as any), + createQueryBuilder: jest.fn().mockReturnValue(transactionQueryBuilder), }; const module: TestingModule = await Test.createTestingModule({ diff --git a/backend/src/modules/users/users.controller.ts b/backend/src/modules/users/users.controller.ts index d45918c..16ba5de 100644 --- a/backend/src/modules/users/users.controller.ts +++ b/backend/src/modules/users/users.controller.ts @@ -12,11 +12,11 @@ import { UseGuards, HttpCode, } from '@nestjs/common'; -import { Request } from 'express'; import { AuthGuard } from '@nestjs/passport'; import { UsersService } from './users.service'; import { UserDto } from './dto/user.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { AuthenticatedRequest } from '../auth/interfaces/authenticated-request.interface'; @Controller('users') export class UsersController { @@ -24,18 +24,20 @@ export class UsersController { @UseGuards(AuthGuard('jwt')) @Get('profile') - getProfile(@Req() req: Request) { - return req.user; + getProfile(@Req() req: AuthenticatedRequest) { + return { userId: req.user.userId, username: req.user.username }; } @UseGuards(AuthGuard('jwt')) @Patch('profile') async updateProfile( - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Body() updateProfileDto: UpdateProfileDto, ) { - const userId = (req.user as any).userId; - return await this.usersService.updateProfile(userId, updateProfileDto); + const userId = req.user.userId; + const { password: _password, ...safeUser } = + await this.usersService.updateProfile(userId, updateProfileDto); + return safeUser; } @UseGuards(AuthGuard('jwt')) diff --git a/docs/matrix-academy-comparison.md b/docs/matrix-academy-comparison.md index f8af379..da1ff64 100644 --- a/docs/matrix-academy-comparison.md +++ b/docs/matrix-academy-comparison.md @@ -10,6 +10,7 @@ Both projects are well-architected NestJS SaaS applications with similar tech stacks (NestJS, TypeORM, PostgreSQL, React, Material-UI). However, Matrix Academy has implemented several production-ready features and best practices that Station could benefit from adopting. **Priority Recommendations:** + 1. ✅ **HIGH**: Implement Redis caching layer 2. ✅ **HIGH**: Add comprehensive user profile fields (firstName, lastName, phoneNumber, bio) 3. ✅ **HIGH**: Build protected Dashboard and admin UI pages @@ -25,18 +26,20 @@ Both projects are well-architected NestJS SaaS applications with similar tech st ### 1. Caching Infrastructure ⭐ HIGH PRIORITY -| Feature | Matrix Academy | Station | Recommendation | -|---------|---------------|---------|----------------| -| **Redis Integration** | ✅ Implemented with caching patterns and automatic invalidation | ⚠️ Config files exist but not utilized | **IMPLEMENT** | -| **Cache Strategy** | Documented invalidation patterns for data consistency | N/A | **IMPLEMENT** | +| Feature | Matrix Academy | Station | Recommendation | +| --------------------- | --------------------------------------------------------------- | -------------------------------------- | -------------- | +| **Redis Integration** | ✅ Implemented with caching patterns and automatic invalidation | ⚠️ Config files exist but not utilized | **IMPLEMENT** | +| **Cache Strategy** | Documented invalidation patterns for data consistency | N/A | **IMPLEMENT** | **Why Station Needs This:** + - Gaming guild data (organizations, members, roles) is frequently read but infrequently updated - Permission aggregation queries could benefit significantly from caching - User session data and organization member lists are perfect cache candidates - Redis would improve API response times by 10-100x for cached queries **Implementation Plan:** + ```typescript // Example: Cache organization members @Injectable() @@ -48,7 +51,7 @@ export class OrganizationsService { const data = await this.orgRepository.findOne({ where: { id }, - relations: ['userOrganizationRoles', 'userOrganizationRoles.user'] + relations: ['userOrganizationRoles', 'userOrganizationRoles.user'], }); await this.redis.set(cacheKey, JSON.stringify(data), 'EX', 300); // 5min TTL @@ -64,6 +67,7 @@ export class OrganizationsService { ``` **Recommended Cache Targets:** + - Organization member lists (TTL: 5-10 minutes) - User permissions aggregation (TTL: 15 minutes) - Role definitions (TTL: 1 hour) @@ -73,15 +77,16 @@ export class OrganizationsService { ### 2. Enhanced User Profile Fields ⭐ HIGH PRIORITY -| Feature | Matrix Academy | Station | Recommendation | -|---------|---------------|---------|----------------| -| **First Name** | ✅ Optional field | ❌ Not implemented | **ADD** | -| **Last Name** | ✅ Optional field | ❌ Not implemented | **ADD** | -| **Phone Number** | ✅ Optional field | ❌ Not implemented | **ADD** | -| **Bio** | ✅ Optional text field | ❌ Not implemented | **ADD** | -| **Profile PATCH Endpoint** | ✅ `/users/profile` | ✅ `/users/profile` (GET only) | **ENHANCE** | +| Feature | Matrix Academy | Station | Recommendation | +| -------------------------- | ---------------------- | ------------------------------ | -------------- | +| **First Name** | ✅ Optional field | ❌ Not implemented | **ADD** | +| **Last Name** | ✅ Optional field | ❌ Not implemented | **ADD** | +| **Phone Number** | ✅ Optional field | ❌ Not implemented | **ADD** | +| **Bio** | ✅ Optional text field | ❌ Not implemented | **ADD** | +| **Profile PATCH Endpoint** | ✅ `/users/profile` | ✅ `/users/profile` (GET only) | **ENHANCE** | **Why Station Needs This:** + - Gaming guilds need member profiles with display names beyond username - Contact information (phone) useful for guild event coordination - Bio field perfect for "main character/class" or "preferred game modes" @@ -90,33 +95,46 @@ export class OrganizationsService { **Implementation Plan:** **Step 1: Database Migration** + ```typescript // migration: add-user-profile-fields.ts export class AddUserProfileFields1700000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - await queryRunner.addColumn('users', new TableColumn({ - name: 'firstName', - type: 'varchar', - length: '100', - isNullable: true - })); - await queryRunner.addColumn('users', new TableColumn({ - name: 'lastName', - type: 'varchar', - length: '100', - isNullable: true - })); - await queryRunner.addColumn('users', new TableColumn({ - name: 'phoneNumber', - type: 'varchar', - length: '20', - isNullable: true - })); - await queryRunner.addColumn('users', new TableColumn({ - name: 'bio', - type: 'text', - isNullable: true - })); + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'firstName', + type: 'varchar', + length: '100', + isNullable: true, + }), + ); + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'lastName', + type: 'varchar', + length: '100', + isNullable: true, + }), + ); + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'phoneNumber', + type: 'varchar', + length: '20', + isNullable: true, + }), + ); + await queryRunner.addColumn( + 'users', + new TableColumn({ + name: 'bio', + type: 'text', + isNullable: true, + }), + ); } public async down(queryRunner: QueryRunner): Promise { @@ -129,6 +147,7 @@ export class AddUserProfileFields1700000000000 implements MigrationInterface { ``` **Step 2: Update User Entity** + ```typescript @Entity('users') export class User { @@ -149,6 +168,7 @@ export class User { ``` **Step 3: Update DTOs** + ```typescript export class UpdateProfileDto { @IsOptional() @@ -174,6 +194,7 @@ export class UpdateProfileDto { ``` **Step 4: Add Profile Update Endpoint** + ```typescript @Patch('profile') @UseGuards(AuthGuard('jwt')) @@ -186,13 +207,14 @@ async updateProfile(@Request() req, @Body() dto: UpdateProfileDto) { ### 3. Frontend Dashboard & Protected Pages ⭐ HIGH PRIORITY -| Feature | Matrix Academy | Station | Recommendation | -|---------|---------------|---------|----------------| -| **Protected Dashboard** | ✅ Implemented with auth redirect | ❌ Not implemented | **BUILD** | -| **Profile Page** | ✅ With update form | ❌ Not implemented | **BUILD** | -| **Responsive Design** | ✅ Mobile/tablet/desktop | ⚠️ Basic responsive | **ENHANCE** | +| Feature | Matrix Academy | Station | Recommendation | +| ----------------------- | --------------------------------- | ------------------- | -------------- | +| **Protected Dashboard** | ✅ Implemented with auth redirect | ❌ Not implemented | **BUILD** | +| **Profile Page** | ✅ With update form | ❌ Not implemented | **BUILD** | +| **Responsive Design** | ✅ Mobile/tablet/desktop | ⚠️ Basic responsive | **ENHANCE** | **Why Station Needs This:** + - Users need a post-login experience beyond API access - Guild members need to view their organizations, roles, and permissions - Admins need UI for managing organizations and members @@ -223,6 +245,7 @@ async updateProfile(@Request() req, @Body() dto: UpdateProfileDto) { - Requires admin permission check **React Router Structure:** + ```typescript const router = createBrowserRouter([ { path: '/', element: }, @@ -244,12 +267,13 @@ const router = createBrowserRouter([ ### 4. Standardized Error Response Patterns ⭐ MEDIUM PRIORITY -| Feature | Matrix Academy | Station | Recommendation | -|---------|---------------|---------|----------------| -| **Error Response Format** | ✅ Standardized across all endpoints | ⚠️ Default NestJS format | **STANDARDIZE** | -| **Response Transformation** | ✅ Consistent transformation patterns | ⚠️ Basic | **IMPLEMENT** | +| Feature | Matrix Academy | Station | Recommendation | +| --------------------------- | ------------------------------------- | ------------------------ | --------------- | +| **Error Response Format** | ✅ Standardized across all endpoints | ⚠️ Default NestJS format | **STANDARDIZE** | +| **Response Transformation** | ✅ Consistent transformation patterns | ⚠️ Basic | **IMPLEMENT** | **Why Station Needs This:** + - Consistent error handling improves frontend development - Better debugging and monitoring - Professional API appearance @@ -258,6 +282,7 @@ const router = createBrowserRouter([ **Implementation Plan:** **Global Exception Filter:** + ```typescript // filters/http-exception.filter.ts @Catch(HttpException) @@ -269,18 +294,21 @@ export class HttpExceptionFilter implements ExceptionFilter { const status = exception.getStatus(); const exceptionResponse = exception.getResponse(); + const body = + typeof exceptionResponse === 'object' && exceptionResponse !== null + ? (exceptionResponse as Record) + : {}; const errorResponse = { success: false, statusCode: status, timestamp: new Date().toISOString(), path: request.url, method: request.method, - message: typeof exceptionResponse === 'string' - ? exceptionResponse - : (exceptionResponse as any).message, - errors: typeof exceptionResponse === 'object' && (exceptionResponse as any).errors - ? (exceptionResponse as any).errors - : undefined + message: + typeof exceptionResponse === 'string' + ? exceptionResponse + : (body['message'] as string | undefined), + errors: body['errors'], }; response.status(status).json(errorResponse); @@ -289,24 +317,26 @@ export class HttpExceptionFilter implements ExceptionFilter { ``` **Success Response Interceptor:** + ```typescript // interceptors/transform.interceptor.ts @Injectable() export class TransformInterceptor implements NestInterceptor { - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( - map(data => ({ + map((data) => ({ success: true, statusCode: context.switchToHttp().getResponse().statusCode, timestamp: new Date().toISOString(), - data - })) + data, + })), ); } } ``` **Apply Globally:** + ```typescript // main.ts async function bootstrap() { @@ -318,6 +348,7 @@ async function bootstrap() { ``` **Example Responses:** + ```json // Success Response { @@ -361,13 +392,14 @@ async function bootstrap() { ### 5. Enhanced Landing Page ⭐ MEDIUM PRIORITY -| Feature | Matrix Academy | Station | Recommendation | -|---------|---------------|---------|----------------| -| **Hero Section** | ✅ Prominent hero with CTA | ⚠️ Basic header | **ENHANCE** | -| **Feature Highlights** | ✅ Feature showcase section | ❌ Not implemented | **ADD** | -| **Theming** | ✅ Matrix-themed (green/black) | ⚠️ Basic MUI theme | **ENHANCE** | +| Feature | Matrix Academy | Station | Recommendation | +| ---------------------- | ------------------------------ | ------------------ | -------------- | +| **Hero Section** | ✅ Prominent hero with CTA | ⚠️ Basic header | **ENHANCE** | +| **Feature Highlights** | ✅ Feature showcase section | ❌ Not implemented | **ADD** | +| **Theming** | ✅ Matrix-themed (green/black) | ⚠️ Basic MUI theme | **ENHANCE** | **Why Station Needs This:** + - First impression matters for attracting gaming guilds - Clear value proposition encourages registration - Professional appearance builds trust @@ -455,12 +487,13 @@ export function Home() { ### 6. Accessibility Compliance ⭐ MEDIUM PRIORITY -| Feature | Matrix Academy | Station | Recommendation | -|---------|---------------|---------|----------------| -| **WCAG 2.1 AA** | ✅ Compliance target | ⚠️ Not documented | **IMPLEMENT** | -| **Accessibility Audits** | ✅ Part of testing | ❌ Not implemented | **ADD** | +| Feature | Matrix Academy | Station | Recommendation | +| ------------------------ | -------------------- | ------------------ | -------------- | +| **WCAG 2.1 AA** | ✅ Compliance target | ⚠️ Not documented | **IMPLEMENT** | +| **Accessibility Audits** | ✅ Part of testing | ❌ Not implemented | **ADD** | **Why Station Needs This:** + - Legal compliance in many jurisdictions - Better user experience for all users - SEO benefits @@ -484,6 +517,7 @@ export function Home() { - Semantic HTML structure 4. **Form Accessibility** + ```typescript setFilters((prev) => ({ ...prev, search: e.target.value }))} + onChange={(e) => + setFilters((prev) => ({ ...prev, search: e.target.value })) + } /> @@ -116,7 +118,8 @@ export const InventoryFiltersPanel = ({ onChange={(e) => setFilters((prev) => ({ ...prev, - categoryId: e.target.value === '' ? '' : Number(e.target.value), + categoryId: + e.target.value === '' ? '' : Number(e.target.value), })) } > @@ -142,7 +145,8 @@ export const InventoryFiltersPanel = ({ onChange={(e) => setFilters((prev) => ({ ...prev, - locationId: e.target.value === '' ? '' : Number(e.target.value), + locationId: + e.target.value === '' ? '' : Number(e.target.value), })) } > @@ -184,7 +188,9 @@ export const InventoryFiltersPanel = ({ { ))} - }> + + } + > { type="number" inputProps={{ min: 0.01, step: 0.01 }} value={newItemQuantity} - onChange={(e) => setNewItemQuantity(Number(e.target.value))} + onChange={(e) => + setNewItemQuantity(Number(e.target.value)) + } /> ) : ( <> - diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index dba3342..b3b1539 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -66,7 +66,11 @@ const Register = () => { } } catch (err: unknown) { console.error('Registration error:', err); - setError(err instanceof Error ? err.message : 'Cannot connect to server. Please make sure the backend is running.'); + setError( + err instanceof Error + ? err.message + : 'Cannot connect to server. Please make sure the backend is running.', + ); } finally { setLoading(false); } diff --git a/frontend/src/pages/ResetPassword.tsx b/frontend/src/pages/ResetPassword.tsx index 04afd97..b726b9c 100644 --- a/frontend/src/pages/ResetPassword.tsx +++ b/frontend/src/pages/ResetPassword.tsx @@ -27,7 +27,9 @@ const ResetPassword = () => { useEffect(() => { if (!token) { - setError('Invalid or missing reset token. Please request a new password reset link.'); + setError( + 'Invalid or missing reset token. Please request a new password reset link.', + ); } }, [token]); diff --git a/frontend/src/services/inventory.service.ts b/frontend/src/services/inventory.service.ts index 3b477bc..a57f126 100644 --- a/frontend/src/services/inventory.service.ts +++ b/frontend/src/services/inventory.service.ts @@ -93,7 +93,8 @@ const buildInventoryQuery = (params: InventorySearchParams) => { if (params.uexItemId !== undefined) query.uex_item_id = params.uexItemId; if (params.locationId !== undefined) query.location_id = params.locationId; if (params.sharedOnly !== undefined) query.shared_only = params.sharedOnly; - if (params.sharedOrgId !== undefined) query.shared_org_id = params.sharedOrgId; + if (params.sharedOrgId !== undefined) + query.shared_org_id = params.sharedOrgId; if (params.search) query.search = params.search; if (params.minQuantity !== undefined) query.min_quantity = params.minQuantity; if (params.maxQuantity !== undefined) query.max_quantity = params.maxQuantity; @@ -185,7 +186,12 @@ export const inventoryService = { /** * Create new inventory item */ - async createItem(item: Omit): Promise { + async createItem( + item: Omit< + InventoryItem, + 'id' | 'userId' | 'dateAdded' | 'dateModified' | 'active' + >, + ): Promise { const response = await axios.post(`${API_URL}/api/inventory`, item, { headers: getAuthHeader(), }); @@ -195,10 +201,17 @@ export const inventoryService = { /** * Update inventory item */ - async updateItem(id: string, updates: Partial): Promise { - const response = await axios.put(`${API_URL}/api/inventory/${id}`, updates, { - headers: getAuthHeader(), - }); + async updateItem( + id: string, + updates: Partial, + ): Promise { + const response = await axios.put( + `${API_URL}/api/inventory/${id}`, + updates, + { + headers: getAuthHeader(), + }, + ); return response.data; }, @@ -268,13 +281,10 @@ export const inventoryService = { offset?: number; }, ): Promise { - const response = await axios.get( - `${API_URL}/api/orgs/${orgId}/inventory`, - { - params: buildOrgInventoryQuery(params), - headers: getAuthHeader(), - }, - ); + const response = await axios.get(`${API_URL}/api/orgs/${orgId}/inventory`, { + params: buildOrgInventoryQuery(params), + headers: getAuthHeader(), + }); return response.data; }, diff --git a/frontend/src/services/uex.service.ts b/frontend/src/services/uex.service.ts index ca3eb99..48fbd37 100644 --- a/frontend/src/services/uex.service.ts +++ b/frontend/src/services/uex.service.ts @@ -40,7 +40,9 @@ export interface StarSystem { } export const uexService = { - async searchItems(params: CatalogSearchParams): Promise { + async searchItems( + params: CatalogSearchParams, + ): Promise { const response = await axios.get(`${API_URL}/api/uex/items`, { params, headers: getAuthHeader(), diff --git a/frontend/src/utils/focusController.test.ts b/frontend/src/utils/focusController.test.ts index 580d673..01cfdad 100644 --- a/frontend/src/utils/focusController.test.ts +++ b/frontend/src/utils/focusController.test.ts @@ -18,8 +18,12 @@ describe('FocusController', () => { const calls: string[] = []; const controller = makeController(['row-1']); - controller.register('row-1', 'location', () => calls.push('row-1:location')); - controller.register('row-1', 'quantity', () => calls.push('row-1:quantity')); + controller.register('row-1', 'location', () => + calls.push('row-1:location'), + ); + controller.register('row-1', 'quantity', () => + calls.push('row-1:quantity'), + ); controller.register('row-1', 'save', () => calls.push('row-1:save')); await controller.focusNext('row-1', 'location'); @@ -31,7 +35,9 @@ describe('FocusController', () => { const controller = makeController(['row-1', 'row-2']); controller.register('row-1', 'save', () => calls.push('row-1:save')); - controller.register('row-2', 'location', () => calls.push('row-2:location')); + controller.register('row-2', 'location', () => + calls.push('row-2:location'), + ); await controller.focusNext('row-1', 'save'); expect(calls).toContain('row-2:location'); @@ -45,7 +51,9 @@ describe('FocusController', () => { }); controller.register('row-1', 'save', () => calls.push('row-1:save')); - controller.register('row-2', 'location', () => calls.push('row-2:location')); + controller.register('row-2', 'location', () => + calls.push('row-2:location'), + ); await controller.focusNext('row-1', 'save'); expect(calls).toEqual(['boundary:row-1', 'row-2:location']); diff --git a/frontend/src/utils/focusController.ts b/frontend/src/utils/focusController.ts index 16ef179..9fbb291 100644 --- a/frontend/src/utils/focusController.ts +++ b/frontend/src/utils/focusController.ts @@ -36,7 +36,11 @@ export class FocusController { * Register a focus callback for a row/field combination. * Returns an unregister function. */ - register(rowKey: RowKey, fieldKey: FieldKey, focus: FocusCallback): () => void { + register( + rowKey: RowKey, + fieldKey: FieldKey, + focus: FocusCallback, + ): () => void { let fields = this.registry.get(rowKey); if (!fields) { fields = new Map(); @@ -81,7 +85,10 @@ export class FocusController { * Advance focus to the next field in the same row, or the next row's first field. * If at the last row, delegates to onBoundary to resolve the next target (e.g., next page). */ - async focusNext(currentRow: RowKey, currentField: FieldKey): Promise { + async focusNext( + currentRow: RowKey, + currentField: FieldKey, + ): Promise { const fieldIndex = this.fieldOrder.indexOf(currentField); const rowOrder = this.getRowOrder(); const rowIndex = rowOrder.indexOf(currentRow);