260320:1131 Refactor Overrall #01
Build and Deploy / deploy (push) Has been cancelled

This commit is contained in:
admin
2026-03-20 11:31:27 +07:00
parent f1b81a7d0d
commit 1d3479770b
147 changed files with 1745 additions and 1567 deletions
+2 -1
View File
@@ -18,6 +18,7 @@ import { User } from '../../modules/user/entities/user.entity';
import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
import { CaslModule } from './casl/casl.module';
import { PermissionsGuard } from './guards/permissions.guard';
import type { StringValue } from 'ms';
@Module({
imports: [
@@ -31,7 +32,7 @@ import { PermissionsGuard } from './guards/permissions.guard';
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
'15m') as any,
'15m') as StringValue,
},
}),
}),
+8 -7
View File
@@ -18,6 +18,7 @@ import { Repository } from 'typeorm';
import type { Cache } from 'cache-manager';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import type { StringValue } from 'ms';
import { UserService } from '../../modules/user/user.service';
import { User } from '../../modules/user/entities/user.entity';
@@ -83,7 +84,7 @@ export class AuthService {
}
// 2. Login: สร้าง Access & Refresh Token และบันทึกลง DB
async login(user: any) {
async login(user: User) {
const payload = {
username: user.username,
sub: user.user_id,
@@ -93,20 +94,20 @@ export class AuthService {
const isBot = user.username === 'migration_bot';
const accessTokenExpiresIn = isBot
? '100y'
: (this.configService.get<string>('JWT_EXPIRATION') || '15m');
: this.configService.get<string>('JWT_EXPIRATION') || '15m';
const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: accessTokenExpiresIn as any,
expiresIn: accessTokenExpiresIn as StringValue,
});
const refreshTokenExpiresIn = isBot
? '100y'
: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d');
: this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d';
const refreshToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: refreshTokenExpiresIn as any,
expiresIn: refreshTokenExpiresIn as StringValue,
});
// [P2-2] Store Refresh Token in DB
@@ -189,13 +190,13 @@ export class AuthService {
const newAccessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
'15m') as any,
'15m') as StringValue,
});
const newRefreshToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') ||
'7d') as any,
'7d') as StringValue,
});
// Revoke OLD token and point to NEW one
@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AbilityFactory, ScopeContext } from './ability.factory';
import { User } from '../../../modules/user/entities/user.entity';
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
import { Role } from '../../../modules/auth/entities/role.entity';
describe('AbilityFactory', () => {
let factory: AbilityFactory;
@@ -158,7 +159,7 @@ function createMockAssignment(props: {
permissions: props.permissionNames.map((name) => ({
permissionName: name,
})),
} as any;
} as Partial<Role> as Role;
return assignment;
}
@@ -4,10 +4,10 @@ import { User } from '../../../modules/user/entities/user.entity';
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
// Define action types
type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
export type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
// Define subject types (resources)
type Subjects =
export type Subjects =
| 'correspondence'
| 'rfa'
| 'drawing'
@@ -65,9 +65,10 @@ export class AbilityFactory {
return build({
// Detect subject type (for future use with objects)
detectSubjectType: (item: any) => {
detectSubjectType: (item: object) => {
if (typeof item === 'string') return item;
return item.constructor;
return (item as Record<string, unknown>)
.constructor as unknown as Subjects;
},
});
}
@@ -5,7 +5,12 @@ import {
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory, ScopeContext } from '../casl/ability.factory';
import {
AbilityFactory,
ScopeContext,
Actions,
Subjects,
} from '../casl/ability.factory';
import { PERMISSIONS_KEY } from '../../decorators/require-permission.decorator';
@Injectable()
@@ -43,7 +48,7 @@ export class PermissionsGuard implements CanActivate {
// Check if user has ALL required permissions
const hasPermission = requiredPermissions.every((permission) => {
const [action, subject] = this.parsePermission(permission);
return ability.can(action as any, subject as any);
return ability.can(action as Actions, subject as Subjects);
});
if (!hasPermission) {
@@ -59,23 +64,31 @@ export class PermissionsGuard implements CanActivate {
* Extract scope context from request
* Priority: params > body > query
*/
private extractScope(request: any): ScopeContext {
return {
private extractScope(request: {
params: Record<string, string>;
body: Record<string, unknown>;
query: Record<string, unknown>;
}): ScopeContext {
const raw = {
organizationId:
request.params.organizationId ||
request.body.organizationId ||
request.query.organizationId ||
undefined,
request.query.organizationId,
projectId:
request.params.projectId ||
request.body.projectId ||
request.query.projectId ||
undefined,
request.query.projectId,
contractId:
request.params.contractId ||
request.body.contractId ||
request.query.contractId ||
undefined,
request.query.contractId,
};
return {
organizationId: raw.organizationId
? Number(raw.organizationId)
: undefined,
projectId: raw.projectId ? Number(raw.projectId) : undefined,
contractId: raw.contractId ? Number(raw.contractId) : undefined,
};
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CryptoService } from './services/crypto.service';
import { RequestContextService } from './services/request-context.service';
import { UuidResolverService } from './services/uuid-resolver.service';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { HttpExceptionFilter } from './exceptions/http-exception.filter';
import { TransformInterceptor } from './interceptors/transform.interceptor';
@@ -16,6 +17,7 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
providers: [
CryptoService,
RequestContextService,
UuidResolverService,
// Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้
{
provide: APP_FILTER,
@@ -26,6 +28,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
useClass: TransformInterceptor,
},
],
exports: [CryptoService, RequestContextService],
exports: [CryptoService, RequestContextService, UuidResolverService],
})
export class CommonModule {}
@@ -6,7 +6,7 @@ export interface CircuitBreakerOptions {
timeout?: number;
errorThresholdPercentage?: number;
resetTimeout?: number;
fallback?: (...args: any[]) => any;
fallback?: (...args: unknown[]) => unknown;
}
/**
@@ -17,7 +17,7 @@ export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
const logger = new Logger('CircuitBreakerDecorator');
@@ -31,7 +31,7 @@ export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
breaker.on('open', () => logger.warn(`Circuit OPEN for ${propertyKey}`));
breaker.on('halfOpen', () =>
logger.log(`Circuit HALF-OPEN for ${propertyKey}`),
logger.log(`Circuit HALF-OPEN for ${propertyKey}`)
);
breaker.on('close', () => logger.log(`Circuit CLOSED for ${propertyKey}`));
@@ -39,7 +39,7 @@ export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
breaker.fallback(options.fallback);
}
descriptor.value = async function (...args: any[]) {
descriptor.value = async function (...args: unknown[]) {
// ✅ ใช้ .fire โดยส่ง this context ให้ถูกต้อง
return breaker.fire.apply(breaker, [this, ...args]);
};
@@ -7,7 +7,7 @@ export interface RetryOptions {
factor?: number;
minTimeout?: number;
maxTimeout?: number;
onRetry?: (e: Error, attempt: number) => any;
onRetry?: (e: Error, attempt: number) => void;
}
/**
@@ -18,12 +18,12 @@ export function Retry(options: RetryOptions = {}) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
const logger = new Logger('RetryDecorator');
descriptor.value = async function (...args: any[]) {
descriptor.value = async function (...args: unknown[]) {
return retry(
// ✅ ระบุ Type ให้กับ bail และ attempt เพื่อแก้ Implicit any
async (bail: (e: Error) => void, attempt: number) => {
@@ -38,7 +38,7 @@ export function Retry(options: RetryOptions = {}) {
}
logger.warn(
`Attempt ${attempt} failed for ${propertyKey}. Error: ${err.message}`, // ✅ ใช้ err.message
`Attempt ${attempt} failed for ${propertyKey}. Error: ${err.message}` // ✅ ใช้ err.message
);
// ถ้าต้องการให้หยุด Retry ทันทีในบางเงื่อนไข สามารถเรียก bail(err) ได้ที่นี่
@@ -51,7 +51,7 @@ export function Retry(options: RetryOptions = {}) {
minTimeout: options.minTimeout || 1000,
maxTimeout: options.maxTimeout || 5000,
...options,
},
}
);
};
@@ -39,7 +39,7 @@ export class AuditLog {
entityId?: string;
@Column({ name: 'details_json', type: 'json', nullable: true })
detailsJson?: any;
detailsJson?: Record<string, unknown>;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress?: string;
@@ -0,0 +1,80 @@
// Mock uuid module to avoid ESM import issue with uuid@13
jest.mock('uuid', () => ({
validate: (str: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
str
),
v7: () => '01912345-6789-7abc-8def-0123456789ab',
}));
import { UuidBaseEntity } from './uuid-base.entity';
// Concrete subclass for testing the abstract base
class TestEntity extends UuidBaseEntity {
id!: number;
}
describe('UuidBaseEntity', () => {
// ==========================================================
// generateUuid() — @BeforeInsert hook
// ==========================================================
describe('generateUuid()', () => {
it('should generate a UUIDv7 when uuid is not set', () => {
const entity = new TestEntity();
expect(entity.uuid).toBeUndefined();
entity.generateUuid();
expect(entity.uuid).toBe('01912345-6789-7abc-8def-0123456789ab');
});
it('should not overwrite an existing uuid', () => {
const entity = new TestEntity();
entity.uuid = 'existing-uuid-value-should-be-kept';
entity.generateUuid();
expect(entity.uuid).toBe('existing-uuid-value-should-be-kept');
});
it('should not overwrite a pre-set UUIDv1 from DB default', () => {
const entity = new TestEntity();
entity.uuid = '550e8400-e29b-11d4-a716-446655440000';
entity.generateUuid();
expect(entity.uuid).toBe('550e8400-e29b-11d4-a716-446655440000');
});
it('should generate uuid when uuid is empty string', () => {
const entity = new TestEntity();
entity.uuid = '';
entity.generateUuid();
// Empty string is falsy, so generateUuid should assign a new value
expect(entity.uuid).toBe('01912345-6789-7abc-8def-0123456789ab');
});
});
// ==========================================================
// Inheritance
// ==========================================================
describe('inheritance', () => {
it('should be an instance of UuidBaseEntity', () => {
const entity = new TestEntity();
expect(entity).toBeInstanceOf(UuidBaseEntity);
});
it('should have uuid property accessible from subclass', () => {
const entity = new TestEntity();
entity.uuid = 'test-uuid';
entity.id = 42;
expect(entity.uuid).toBe('test-uuid');
expect(entity.id).toBe(42);
});
});
});
@@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageController } from './file-storage.controller';
import { FileStorageService } from './file-storage.service';
import { RequestWithUser } from '../interfaces/request-with-user.interface';
describe('FileStorageController', () => {
let controller: FileStorageController;
@@ -44,8 +45,10 @@ describe('FileStorageController', () => {
mockResult
);
const mockReq = { user: { userId: 1, username: 'testuser' } };
const result = await controller.uploadFile(mockFile, mockReq as any);
const mockReq = {
user: { user_id: 1, username: 'testuser' },
} as unknown as RequestWithUser;
const result = await controller.uploadFile(mockFile, mockReq);
expect(mockFileStorageService.upload).toHaveBeenCalledWith(mockFile, 1);
});
@@ -145,8 +145,8 @@ export class FileStorageService {
// อัปเดตข้อมูลใน DB
att.filePath = newPath;
att.isTemporary = false;
att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable)
att.expiresAt = null as any; // เคลียร์วันหมดอายุ
att.tempId = undefined; // เคลียร์ tempId
att.expiresAt = undefined; // เคลียร์วันหมดอายุ
att.referenceDate = effectiveDate; // Save reference date
committedAttachments.push(await this.attachmentRepository.save(att));
@@ -0,0 +1,89 @@
import { BadRequestException } from '@nestjs/common';
import { ParseUuidPipe } from './parse-uuid.pipe';
// Mock uuid module to avoid ESM import issue with uuid@13
jest.mock('uuid', () => ({
validate: (str: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
str
),
v7: () => '01912345-6789-7abc-8def-0123456789ab',
}));
describe('ParseUuidPipe', () => {
let pipe: ParseUuidPipe;
beforeEach(() => {
pipe = new ParseUuidPipe();
});
// ==========================================================
// Valid UUIDs
// ==========================================================
describe('valid UUIDs', () => {
it('should accept a valid UUIDv4 and return lowercase', () => {
const uuid = 'a1b2c3d4-e5f6-4789-abcd-ef0123456789';
expect(pipe.transform(uuid)).toBe(uuid);
});
it('should accept a valid UUIDv7 and return lowercase', () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
expect(pipe.transform(uuid)).toBe(uuid);
});
it('should accept a valid UUIDv1 (MariaDB DEFAULT)', () => {
const uuid = '550e8400-e29b-11d4-a716-446655440000';
expect(pipe.transform(uuid)).toBe(uuid);
});
it('should normalize uppercase UUID to lowercase', () => {
const uuid = 'A1B2C3D4-E5F6-4789-ABCD-EF0123456789';
expect(pipe.transform(uuid)).toBe(uuid.toLowerCase());
});
it('should normalize mixed case UUID to lowercase', () => {
const uuid = 'a1B2c3D4-e5F6-4789-AbCd-eF0123456789';
expect(pipe.transform(uuid)).toBe(uuid.toLowerCase());
});
});
// ==========================================================
// Invalid inputs
// ==========================================================
describe('invalid inputs', () => {
it('should throw BadRequestException for empty string', () => {
expect(() => pipe.transform('')).toThrow(BadRequestException);
});
it('should throw BadRequestException for random string', () => {
expect(() => pipe.transform('not-a-uuid')).toThrow(BadRequestException);
});
it('should throw BadRequestException for numeric string', () => {
expect(() => pipe.transform('12345')).toThrow(BadRequestException);
});
it('should throw BadRequestException for UUID without hyphens', () => {
expect(() => pipe.transform('a1b2c3d4e5f64789abcdef0123456789')).toThrow(
BadRequestException
);
});
it('should throw BadRequestException for UUID with extra characters', () => {
expect(() =>
pipe.transform('a1b2c3d4-e5f6-4789-abcd-ef0123456789-extra')
).toThrow(BadRequestException);
});
it('should include the invalid value in error message', () => {
try {
pipe.transform('bad-value');
fail('Should have thrown');
} catch (error) {
expect((error as BadRequestException).message).toContain('bad-value');
}
});
});
});
@@ -30,9 +30,10 @@ export class CryptoService {
let encrypted = cipher.update(stringValue, 'utf8', 'hex');
encrypted += cipher.final('hex');
return `${iv.toString('hex')}:${encrypted}`;
} catch (error: any) {
// Fix TS18046: Cast error to any or Error to access .message
this.logger.error(`Encryption failed: ${error.message}`);
} catch (error: unknown) {
this.logger.error(
`Encryption failed: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
}
}
@@ -49,10 +50,9 @@ export class CryptoService {
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error: any) {
// Fix TS18046: Cast error to any or Error to access .message
} catch (error: unknown) {
this.logger.warn(
`Decryption failed for value. Returning original text. Error: ${error.message}`,
`Decryption failed for value. Returning original text. Error: ${error instanceof Error ? error.message : String(error)}`
);
// กรณี Decrypt ไม่ได้ ให้คืนค่าเดิมเพื่อป้องกัน App Crash
return text;
@@ -12,7 +12,7 @@ export class RequestContextService {
this.cls.run(new Map(), fn);
}
static set(key: string, value: any) {
static set(key: string, value: unknown) {
const store = this.cls.getStore();
if (store) {
store.set(key, value);
@@ -0,0 +1,184 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { UuidResolverService } from './uuid-resolver.service';
// Mock uuid module to avoid ESM import issue with uuid@13
jest.mock('uuid', () => ({
validate: (str: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
str
),
v7: () => '01912345-6789-7abc-8def-0123456789ab',
}));
describe('UuidResolverService', () => {
let service: UuidResolverService;
let mockQuery: jest.Mock;
beforeEach(async () => {
mockQuery = jest.fn();
const module: TestingModule = await Test.createTestingModule({
providers: [
UuidResolverService,
{
provide: DataSource,
useValue: {
manager: { query: mockQuery },
},
},
],
}).compile();
service = module.get<UuidResolverService>(UuidResolverService);
});
afterEach(() => jest.clearAllMocks());
// ==========================================================
// resolve() — Core generic resolver
// ==========================================================
describe('resolve()', () => {
it('should return number directly when value is a number', async () => {
const result = await service.resolve('Project', 'projects', 'id', 42);
expect(result).toBe(42);
expect(mockQuery).not.toHaveBeenCalled();
});
it('should parse numeric string and return number', async () => {
const result = await service.resolve('Project', 'projects', 'id', '99');
expect(result).toBe(99);
expect(mockQuery).not.toHaveBeenCalled();
});
it('should look up UUID string and return PK from DB', async () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
mockQuery.mockResolvedValue([{ id: 7 }]);
const result = await service.resolve('Project', 'projects', 'id', uuid);
expect(result).toBe(7);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT `id` FROM `projects` WHERE `uuid` = ? LIMIT 1',
[uuid]
);
});
it('should throw NotFoundException for invalid UUID string', async () => {
await expect(
service.resolve('Project', 'projects', 'id', 'not-a-uuid')
).rejects.toThrow(NotFoundException);
expect(mockQuery).not.toHaveBeenCalled();
});
it('should throw NotFoundException when UUID not found in DB', async () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
mockQuery.mockResolvedValue([]);
await expect(
service.resolve('Project', 'projects', 'id', uuid)
).rejects.toThrow(NotFoundException);
});
});
// ==========================================================
// Named convenience resolvers
// ==========================================================
describe('resolveProjectId()', () => {
it('should delegate to resolve with projects table', async () => {
const result = await service.resolveProjectId(5);
expect(result).toBe(5);
});
it('should look up UUID for project', async () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
mockQuery.mockResolvedValue([{ id: 5 }]);
const result = await service.resolveProjectId(uuid);
expect(result).toBe(5);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT `id` FROM `projects` WHERE `uuid` = ? LIMIT 1',
[uuid]
);
});
});
describe('resolveOrganizationId()', () => {
it('should delegate to resolve with organizations table', async () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
mockQuery.mockResolvedValue([{ id: 3 }]);
const result = await service.resolveOrganizationId(uuid);
expect(result).toBe(3);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT `id` FROM `organizations` WHERE `uuid` = ? LIMIT 1',
[uuid]
);
});
});
describe('resolveCorrespondenceId()', () => {
it('should delegate to resolve with correspondences table', async () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
mockQuery.mockResolvedValue([{ id: 10 }]);
const result = await service.resolveCorrespondenceId(uuid);
expect(result).toBe(10);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT `id` FROM `correspondences` WHERE `uuid` = ? LIMIT 1',
[uuid]
);
});
});
describe('resolveUserId()', () => {
it('should use user_id as PK column', async () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
mockQuery.mockResolvedValue([{ user_id: 8 }]);
const result = await service.resolveUserId(uuid);
expect(result).toBe(8);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT `user_id` FROM `users` WHERE `uuid` = ? LIMIT 1',
[uuid]
);
});
});
describe('resolveContractId()', () => {
it('should delegate to resolve with contracts table', async () => {
const uuid = '01912345-6789-7abc-8def-0123456789ab';
mockQuery.mockResolvedValue([{ id: 2 }]);
const result = await service.resolveContractId(uuid);
expect(result).toBe(2);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT `id` FROM `contracts` WHERE `uuid` = ? LIMIT 1',
[uuid]
);
});
});
// ==========================================================
// Edge cases
// ==========================================================
describe('edge cases', () => {
it('should handle zero as a valid number', async () => {
const result = await service.resolve('Project', 'projects', 'id', 0);
expect(result).toBe(0);
});
it('should handle string "0" as numeric', async () => {
const result = await service.resolve('Project', 'projects', 'id', '0');
expect(result).toBe(0);
});
it('should handle negative number', async () => {
const result = await service.resolve('Project', 'projects', 'id', -1);
expect(result).toBe(-1);
});
});
});
@@ -0,0 +1,105 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { validate as uuidValidate } from 'uuid';
/**
* Shared service to resolve hybrid identifiers (INT | UUID string) to internal INT IDs.
* Eliminates duplicated resolveId helpers across 8+ services.
*
* @see ADR-019 Hybrid Identifier Strategy
*/
@Injectable()
export class UuidResolverService {
constructor(private readonly dataSource: DataSource) {}
/**
* Checks if a string value is a numeric string (not UUID).
* Returns the parsed number or null if not numeric.
*/
private tryParseInt(value: string): number | null {
const num = Number(value);
return isNaN(num) ? null : num;
}
/**
* Low-level UUID lookup: find entity by uuid column, return pkColumn value.
*/
private async lookupByUuid(
entityName: string,
tableName: string,
pkColumn: string,
uuid: string
): Promise<number> {
if (!uuidValidate(uuid)) {
throw new NotFoundException(
`Invalid identifier for ${entityName}: ${uuid}`
);
}
const rows: Record<string, number>[] = await this.dataSource.manager.query(
`SELECT \`${pkColumn}\` FROM \`${tableName}\` WHERE \`uuid\` = ? LIMIT 1`,
[uuid]
);
if (!rows.length) {
throw new NotFoundException(`${entityName} with UUID ${uuid} not found`);
}
return rows[0][pkColumn];
}
/**
* Generic resolver: accepts INT or UUID string, returns internal INT ID.
* - If value is a number, returns it directly.
* - If value is a numeric string, parses and returns it.
* - If value is a UUID string, looks up the entity by uuid column.
*/
async resolve(
entityName: string,
tableName: string,
pkColumn: string,
value: number | string
): Promise<number> {
if (typeof value === 'number') return value;
const num = this.tryParseInt(value);
if (num !== null) return num;
return this.lookupByUuid(entityName, tableName, pkColumn, value);
}
/**
* Resolve projectId (INT or UUID string) to internal INT ID.
*/
async resolveProjectId(projectId: number | string): Promise<number> {
return this.resolve('Project', 'projects', 'id', projectId);
}
/**
* Resolve organizationId (INT or UUID string) to internal INT ID.
*/
async resolveOrganizationId(orgId: number | string): Promise<number> {
return this.resolve('Organization', 'organizations', 'id', orgId);
}
/**
* Resolve correspondenceId (INT or UUID string) to internal INT ID.
*/
async resolveCorrespondenceId(corrId: number | string): Promise<number> {
return this.resolve('Correspondence', 'correspondences', 'id', corrId);
}
/**
* Resolve userId (INT or UUID string) to internal user_id.
*/
async resolveUserId(userId: number | string): Promise<number> {
return this.resolve('User', 'users', 'user_id', userId);
}
/**
* Resolve contractId (INT or UUID string) to internal INT ID.
*/
async resolveContractId(contractId: number | string): Promise<number> {
return this.resolve('Contract', 'contracts', 'id', contractId);
}
}