260316:1117 20260316:1100 Refactor UUID
Build and Deploy / deploy (push) Successful in 9m24s

This commit is contained in:
admin
2026-03-16 11:17:15 +07:00
parent b93cd91325
commit c5c3ed9016
92 changed files with 1726 additions and 620 deletions
+2 -2
View File
@@ -75,7 +75,7 @@
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27",
"uuid": "^9.0.1",
"uuid": "^11.1.0",
"winston": "^3.18.3",
"zod": "^4.1.13"
},
@@ -98,7 +98,7 @@
"@types/opossum": "^8.1.9",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
@@ -0,0 +1,28 @@
import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
/**
* Abstract base entity providing a UUID public identifier column.
* 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.
*
* @see ADR-019 Hybrid Identifier Strategy
*/
export abstract class UuidBaseEntity {
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid!: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
}
@@ -7,10 +7,13 @@ import {
JoinColumn,
} from 'typeorm';
import { User } from '../../../modules/user/entities/user.entity';
import { UuidBaseEntity } from '../../entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('attachments')
export class Attachment {
export class Attachment extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'original_filename', length: 255 })
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { instanceToPlain } from 'class-transformer';
/** Metadata สำหรับ Paginated Response */
export interface ResponseMeta {
@@ -53,24 +54,31 @@ export class TransformInterceptor<T>
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data: T) => {
const response = context.switchToHttp().getResponse<{ statusCode: number }>();
const response = context
.switchToHttp()
.getResponse<{ statusCode: number }>();
// ADR-019: Serialize entities via class-transformer
// This applies @Exclude() decorators to strip internal INT ids from responses
const serialized = instanceToPlain(data) as T;
// Handle Pagination Response (Standardize)
// ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา
if (isPaginatedPayload(data)) {
if (isPaginatedPayload(serialized)) {
return {
statusCode: response.statusCode,
message: data.message ?? 'Success',
data: data.data as unknown as T,
meta: data.meta,
message: serialized.message ?? 'Success',
data: serialized.data as unknown as T,
meta: serialized.meta,
};
}
const dataAsRecord = data as Record<string, unknown>;
const dataAsRecord = serialized as Record<string, unknown>;
return {
statusCode: response.statusCode,
message: (dataAsRecord?.['message'] as string | undefined) ?? 'Success',
data: (dataAsRecord?.['result'] as T | undefined) ?? data,
message:
(dataAsRecord?.['message'] as string | undefined) ?? 'Success',
data: (dataAsRecord?.['result'] as T | undefined) ?? serialized,
};
})
);
@@ -0,0 +1,20 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { validate as uuidValidate } from 'uuid';
/**
* Validates that a route parameter is a valid UUID string.
* Accepts any UUID version (v1 from DB DEFAULT, v7 from app generation).
*
* Usage: @Param('uuid', ParseUuidPipe) uuid: string
*
* @see ADR-019 Hybrid Identifier Strategy
*/
@Injectable()
export class ParseUuidPipe implements PipeTransform<string> {
transform(value: string): string {
if (!uuidValidate(value)) {
throw new BadRequestException(`Invalid UUID format: ${value}`);
}
return value.toLowerCase();
}
}
@@ -22,6 +22,7 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Audit } from '../../common/decorators/audit.decorator'; // Import
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Circulations')
@ApiBearerAuth()
@@ -45,11 +46,11 @@ export class CirculationController {
return this.circulationService.findAll(searchDto, user);
}
@Get(':id')
@Get(':uuid')
@ApiOperation({ summary: 'Get circulation details' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.circulationService.findOne(id);
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.circulationService.findOneByUuid(uuid);
}
@Patch('routings/:id')
@@ -58,7 +59,7 @@ export class CirculationController {
updateRouting(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateCirculationRoutingDto,
@CurrentUser() user: User,
@CurrentUser() user: User
) {
return this.circulationService.updateRoutingStatus(id, updateDto, user);
}
@@ -113,6 +113,17 @@ export class CirculationService {
return circulation;
}
async findOneByUuid(uuid: string) {
const circulation = await this.circulationRepo.findOne({
where: { uuid },
relations: ['routings', 'routings.assignee', 'correspondence', 'creator'],
order: { routings: { stepNumber: 'ASC' } },
});
if (!circulation)
throw new NotFoundException(`Circulation UUID ${uuid} not found`);
return circulation;
}
// ✅ Logic อัปเดตสถานะและปิดงาน
async updateRoutingStatus(
routingId: number,
@@ -13,10 +13,13 @@ import { Organization } from '../../organization/entities/organization.entity';
import { User } from '../../user/entities/user.entity';
import { CirculationStatusCode } from './circulation-status-code.entity';
import { CirculationRouting } from './circulation-routing.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('circulations')
export class Circulation {
export class Circulation extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'correspondence_id', nullable: true })
@@ -7,7 +7,6 @@ import {
Param,
Delete,
UseGuards,
ParseIntPipe,
Query,
} from '@nestjs/common';
import {
@@ -22,6 +21,7 @@ import { UpdateContractDto } from './dto/update-contract.dto.js';
import { SearchContractDto } from './dto/search-contract.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Contracts')
@ApiBearerAuth()
@@ -45,26 +45,26 @@ export class ContractController {
return this.contractService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get Contract by ID' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.contractService.findOne(id);
@Get(':uuid')
@ApiOperation({ summary: 'Get Contract by UUID' })
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.contractService.findOneByUuid(uuid);
}
@Patch(':id')
@Patch(':uuid')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update Contract' })
update(
@Param('id', ParseIntPipe) id: number,
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() dto: UpdateContractDto
) {
return this.contractService.update(id, dto);
return this.contractService.update(uuid, dto);
}
@Delete(':id')
@Delete(':uuid')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete Contract' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.contractService.remove(id);
remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.contractService.remove(uuid);
}
}
@@ -87,15 +87,24 @@ export class ContractService {
return contract;
}
async update(id: number, dto: UpdateContractDto) {
const contract = await this.findOne(id);
async findOneByUuid(uuid: string) {
const contract = await this.contractRepo.findOne({
where: { uuid },
relations: ['project'],
});
if (!contract)
throw new NotFoundException(`Contract UUID ${uuid} not found`);
return contract;
}
async update(uuid: string, dto: UpdateContractDto) {
const contract = await this.findOneByUuid(uuid);
Object.assign(contract, dto);
return this.contractRepo.save(contract);
}
async remove(id: number) {
const contract = await this.findOne(id);
// Schema doesn't have deleted_at for Contract either.
async remove(uuid: string) {
const contract = await this.findOneByUuid(uuid);
return this.contractRepo.remove(contract);
}
}
@@ -4,15 +4,34 @@ import {
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
BeforeInsert,
} from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
import { Exclude } from 'class-transformer';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Project } from '../../project/entities/project.entity';
@Entity('contracts')
export class Contract extends BaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@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' })
projectId!: number;
@@ -30,6 +30,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Correspondences')
@Controller('correspondences')
@@ -119,7 +120,7 @@ export class CorrespondenceController {
return this.correspondenceService.findAll(searchDto);
}
@Post(':id/submit')
@Post(':uuid/submit')
@ApiOperation({ summary: 'Submit correspondence to Unified Workflow Engine' })
@ApiResponse({
status: 201,
@@ -127,8 +128,8 @@ export class CorrespondenceController {
})
@RequirePermission('correspondence.create')
@Audit('correspondence.submit', 'correspondence')
submit(
@Param('id', ParseIntPipe) id: number,
async submit(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() submitDto: SubmitCorrespondenceDto,
@Request()
req: Request & {
@@ -138,28 +139,29 @@ export class CorrespondenceController {
};
}
) {
const corr = await this.correspondenceService.findOneByUuid(uuid);
// Extract roles from user assignments
const userRoles =
req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || [];
// Use Unified Workflow Engine - pass user roles for DSL requirements check
return this.workflowService.submitWorkflow(
id,
corr.id,
req.user.user_id,
userRoles,
submitDto.note
);
}
@Get(':id')
@ApiOperation({ summary: 'Get correspondence by ID' })
@Get(':uuid')
@ApiOperation({ summary: 'Get correspondence by UUID' })
@ApiResponse({ status: 200, description: 'Return correspondence details.' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.correspondenceService.findOne(id);
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.correspondenceService.findOneByUuid(uuid);
}
@Put(':id')
@Put(':uuid')
@ApiOperation({ summary: 'Update correspondence (Draft only)' })
@ApiResponse({
status: 200,
@@ -167,48 +169,52 @@ export class CorrespondenceController {
})
@RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit'
@Audit('correspondence.update', 'correspondence')
update(
@Param('id', ParseIntPipe) id: number,
async update(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateCorrespondenceDto,
@Request() req: Request & { user: unknown }
) {
const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.update(
id,
corr.id,
updateDto,
req.user as Parameters<typeof this.correspondenceService.create>[1]
);
}
@Get(':id/references')
@Get(':uuid/references')
@ApiOperation({ summary: 'Get referenced documents' })
@ApiResponse({
status: 200,
description: 'Return list of referenced documents.',
})
@RequirePermission('document.view')
getReferences(@Param('id', ParseIntPipe) id: number) {
return this.correspondenceService.getReferences(id);
async getReferences(@Param('uuid', ParseUuidPipe) uuid: string) {
const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.getReferences(corr.id);
}
@Post(':id/references')
@Post(':uuid/references')
@ApiOperation({ summary: 'Add reference to another document' })
@ApiResponse({ status: 201, description: 'Reference added successfully.' })
@RequirePermission('document.edit')
addReference(
@Param('id', ParseIntPipe) id: number,
async addReference(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() dto: AddReferenceDto
) {
return this.correspondenceService.addReference(id, dto);
const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.addReference(corr.id, dto);
}
@Delete(':id/references/:targetId')
@Delete(':uuid/references/:targetId')
@ApiOperation({ summary: 'Remove reference' })
@ApiResponse({ status: 200, description: 'Reference removed successfully.' })
@RequirePermission('document.edit')
removeReference(
@Param('id', ParseIntPipe) id: number,
async removeReference(
@Param('uuid', ParseUuidPipe) uuid: string,
@Param('targetId', ParseIntPipe) targetId: number
) {
return this.correspondenceService.removeReference(id, targetId);
const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.removeReference(corr.id, targetId);
}
}
@@ -333,6 +333,26 @@ export class CorrespondenceService {
return correspondence;
}
async findOneByUuid(uuid: string) {
const correspondence = await this.correspondenceRepo.findOne({
where: { uuid },
relations: [
'revisions',
'revisions.status',
'type',
'project',
'originator',
'recipients',
'recipients.recipientOrganization',
],
});
if (!correspondence) {
throw new NotFoundException(`Correspondence with UUID ${uuid} not found`);
}
return correspondence;
}
async addReference(id: number, dto: AddReferenceDto) {
const source = await this.correspondenceRepo.findOne({ where: { id } });
const target = await this.correspondenceRepo.findOne({
@@ -13,13 +13,16 @@ import { RfaRevision } from '../../rfa/entities/rfa-revision.entity';
import { Correspondence } from './correspondence.entity';
import { CorrespondenceStatus } from './correspondence-status.entity';
import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('correspondence_revisions')
// ✅ เพิ่ม Index สำหรับ Virtual Columns เพื่อให้ Search เร็วขึ้น
@Index('idx_corr_rev_v_project', ['vRefProjectId'])
@Index('idx_corr_rev_v_type', ['vRefType'])
export class CorrespondenceRevision {
export class CorrespondenceRevision extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'correspondence_id' })
@@ -15,10 +15,13 @@ import { User } from '../../user/entities/user.entity';
import { CorrespondenceRecipient } from './correspondence-recipient.entity';
import { CorrespondenceRevision } from './correspondence-revision.entity';
import { Discipline } from '../../master/entities/discipline.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('correspondences')
export class Correspondence {
export class Correspondence extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'correspondence_number', length: 100 })
@@ -6,7 +6,6 @@ import {
Body,
Param,
Query,
ParseIntPipe,
HttpCode,
HttpStatus,
UseGuards,
@@ -34,6 +33,7 @@ import { SearchAsBuiltDrawingDto } from './dto/search-asbuilt-drawing.dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { User } from '../user/entities/user.entity';
@ApiTags('Drawings - AS Built')
@@ -56,16 +56,17 @@ export class AsBuiltDrawingController {
return this.asBuiltDrawingService.create(createDto, user);
}
@Post(':id/revisions')
@Post(':uuid/revisions')
@ApiOperation({ summary: 'Create new revision for AS Built Drawing' })
@ApiResponse({ status: 201, description: 'Revision created' })
@ApiResponse({ status: 404, description: 'AS Built Drawing not found' })
@ApiResponse({ status: 409, description: 'Revision label already exists' })
async createRevision(
@Param('id', ParseIntPipe) id: number,
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() createDto: CreateAsBuiltDrawingRevisionDto
) {
return this.asBuiltDrawingService.createRevision(id, createDto);
const drawing = await this.asBuiltDrawingService.findOneByUuid(uuid);
return this.asBuiltDrawingService.createRevision(drawing.id, createDto);
}
@Get()
@@ -76,16 +77,16 @@ export class AsBuiltDrawingController {
return this.asBuiltDrawingService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get AS Built Drawing by ID' })
@Get(':uuid')
@ApiOperation({ summary: 'Get AS Built Drawing by UUID' })
@ApiResponse({ status: 200, description: 'AS Built Drawing details' })
@ApiResponse({ status: 404, description: 'AS Built Drawing not found' })
@RequirePermission('drawing.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.asBuiltDrawingService.findOne(id);
async findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.asBuiltDrawingService.findOneByUuid(uuid);
}
@Delete(':id')
@Delete(':uuid')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft delete AS Built Drawing' })
@ApiResponse({ status: 204, description: 'AS Built Drawing deleted' })
@@ -93,9 +94,10 @@ export class AsBuiltDrawingController {
@RequirePermission('drawing.delete')
@Audit('drawing.delete', 'asbuilt_drawing')
async remove(
@Param('id', ParseIntPipe) id: number,
@Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User
) {
return this.asBuiltDrawingService.remove(id, user);
const drawing = await this.asBuiltDrawingService.findOneByUuid(uuid);
return this.asBuiltDrawingService.remove(drawing.id, user);
}
}
@@ -295,6 +295,28 @@ export class AsBuiltDrawingService {
return asBuiltDrawing;
}
async findOneByUuid(uuid: string) {
const asBuiltDrawing = await this.asBuiltDrawingRepo.findOne({
where: { uuid },
relations: [
'mainCategory',
'subCategory',
'revisions',
'revisions.attachments',
'revisions.shopDrawingRevisions',
],
order: {
revisions: { revisionNumber: 'DESC' },
},
});
if (!asBuiltDrawing) {
throw new NotFoundException(`AS Built Drawing UUID ${uuid} not found`);
}
return asBuiltDrawing;
}
/**
* ลบ AS Built Drawing
*/
@@ -8,7 +8,6 @@ import {
Put,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -20,6 +19,7 @@ import { SearchContractDrawingDto } from './dto/search-contract-drawing.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
@@ -51,28 +51,33 @@ export class ContractDrawingController {
return this.contractDrawingService.findAll(searchDto);
}
@Get(':id')
@Get(':uuid')
@ApiOperation({ summary: 'Get Contract Drawing details' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.contractDrawingService.findOne(id);
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.contractDrawingService.findOneByUuid(uuid);
}
@Put(':id')
@Put(':uuid')
@ApiOperation({ summary: 'Update Contract Drawing' })
@RequirePermission('drawing.create') // สิทธิ์ ID 39 ครอบคลุมการแก้ไขด้วย
update(
@Param('id', ParseIntPipe) id: number,
async update(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateContractDrawingDto,
@CurrentUser() user: User
) {
return this.contractDrawingService.update(id, updateDto, user);
const drawing = await this.contractDrawingService.findOneByUuid(uuid);
return this.contractDrawingService.update(drawing.id, updateDto, user);
}
@Delete(':id')
@Delete(':uuid')
@ApiOperation({ summary: 'Delete Contract Drawing (Soft Delete)' })
@RequirePermission('document.delete') // สิทธิ์ ID 34: ลบเอกสาร
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) {
return this.contractDrawingService.remove(id, user);
async remove(
@Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User
) {
const drawing = await this.contractDrawingService.findOneByUuid(uuid);
return this.contractDrawingService.remove(drawing.id, user);
}
}
@@ -180,6 +180,19 @@ export class ContractDrawingService {
return drawing;
}
async findOneByUuid(uuid: string) {
const drawing = await this.drawingRepo.findOne({
where: { uuid },
relations: ['attachments'],
});
if (!drawing) {
throw new NotFoundException(`Contract Drawing UUID ${uuid} not found`);
}
return drawing;
}
/**
* แก้ไขข้อมูลแบบ (Update)
*/
@@ -13,11 +13,14 @@ import { AsBuiltDrawing } from './asbuilt-drawing.entity';
import { ShopDrawingRevision } from './shop-drawing-revision.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('asbuilt_drawing_revisions')
@Unique(['asBuiltDrawingId', 'isCurrent'])
export class AsBuiltDrawingRevision {
export class AsBuiltDrawingRevision extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'asbuilt_drawing_id' })
@@ -14,10 +14,13 @@ import { AsBuiltDrawingRevision } from './asbuilt-drawing-revision.entity';
import { User } from '../../user/entities/user.entity';
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('asbuilt_drawings')
export class AsBuiltDrawing {
export class AsBuiltDrawing extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'project_id' })
@@ -15,10 +15,13 @@ import { User } from '../../user/entities/user.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { ContractDrawingSubcatCatMap } from './contract-drawing-subcat-cat-map.entity';
import { ContractDrawingVolume } from './contract-drawing-volume.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('contract_drawings')
export class ContractDrawing {
export class ContractDrawing extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number; // ! ห้ามว่าง
@Column({ name: 'project_id' })
@@ -13,11 +13,14 @@ import { ShopDrawing } from './shop-drawing.entity';
import { ContractDrawing } from './contract-drawing.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('shop_drawing_revisions')
@Unique(['shopDrawingId', 'isCurrent'])
export class ShopDrawingRevision {
export class ShopDrawingRevision extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number; // เติม !
@Column({ name: 'shop_drawing_id' })
@@ -13,10 +13,13 @@ import { ShopDrawingRevision } from './shop-drawing-revision.entity';
import { Project } from '../../project/entities/project.entity';
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('shop_drawings')
export class ShopDrawing {
export class ShopDrawing extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number; // เติม !
@Column({ name: 'project_id' })
@@ -6,7 +6,6 @@ import {
Param,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -18,6 +17,7 @@ import { CreateShopDrawingRevisionDto } from './dto/create-shop-drawing-revision
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
import { Audit } from '../../common/decorators/audit.decorator'; // Import
@@ -44,21 +44,22 @@ export class ShopDrawingController {
return this.shopDrawingService.findAll(searchDto);
}
@Get(':id')
@Get(':uuid')
@ApiOperation({ summary: 'Get Shop Drawing details with revisions' })
@RequirePermission('drawing.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.shopDrawingService.findOne(id);
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.shopDrawingService.findOneByUuid(uuid);
}
@Post(':id/revisions')
@Post(':uuid/revisions')
@ApiOperation({ summary: 'Add new revision to existing Shop Drawing' })
@RequirePermission('drawing.create') // หรือ drawing.edit ตาม Logic องค์กร
@Audit('drawing.create', 'shop_drawing') // ✅ แปะตรงนี้
createRevision(
@Param('id', ParseIntPipe) id: number,
@Body() createRevisionDto: CreateShopDrawingRevisionDto,
async createRevision(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() createRevisionDto: CreateShopDrawingRevisionDto
) {
return this.shopDrawingService.createRevision(id, createRevisionDto);
const sd = await this.shopDrawingService.findOneByUuid(uuid);
return this.shopDrawingService.createRevision(sd.id, createRevisionDto);
}
}
@@ -289,6 +289,28 @@ export class ShopDrawingService {
return shopDrawing;
}
async findOneByUuid(uuid: string) {
const shopDrawing = await this.shopDrawingRepo.findOne({
where: { uuid },
relations: [
'mainCategory',
'subCategory',
'revisions',
'revisions.attachments',
'revisions.contractDrawings',
],
order: {
revisions: { revisionNumber: 'DESC' },
},
});
if (!shopDrawing) {
throw new NotFoundException(`Shop Drawing UUID ${uuid} not found`);
}
return shopDrawing;
}
/**
* Shop Drawing
*/
@@ -8,6 +8,8 @@ import {
PrimaryColumn, // ✅ [Fix] เพิ่ม Import นี้
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
export enum NotificationType {
EMAIL = 'EMAIL',
@@ -16,8 +18,9 @@ export enum NotificationType {
}
@Entity('notifications')
export class Notification {
export class Notification extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'user_id' })
@@ -1,12 +1,4 @@
import {
Controller,
Get,
Put,
Param,
UseGuards,
ParseIntPipe,
Query,
} from '@nestjs/common';
import { Controller, Get, Put, Param, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -17,6 +9,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
import { SearchNotificationDto } from './dto/search-notification.dto'; // ✅ Import
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Notifications')
@ApiBearerAuth()
@@ -26,14 +19,14 @@ export class NotificationController {
constructor(
private readonly notificationService: NotificationService,
@InjectRepository(Notification)
private notificationRepo: Repository<Notification>,
private notificationRepo: Repository<Notification>
) {}
@Get()
@ApiOperation({ summary: 'Get my notifications' })
async getMyNotifications(
@CurrentUser() user: User,
@Query() searchDto: SearchNotificationDto, // ✅ ใช้ DTO แทน
@Query() searchDto: SearchNotificationDto // ✅ ใช้ DTO แทน
) {
const { page = 1, limit = 20, isRead } = searchDto;
@@ -65,13 +58,13 @@ export class NotificationController {
return { unreadCount: count };
}
@Put(':id/read')
@Put(':uuid/read')
@ApiOperation({ summary: 'Mark notification as read' })
async markAsRead(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User,
@Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User
) {
return this.notificationService.markAsRead(id, user.user_id);
return this.notificationService.markAsReadByUuid(uuid, user.user_id);
}
@Put('read-all')
@@ -39,7 +39,7 @@ export class NotificationService {
@InjectRepository(User)
private userRepo: Repository<User>,
// ไม่ต้อง Inject UserPrefRepo แล้ว เพราะ Processor จะจัดการเอง
private notificationGateway: NotificationGateway,
private notificationGateway: NotificationGateway
) {}
/**
@@ -84,14 +84,14 @@ export class NotificationService {
delay: 5000,
},
removeOnComplete: true,
},
}
);
this.logger.debug(`Dispatched notification job for user ${data.userId}`);
} catch (error) {
this.logger.error(
`Failed to process notification for user ${data.userId}`,
(error as Error).stack,
(error as Error).stack
);
}
}
@@ -154,10 +154,25 @@ export class NotificationService {
}
}
async markAsReadByUuid(uuid: string, userId: number): Promise<void> {
const notification = await this.notificationRepo.findOne({
where: { uuid, userId },
});
if (!notification) {
throw new NotFoundException(`Notification UUID ${uuid} not found`);
}
if (!notification.isRead) {
notification.isRead = true;
await this.notificationRepo.save(notification);
}
}
async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update(
{ userId, isRead: false },
{ isRead: true },
{ isRead: true }
);
}
@@ -10,10 +10,13 @@ import {
JoinColumn,
} from 'typeorm';
import { OrganizationRole } from './organization-role.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('organizations')
export class Organization {
export class Organization extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'organization_code', length: 20, unique: true })
@@ -8,7 +8,6 @@ import {
Delete,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationService } from './organization.service.js';
@@ -17,6 +16,7 @@ import { UpdateOrganizationDto } from './dto/update-organization.dto.js';
import { SearchOrganizationDto } from './dto/search-organization.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Organizations')
@ApiBearerAuth()
@@ -38,26 +38,26 @@ export class OrganizationController {
return this.orgService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get Organization by ID' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.orgService.findOne(id);
@Get(':uuid')
@ApiOperation({ summary: 'Get Organization by UUID' })
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.orgService.findOneByUuid(uuid);
}
@Patch(':id')
@Patch(':uuid')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update Organization' })
update(
@Param('id', ParseIntPipe) id: number,
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() dto: UpdateOrganizationDto
) {
return this.orgService.update(id, dto);
return this.orgService.update(uuid, dto);
}
@Delete(':id')
@Delete(':uuid')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete Organization' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.orgService.remove(id);
remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.orgService.remove(uuid);
}
}
@@ -86,17 +86,21 @@ export class OrganizationService {
return org;
}
async update(id: number, dto: UpdateOrganizationDto) {
const org = await this.findOne(id);
async findOneByUuid(uuid: string) {
const org = await this.orgRepo.findOne({ where: { uuid } });
if (!org)
throw new NotFoundException(`Organization UUID ${uuid} not found`);
return org;
}
async update(uuid: string, dto: UpdateOrganizationDto) {
const org = await this.findOneByUuid(uuid);
Object.assign(org, dto);
return this.orgRepo.save(org);
}
async remove(id: number) {
const org = await this.findOne(id);
// Hard delete or Soft delete? Schema doesn't have deleted_at for Organization, but let's check.
// Schema says: created_at, updated_at. No deleted_at.
// So hard delete.
async remove(uuid: string) {
const org = await this.findOneByUuid(uuid);
return this.orgRepo.remove(org);
}
@@ -1,12 +1,36 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToMany,
BeforeInsert,
} from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
import { Exclude } from 'class-transformer';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Contract } from '../../contract/entities/contract.entity';
@Entity('projects')
export class Project extends BaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@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 })
projectCode!: string;
@@ -8,7 +8,6 @@ import {
Delete,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -19,6 +18,7 @@ import { SearchProjectDto } from './dto/search-project.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Projects')
@@ -49,34 +49,34 @@ export class ProjectController {
return this.projectService.findAllOrganizations();
}
@Get(':id/contracts')
@Get(':uuid/contracts')
@ApiOperation({ summary: 'List All Contracts in Project' })
@RequirePermission('project.view')
findContracts(@Param('id', ParseIntPipe) id: number) {
return this.projectService.findContracts(id);
findContracts(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.projectService.findContracts(uuid);
}
@Get(':id')
@Get(':uuid')
@ApiOperation({ summary: 'Get Project Details' })
@RequirePermission('project.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.projectService.findOne(id);
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.projectService.findOneByUuid(uuid);
}
@Patch(':id')
@Patch(':uuid')
@ApiOperation({ summary: 'Update Project' })
@RequirePermission('project.edit')
update(
@Param('id', ParseIntPipe) id: number,
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateProjectDto
) {
return this.projectService.update(id, updateDto);
return this.projectService.update(uuid, updateDto);
}
@Delete(':id')
@Delete(':uuid')
@ApiOperation({ summary: 'Delete Project (Soft Delete)' })
@RequirePermission('project.delete')
remove(@Param('id', ParseIntPipe) id: number) {
return this.projectService.remove(id);
remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.projectService.remove(uuid);
}
}
+19 -14
View File
@@ -91,8 +91,21 @@ export class ProjectService {
return project;
}
async update(id: number, updateDto: UpdateProjectDto) {
const project = await this.findOne(id);
async findOneByUuid(uuid: string) {
const project = await this.projectRepository.findOne({
where: { uuid },
relations: ['contracts'],
});
if (!project) {
throw new NotFoundException(`Project UUID ${uuid} not found`);
}
return project;
}
async update(uuid: string, updateDto: UpdateProjectDto) {
const project = await this.findOneByUuid(uuid);
// Merge ข้อมูลใหม่ใส่ข้อมูลเดิม
this.projectRepository.merge(project, updateDto);
@@ -100,22 +113,14 @@ export class ProjectService {
return this.projectRepository.save(project);
}
async remove(id: number) {
const project = await this.findOne(id);
async remove(uuid: string) {
const project = await this.findOneByUuid(uuid);
// ใช้ Soft Delete
return this.projectRepository.softRemove(project);
}
async findContracts(projectId: number) {
const project = await this.projectRepository.findOne({
where: { id: projectId },
relations: ['contracts'],
});
if (!project) {
throw new NotFoundException(`Project ID ${projectId} not found`);
}
async findContracts(uuid: string) {
const project = await this.findOneByUuid(uuid);
return project.contracts;
}
@@ -16,10 +16,13 @@ import {
import { Organization } from '../../organization/entities/organization.entity'; // Adjust path as needed
import { UserAssignment } from './user-assignment.entity';
import { UserPreference } from './user-preference.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('users')
export class User {
export class User extends UuidBaseEntity {
@PrimaryGeneratedColumn({ name: 'user_id' })
@Exclude()
user_id!: number;
@Column({ unique: true, length: 50 })
+13 -12
View File
@@ -33,6 +33,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { User } from './entities/user.entity';
@ApiTags('Users')
@@ -123,35 +124,35 @@ export class UserController {
return this.userService.findAll(query);
}
@Get(':id')
@Get(':uuid')
@ApiOperation({ summary: 'Get user details' })
@ApiParam({ name: 'id', description: 'User ID' })
@ApiParam({ name: 'uuid', description: 'User UUID' })
@ApiResponse({ status: 200, description: 'User details' })
@RequirePermission('user.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.userService.findOneByUuid(uuid);
}
@Patch(':id')
@Patch(':uuid')
@ApiOperation({ summary: 'Update user' })
@ApiParam({ name: 'id', description: 'User ID' })
@ApiParam({ name: 'uuid', description: 'User UUID' })
@ApiBody({ type: UpdateUserDto })
@ApiResponse({ status: 200, description: 'User updated' })
@RequirePermission('user.edit')
update(
@Param('id', ParseIntPipe) id: number,
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateUserDto: UpdateUserDto
) {
return this.userService.update(id, updateUserDto);
return this.userService.update(uuid, updateUserDto);
}
@Delete(':id')
@Delete(':uuid')
@ApiOperation({ summary: 'Delete user (Soft delete)' })
@ApiParam({ name: 'id', description: 'User ID' })
@ApiParam({ name: 'uuid', description: 'User UUID' })
@ApiResponse({ status: 200, description: 'User deleted' })
@RequirePermission('user.delete')
remove(@Param('id', ParseIntPipe) id: number) {
return this.userService.remove(id);
remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.userService.remove(uuid);
}
// --- Role Assignment ---
+26 -7
View File
@@ -133,13 +133,31 @@ export class UserService {
return user;
}
async findOneByUuid(uuid: string): Promise<User> {
const user = await this.usersRepository.findOne({
where: { uuid },
relations: [
'preference',
'assignments',
'assignments.role',
'assignments.role.permissions',
],
});
if (!user) {
throw new NotFoundException(`User with UUID ${uuid} not found`);
}
return user;
}
async findOneByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } });
}
// 4. แก้ไขข้อมูล
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
async update(uuid: string, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOneByUuid(uuid);
if (updateUserDto.password) {
const salt = await bcrypt.genSalt();
@@ -150,20 +168,21 @@ export class UserService {
const savedUser = await this.usersRepository.save(updatedUser);
// ⚠️ สำคัญ: เมื่อมีการแก้ไขข้อมูล User ต้องเคลียร์ Cache สิทธิ์เสมอ
await this.clearUserCache(id);
await this.clearUserCache(user.user_id);
return savedUser;
}
// 5. ลบผู้ใช้ (Soft Delete)
async remove(id: number): Promise<void> {
const result = await this.usersRepository.softDelete(id);
async remove(uuid: string): Promise<void> {
const user = await this.findOneByUuid(uuid);
const result = await this.usersRepository.softDelete(user.user_id);
if (result.affected === 0) {
throw new NotFoundException(`User with ID ${id} not found`);
throw new NotFoundException(`User with UUID ${uuid} not found`);
}
// เคลียร์ Cache เมื่อลบ
await this.clearUserCache(id);
await this.clearUserCache(user.user_id);
}
async findDocControlIdByOrg(organizationId: number): Promise<number | null> {