260326:1347 Fixing Refactor ADR-019 Naming convention uuid #01
CI / CD Pipeline / build (push) Failing after 17m29s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
admin
2026-03-26 13:47:07 +07:00
parent 978d66e49e
commit 1aff83214f
34 changed files with 217 additions and 222 deletions
@@ -14,45 +14,45 @@ class TestEntity extends UuidBaseEntity {
describe('UuidBaseEntity', () => { describe('UuidBaseEntity', () => {
// ========================================================== // ==========================================================
// generateUuid() — @BeforeInsert hook // generatePublicId() — @BeforeInsert hook
// ========================================================== // ==========================================================
describe('generateUuid()', () => { describe('generatePublicId()', () => {
it('should generate a UUIDv7 when uuid is not set', () => { it('should generate a UUIDv7 when publicId is not set', () => {
const entity = new TestEntity(); const entity = new TestEntity();
expect(entity.uuid).toBeUndefined(); expect(entity.publicId).toBeUndefined();
entity.generateUuid(); entity.generatePublicId();
expect(entity.uuid).toBe('01912345-6789-7abc-8def-0123456789ab'); expect(entity.publicId).toBe('01912345-6789-7abc-8def-0123456789ab');
}); });
it('should not overwrite an existing uuid', () => { it('should not overwrite an existing publicId', () => {
const entity = new TestEntity(); const entity = new TestEntity();
entity.uuid = 'existing-uuid-value-should-be-kept'; entity.publicId = 'existing-publicId-value-should-be-kept';
entity.generateUuid(); entity.generatePublicId();
expect(entity.uuid).toBe('existing-uuid-value-should-be-kept'); expect(entity.publicId).toBe('existing-publicId-value-should-be-kept');
}); });
it('should not overwrite a pre-set UUIDv1 from DB default', () => { it('should not overwrite a pre-set UUIDv1 from DB default', () => {
const entity = new TestEntity(); const entity = new TestEntity();
entity.uuid = '550e8400-e29b-11d4-a716-446655440000'; entity.publicId = '550e8400-e29b-11d4-a716-446655440000';
entity.generateUuid(); entity.generatePublicId();
expect(entity.uuid).toBe('550e8400-e29b-11d4-a716-446655440000'); expect(entity.publicId).toBe('550e8400-e29b-11d4-a716-446655440000');
}); });
it('should generate uuid when uuid is empty string', () => { it('should generate publicId when publicId is empty string', () => {
const entity = new TestEntity(); const entity = new TestEntity();
entity.uuid = ''; entity.publicId = '';
entity.generateUuid(); entity.generatePublicId();
// Empty string is falsy, so generateUuid should assign a new value // Empty string is falsy, so generatePublicId should assign a new value
expect(entity.uuid).toBe('01912345-6789-7abc-8def-0123456789ab'); expect(entity.publicId).toBe('01912345-6789-7abc-8def-0123456789ab');
}); });
}); });
@@ -66,12 +66,12 @@ describe('UuidBaseEntity', () => {
expect(entity).toBeInstanceOf(UuidBaseEntity); expect(entity).toBeInstanceOf(UuidBaseEntity);
}); });
it('should have uuid property accessible from subclass', () => { it('should have publicId property accessible from subclass', () => {
const entity = new TestEntity(); const entity = new TestEntity();
entity.uuid = 'test-uuid'; entity.publicId = 'test-publicId';
entity.id = 42; entity.id = 42;
expect(entity.uuid).toBe('test-uuid'); expect(entity.publicId).toBe('test-publicId');
expect(entity.id).toBe(42); expect(entity.id).toBe(42);
}); });
}); });
@@ -2,27 +2,29 @@ import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid'; import { v7 as uuidv7 } from 'uuid';
/** /**
* Abstract base entity providing a UUID public identifier column. * Abstract base entity providing a UUID public identifier.
* Uses MariaDB native UUID type (stored as BINARY(16) internally,
* auto-converts to string format — no transformer needed).
* *
* App generates UUIDv7 via @BeforeInsert(); DB DEFAULT UUID() is fallback. * Naming Convention (ADR-019 v1.8.1):
* - TypeScript Property: `publicId` — semantic name indicating this is the public-facing identifier
* - Database Column: `uuid` — MariaDB native UUID type (stored as BINARY(16))
* *
* @see ADR-019 Hybrid Identifier Strategy * This avoids confusion between the property name and the DB data type,
* while clearly indicating this is the ID exposed via API (not internal INT PK).
*/ */
export abstract class UuidBaseEntity { export abstract class UuidBaseEntity {
@Column({ @Column({
type: 'uuid', type: 'uuid',
name: 'uuid', // DB column name (MariaDB native UUID type)
unique: true, unique: true,
nullable: false, nullable: false,
comment: 'UUID Public Identifier (ADR-019)', comment: 'UUID Public Identifier (ADR-019)',
}) })
uuid!: string; publicId!: string; // TypeScript property name — semantic, avoids type confusion
@BeforeInsert() @BeforeInsert()
generateUuid(): void { generatePublicId(): void {
if (!this.uuid) { if (!this.publicId) {
this.uuid = uuidv7(); this.publicId = uuidv7();
} }
} }
} }
@@ -95,7 +95,7 @@ export class CirculationService {
} }
async findAll(searchDto: SearchCirculationDto, user: User) { async findAll(searchDto: SearchCirculationDto, user: User) {
const { status, correspondenceUuid, page = 1, limit = 20 } = searchDto; const { status, correspondencePublicId, page = 1, limit = 20 } = searchDto;
const query = this.circulationRepo const query = this.circulationRepo
.createQueryBuilder('c') .createQueryBuilder('c')
.leftJoinAndSelect('c.creator', 'creator') .leftJoinAndSelect('c.creator', 'creator')
@@ -103,9 +103,9 @@ export class CirculationService {
.leftJoinAndSelect('routings.assignee', 'assignee') .leftJoinAndSelect('routings.assignee', 'assignee')
.leftJoinAndSelect('c.correspondence', 'correspondence'); .leftJoinAndSelect('c.correspondence', 'correspondence');
if (correspondenceUuid) { if (correspondencePublicId) {
query.where('correspondence.uuid = :corrUuid', { query.where('correspondence.publicId = :corrPublicId', {
corrUuid: correspondenceUuid, corrPublicId: correspondencePublicId,
}); });
} else { } else {
query.where('c.organizationId = :orgId', { query.where('c.organizationId = :orgId', {
@@ -136,14 +136,14 @@ export class CirculationService {
return circulation; return circulation;
} }
async findOneByUuid(uuid: string) { async findOneByUuid(publicId: string) {
const circulation = await this.circulationRepo.findOne({ const circulation = await this.circulationRepo.findOne({
where: { uuid }, where: { publicId },
relations: ['routings', 'routings.assignee', 'correspondence', 'creator'], relations: ['routings', 'routings.assignee', 'correspondence', 'creator'],
order: { routings: { stepNumber: 'ASC' } }, order: { routings: { stepNumber: 'ASC' } },
}); });
if (!circulation) if (!circulation)
throw new NotFoundException(`Circulation UUID ${uuid} not found`); throw new NotFoundException(`Circulation publicId ${publicId} not found`);
return circulation; return circulation;
} }
@@ -8,7 +8,7 @@ export class SearchCirculationDto {
@IsOptional() @IsOptional()
@IsUUID('all') @IsUUID('all')
correspondenceUuid?: string; // กรองตาม correspondence UUID (ADR-019) correspondencePublicId?: string; // กรองตาม correspondence publicId (ADR-019)
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -100,18 +100,20 @@ export class ContractService {
return contract; return contract;
} }
async findOneByUuid(uuid: string) { async findOneByPublicId(publicId: string) {
const contract = await this.contractRepo.findOne({ const contract = await this.contractRepo.findOne({
where: { uuid }, where: { publicId },
relations: ['project'], relations: ['project'],
}); });
if (!contract) if (!contract)
throw new NotFoundException(`Contract UUID ${uuid} not found`); throw new NotFoundException(
`Contract with publicId ${publicId} not found`
);
return contract; return contract;
} }
async update(uuid: string, dto: UpdateContractDto) { async update(publicId: string, dto: UpdateContractDto) {
const contract = await this.findOneByUuid(uuid); const contract = await this.findOneByPublicId(publicId);
if (dto.projectId) { if (dto.projectId) {
dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId); dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId);
} }
@@ -119,8 +121,8 @@ export class ContractService {
return this.contractRepo.save(contract); return this.contractRepo.save(contract);
} }
async remove(uuid: string) { async remove(publicId: string) {
const contract = await this.findOneByUuid(uuid); const contract = await this.findOneByPublicId(publicId);
return this.contractRepo.remove(contract); return this.contractRepo.remove(contract);
} }
} }
@@ -4,34 +4,18 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
BeforeInsert,
} from 'typeorm'; } from 'typeorm';
import { v7 as uuidv7 } from 'uuid'; import { Exclude } from 'class-transformer';
import { Exclude, Expose } from 'class-transformer'; import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Project } from '../../project/entities/project.entity'; import { Project } from '../../project/entities/project.entity';
@Entity('contracts') @Entity('contracts')
export class Contract extends BaseEntity { export class Contract extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude() @Exclude()
id!: number; id!: number;
@Expose({ name: 'id' }) // publicId inherited from UuidBaseEntity (DB column: uuid)
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid!: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
@Column({ name: 'project_id' }) @Column({ name: 'project_id' })
projectId!: number; projectId!: number;
@@ -112,7 +112,7 @@ export class CorrespondenceWorkflowService {
type: 'EMAIL', type: 'EMAIL',
entityType: 'correspondence', entityType: 'correspondence',
entityId: revision.correspondenceId, entityId: revision.correspondenceId,
link: `/correspondences/${corrForNotify.uuid}`, link: `/correspondences/${corrForNotify.publicId}`,
}); });
} }
} }
@@ -344,7 +344,7 @@ export class CorrespondenceService {
// Fire-and-forget search indexing (non-blocking, void intentional) // Fire-and-forget search indexing (non-blocking, void intentional)
void this.searchService.indexDocument({ void this.searchService.indexDocument({
id: savedCorr.id, id: savedCorr.id,
uuid: savedCorr.uuid, publicId: savedCorr.publicId,
type: 'correspondence', type: 'correspondence',
docNumber: docNumber.number, docNumber: docNumber.number,
title: createDto.subject, title: createDto.subject,
@@ -459,9 +459,9 @@ export class CorrespondenceService {
return correspondence; return correspondence;
} }
async findOneByUuid(uuid: string) { async findOneByUuid(publicId: string) {
const correspondence = await this.correspondenceRepo.findOne({ const correspondence = await this.correspondenceRepo.findOne({
where: { uuid }, where: { publicId },
relations: [ relations: [
'revisions', 'revisions',
'revisions.status', 'revisions.status',
@@ -474,16 +474,18 @@ export class CorrespondenceService {
}); });
if (!correspondence) { if (!correspondence) {
throw new NotFoundException(`Correspondence with UUID ${uuid} not found`); throw new NotFoundException(
`Correspondence with UUID ${publicId} not found`
);
} }
return correspondence; return correspondence;
} }
async addReference(id: number, dto: AddReferenceDto) { async addReference(id: number, dto: AddReferenceDto) {
const source = await this.correspondenceRepo.findOne({ where: { id } }); const source = await this.correspondenceRepo.findOne({ where: { id } });
// ADR-019: Resolve target UUID → internal INT id // ADR-019: Resolve target publicId → internal INT id
const target = await this.correspondenceRepo.findOne({ const target = await this.correspondenceRepo.findOne({
where: { uuid: dto.targetUuid }, where: { publicId: dto.targetUuid },
}); });
if (!source || !target) { if (!source || !target) {
@@ -814,7 +816,7 @@ export class CorrespondenceService {
// Re-index updated document in Elasticsearch (fire-and-forget) // Re-index updated document in Elasticsearch (fire-and-forget)
void this.searchService.indexDocument({ void this.searchService.indexDocument({
id: updated.id, id: updated.id,
uuid: updated.uuid, publicId: updated.publicId,
type: 'correspondence', type: 'correspondence',
docNumber: updated.correspondenceNumber, docNumber: updated.correspondenceNumber,
title: updateDto.subject ?? updated.revisions?.[0]?.subject, title: updateDto.subject ?? updated.revisions?.[0]?.subject,
@@ -896,8 +898,8 @@ export class CorrespondenceService {
* Business Rule Implementation: EC-CORR-001 - Cancel Correspondence with Downstream Circulation * Business Rule Implementation: EC-CORR-001 - Cancel Correspondence with Downstream Circulation
* Cancel correspondence and handle related circulations * Cancel correspondence and handle related circulations
*/ */
async cancel(uuid: string, reason: string, user: User) { async cancel(publicId: string, reason: string, user: User) {
const correspondence = await this.findOneByUuid(uuid); const correspondence = await this.findOneByUuid(publicId);
// Check if user has permission to cancel (Org Admin or Superadmin only) // Check if user has permission to cancel (Org Admin or Superadmin only)
const permissions = await this.userService.getUserPermissions(user.user_id); const permissions = await this.userService.getUserPermissions(user.user_id);
@@ -983,7 +985,7 @@ export class CorrespondenceService {
// Re-index cancelled status in Elasticsearch (fire-and-forget) // Re-index cancelled status in Elasticsearch (fire-and-forget)
void this.searchService.indexDocument({ void this.searchService.indexDocument({
id: correspondence.id, id: correspondence.id,
uuid: correspondence.uuid, publicId: correspondence.publicId,
type: 'correspondence', type: 'correspondence',
docNumber: correspondence.correspondenceNumber, docNumber: correspondence.correspondenceNumber,
title: currentRevision.subject, title: currentRevision.subject,
@@ -1005,7 +1007,7 @@ export class CorrespondenceService {
type: 'EMAIL', type: 'EMAIL',
entityType: 'correspondence', entityType: 'correspondence',
entityId: correspondence.id, entityId: correspondence.id,
link: `/correspondences/${correspondence.uuid}`, link: `/correspondences/${correspondence.publicId}`,
}); });
} }
}) })
@@ -1031,19 +1033,19 @@ export class CorrespondenceService {
} }
async bulkCancel( async bulkCancel(
uuids: string[], publicIds: string[],
reason: string, reason: string,
user: User user: User
): Promise<{ succeeded: string[]; failed: string[] }> { ): Promise<{ succeeded: string[]; failed: string[] }> {
const succeeded: string[] = []; const succeeded: string[] = [];
const failed: string[] = []; const failed: string[] = [];
for (const uuid of uuids) { for (const publicId of publicIds) {
try { try {
await this.cancel(uuid, reason, user); await this.cancel(publicId, reason, user);
succeeded.push(uuid); succeeded.push(publicId);
} catch { } catch {
failed.push(uuid); failed.push(publicId);
} }
} }
@@ -63,7 +63,7 @@ export class DueDateReminderService {
type: 'EMAIL', type: 'EMAIL',
entityType: 'correspondence', entityType: 'correspondence',
entityId: corr.id, entityId: corr.id,
link: `/correspondences/${corr.uuid}`, link: `/correspondences/${corr.publicId}`,
}); });
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
@@ -89,10 +89,12 @@ export class OrganizationService {
return org; return org;
} }
async findOneByUuid(uuid: string) { async findOneByUuid(publicId: string) {
const org = await this.orgRepo.findOne({ where: { uuid } }); const org = await this.orgRepo.findOne({ where: { publicId } });
if (!org) if (!org)
throw new NotFoundException(`Organization UUID ${uuid} not found`); throw new NotFoundException(
`Organization publicId ${publicId} not found`
);
return org; return org;
} }
@@ -1,36 +1,15 @@
import { import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
Entity, import { Exclude } from 'class-transformer';
Column, import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
PrimaryGeneratedColumn,
OneToMany,
BeforeInsert,
} from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
import { Exclude, Expose } from 'class-transformer';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Contract } from '../../contract/entities/contract.entity'; import { Contract } from '../../contract/entities/contract.entity';
@Entity('projects') @Entity('projects')
export class Project extends BaseEntity { export class Project extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude() @Exclude()
id!: number; id!: number;
@Expose({ name: 'id' }) // publicId inherited from UuidBaseEntity (DB column: uuid)
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid!: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
@Column({ name: 'project_code', unique: true, length: 50 }) @Column({ name: 'project_code', unique: true, length: 50 })
projectCode!: string; projectCode!: string;
+11 -9
View File
@@ -91,21 +91,23 @@ export class ProjectService {
return project; return project;
} }
async findOneByUuid(uuid: string) { async findOneByUuid(publicId: string) {
const project = await this.projectRepository.findOne({ const project = await this.projectRepository.findOne({
where: { uuid }, where: { publicId },
relations: ['contracts'], relations: ['contracts'],
}); });
if (!project) { if (!project) {
throw new NotFoundException(`Project UUID ${uuid} not found`); throw new NotFoundException(
`Project with publicId ${publicId} not found`
);
} }
return project; return project;
} }
async update(uuid: string, updateDto: UpdateProjectDto) { async update(publicId: string, updateDto: UpdateProjectDto) {
const project = await this.findOneByUuid(uuid); const project = await this.findOneByUuid(publicId);
// Merge ข้อมูลใหม่ใส่ข้อมูลเดิม // Merge ข้อมูลใหม่ใส่ข้อมูลเดิม
this.projectRepository.merge(project, updateDto); this.projectRepository.merge(project, updateDto);
@@ -113,14 +115,14 @@ export class ProjectService {
return this.projectRepository.save(project); return this.projectRepository.save(project);
} }
async remove(uuid: string) { async remove(publicId: string) {
const project = await this.findOneByUuid(uuid); const project = await this.findOneByUuid(publicId);
// ใช้ Soft Delete // ใช้ Soft Delete
return this.projectRepository.softRemove(project); return this.projectRepository.softRemove(project);
} }
async findContracts(uuid: string) { async findContracts(publicId: string) {
const project = await this.findOneByUuid(uuid); const project = await this.findOneByUuid(publicId);
return project.contracts; return project.contracts;
} }
+5 -5
View File
@@ -63,7 +63,7 @@ export class RfaController {
@ApiOperation({ summary: 'Submit RFA to Workflow' }) @ApiOperation({ summary: 'Submit RFA to Workflow' })
@ApiParam({ @ApiParam({
name: 'uuid', name: 'uuid',
description: 'RFA UUID (from correspondences.uuid)', description: 'RFA publicId (from correspondences.publicId)',
}) })
@ApiBody({ type: SubmitRfaDto }) @ApiBody({ type: SubmitRfaDto })
@ApiResponse({ status: 200, description: 'RFA submitted successfully' }) @ApiResponse({ status: 200, description: 'RFA submitted successfully' })
@@ -83,7 +83,7 @@ export class RfaController {
@ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' }) @ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' })
@ApiParam({ @ApiParam({
name: 'uuid', name: 'uuid',
description: 'RFA UUID (from correspondences.uuid)', description: 'RFA publicId (from correspondences.publicId)',
}) })
@ApiBody({ type: WorkflowActionDto }) @ApiBody({ type: WorkflowActionDto })
@ApiResponse({ @ApiResponse({
@@ -120,7 +120,7 @@ export class RfaController {
@ApiOperation({ summary: 'Get RFA details with revisions and items' }) @ApiOperation({ summary: 'Get RFA details with revisions and items' })
@ApiParam({ @ApiParam({
name: 'uuid', name: 'uuid',
description: 'RFA UUID (from correspondences.uuid)', description: 'RFA publicId (from correspondences.publicId)',
}) })
@ApiResponse({ status: 200, description: 'RFA details' }) @ApiResponse({ status: 200, description: 'RFA details' })
@RequirePermission('document.view') @RequirePermission('document.view')
@@ -130,7 +130,7 @@ export class RfaController {
@Put(':uuid') @Put(':uuid')
@ApiOperation({ summary: 'Update Draft RFA fields (EC-RFA-002: DFT only)' }) @ApiOperation({ summary: 'Update Draft RFA fields (EC-RFA-002: DFT only)' })
@ApiParam({ name: 'uuid', description: 'RFA UUID' }) @ApiParam({ name: 'uuid', description: 'RFA publicId' })
@ApiBody({ type: UpdateRfaDto }) @ApiBody({ type: UpdateRfaDto })
@ApiResponse({ status: 200, description: 'RFA updated successfully' }) @ApiResponse({ status: 200, description: 'RFA updated successfully' })
@RequirePermission('rfa.create') @RequirePermission('rfa.create')
@@ -146,7 +146,7 @@ export class RfaController {
@Delete(':uuid') @Delete(':uuid')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel Draft RFA (sets status to CC)' }) @ApiOperation({ summary: 'Cancel Draft RFA (sets status to CC)' })
@ApiParam({ name: 'uuid', description: 'RFA UUID' }) @ApiParam({ name: 'uuid', description: 'RFA publicId' })
@ApiResponse({ status: 200, description: 'RFA cancelled successfully' }) @ApiResponse({ status: 200, description: 'RFA cancelled successfully' })
@RequirePermission('rfa.create') @RequirePermission('rfa.create')
@Audit('rfa.cancel', 'rfa') @Audit('rfa.cancel', 'rfa')
+17 -17
View File
@@ -44,7 +44,7 @@ type CorrRevWithRfa = CorrespondenceRevision & { rfaRevision?: RfaRevision };
/** RFA entity + a flat `revisions` convenience array for the frontend */ /** RFA entity + a flat `revisions` convenience array for the frontend */
export interface RfaMapped extends Rfa { export interface RfaMapped extends Rfa {
uuid?: string; // ADR-019: top-level UUID from correspondence publicId?: string; // ADR-019: top-level publicId from correspondence
revisions: CorrRevWithRfa[]; revisions: CorrRevWithRfa[];
} }
@@ -429,7 +429,7 @@ export class RfaService {
this.searchService this.searchService
.indexDocument({ .indexDocument({
id: savedCorr.id, id: savedCorr.id,
uuid: savedCorr.uuid, // ADR-019: index UUID for search publicId: savedCorr.publicId, // ADR-019: index publicId for search
type: 'rfa', type: 'rfa',
docNumber: docNumber.number, docNumber: docNumber.number,
title: createDto.subject, title: createDto.subject,
@@ -536,7 +536,7 @@ export class RfaService {
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
return { return {
...rfa, ...rfa,
uuid: rfa.correspondence?.uuid, // ADR-019: expose UUID at top level publicId: rfa.correspondence?.publicId, // ADR-019: expose publicId at top level
revisions: revisions.map((cr) => ({ revisions: revisions.map((cr) => ({
...cr, ...cr,
...(cr.rfaRevision ?? {}), ...(cr.rfaRevision ?? {}),
@@ -557,27 +557,27 @@ export class RfaService {
} }
/** /**
* ADR-019: Find RFA by the parent Correspondence UUID (public identifier). * ADR-019: Find RFA by the parent Correspondence publicId (public identifier).
* Resolves correspondence.uuid → internal rfa.id * Resolves correspondence.publicId → internal rfa.id
*/ */
async findOneByUuid(uuid: string) { async findOneByUuid(publicId: string) {
const correspondence = await this.correspondenceRepo.findOne({ const correspondence = await this.correspondenceRepo.findOne({
where: { uuid }, where: { publicId },
select: ['id'], select: ['id'],
}); });
if (!correspondence) { if (!correspondence) {
throw new NotFoundException(`RFA with UUID ${uuid} not found`); throw new NotFoundException(`RFA with publicId ${publicId} not found`);
} }
return this.findOne(correspondence.id); return this.findOne(correspondence.id);
} }
async findOneByUuidRaw(uuid: string) { async findOneByUuidRaw(publicId: string) {
const correspondence = await this.correspondenceRepo.findOne({ const correspondence = await this.correspondenceRepo.findOne({
where: { uuid }, where: { publicId },
select: ['id'], select: ['id'],
}); });
if (!correspondence) { if (!correspondence) {
throw new NotFoundException(`RFA with UUID ${uuid} not found`); throw new NotFoundException(`RFA with publicId ${publicId} not found`);
} }
return this.findOne(correspondence.id, true); return this.findOne(correspondence.id, true);
} }
@@ -618,7 +618,7 @@ export class RfaService {
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
const mappedRfa: RfaMapped = { const mappedRfa: RfaMapped = {
...rfa, ...rfa,
uuid: rfa.correspondence?.uuid, // ADR-019: expose UUID at top level publicId: rfa.correspondence?.publicId, // ADR-019: expose publicId at top level
revisions: revisions.map((cr) => ({ revisions: revisions.map((cr) => ({
...cr, ...cr,
...(cr.rfaRevision ?? {}), ...(cr.rfaRevision ?? {}),
@@ -846,8 +846,8 @@ export class RfaService {
* Update a Draft RFA's revision fields (subject, body, remarks, description, dueDate). * Update a Draft RFA's revision fields (subject, body, remarks, description, dueDate).
* EC-RFA-002: Only allowed when current revision is in DFT status. * EC-RFA-002: Only allowed when current revision is in DFT status.
*/ */
async update(uuid: string, dto: UpdateRfaDto, _user: User) { async update(publicId: string, dto: UpdateRfaDto, _user: User) {
const rfa = await this.findOneByUuidRaw(uuid); const rfa = await this.findOneByUuidRaw(publicId);
const corrRevisions = const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
const currentCorrRev = corrRevisions.find((r) => r.isCurrent); const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
@@ -880,15 +880,15 @@ export class RfaService {
await this.rfaRevisionRepo.save(currentRfaRev); await this.rfaRevisionRepo.save(currentRfaRev);
} }
return this.findOneByUuid(uuid); return this.findOneByUuid(publicId);
} }
/** /**
* Cancel (soft-delete) a Draft RFA by setting its status to CC. * Cancel (soft-delete) a Draft RFA by setting its status to CC.
* EC-RFA-002: Only allowed when current revision is in DFT status. * EC-RFA-002: Only allowed when current revision is in DFT status.
*/ */
async cancel(uuid: string, user: User) { async cancel(publicId: string, user: User) {
const rfa = await this.findOneByUuidRaw(uuid); const rfa = await this.findOneByUuidRaw(publicId);
const corrRevisions = const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
const currentCorrRev = corrRevisions.find((r) => r.isCurrent); const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
+8 -2
View File
@@ -73,12 +73,18 @@ export class SearchService implements OnModuleInit {
* Index เอกสาร (Create/Update) * Index เอกสาร (Create/Update)
*/ */
async indexDocument( async indexDocument(
doc: Record<string, unknown> & { type: string; id?: number; uuid?: string } doc: Record<string, unknown> & {
type: string;
id?: number;
publicId?: string;
}
) { ) {
try { try {
return await this.esService.index({ return await this.esService.index({
index: this.indexName, index: this.indexName,
id: doc.uuid ? `${doc.type}_${doc.uuid}` : `${doc.type}_${doc.id}`, // ADR-019: prefer UUID key id: doc.publicId
? `${doc.type}_${doc.publicId}`
: `${doc.type}_${doc.id}`, // ADR-019: prefer publicId key
document: doc, // ✅ Library รุ่นใหม่ใช้ 'document' แทน 'body' ในบางเวอร์ชัน document: doc, // ✅ Library รุ่นใหม่ใช้ 'document' แทน 'body' ในบางเวอร์ชัน
}); });
} catch (error) { } catch (error) {
@@ -61,8 +61,8 @@ export class TransmittalController {
@Get(':uuid') @Get(':uuid')
@ApiOperation({ summary: 'Get Transmittal details' }) @ApiOperation({ summary: 'Get Transmittal details' })
@ApiParam({ @ApiParam({
name: 'uuid', name: 'publicId',
description: 'Transmittal UUID (from correspondences.uuid)', description: 'Transmittal publicId (from correspondences.publicId)',
}) })
@RequirePermission('document.view') @RequirePermission('document.view')
findOne(@Param('uuid', ParseUuidPipe) uuid: string) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
@@ -159,16 +159,18 @@ export class TransmittalService {
} }
/** /**
* ADR-019: Find Transmittal by parent Correspondence UUID (public identifier). * ADR-019: Find Transmittal by parent Correspondence publicId (public identifier).
* Resolves correspondence.uuid → internal correspondenceId (INT) * Resolves correspondence.publicId → internal correspondenceId (INT)
*/ */
async findOneByUuid(uuid: string): Promise<Transmittal> { async findOneByUuid(publicId: string): Promise<Transmittal> {
const correspondence = await this.dataSource.manager.findOne( const correspondence = await this.dataSource.manager.findOne(
Correspondence, Correspondence,
{ where: { uuid }, select: ['id'] } { where: { publicId }, select: ['id'] }
); );
if (!correspondence) { if (!correspondence) {
throw new NotFoundException(`Transmittal with UUID ${uuid} not found`); throw new NotFoundException(
`Transmittal with publicId ${publicId} not found`
);
} }
return this.findOne(correspondence.id); return this.findOne(correspondence.id);
} }
@@ -218,10 +220,10 @@ export class TransmittalService {
.take(limit) .take(limit)
.getManyAndCount(); .getManyAndCount();
// ADR-019: Map correspondence.uuid to top level for frontend convenience // ADR-019: Map correspondence.publicId to top level for frontend convenience
const mappedItems = items.map((t) => ({ const mappedItems = items.map((t) => ({
...t, ...t,
uuid: t.correspondence?.uuid, publicId: t.correspondence?.publicId,
})); }));
return { return {
+4 -4
View File
@@ -86,7 +86,7 @@ export class UserService {
.leftJoinAndSelect('assignments.role', 'role') .leftJoinAndSelect('assignments.role', 'role')
.select([ .select([
'user.user_id', 'user.user_id',
'user.uuid', 'user.publicId',
'user.username', 'user.username',
'user.email', 'user.email',
'user.firstName', 'user.firstName',
@@ -156,9 +156,9 @@ export class UserService {
return user; return user;
} }
async findOneByUuid(uuid: string): Promise<User> { async findOneByUuid(publicId: string): Promise<User> {
const user = await this.usersRepository.findOne({ const user = await this.usersRepository.findOne({
where: { uuid }, where: { publicId },
relations: [ relations: [
'preference', 'preference',
'assignments', 'assignments',
@@ -168,7 +168,7 @@ export class UserService {
}); });
if (!user) { if (!user) {
throw new NotFoundException(`User with UUID ${uuid} not found`); throw new NotFoundException(`User with publicId ${publicId} not found`);
} }
return user; return user;
@@ -37,7 +37,7 @@ export default function TagsPage() {
accessorKey: 'tag_name', accessorKey: 'tag_name',
header: 'Tag Name', header: 'Tag Name',
cell: ({ row }) => { cell: ({ row }) => {
const color = String(row.original.color_code || 'default'); const color = String(row.original.colorCode || 'default');
const isHex = color.startsWith('#'); const isHex = color.startsWith('#');
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -45,7 +45,7 @@ export default function TagsPage() {
className="w-3 h-3 rounded-full border border-border" className="w-3 h-3 rounded-full border border-border"
style={{ backgroundColor: isHex ? color : color === 'default' ? '#e2e8f0' : color }} style={{ backgroundColor: isHex ? color : color === 'default' ? '#e2e8f0' : color }}
/> />
{String(row.original.tag_name)} {String(row.original.tagName)}
</div> </div>
); );
}, },
@@ -97,13 +97,13 @@ export default function TagsPage() {
required: false, required: false,
}, },
{ {
name: 'tag_name', name: 'tagName',
label: 'Tag Name', label: 'Tag Name',
type: 'text', type: 'text',
required: true, required: true,
}, },
{ {
name: 'color_code', name: 'colorCode',
label: 'Color Code (Hex or Name)', label: 'Color Code (Hex or Name)',
type: 'text', type: 'text',
required: false, required: false,
@@ -52,13 +52,13 @@ export default function WorkflowEditPage() {
try { try {
const dto: CreateWorkflowDefinitionDto = { const dto: CreateWorkflowDefinitionDto = {
workflow_code: workflowData.workflowType || 'CORRESPONDENCE', workflowCode: workflowData.workflowType || 'CORRESPONDENCE',
dsl: { dsl: {
workflowName: workflowData.workflowName, workflowName: workflowData.workflowName,
description: workflowData.description, description: workflowData.description,
dslDefinition: workflowData.dslDefinition, dslDefinition: workflowData.dslDefinition,
}, },
is_active: workflowData.isActive, isActive: workflowData.isActive,
}; };
if (id) { if (id) {
@@ -124,13 +124,13 @@ export default function CirculationDetailPage() {
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div> <div>
<p className="text-sm text-muted-foreground">Organization</p> <p className="text-sm text-muted-foreground">Organization</p>
<p className="font-medium">{circulation.organization?.organization_name || '-'}</p> <p className="font-medium">{circulation.organization?.organizationName || '-'}</p>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Created By</p> <p className="text-sm text-muted-foreground">Created By</p>
<p className="font-medium"> <p className="font-medium">
{circulation.creator {circulation.creator
? `${circulation.creator.first_name || ''} ${circulation.creator.last_name || ''}`.trim() || ? `${circulation.creator.firstName || ''} ${circulation.creator.lastName || ''}`.trim() ||
circulation.creator.username circulation.creator.username
: '-'} : '-'}
</p> </p>
@@ -146,7 +146,7 @@ export default function CirculationDetailPage() {
href={`/correspondences/${circulation.correspondence.uuid}`} href={`/correspondences/${circulation.correspondence.uuid}`}
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
> >
{circulation.correspondence.correspondence_number} {circulation.correspondence.correspondenceNumber}
</Link> </Link>
</div> </div>
)} )}
@@ -166,13 +166,13 @@ export default function CirculationDetailPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar> <Avatar>
<AvatarFallback> <AvatarFallback>
{getInitials(routing.assignee?.first_name, routing.assignee?.last_name)} {getInitials(routing.assignee?.firstName, routing.assignee?.lastName)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<p className="font-medium"> <p className="font-medium">
{routing.assignee {routing.assignee
? `${routing.assignee.first_name || ''} ${routing.assignee.last_name || ''}`.trim() || ? `${routing.assignee.firstName || ''} ${routing.assignee.lastName || ''}`.trim() ||
routing.assignee.username routing.assignee.username
: 'Unassigned'} : 'Unassigned'}
</p> </p>
@@ -63,7 +63,7 @@ export function CirculationList({ data }: CirculationListProps) {
header: 'Organization', header: 'Organization',
cell: ({ row }) => { cell: ({ row }) => {
const org = row.original.organization; const org = row.original.organization;
return org?.organization_name || '-'; return org?.organizationName || '-';
}, },
}, },
{ {
@@ -30,7 +30,7 @@ function RoutingStep({ routing }: { routing: CirculationRouting }) {
const meta = ROUTING_STATUS_META[routing.status] ?? ROUTING_STATUS_META.PENDING; const meta = ROUTING_STATUS_META[routing.status] ?? ROUTING_STATUS_META.PENDING;
const Icon = meta.icon; const Icon = meta.icon;
const assigneeName = routing.assignee const assigneeName = routing.assignee
? `${routing.assignee.first_name ?? ''} ${routing.assignee.last_name ?? ''}`.trim() || ? `${routing.assignee.firstName ?? ''} ${routing.assignee.lastName ?? ''}`.trim() ||
routing.assignee.username routing.assignee.username
: '—'; : '—';
@@ -74,16 +74,16 @@ export function TagManager({ uuid, canEdit }: TagManagerProps) {
key={tag.id} key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border"
style={{ style={{
backgroundColor: `${getTagColor(tag.color_code)}22`, backgroundColor: `${getTagColor(tag.colorCode)}22`,
borderColor: `${getTagColor(tag.color_code)}66`, borderColor: `${getTagColor(tag.colorCode)}66`,
color: getTagColor(tag.color_code) === '#e2e8f0' ? 'inherit' : getTagColor(tag.color_code), color: getTagColor(tag.colorCode) === '#e2e8f0' ? 'inherit' : getTagColor(tag.colorCode),
}} }}
> >
<span <span
className="w-1.5 h-1.5 rounded-full shrink-0" className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: getTagColor(tag.color_code) }} style={{ backgroundColor: getTagColor(tag.colorCode) }}
/> />
{tag.tag_name} {tag.tagName}
{canEdit && ( {canEdit && (
<button <button
onClick={() => handleRemove(tag.id)} onClick={() => handleRemove(tag.id)}
@@ -117,9 +117,9 @@ export function TagManager({ uuid, canEdit }: TagManagerProps) {
> >
<span <span
className="w-2 h-2 rounded-full shrink-0" className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getTagColor(tag.color_code) }} style={{ backgroundColor: getTagColor(tag.colorCode) }}
/> />
{tag.tag_name} {tag.tagName}
</button> </button>
)) ))
)} )}
+4 -4
View File
@@ -38,7 +38,7 @@ export interface CirculationRouting {
* Main Circulation entity * Main Circulation entity
*/ */
export interface Circulation { export interface Circulation {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
correspondenceId?: number; correspondenceId?: number;
organizationId: number; organizationId: number;
@@ -53,18 +53,18 @@ export interface Circulation {
// Joined relations from API // Joined relations from API
routings?: CirculationRouting[]; routings?: CirculationRouting[];
correspondence?: { correspondence?: {
uuid: string; publicId: string;
id?: number; id?: number;
correspondenceNumber: string; correspondenceNumber: string;
}; };
organization?: { organization?: {
uuid: string; publicId: string;
id?: number; id?: number;
organizationCode: string; organizationCode: string;
organizationName: string; organizationName: string;
}; };
creator?: { creator?: {
uuid: string; publicId: string;
userId?: number; userId?: number;
username: string; username: string;
firstName?: string; firstName?: string;
+7 -7
View File
@@ -1,12 +1,12 @@
export interface Organization { export interface Organization {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
organizationName: string; organizationName: string;
organizationCode: string; organizationCode: string;
} }
export interface Attachment { export interface Attachment {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
name: string; name: string;
url: string; url: string;
@@ -17,7 +17,7 @@ export interface Attachment {
// Used in List View mainly // Used in List View mainly
export interface CorrespondenceRevision { export interface CorrespondenceRevision {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
revisionNumber: number; revisionNumber: number;
revisionLabel?: string; // e.g. "A", "00" revisionLabel?: string; // e.g. "A", "00"
@@ -42,21 +42,21 @@ export interface CorrespondenceRevision {
// Nested Relation from Backend Refactor // Nested Relation from Backend Refactor
correspondence: { correspondence: {
uuid: string; publicId: string;
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
originatorId?: number; originatorId?: number;
isInternal: boolean; isInternal: boolean;
originator?: Organization; originator?: Organization;
project?: { uuid: string; id?: number; projectName: string; projectCode: string }; project?: { publicId: string; id?: number; projectName: string; projectCode: string };
type?: { id: number; typeName: string; typeCode: string }; type?: { id: number; typeName: string; typeCode: string };
}; };
} }
// Keep explicit Correspondence for Detail View if needed, or merge concepts // Keep explicit Correspondence for Detail View if needed, or merge concepts
export interface Correspondence { export interface Correspondence {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
@@ -67,7 +67,7 @@ export interface Correspondence {
// Relations // Relations
originator?: Organization; originator?: Organization;
project?: { uuid: string; id?: number; projectName: string; projectCode: string }; project?: { publicId: string; id?: number; projectName: string; projectCode: string };
type?: { id: number; typeName: string; typeCode: string }; type?: { id: number; typeName: string; typeCode: string };
revisions?: CorrespondenceRevision[]; // Nested revisions revisions?: CorrespondenceRevision[]; // Nested revisions
recipients?: { recipients?: {
+5 -5
View File
@@ -1,6 +1,6 @@
// Entity Interfaces // Entity Interfaces
export interface DrawingRevision { export interface DrawingRevision {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
revisionId?: number; // Excluded from API responses (ADR-019) revisionId?: number; // Excluded from API responses (ADR-019)
revisionNumber: string; revisionNumber: string;
title?: string; // Added title?: string; // Added
@@ -15,7 +15,7 @@ export interface DrawingRevision {
} }
export interface ContractDrawing { export interface ContractDrawing {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
contractDrawingNo: string; contractDrawingNo: string;
title: string; title: string;
@@ -28,7 +28,7 @@ export interface ContractDrawing {
} }
export interface ShopDrawing { export interface ShopDrawing {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
projectId: number; projectId: number;
@@ -41,7 +41,7 @@ export interface ShopDrawing {
} }
export interface AsBuiltDrawing { export interface AsBuiltDrawing {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
projectId: number; projectId: number;
@@ -54,7 +54,7 @@ export interface AsBuiltDrawing {
// Unified Type for List // Unified Type for List
export interface Drawing { export interface Drawing {
uuid?: string; publicId?: string; // ADR-019: exposed as 'id' in API responses
drawingId?: number; // Excluded from API responses (ADR-019) drawingId?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
title: string; // Display title (from current revision for Shop/AsBuilt) title: string; // Display title (from current revision for Shop/AsBuilt)
+1 -1
View File
@@ -1,5 +1,5 @@
export interface Notification { export interface Notification {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
notificationId?: number; // Excluded from API responses (ADR-019) notificationId?: number; // Excluded from API responses (ADR-019)
title: string; title: string;
message: string; message: string;
+1 -1
View File
@@ -1,5 +1,5 @@
export interface Organization { export interface Organization {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
organizationCode: string; organizationCode: string;
organizationName: string; organizationName: string;
+7 -7
View File
@@ -2,33 +2,33 @@ export interface RFAItem {
id?: number; id?: number;
itemType: 'SHOP' | 'AS_BUILT'; itemType: 'SHOP' | 'AS_BUILT';
shopDrawingRevision?: { shopDrawingRevision?: {
uuid?: string; publicId?: string;
revisionLabel?: string; revisionLabel?: string;
revisionNumber?: number; revisionNumber?: number;
title?: string; title?: string;
legacyDrawingNumber?: string; legacyDrawingNumber?: string;
attachments?: { id?: number; url?: string; name?: string }[]; attachments?: { id?: number; url?: string; name?: string }[];
shopDrawing?: { shopDrawing?: {
uuid?: string; publicId?: string;
drawingNumber?: string; drawingNumber?: string;
}; };
}; };
asBuiltDrawingRevision?: { asBuiltDrawingRevision?: {
uuid?: string; publicId?: string;
revisionLabel?: string; revisionLabel?: string;
revisionNumber?: number; revisionNumber?: number;
title?: string; title?: string;
legacyDrawingNumber?: string; legacyDrawingNumber?: string;
attachments?: { id?: number; url?: string; name?: string }[]; attachments?: { id?: number; url?: string; name?: string }[];
asBuiltDrawing?: { asBuiltDrawing?: {
uuid?: string; publicId?: string;
drawingNumber?: string; drawingNumber?: string;
}; };
}; };
} }
export interface RFA { export interface RFA {
uuid: string; // ADR-019: from correspondence.uuid publicId: string; // ADR-019: from correspondence.publicId
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
rfaTypeId: number; rfaTypeId: number;
createdBy: number; createdBy: number;
@@ -56,14 +56,14 @@ export interface RFA {
}; };
// Shared Correspondence Relation // Shared Correspondence Relation
correspondence?: { correspondence?: {
uuid: string; publicId: string;
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
originatorId?: number; originatorId?: number;
createdAt?: string; createdAt?: string;
project?: { project?: {
uuid: string; publicId: string;
projectName: string; projectName: string;
projectCode: string; projectCode: string;
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
export interface SearchResult { export interface SearchResult {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
type: 'correspondence' | 'rfa' | 'drawing'; type: 'correspondence' | 'rfa' | 'drawing';
title: string; title: string;
+2 -2
View File
@@ -28,7 +28,7 @@ export interface TransmittalItem {
* Main Transmittal entity * Main Transmittal entity
*/ */
export interface Transmittal { export interface Transmittal {
uuid: string; // ADR-019: from correspondence.uuid publicId: string; // ADR-019: from correspondence.publicId
id?: number; // Excluded from API responses (ADR-019) id?: number; // Excluded from API responses (ADR-019)
correspondenceId?: number | string; correspondenceId?: number | string;
transmittalNo: string; transmittalNo: string;
@@ -39,7 +39,7 @@ export interface Transmittal {
// Joined relations from API // Joined relations from API
items?: TransmittalItem[]; items?: TransmittalItem[];
correspondence?: { correspondence?: {
uuid: string; publicId: string;
id?: number; id?: number;
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
+1 -1
View File
@@ -12,7 +12,7 @@ export interface UserOrganization {
} }
export interface User { export interface User {
uuid: string; publicId: string; // ADR-019: exposed as 'id' in API responses
userId?: number; // Excluded from API responses (ADR-019) userId?: number; // Excluded from API responses (ADR-019)
username: string; username: string;
email: string; email: string;
@@ -179,19 +179,30 @@ UNIQUE INDEX idx_{table}_uuid (uuid)
import { Column, BeforeInsert } from 'typeorm'; import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid'; import { v7 as uuidv7 } from 'uuid';
export abstract class BaseUuidEntity { /**
* Abstract base entity providing a UUID public identifier.
*
* Naming Convention:
* - TypeScript Property: `publicId` — semantic name indicating this is the public-facing identifier
* - Database Column: `uuid` — MariaDB native UUID type (stored as BINARY(16))
*
* This avoids confusion between the property name and the DB data type,
* while clearly indicating this is the ID exposed via API (not internal INT PK).
*/
export abstract class UuidBaseEntity {
@Column({ @Column({
type: 'uuid', type: 'uuid',
name: 'uuid', // DB column name (MariaDB native UUID type)
unique: true, unique: true,
nullable: false, nullable: false,
comment: 'UUID Public Identifier (ADR-019)', comment: 'UUID Public Identifier (ADR-019)',
}) })
uuid!: string; publicId!: string; // TypeScript property name — semantic, avoids type confusion
@BeforeInsert() @BeforeInsert()
generateUuid(): void { generateUuid(): void {
if (!this.uuid) { if (!this.publicId) {
this.uuid = uuidv7(); this.publicId = uuidv7();
} }
} }
} }
@@ -209,7 +220,10 @@ import { BaseUuidEntity } from '../../common/entities/base-uuid.entity';
@Entity('correspondences') @Entity('correspondences')
export class Correspondence extends BaseUuidEntity { export class Correspondence extends BaseUuidEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number; // Internal INT PK — never exposed via API
// publicId (from BaseUuidEntity) is the UUID exposed via API
// DB Column: uuid (MariaDB native UUID type)
// ... existing columns // ... existing columns
} }
@@ -243,9 +257,9 @@ async findOne(@Param('uuid', ParseUUIDPipe) uuid: string) {
### Service Pattern ### Service Pattern
```typescript ```typescript
async findByUuid(uuid: string): Promise<CorrespondenceDto> { async findByUuid(publicId: string): Promise<CorrespondenceDto> {
const entity = await this.repository.findOne({ const entity = await this.repository.findOne({
where: { uuid }, where: { publicId }, // Use publicId property (DB column is 'uuid')
}); });
if (!entity) throw new NotFoundException(); if (!entity) throw new NotFoundException();
return this.toDto(entity); return this.toDto(entity);
@@ -256,17 +270,17 @@ async findByUuid(uuid: string): Promise<CorrespondenceDto> {
```typescript ```typescript
export class CorrespondenceResponseDto { export class CorrespondenceResponseDto {
// ✅ Expose UUID as 'id' in API response // ✅ Expose publicId as 'id' in API response
@Expose({ name: 'id' }) @Expose({ name: 'id' })
uuid!: string; publicId!: string;
// ❌ Never expose internal INT id // ❌ Never expose internal INT id
// id: number; — REMOVED from response // id: number; — REMOVED from response
// ... other fields // ... other fields
// For FK references, also use UUID // For FK references, also use publicId
@Expose({ name: 'project_id' }) @Expose({ name: 'project_id' })
projectUuid!: string; projectPublicId!: string;
} }
``` ```
@@ -454,10 +468,10 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
### Phase 2: Backend (Dual-Mode) ### Phase 2: Backend (Dual-Mode)
- เพิ่ม `uuid` field ใน TypeORM Entities - เพิ่ม `publicId` field ใน TypeORM Entities (DB column ยังชื่อ `uuid`)
- สร้าง `BaseUuidEntity` class - สร้าง `BaseUuidEntity` class ด้วย `publicId` property
- API รับได้ทั้ง INT และ UUID ผ่าน `FindByIdOrUuid` pattern - API รับได้ทั้ง INT และ UUID ผ่าน `FindByIdOrUuid` pattern
- API Response รวม UUID เป็น `id` field - API Response รวม UUID เป็น `id` field (via @Expose)
### Phase 3: Frontend (Gradual Migration) ### Phase 3: Frontend (Gradual Migration)
@@ -486,4 +500,4 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
--- ---
_สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน ADR-019-implementation-plan.md_ _สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน `05-07-hybrid-uuid-implementation-plan.md`_