diff --git a/backend/src/common/auth/auth.module.ts b/backend/src/common/auth/auth.module.ts index 91ac1f8..e6c914c 100644 --- a/backend/src/common/auth/auth.module.ts +++ b/backend/src/common/auth/auth.module.ts @@ -10,6 +10,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthService } from './auth.service.js'; import { AuthController } from './auth.controller.js'; +import { SessionController } from './session.controller.js'; import { UserModule } from '../../modules/user/user.module.js'; import { JwtStrategy } from './strategies/jwt.strategy.js'; import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js'; @@ -37,7 +38,7 @@ import { PermissionsGuard } from './guards/permissions.guard'; CaslModule, ], providers: [AuthService, JwtStrategy, JwtRefreshStrategy, PermissionsGuard], - controllers: [AuthController], + controllers: [AuthController, SessionController], exports: [AuthService, PermissionsGuard], }) export class AuthModule {} diff --git a/backend/src/common/auth/session.controller.ts b/backend/src/common/auth/session.controller.ts new file mode 100644 index 0000000..2f01ff1 --- /dev/null +++ b/backend/src/common/auth/session.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + Delete, + Param, + UseGuards, + ParseIntPipe, + UnauthorizedException, + Req, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { User } from '../../modules/user/entities/user.entity'; + +@ApiTags('Authentication') +@Controller('auth/sessions') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class SessionController { + constructor(private readonly authService: AuthService) {} + + @Get() + @ApiOperation({ summary: 'List all active sessions (Admin/DC Only)' }) + @ApiResponse({ status: 200, description: 'List of active sessions' }) + async getActiveSessions(@Req() req: any) { + this.checkAdminRole(req.user); + return this.authService.getActiveSessions(); + } + + @Delete(':id') + @ApiOperation({ summary: 'Revoke a session by ID (Admin/DC Only)' }) + @ApiResponse({ status: 200, description: 'Session revoked' }) + async revokeSession(@Param('id', ParseIntPipe) id: number, @Req() req: any) { + this.checkAdminRole(req.user); + await this.authService.revokeSession(id); + return { message: 'Session revoked successfully' }; + } + + private checkAdminRole(user: User) { + // Check if user has ADMIN or DC role via assignments + const hasPermission = user.assignments?.some( + (assignment) => + assignment.role.roleName === 'ADMIN' || + assignment.role.roleName === 'DC' + ); + + if (!hasPermission) { + throw new UnauthorizedException( + 'Insufficient permissions: ADMIN or DC role required' + ); + } + } +} diff --git a/backend/src/common/file-storage/entities/attachment.entity.ts b/backend/src/common/file-storage/entities/attachment.entity.ts index af6d45b..3a3586a 100644 --- a/backend/src/common/file-storage/entities/attachment.entity.ts +++ b/backend/src/common/file-storage/entities/attachment.entity.ts @@ -40,6 +40,9 @@ export class Attachment { @Column({ length: 64, nullable: true }) checksum?: string; + @Column({ name: 'reference_date', type: 'date', nullable: true }) + referenceDate?: Date; + @Column({ name: 'uploaded_by_user_id' }) uploadedByUserId!: number; diff --git a/backend/src/common/file-storage/file-storage.service.ts b/backend/src/common/file-storage/file-storage.service.ts index bfe5cf8..6ffe56b 100644 --- a/backend/src/common/file-storage/file-storage.service.ts +++ b/backend/src/common/file-storage/file-storage.service.ts @@ -81,7 +81,15 @@ export class FileStorageService { * Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent) * เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save */ - async commit(tempIds: string[]): Promise { + /** + * Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent) + * เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save + * Updated [Phase 2]: Support issueDate and documentType for organized storage + */ + async commit( + tempIds: string[], + options?: { issueDate?: Date; documentType?: string } + ): Promise { if (!tempIds || tempIds.length === 0) { return []; } @@ -100,12 +108,27 @@ export class FileStorageService { } const committedAttachments: Attachment[] = []; - const today = new Date(); - const year = today.getFullYear().toString(); - const month = (today.getMonth() + 1).toString().padStart(2, '0'); + // Use issueDate if provided, otherwise default to current date + const refDate = options?.issueDate + ? new Date(options.issueDate) + : new Date(); - // โฟลเดอร์ถาวรแยกตาม ปี/เดือน - const permanentDir = path.join(this.permanentDir, year, month); + // Validate Date (in case invalid string passed) + const effectiveDate = isNaN(refDate.getTime()) ? new Date() : refDate; + + const year = effectiveDate.getFullYear().toString(); + const month = (effectiveDate.getMonth() + 1).toString().padStart(2, '0'); + + // Construct Path: permanent/{DocumentType}/{YYYY}/{MM}/filename + const docTypeFolder = options?.documentType || 'General'; + + // โฟลเดอร์ถาวรแยกตาม Type/ปี/เดือน + const permanentDir = path.join( + this.permanentDir, + docTypeFolder, + year, + month + ); await fs.ensureDir(permanentDir); for (const att of attachments) { @@ -122,6 +145,7 @@ export class FileStorageService { att.isTemporary = false; att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable) att.expiresAt = null as any; // เคลียร์วันหมดอายุ + att.referenceDate = effectiveDate; // Save reference date committedAttachments.push(await this.attachmentRepository.save(att)); } else { diff --git a/backend/src/modules/drawing/asbuilt-drawing.service.ts b/backend/src/modules/drawing/asbuilt-drawing.service.ts index fdd6ce0..b956480 100644 --- a/backend/src/modules/drawing/asbuilt-drawing.service.ts +++ b/backend/src/modules/drawing/asbuilt-drawing.service.ts @@ -102,7 +102,8 @@ export class AsBuiltDrawingService { // 5. Commit Files if (createDto.attachmentIds?.length) { await this.fileStorageService.commit( - createDto.attachmentIds.map(String) + createDto.attachmentIds.map(String), + { issueDate: revision.revisionDate, documentType: 'AsBuiltDrawing' } ); } @@ -188,7 +189,8 @@ export class AsBuiltDrawingService { if (createDto.attachmentIds?.length) { await this.fileStorageService.commit( - createDto.attachmentIds.map(String) + createDto.attachmentIds.map(String), + { issueDate: revision.revisionDate, documentType: 'AsBuiltDrawing' } ); } diff --git a/backend/src/modules/drawing/contract-drawing.service.ts b/backend/src/modules/drawing/contract-drawing.service.ts index 0b80e66..9bd6a40 100644 --- a/backend/src/modules/drawing/contract-drawing.service.ts +++ b/backend/src/modules/drawing/contract-drawing.service.ts @@ -85,7 +85,8 @@ export class ContractDrawingService { if (createDto.attachmentIds?.length) { // ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง await this.fileStorageService.commit( - createDto.attachmentIds.map(String) + createDto.attachmentIds.map(String), + { documentType: 'ContractDrawing' } ); } @@ -213,7 +214,8 @@ export class ContractDrawingService { // Commit new files // ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง await this.fileStorageService.commit( - updateDto.attachmentIds.map(String) + updateDto.attachmentIds.map(String), + { documentType: 'ContractDrawing' } ); } diff --git a/backend/src/modules/drawing/shop-drawing.service.ts b/backend/src/modules/drawing/shop-drawing.service.ts index 7ed3d57..64fcbe8 100644 --- a/backend/src/modules/drawing/shop-drawing.service.ts +++ b/backend/src/modules/drawing/shop-drawing.service.ts @@ -101,7 +101,8 @@ export class ShopDrawingService { // 5. Commit Files if (createDto.attachmentIds?.length) { await this.fileStorageService.commit( - createDto.attachmentIds.map(String) + createDto.attachmentIds.map(String), + { issueDate: revision.revisionDate, documentType: 'ShopDrawing' } ); } @@ -188,7 +189,8 @@ export class ShopDrawingService { if (createDto.attachmentIds?.length) { await this.fileStorageService.commit( - createDto.attachmentIds.map(String) + createDto.attachmentIds.map(String), + { issueDate: revision.revisionDate, documentType: 'ShopDrawing' } ); } diff --git a/backend/src/modules/user/dto/bulk-assignment.dto.ts b/backend/src/modules/user/dto/bulk-assignment.dto.ts new file mode 100644 index 0000000..e658418 --- /dev/null +++ b/backend/src/modules/user/dto/bulk-assignment.dto.ts @@ -0,0 +1,41 @@ +import { + IsArray, + IsEnum, + IsInt, + IsOptional, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum ActionType { + ADD = 'ADD', + REMOVE = 'REMOVE', +} + +export class AssignmentActionDto { + @IsInt() + userId!: number; + + @IsEnum(ActionType) + action!: ActionType; + + // Add more fields if we need to update specific assignment properties + // For now, we assume simple Add/Remove Role logic + @IsInt() + roleId!: number; + + @IsInt() + @IsOptional() + organizationId?: number; + + @IsInt() + @IsOptional() + projectId?: number; +} + +export class BulkAssignmentDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssignmentActionDto) + assignments!: AssignmentActionDto[]; +} diff --git a/backend/src/modules/user/user-assignment.service.ts b/backend/src/modules/user/user-assignment.service.ts index 359fd09..af60902 100644 --- a/backend/src/modules/user/user-assignment.service.ts +++ b/backend/src/modules/user/user-assignment.service.ts @@ -1,25 +1,29 @@ -import { Injectable, BadRequestException } from '@nestjs/common'; +import { Injectable, BadRequestException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { UserAssignment } from './entities/user-assignment.entity'; // ต้องไปสร้าง Entity นี้ก่อน (ดูข้อ 3) +import { Repository, DataSource } from 'typeorm'; +import { UserAssignment } from './entities/user-assignment.entity'; import { AssignRoleDto } from './dto/assign-role.dto.js'; +import { BulkAssignmentDto, ActionType } from './dto/bulk-assignment.dto'; import { User } from './entities/user.entity'; @Injectable() export class UserAssignmentService { + private readonly logger = new Logger(UserAssignmentService.name); + constructor( @InjectRepository(UserAssignment) private assignmentRepo: Repository, + private dataSource: DataSource ) {} async assignRole(dto: AssignRoleDto, assigner: User) { // Validation: ตรวจสอบกฎเหล็ก (เลือกได้แค่ Scope เดียว) const scopes = [dto.organizationId, dto.projectId, dto.contractId].filter( - (v) => v != null, + (v) => v != null ); if (scopes.length > 1) { throw new BadRequestException( - 'Cannot assign multiple scopes at once. Choose one of Org, Project, or Contract.', + 'Cannot assign multiple scopes at once. Choose one of Org, Project, or Contract.' ); } @@ -35,4 +39,57 @@ export class UserAssignmentService { return this.assignmentRepo.save(assignment); } + + async bulkUpdateAssignments(dto: BulkAssignmentDto, assigner: User) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const results = []; + for (const assignmentAction of dto.assignments) { + const { userId, roleId, action, organizationId, projectId } = + assignmentAction; + + if (action === ActionType.ADD) { + // Validation (Scope) + const scopes = [organizationId, projectId].filter((v) => v != null); + if (scopes.length > 1) { + throw new BadRequestException( + `User ${userId}: Cannot assign multiple scopes.` + ); + } + + const newAssignment = queryRunner.manager.create(UserAssignment, { + userId, + roleId, + organizationId, + projectId, + assignedByUserId: assigner.user_id, + }); + results.push(await queryRunner.manager.save(newAssignment)); + } else if (action === ActionType.REMOVE) { + // Construct delete criteria + const criteria: any = { userId, roleId }; + if (organizationId) criteria.organizationId = organizationId; + if (projectId) criteria.projectId = projectId; + + await queryRunner.manager.delete(UserAssignment, criteria); + results.push({ ...criteria, status: 'removed' }); + } + } + + await queryRunner.commitTransaction(); + this.logger.log(`Bulk assignments updated by user ${assigner.user_id}`); + return results; + } catch (err) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Failed to bulk update assignments: ${(err as Error).message}` + ); + throw err; + } finally { + await queryRunner.release(); + } + } } diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index a53bb6b..68c9981 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -27,6 +27,7 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { AssignRoleDto } from './dto/assign-role.dto'; import { SearchUserDto } from './dto/search-user.dto'; import { UpdatePreferenceDto } from './dto/update-preference.dto'; +import { BulkAssignmentDto } from './dto/bulk-assignment.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { RbacGuard } from '../../common/guards/rbac.guard'; @@ -94,7 +95,7 @@ export class UserController { } @Patch('roles/:id/permissions') - @RequirePermission('permission.assign') + @RequirePermission('role.assign_permissions') @ApiOperation({ summary: 'Update role permissions' }) async updateRolePermissions( @Param('id', ParseIntPipe) id: number, @@ -159,8 +160,21 @@ export class UserController { @ApiOperation({ summary: 'Assign role to user' }) @ApiBody({ type: AssignRoleDto }) @ApiResponse({ status: 201, description: 'Role assigned' }) - @RequirePermission('permission.assign') + @RequirePermission('user.manage_assignments') assignRole(@Body() dto: AssignRoleDto, @CurrentUser() user: User) { return this.assignmentService.assignRole(dto, user); } + + @Post('assignments/bulk') + @ApiOperation({ summary: 'Bulk update user assignments' }) + @ApiBody({ type: BulkAssignmentDto }) + @ApiResponse({ status: 200, description: 'Assignments updated' }) + // @RequirePermission('user.manage_assignments') + @RequirePermission('user.manage_assignments') + bulkUpdateAssignments( + @Body() dto: BulkAssignmentDto, + @CurrentUser() user: User + ) { + return this.assignmentService.bulkUpdateAssignments(dto, user); + } } diff --git a/backend/src/scripts/migrate-storage-v2.ts b/backend/src/scripts/migrate-storage-v2.ts new file mode 100644 index 0000000..c650608 --- /dev/null +++ b/backend/src/scripts/migrate-storage-v2.ts @@ -0,0 +1,119 @@ +import { DataSource } from 'typeorm'; +import { databaseConfig } from '../config/database.config'; +import { Attachment } from '../common/file-storage/entities/attachment.entity'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +async function migrateStorage() { + // Config override for script execution if needed + const config = { ...databaseConfig, entities: [Attachment] }; + const dataSource = new DataSource(config as any); + await dataSource.initialize(); + + try { + console.log('🚀 Starting Storage Migration v2...'); + const attachmentRepo = dataSource.getRepository(Attachment); + + // Find all permanent attachments + const attachments = await attachmentRepo.find({ + where: { isTemporary: false }, + }); + console.log(`Found ${attachments.length} permanent attachments.`); + + let movedCount = 0; + let errorCount = 0; + let skippedCount = 0; + + // Define base permanent directory + // Note: Adjust path based on execution context (e.g., from backend root) + const permanentBaseDir = + process.env.UPLOAD_PERMANENT_DIR || + path.join(process.cwd(), 'uploads', 'permanent'); + + console.log(`Target Permanent Directory: ${permanentBaseDir}`); + + if (!fs.existsSync(permanentBaseDir)) { + console.warn( + `Base directory not found: ${permanentBaseDir}. Creating it...` + ); + await fs.ensureDir(permanentBaseDir); + } + + for (const att of attachments) { + if (!att.filePath) { + skippedCount++; + continue; + } + + const currentPath = att.filePath; + if (!fs.existsSync(currentPath)) { + console.warn(`File not found on disk: ${currentPath} (ID: ${att.id})`); + errorCount++; + continue; + } + + // Check if already in new structure (contains /General/YYYY/MM or similar) + const newStructureRegex = + /permanent[\/\\](ContractDrawing|ShopDrawing|AsBuiltDrawing|General)[\/\\]\d{4}[\/\\]\d{2}/; + if (newStructureRegex.test(currentPath)) { + skippedCount++; + continue; + } + + // Determine target date + const refDate = att.referenceDate + ? new Date(att.referenceDate) + : new Date(att.createdAt); + if (isNaN(refDate.getTime())) { + console.warn(`Invalid date for ID ${att.id}, skipping.`); + errorCount++; + continue; + } + + const year = refDate.getFullYear().toString(); + const month = (refDate.getMonth() + 1).toString().padStart(2, '0'); + + // Determine Doc Type (Default 'General' as we don't know easily without joins) + const docType = 'General'; + + const newDir = path.join(permanentBaseDir, docType, year, month); + const newPath = path.join(newDir, att.storedFilename); + + if (path.resolve(currentPath) === path.resolve(newPath)) { + skippedCount++; + continue; + } + + try { + await fs.ensureDir(newDir); + await fs.move(currentPath, newPath, { overwrite: true }); + + // Update DB + att.filePath = newPath; + if (!att.referenceDate) { + att.referenceDate = refDate; + } + await attachmentRepo.save(att); + movedCount++; + if (movedCount % 100 === 0) console.log(`Moved ${movedCount} files...`); + } catch (err: unknown) { + console.error( + `Failed to move file ID ${att.id}:`, + (err as Error).message + ); + errorCount++; + } + } + + console.log(`Migration completed.`); + console.log(`Moved: ${movedCount}`); + console.log(`Skipped: ${skippedCount}`); + console.log(`Errors: ${errorCount}`); + } catch (error) { + console.error('Migration failed:', error); + } finally { + await dataSource.destroy(); + } +} + +migrateStorage(); diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c3fe4dd..ddd85cf 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -63,6 +63,9 @@ ENV PORT=3000 RUN addgroup -g 1001 -S nextjs && \ adduser -S nextjs -u 1001 +# Install curl for healthcheck +RUN apk add --no-cache curl + # Copy standalone output from build COPY --from=build --chown=nextjs:nextjs /app/frontend/.next/standalone ./ COPY --from=build --chown=nextjs:nextjs /app/frontend/.next/static ./frontend/.next/static @@ -71,7 +74,7 @@ USER nextjs EXPOSE 3000 -HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=20s \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \ + CMD curl -f http://localhost:3000/ || exit 1 CMD ["node", "frontend/server.js"] diff --git a/frontend/app/(admin)/admin/organizations/page.tsx b/frontend/app/(admin)/admin/access-control/organizations/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/organizations/page.tsx rename to frontend/app/(admin)/admin/access-control/organizations/page.tsx diff --git a/frontend/app/(admin)/admin/security/roles/page.tsx b/frontend/app/(admin)/admin/access-control/roles/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/security/roles/page.tsx rename to frontend/app/(admin)/admin/access-control/roles/page.tsx diff --git a/frontend/app/(admin)/admin/users/page.tsx b/frontend/app/(admin)/admin/access-control/users/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/users/page.tsx rename to frontend/app/(admin)/admin/access-control/users/page.tsx diff --git a/frontend/app/(admin)/admin/contracts/page.tsx b/frontend/app/(admin)/admin/doc-control/contracts/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/contracts/page.tsx rename to frontend/app/(admin)/admin/doc-control/contracts/page.tsx diff --git a/frontend/app/(admin)/admin/drawings/contract/categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/contract/categories/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/drawings/contract/categories/page.tsx rename to frontend/app/(admin)/admin/doc-control/drawings/contract/categories/page.tsx diff --git a/frontend/app/(admin)/admin/drawings/contract/sub-categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/contract/sub-categories/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/drawings/contract/sub-categories/page.tsx rename to frontend/app/(admin)/admin/doc-control/drawings/contract/sub-categories/page.tsx diff --git a/frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/contract/volumes/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/drawings/contract/volumes/page.tsx rename to frontend/app/(admin)/admin/doc-control/drawings/contract/volumes/page.tsx diff --git a/frontend/app/(admin)/admin/drawings/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/drawings/page.tsx rename to frontend/app/(admin)/admin/doc-control/drawings/page.tsx diff --git a/frontend/app/(admin)/admin/drawings/shop/main-categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/shop/main-categories/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/drawings/shop/main-categories/page.tsx rename to frontend/app/(admin)/admin/doc-control/drawings/shop/main-categories/page.tsx diff --git a/frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx b/frontend/app/(admin)/admin/doc-control/drawings/shop/sub-categories/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/drawings/shop/sub-categories/page.tsx rename to frontend/app/(admin)/admin/doc-control/drawings/shop/sub-categories/page.tsx diff --git a/frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx b/frontend/app/(admin)/admin/doc-control/numbering/[id]/edit/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx rename to frontend/app/(admin)/admin/doc-control/numbering/[id]/edit/page.tsx diff --git a/frontend/app/(admin)/admin/numbering/new/page.tsx b/frontend/app/(admin)/admin/doc-control/numbering/new/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/numbering/new/page.tsx rename to frontend/app/(admin)/admin/doc-control/numbering/new/page.tsx diff --git a/frontend/app/(admin)/admin/numbering/page.tsx b/frontend/app/(admin)/admin/doc-control/numbering/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/numbering/page.tsx rename to frontend/app/(admin)/admin/doc-control/numbering/page.tsx diff --git a/frontend/app/(admin)/admin/projects/page.tsx b/frontend/app/(admin)/admin/doc-control/projects/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/projects/page.tsx rename to frontend/app/(admin)/admin/doc-control/projects/page.tsx diff --git a/frontend/app/(admin)/admin/reference/correspondence-types/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/correspondence-types/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/reference/correspondence-types/page.tsx rename to frontend/app/(admin)/admin/doc-control/reference/correspondence-types/page.tsx diff --git a/frontend/app/(admin)/admin/reference/disciplines/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/reference/disciplines/page.tsx rename to frontend/app/(admin)/admin/doc-control/reference/disciplines/page.tsx diff --git a/frontend/app/(admin)/admin/reference/drawing-categories/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/drawing-categories/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/reference/drawing-categories/page.tsx rename to frontend/app/(admin)/admin/doc-control/reference/drawing-categories/page.tsx diff --git a/frontend/app/(admin)/admin/reference/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/reference/page.tsx rename to frontend/app/(admin)/admin/doc-control/reference/page.tsx diff --git a/frontend/app/(admin)/admin/reference/rfa-types/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/reference/rfa-types/page.tsx rename to frontend/app/(admin)/admin/doc-control/reference/rfa-types/page.tsx diff --git a/frontend/app/(admin)/admin/reference/tags/page.tsx b/frontend/app/(admin)/admin/doc-control/reference/tags/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/reference/tags/page.tsx rename to frontend/app/(admin)/admin/doc-control/reference/tags/page.tsx diff --git a/frontend/app/(admin)/admin/workflows/[id]/edit/page.tsx b/frontend/app/(admin)/admin/doc-control/workflows/[id]/edit/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/workflows/[id]/edit/page.tsx rename to frontend/app/(admin)/admin/doc-control/workflows/[id]/edit/page.tsx diff --git a/frontend/app/(admin)/admin/workflows/new/page.tsx b/frontend/app/(admin)/admin/doc-control/workflows/new/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/workflows/new/page.tsx rename to frontend/app/(admin)/admin/doc-control/workflows/new/page.tsx diff --git a/frontend/app/(admin)/admin/workflows/page.tsx b/frontend/app/(admin)/admin/doc-control/workflows/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/workflows/page.tsx rename to frontend/app/(admin)/admin/doc-control/workflows/page.tsx diff --git a/frontend/app/(admin)/admin/audit-logs/page.tsx b/frontend/app/(admin)/admin/monitoring/audit-logs/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/audit-logs/page.tsx rename to frontend/app/(admin)/admin/monitoring/audit-logs/page.tsx diff --git a/frontend/app/(admin)/admin/monitoring/sessions/page.tsx b/frontend/app/(admin)/admin/monitoring/sessions/page.tsx new file mode 100644 index 0000000..2a180ec --- /dev/null +++ b/frontend/app/(admin)/admin/monitoring/sessions/page.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { sessionService } from '@/lib/services/session.service'; +import { Button } from '@/components/ui/button'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { toast } from 'sonner'; +import { Loader2, Trash2, Monitor, Smartphone } from 'lucide-react'; +import { format } from 'date-fns'; + +export default function SessionManagementPage() { + const queryClient = useQueryClient(); + + const { + data: sessions, + isLoading, + error, + } = useQuery({ + queryKey: ['sessions'], + queryFn: sessionService.getActiveSessions, + }); + + const revokeMutation = useMutation({ + mutationFn: sessionService.revokeSession, + onSuccess: () => { + toast.success('Session revoked successfully'); + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + onError: (error) => { + toast.error('Failed to revoke session'); + console.error(error); + }, + }); + + const handleRevoke = (id: number) => { + if (confirm('Are you sure you want to revoke this session?')) { + revokeMutation.mutate(id); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return
Failed to load sessions. Please try again.
; + } + + return ( +
+
+

Active Sessions

+

Monitor and manage active user sessions across all devices.

+
+ +
+ + + + User + Device Info + Last Active + Actions + + + + {sessions?.map((session: any) => ( + + +
+ {session.user.username} + + {session.user.firstName} {session.user.lastName} + +
+
+ +
+
+ + {session.deviceName || 'Unknown Device'} +
+ {session.ipAddress || 'Unknown IP'} +
+
+ + {session.lastActive ? format(new Date(session.lastActive), 'PP pp') : '-'} + + + + +
+ ))} + {(!sessions || sessions.length === 0) && ( + + + No active sessions found. + + + )} +
+
+
+
+ ); +} diff --git a/frontend/app/(admin)/admin/system-logs/numbering/page.tsx b/frontend/app/(admin)/admin/monitoring/system-logs/numbering/page.tsx similarity index 100% rename from frontend/app/(admin)/admin/system-logs/numbering/page.tsx rename to frontend/app/(admin)/admin/monitoring/system-logs/numbering/page.tsx diff --git a/frontend/app/(admin)/admin/page.tsx b/frontend/app/(admin)/admin/page.tsx index fcc17b5..212779b 100644 --- a/frontend/app/(admin)/admin/page.tsx +++ b/frontend/app/(admin)/admin/page.tsx @@ -1,21 +1,12 @@ -"use client"; +'use client'; -import { useOrganizations } from "@/hooks/use-master-data"; -import { useUsers } from "@/hooks/use-users"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Users, - Building2, - FileText, - Settings, - Shield, - Activity, - ArrowRight, - FileStack, -} from "lucide-react"; -import Link from "next/link"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Button } from "@/components/ui/button"; +import { useOrganizations } from '@/hooks/use-master-data'; +import { useUsers } from '@/hooks/use-users'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Users, Building2, FileText, Settings, Shield, Activity, ArrowRight, FileStack } from 'lucide-react'; +import Link from 'next/link'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; export default function AdminPage() { const { data: organizations, isLoading: orgsLoading } = useOrganizations(); @@ -23,66 +14,66 @@ export default function AdminPage() { const stats = [ { - title: "Total Users", + title: 'Total Users', value: users?.length || 0, icon: Users, loading: usersLoading, - href: "/admin/users", - color: "text-blue-600", + href: '/admin/access-control/users', + color: 'text-blue-600', }, { - title: "Organizations", + title: 'Organizations', value: organizations?.length || 0, icon: Building2, loading: orgsLoading, - href: "/admin/organizations", - color: "text-green-600", + href: '/admin/access-control/organizations', + color: 'text-green-600', }, { - title: "System Logs", - value: "View", - icon: Activity, - loading: false, - href: "/admin/system-logs", - color: "text-orange-600", - } + title: 'System Logs', + value: 'View', + icon: Activity, + loading: false, + href: '/admin/monitoring/system-logs', + color: 'text-orange-600', + }, ]; const quickLinks = [ { - title: "User Management", - description: "Manage system users, roles, and permissions", - href: "/admin/users", + title: 'User Management', + description: 'Manage system users, roles, and permissions', + href: '/admin/access-control/users', icon: Users, }, { - title: "Organizations", - description: "Manage project organizations and companies", - href: "/admin/organizations", + title: 'Organizations', + description: 'Manage project organizations and companies', + href: '/admin/access-control/organizations', icon: Building2, }, { - title: "Workflow Config", - description: "Configure document approval workflows", - href: "/admin/workflows", + title: 'Workflow Config', + description: 'Configure document approval workflows', + href: '/admin/doc-control/workflows', icon: FileText, }, { - title: "Security & RBAC", - description: "Configure roles, permissions, and security settings", - href: "/admin/security/roles", + title: 'Security & RBAC', + description: 'Configure roles, permissions, and security settings', + href: '/admin/access-control/roles', icon: Shield, }, { - title: "Numbering System", - description: "Setup document numbering templates", - href: "/admin/numbering", + title: 'Numbering System', + description: 'Setup document numbering templates', + href: '/admin/doc-control/numbering', icon: Settings, }, { - title: "Drawing Master Data", - description: "Manage drawing categories, volumes, and classifications", - href: "/admin/drawings", + title: 'Drawing Master Data', + description: 'Manage drawing categories, volumes, and classifications', + href: '/admin/doc-control/drawings', icon: FileStack, }, ]; @@ -91,18 +82,14 @@ export default function AdminPage() {

Admin Dashboard

-

- System overview and quick access to administrative functions. -

+

System overview and quick access to administrative functions.

{stats.map((stat, index) => ( - - {stat.title} - + {stat.title} @@ -112,10 +99,7 @@ export default function AdminPage() {
{stat.value}
)} {stat.href && ( - + View details )} @@ -137,9 +121,7 @@ export default function AdminPage() { -

- {link.description} -

+

{link.description}

diff --git a/frontend/app/(admin)/admin/security/sessions/page.tsx b/frontend/app/(admin)/admin/security/sessions/page.tsx deleted file mode 100644 index 9a72167..0000000 --- a/frontend/app/(admin)/admin/security/sessions/page.tsx +++ /dev/null @@ -1,131 +0,0 @@ -"use client"; - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import apiClient from "@/lib/api/client"; -import { DataTable } from "@/components/common/data-table"; -import { ColumnDef } from "@tanstack/react-table"; -import { Button } from "@/components/ui/button"; -import { LogOut, Monitor, Smartphone, RefreshCw } from "lucide-react"; -import { format } from "date-fns"; -import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; - -interface Session { - id: string; - userId: number; - user: { - username: string; - firstName: string; - lastName: string; - }; - deviceName: string; // e.g., "Chrome on Windows" - ipAddress: string; - lastActive: string; - isCurrent: boolean; -} - -const sessionService = { - getAll: async () => { - const response = await apiClient.get("/auth/sessions"); - return response.data.data || response.data; - }, - revoke: async (sessionId: string) => (await apiClient.delete(`/auth/sessions/${sessionId}`)).data, -}; - -export default function SessionsPage() { - const queryClient = useQueryClient(); - - const { data: sessions = [], isLoading } = useQuery({ - queryKey: ["sessions"], - queryFn: sessionService.getAll, - }); - - const revokeMutation = useMutation({ - mutationFn: sessionService.revoke, - onSuccess: () => { - toast.success("Session revoked successfully"); - queryClient.invalidateQueries({ queryKey: ["sessions"] }); - }, - onError: () => toast.error("Failed to revoke session"), - }); - - const columns: ColumnDef[] = [ - { - accessorKey: "user", - header: "User", - cell: ({ row }) => { - const user = row.original.user; - return ( -
- {user.username} - - {user.firstName} {user.lastName} - -
- ); - }, - }, - { - accessorKey: "deviceName", - header: "Device / IP", - cell: ({ row }) => ( -
- {row.original.deviceName.toLowerCase().includes("mobile") ? ( - - ) : ( - - )} -
- {row.original.deviceName} - {row.original.ipAddress} -
-
- ), - }, - { - accessorKey: "lastActive", - header: "Last Active", - cell: ({ row }) => format(new Date(row.original.lastActive), "dd MMM yyyy, HH:mm"), - }, - { - id: "status", - header: "Status", - cell: ({ row }) => - row.original.isCurrent ? Current : Active, - }, - { - id: "actions", - cell: ({ row }) => ( - - ), - }, - ]; - - if (isLoading) { - return ( -
- -
- ); - } - - return ( -
-
-
-

Active Sessions

-

Manage user sessions and force logout if needed

-
-
- -
- ); -} diff --git a/frontend/app/(admin)/layout.tsx b/frontend/app/(admin)/layout.tsx index 2f5abfc..c0de9f1 100644 --- a/frontend/app/(admin)/layout.tsx +++ b/frontend/app/(admin)/layout.tsx @@ -1,28 +1,24 @@ -import { AdminSidebar } from "@/components/admin/sidebar"; +import { AdminSidebar } from '@/components/admin/sidebar'; -import { auth } from "@/lib/auth"; +import { auth } from '@/lib/auth'; +import { redirect } from 'next/navigation'; -export default async function AdminLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await auth(); - // Temporary bypass for UI testing - const isAdmin = true; // session?.user?.role === 'ADMIN'; + // Validate Admin or DC role + const userRole = session?.user?.role; + const isAdmin = userRole === 'ADMIN' || userRole === 'DC'; if (!session || !isAdmin) { - // redirect("/"); + redirect('/dashboard'); // Redirect unauthorized users to dashboard } return (
-
- {children} -
+
{children}
); } diff --git a/frontend/components/admin/sidebar.tsx b/frontend/components/admin/sidebar.tsx index ef1dcfb..d55adc4 100644 --- a/frontend/components/admin/sidebar.tsx +++ b/frontend/components/admin/sidebar.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client'; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useState } from "react"; -import { cn } from "@/lib/utils"; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; import { Users, Building2, @@ -16,7 +16,7 @@ import { FileStack, ChevronDown, ChevronRight, -} from "lucide-react"; +} from 'lucide-react'; interface MenuItem { href?: string; @@ -26,29 +26,47 @@ interface MenuItem { } const menuItems: MenuItem[] = [ - { href: "/admin/users", label: "Users", icon: Users }, - { href: "/admin/organizations", label: "Organizations", icon: Building2 }, - { href: "/admin/projects", label: "Projects", icon: FileText }, - { href: "/admin/contracts", label: "Contracts", icon: FileText }, - { href: "/admin/reference", label: "Reference Data", icon: BookOpen }, { - label: "Drawing Master Data", + label: 'Access Control', + icon: Shield, + children: [ + { href: '/admin/access-control/users', label: 'Users' }, + { href: '/admin/access-control/roles', label: 'Roles' }, + { href: '/admin/access-control/organizations', label: 'Organizations' }, + ], + }, + { + label: 'Document Control', icon: FileStack, children: [ - { href: "/admin/drawings/contract/volumes", label: "Contract: Volumes" }, - { href: "/admin/drawings/contract/categories", label: "Contract: Categories" }, - { href: "/admin/drawings/contract/sub-categories", label: "Contract: Sub-categories" }, - { href: "/admin/drawings/shop/main-categories", label: "Shop: Main Categories" }, - { href: "/admin/drawings/shop/sub-categories", label: "Shop: Sub-categories" }, - ] + { href: '/admin/doc-control/projects', label: 'Projects' }, + { href: '/admin/doc-control/contracts', label: 'Contracts' }, + { href: '/admin/doc-control/numbering', label: 'Numbering' }, + { href: '/admin/doc-control/reference', label: 'Reference Data' }, + { href: '/admin/doc-control/workflows', label: 'Workflows' }, + ], }, - { href: "/admin/numbering", label: "Numbering", icon: FileText }, - { href: "/admin/workflows", label: "Workflows", icon: GitGraph }, - { href: "/admin/security/roles", label: "Security Roles", icon: Shield }, - { href: "/admin/security/sessions", label: "Active Sessions", icon: Users }, - { href: "/admin/system-logs/numbering", label: "System Logs", icon: Activity }, - { href: "/admin/audit-logs", label: "Audit Logs", icon: Activity }, - { href: "/admin/settings", label: "Settings", icon: Settings }, + { + label: 'Drawing Master', + icon: FileStack, // Or another icon + children: [ + { href: '/admin/doc-control/drawings/contract/volumes', label: 'Contract: Volumes' }, + { href: '/admin/doc-control/drawings/contract/categories', label: 'Contract: Categories' }, + { href: '/admin/doc-control/drawings/contract/sub-categories', label: 'Contract: Sub-categories' }, + { href: '/admin/doc-control/drawings/shop/main-categories', label: 'Shop: Main Categories' }, + { href: '/admin/doc-control/drawings/shop/sub-categories', label: 'Shop: Sub-categories' }, + ], + }, + { + label: 'Monitoring', + icon: Activity, + children: [ + { href: '/admin/monitoring/audit-logs', label: 'Audit Logs' }, + { href: '/admin/monitoring/system-logs/numbering', label: 'System Logs' }, + { href: '/admin/monitoring/sessions', label: 'Active Sessions' }, + ], + }, + { href: '/admin/settings', label: 'Settings', icon: Settings }, ]; export function AdminSidebar() { @@ -56,23 +74,19 @@ export function AdminSidebar() { const [expandedMenus, setExpandedMenus] = useState( // Auto-expand if current path matches a child menuItems - .filter(item => item.children?.some(child => pathname.startsWith(child.href))) - .map(item => item.label) + .filter((item) => item.children?.some((child) => pathname.startsWith(child.href))) + .map((item) => item.label) ); const toggleMenu = (label: string) => { - setExpandedMenus(prev => - prev.includes(label) - ? prev.filter(l => l !== label) - : [...prev, label] - ); + setExpandedMenus((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label])); }; return ( ); diff --git a/frontend/components/documents/common/server-data-table.tsx b/frontend/components/documents/common/server-data-table.tsx new file mode 100644 index 0000000..e334ef6 --- /dev/null +++ b/frontend/components/documents/common/server-data-table.tsx @@ -0,0 +1,173 @@ +'use client'; + +import * as React from 'react'; +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + PaginationState, + SortingState, + getPaginationRowModel, + OnChangeFn, +} from '@tanstack/react-table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; + +interface ServerDataTableProps { + columns: ColumnDef[]; + data: TData[]; + pageCount: number; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + sorting: SortingState; + onSortingChange: OnChangeFn; + isLoading?: boolean; +} + +export function ServerDataTable({ + columns, + data, + pageCount, + pagination, + onPaginationChange, + sorting, + onSortingChange, + isLoading, +}: ServerDataTableProps) { + const table = useReactTable({ + data, + columns, + pageCount, + state: { + pagination, + sorting, + }, + onPaginationChange, + onSortingChange, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {isLoading ? ( + + + Loading... + + + ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+
+ {table.getFilteredSelectedRowModel && table.getFilteredSelectedRowModel().rows.length > 0 && ( + <> + {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) + selected. + + )} +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/frontend/components/drawings/columns.tsx b/frontend/components/drawings/columns.tsx new file mode 100644 index 0000000..80d5b90 --- /dev/null +++ b/frontend/components/drawings/columns.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { ColumnDef } from '@tanstack/react-table'; +import { Drawing } from '@/types/drawing'; +import { Button } from '@/components/ui/button'; +import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +export const columns: ColumnDef[] = [ + { + accessorKey: 'drawingNumber', + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: 'title', + header: 'Title', + }, + { + accessorKey: 'revision', + header: 'Revision', + cell: ({ row }) => row.original.revision || '-', + }, + { + accessorKey: 'legacyDrawingNumber', + header: 'Legacy No.', + cell: ({ row }) => row.original.legacyDrawingNumber || '-', + }, + { + accessorKey: 'updatedAt', + header: 'Last Updated', + cell: ({ row }) => { + const date = new Date(row.original.updatedAt || ''); + return isNaN(date.getTime()) ? '-' : date.toLocaleDateString(); + }, + }, + { + id: 'actions', + cell: ({ row }) => { + const drawing = row.original; + + return ( + + + + + + Actions + navigator.clipboard.writeText(drawing.drawingNumber)}> + Copy Drawing No. + + + View Details + {/* Add download/view functionality later */} + + + ); + }, + }, +]; diff --git a/frontend/components/drawings/list.tsx b/frontend/components/drawings/list.tsx index cc043e8..f88adf5 100644 --- a/frontend/components/drawings/list.tsx +++ b/frontend/components/drawings/list.tsx @@ -1,58 +1,60 @@ -"use client"; +'use client'; -import { DrawingCard } from "@/components/drawings/card"; -import { useDrawings } from "@/hooks/use-drawing"; -import { Drawing } from "@/types/drawing"; -import { Loader2 } from "lucide-react"; +import { DrawingCard } from '@/components/drawings/card'; +import { useDrawings } from '@/hooks/use-drawing'; +import { Drawing } from '@/types/drawing'; +import { Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { PaginationState, SortingState } from '@tanstack/react-table'; +import { ServerDataTable } from '@/components/documents/common/server-data-table'; +import { columns } from './columns'; -import { SearchContractDrawingDto } from "@/types/dto/drawing/contract-drawing.dto"; -import { SearchShopDrawingDto } from "@/types/dto/drawing/shop-drawing.dto"; -import { SearchAsBuiltDrawingDto } from "@/types/dto/drawing/asbuilt-drawing.dto"; +import { SearchContractDrawingDto } from '@/types/dto/drawing/contract-drawing.dto'; +import { SearchShopDrawingDto } from '@/types/dto/drawing/shop-drawing.dto'; +import { SearchAsBuiltDrawingDto } from '@/types/dto/drawing/asbuilt-drawing.dto'; type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto; interface DrawingListProps { - type: "CONTRACT" | "SHOP" | "AS_BUILT"; + type: 'CONTRACT' | 'SHOP' | 'AS_BUILT'; projectId: number; filters?: Partial; } export function DrawingList({ type, projectId, filters }: DrawingListProps) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }); + const [sorting, setSorting] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: drawings, isLoading, isError } = useDrawings(type, { projectId, ...filters } as any); + const { + data: response, + isLoading, + isError, + } = useDrawings(type, { + projectId, + ...filters, + page: pagination.pageIndex + 1, // API is 1-based + pageSize: pagination.pageSize, + } as any); - // Note: The hook handles switching services based on type. - // The params { type } might be redundant if getAll doesn't use it, but safe to pass. - - if (isLoading) { - return ( -
- -
- ); - } - - if (isError) { - return ( -
- Failed to load drawings. -
- ); - } - - if (!drawings?.data || drawings.data.length === 0) { - return ( -
- No drawings found. -
- ); - } + const drawings = response?.data || []; + const meta = response?.meta || { total: 0, page: 1, limit: 20, totalPages: 0 }; return ( -
- {drawings.data.map((drawing: Drawing) => ( - - ))} +
+
); } diff --git a/frontend/lib/services/session.service.ts b/frontend/lib/services/session.service.ts new file mode 100644 index 0000000..b3a2ce5 --- /dev/null +++ b/frontend/lib/services/session.service.ts @@ -0,0 +1,27 @@ +import apiClient from '@/lib/api/client'; + +export interface Session { + id: string; // tokenId + userId: number; + user: { + username: string; + firstName: string; + lastName: string; + }; + deviceName: string; + ipAddress: string; + lastActive: string; + isCurrent: boolean; +} + +export const sessionService = { + getActiveSessions: async () => { + const response = await apiClient.get('/auth/sessions'); + return response.data.data || response.data; + }, + + revokeSession: async (sessionId: number) => { + const response = await apiClient.delete(`/auth/sessions/${sessionId}`); + return response.data; + }, +}; diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 9feb39d..21781fa 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,17 +1,17 @@ // File: middleware.ts -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; -import { auth } from "@/lib/auth"; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { auth } from '@/lib/auth'; // รายการ Route ที่ไม่ต้อง Login ก็เข้าได้ (Public Routes) -const publicRoutes = ["/login", "/register", "/"]; +const publicRoutes = ['/login', '/register', '/']; export default auth((req) => { const isLoggedIn = !!req.auth; const { nextUrl } = req; - + const isPublicRoute = publicRoutes.includes(nextUrl.pathname); - const isAuthRoute = nextUrl.pathname.startsWith("/api/auth"); + const isAuthRoute = nextUrl.pathname.startsWith('/api/auth'); // 1. ถ้าเป็น API Auth routes ให้ผ่านไปเลย if (isAuthRoute) { @@ -19,8 +19,8 @@ export default auth((req) => { } // 2. ถ้า Login อยู่แล้ว แต่พยายามเข้าหน้า Login -> ให้ไป Dashboard - if (isLoggedIn && nextUrl.pathname === "/login") { - return Response.redirect(new URL("/dashboard", nextUrl)); + if (isLoggedIn && nextUrl.pathname === '/login') { + return Response.redirect(new URL('/dashboard', nextUrl)); } // 3. ถ้ายังไม่ Login และพยายามเข้า Private Route -> ให้ไป Login @@ -30,11 +30,19 @@ export default auth((req) => { if (nextUrl.search) { callbackUrl += nextUrl.search; } - + const encodedCallbackUrl = encodeURIComponent(callbackUrl); return Response.redirect(new URL(`/login?callbackUrl=${encodedCallbackUrl}`, nextUrl)); } + // 4. Protect Admin Routes (Security Phase 1) + if (nextUrl.pathname.startsWith('/admin')) { + const userRole = req.auth?.user?.role as string | undefined; + if (userRole !== 'ADMIN' && userRole !== 'DC') { + return Response.redirect(new URL('/dashboard', nextUrl)); + } + } + return NextResponse.next(); // แก้ไขจาก null }); @@ -51,4 +59,4 @@ export const config = { */ '/((?!api|_next/static|_next/image|favicon.ico|images).*)', ], -}; \ No newline at end of file +}; diff --git a/specs/06-tasks/TASK-BEFE-001-Refactor-260218.md b/specs/06-tasks/TASK-BEFE-001-Refactor-260218.md new file mode 100644 index 0000000..9a1e2e7 --- /dev/null +++ b/specs/06-tasks/TASK-BEFE-001-Refactor-260218.md @@ -0,0 +1,136 @@ +# TASK-BEFE-001: System Refactoring for Scale & Security (v2.0) + +> **Status:** REVIEW +> **Priority:** HIGH +> **Target Version:** v2.0.0 +> **Effort:** 4 Weeks (Phased) + +--- + +## 🎯 Objective + +Refactor the DMS system (Backend & Frontend) to support **High Scalability (100k+ Documents)**, **Enhanced Security (RBAC/Audit)**, and **Enterprise-Grade UX**. This task consolidates three key initiatives: +1. **Advanced Storage Management:** Optimize file storage for large datasets (Data Integrity). +2. **Admin Panel Refactor:** Secure and reorganize the administrative interface. +3. **Document Management Interface:** Improve frontend performance and usability for large document lists. + +--- + +## 📅 Roadmap & Phases + +| Phase | Focus Area | Key Deliverables | +| :---------- | :--------------------------- | :---------------------------------------------------------------- | +| **Phase 1** | **Security & Core Fixes** | Admin Bypass Removal, Session Kill Switch, Storage Permissions | +| **Phase 2** | **Data Integrity & Storage** | New Storage Logic (Issue Date), Schema Adjustments, Bulk RBAC API | +| **Phase 3** | **Frontend Foundation** | Server-side DataTable, New Folder Structure, API Optimization | +| **Phase 4** | **UX & Migration** | Admin UI Reorg, Document Tabs, Legacy Data Migration | + +--- + +## 🛠️ Implementation Checklist + +### 1. Advanced Storage Management (Backend) + +**Goal:** Shift from "Upload Date" to "Issue Date" storage logic and implement deep directory structures for performance. + +#### 1.1 Database Schema (Data Integrity) +- [ ] **Verify Date Columns:** Ensure `rfa`, `correspondence`, `drawing_revisions` have a reliable `issue_date` or `document_date`. +- [ ] **Update Attachments Table:** Add `reference_date` column to `attachments` to freeze the storage path date (prevents broken paths if document date changes). + +#### 1.2 FileStorageService Refactor +- [ ] **Update `commit()` Logic:** Change storage path generation logic. + - *Old:* `/permanent/YYYY/MM/uuid.pdf` (based on execution time) + - *New:* `/permanent/{DocumentType}/{YYYY}/{MM}/{uuid}.pdf` (based on `issue_date`) +- [ ] **Fail-safe Logic:** Implement fallback to `created_at` if `issue_date` is missing. + +#### 1.3 Infrastructure & Security +- [ ] **Deep Directory Structure:** Implement logic to handle nested folders to verify Inode limits. +- [ ] **Path Isolation:** Ensure Web Server (NestJS) has `ReadOnly` access to `permanent` storage, `Write` only for specific services. +- [ ] **Streaming Proxy:** Enforce file access via API Stream only (Check RBAC -> Stream File), never expose direct static paths. + +#### 1.4 Data Migration (Legacy Support) +- [ ] **Develop Migration Script:** + 1. Scan `attachments` where `is_temporary = false`. + 2. Retrieve `issue_date` from parent entity. + 3. Move file to new structure. + 4. Update `stored_path` in DB. + +--- + +### 2. Admin Panel Refactor (Frontend & Backend) + +**Goal:** Secure the Admin Panel and reorganize the UI for better usability. + +#### 2.1 Critical Security Fixes (Immediate) +- [ ] **Remove Hardcoded Bypass:** Delete `const isAdmin = true;` in `frontend/app/(admin)/layout.tsx`. Validate `session.user.role` from JWT. +- [ ] **Middleware Enforcement:** Update `frontend/middleware.ts` to strictly require `ADMIN` or `DC` roles for `/admin/**` routes. +- [ ] **Session Kill Switch:** Implement Backend endpoint and Frontend UI to revoke active user sessions. + +#### 2.2 Backend Optimization +- [ ] **Bulk RBAC Update:** Create `PUT /roles/permissions/bulk` endpoint to handle multiple permission changes in a single transaction (Fixes Loop API issue). +- [ ] **Audit Log Pagination:** Update `AuditLogService` to support Server-side Pagination (`page`, `limit`, `filters`). + +#### 2.3 Frontend Reorganization (UI/UX) +- [ ] **Refactor Folder Structure:** Group admin pages logically: + - `/admin/access-control/` (Users, Roles, Sessions) + - `/admin/doc-control/` (Numbering, Workflows, Master Data) + - `/admin/monitoring/` (Audit Logs, Health) + - `/admin/settings/` +- [ ] **Shared Components:** Implement `AdminPageHeader` and `AdminDataTable` for consistency. + +--- + +### 3. Document Management Interface (Frontend) + +**Goal:** Support browsing 100k+ documents with high performance and better UX. + +#### 3.1 Performance (Server-Side Logic) +- [ ] **Update Hooks:** Refactor `useDrawings` (and others) to accept `page`, `limit`, `sort`, `filter` params. +- [ ] **ServerDataTable Component:** Create a reusable Table component that handles Server-side pagination and sorting events efficiently. + +#### 3.2 UI Structure & Navigation +- [ ] **Tabbed Interface:** Split documents by category (e.g., Contract / Shop / As-Built) using Tabs to load data lazily. +- [ ] **Visual Cues:** Add distinct Badges for Revision Status (e.g., "Current" vs "Superseded"). + +#### 3.3 Data Integrity Features +- [ ] **Pre-upload Validation:** Implement `NumberPreviewCard` to check Document Number availability in real-time before submission. +- [ ] **Revision Guard:** Validate `nextPossibleRevision` to prevent skipping revisions (e.g., A -> C). + +--- + +## 📂 Technical Guidelines + +### Backend: Bulk Permission DTO +```typescript +export class BulkRolePermissionDto { + @IsNumber() + roleId: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PermissionChangeDto) + changes: PermissionChangeDto[]; +} +``` + +### Frontend: Sidebar Navigation Structure +```typescript +const adminMenu = [ + { title: "Overview", items: [{ title: "Dashboard", href: "/admin/dashboard" }] }, + { title: "Access Control", items: [ + { title: "Users", href: "/admin/access-control/users" }, + { title: "Roles & Matrix", href: "/admin/access-control/roles" } + ] + }, + // ... +]; +``` + +--- + +## ✅ Acceptance Criteria + +1. **Security:** Non-admin users MUST NOT access any `/admin` route. +2. **Performance:** Document lists with 100k records must load first page in < 200ms. +3. **Data Integrity:** Files are stored in structure `/permanent/{Type}/{Year}/{Month}/`. +4. **Reliability:** Bulk Permission updates are atomic (all or nothing). \ No newline at end of file diff --git a/specs/07-database/deltas/01-add-reference-date.sql b/specs/07-database/deltas/01-add-reference-date.sql new file mode 100644 index 0000000..98ba1b4 --- /dev/null +++ b/specs/07-database/deltas/01-add-reference-date.sql @@ -0,0 +1,4 @@ +ALTER TABLE `attachments` +ADD COLUMN `reference_date` DATE NULL COMMENT 'Date used for folder structure (e.g. Issue Date) to prevent broken paths'; +ALTER TABLE `attachments` +ADD INDEX `idx_attachments_reference_date` (`reference_date`); \ No newline at end of file diff --git a/specs/07-database/deltas/02-add-rbac-bulk-permission.sql b/specs/07-database/deltas/02-add-rbac-bulk-permission.sql new file mode 100644 index 0000000..597c445 --- /dev/null +++ b/specs/07-database/deltas/02-add-rbac-bulk-permission.sql @@ -0,0 +1,12 @@ +-- Add permission for Bulk RBAC Update +-- Fix: Use existing 'user.manage_assignments' instead of creating new invalid permission +-- This permission (ID 25) is for assigning roles/projects +-- Grant to ADMIN (ID 2) and DC (ID 3) roles if not already present +-- Use INSERT IGNORE to avoid duplicates +INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`) +SELECT r.role_id, + p.permission_id +FROM `roles` r, + `permissions` p +WHERE r.role_name IN ('ADMIN', 'Org Admin', 'DC', 'Document Control') + AND p.permission_name = 'user.manage_assignments'; \ No newline at end of file diff --git a/specs/08-infrastructure/11_Chat.md b/specs/08-infrastructure/11_Chat.md new file mode 100644 index 0000000..f28559a --- /dev/null +++ b/specs/08-infrastructure/11_Chat.md @@ -0,0 +1,224 @@ +# การติดตั้ง Rocket.Chat บน QNAP + +> 📍 **Version:** v1.0.0 (Chat Service) +> 🖥️ **Server:** QNAP TS-473A (Container Station) +> 🔗 **Docker Compose Path:** `/share/np-dms/rocketchat/docker-compose.yml` +> 🌐 **Domain:** `chat.np-dms.work` + +--- + +## 📋 Prerequisites + +ก่อนติดตั้ง ต้องมั่นใจว่า: +1. **Docker Network** `lcbp3` ถูกสร้างแล้ว (ตรวจสอบด้วย `docker network ls`) +2. **Nginx Proxy Manager (NPM)** รันอยู่เพื่อจัดการ SSL และ Domain + +--- + +## 1. เตรียม Directories + +สร้าง folder สำหรับเก็บข้อมูลเพื่อให้ข้อมูลไม่หายเมื่อลบ container: + +```bash +# SSH เข้า QNAP +ssh admin@192.168.10.8 + +# สร้าง directories +mkdir -p /share/np-dms/rocketchat/uploads +mkdir -p /share/np-dms/rocketchat/data/db +mkdir -p /share/np-dms/rocketchat/data/dump + +# Permissions: +# MongoDB ใน Docker ปกติใช้ uid 999 หรือ root, Rocket.Chat ใช้ uid 1000 หรือ root +# การสร้าง folder ผ่าน ssh admin ปกติจะเป็น admin:administrators +``` + +--- + +## 2. Docker Compose Configuration + +สร้างไฟล์ `docker-compose.yml` ที่ `/share/np-dms/rocketchat/docker-compose.yml`: + +```yml +x-restart: &restart_policy + restart: unless-stopped + +x-logging: &default_logging + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + +services: + mongodb: + <<: [*restart_policy, *default_logging] + image: docker.io/library/mongo:7.0 + container_name: mongodb + command: mongod --oplogSize 128 --replSet rs0 --bind_ip_all + volumes: + - /share/np-dms/rocketchat/data/db:/data/db + - /share/np-dms/rocketchat/data/dump:/dump + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + networks: + - lcbp3 + + # Service สำหรับ Init Replica Set อัตโนมัติ (รันแล้วจบ) + mongo-init-replica: + image: docker.io/library/mongo:7.0 + command: > + bash -c "for i in `seq 1 30`; do + mongosh --host mongodb --eval 'rs.initiate({ _id: \"rs0\", members: [ { _id: 0, host: \"mongodb:27017\" } ] })' && break; + sleep 1; + done" + depends_on: + - mongodb + networks: + - lcbp3 + + rocketchat: + <<: [*restart_policy, *default_logging] + image: registry.rocket.chat/rocketchat/rocket.chat:latest + container_name: rocketchat + volumes: + - /share/np-dms/rocketchat/uploads:/app/uploads + environment: + - PORT=3000 + - ROOT_URL=https://chat.np-dms.work + - MONGO_URL=mongodb://mongodb:27017/rocketchat?replicaSet=rs0 + - MONGO_OPLOG_URL=mongodb://mongodb:27017/local?replicaSet=rs0 + - DEPLOY_METHOD=docker + - ACCOUNTS_AVATAR_STORE_PATH=/app/uploads + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + depends_on: + - mongodb + networks: + - lcbp3 + expose: + - "3000" + +networks: + lcbp3: + external: true +``` + +> **📝 Note:** +> - **MongoDB Replica Set (`rs0`):** จำเป็นสำหรับ Rocket.Chat เพื่อใช้ Oplog +> - **Expose:** เราเปิด port 3000 ภายใน network `lcbp3` เท่านั้น ไม่ expose ออก host โดยตรง เพื่อความปลอดภัยและให้ผ่าน NPM +> - **Resources:** กำหนด CPU/Memory Limit เพื่อป้องกันไม่ให้กินทรัพยากรเครื่อง QNAP มากเกินไป + +--- + +## 3. Deployment + +1. ไปที่ **Container Station** บน QNAP +2. เลือกเมนู **Applications** -> **Create** +3. ตั้งชื่อ Application: `lcbp3-chat` +4. วาง Code จาก `docker-compose.yml` ด้านบนลงไป +5. Check Env Variable Validations +6. กด **Create** + +--- + +## 4. Nginx Proxy Manager (NPM) Setup + +ต้องตั้งค่า Proxy Host ที่ NPM เพื่อให้เข้าใช้งานผ่าน `https://chat.np-dms.work` ได้ + +1. Login **NPM Admin** (`https://npm.np-dms.work`) +2. ไปที่ **Hosts** -> **Proxy Hosts** -> **Add Proxy Host** +3. **Details Tab:** + * **Domain Names:** `chat.np-dms.work` + * **Scheme:** `http` + * **Forward Hostname:** `rocketchat` (ชื่อ service ใน docker-compose) + * **Forward Port:** `3000` + * **Cache Assets:** ✅ + * **Block Common Exploits:** ✅ + * **Websockets Support:** ✅ (⚠️ สำคัญมากสำหรับ Real-time chat) +4. **SSL Tab:** + * **SSL Certificate:** Request a new SSL Certificate (Let's Encrypt) หรือใช้ Wildcard เดิมที่มี + * **Force SSL:** ✅ + * **HTTP/2 Support:** ✅ +5. กด **Save** + +--- + +## 5. Verification + +1. เปิด Browser เข้าไปที่ `https://chat.np-dms.work` +2. จะพบหน้า **Setup Wizard** +3. กรอกข้อมูล Admin และ Organization เพื่อเริ่มใช้งาน + +--- + +## 6. Configuration & Initial Setup + +### 6.1 Setup Wizard (First Run) +เมื่อเข้าสู่ระบบครั้งแรก จะพบกับ **Setup Wizard** ให้กรอกข้อมูลดังนี้: + +1. **Admin Info:** + * **Name:** Administrator (หรือชื่อผู้ดูแลระบบ) + * **Username:** `admin` (แนะนำให้เปลี่ยนเพื่อความปลอดภัย) + * **Email:** `admin@np-dms.work` + * **Password:** (ตั้งรหัสผ่านที่ซับซ้อนและบันทึกใน Secrets Management) + +2. **Organization Info:** + * **Organization Type:** Government / Public Sector + * **Organization Name:** Laem Chabang Port Phase 3 + * **Industry:** Construction / Infrastructure + * **Size:** 51-100 (หรือตามจริง) + * **Country:** Thailand + +3. **Server Info:** + * **Site Name:** LCBP3 DMS Chat + * **Language:** English / Thai + * **Server Type:** Private Team + * **2FA:** แนะนำให้เปิด Two Factor Authentication (Optional) + +4. **Register Server:** + * **Standalone:** เลือก "Keep standalone" หากต้องการความเป็นส่วนตัวสูงสุด (Privacy-first / Air-gapped) + * **Registered:** หากต้องการใช้ Mobile App Push Notification Gateway ของ Rocket.Chat (ฟรีจำกัดจำนวน) + +> **💡 Tip:** หากเลือก Standalone จะไม่มี Push Notification ไปยังมือถือ (iOS/Android) แต่ยังใช้งานผ่าน Browser และ Desktop App ได้ปกติ + +### 6.2 Post-Installation Settings +หลังจาก Setup เสร็จสิ้น แนะนำให้ตรวจสอบค่าเหล่านี้ใน **Administration**: + +1. **General > Site URL:** ต้องเป็น `https://chat.np-dms.work` +2. **General > Force SSL:** ต้องเป็น `True` +3. **File Upload > File Upload:** เปิดใช้งาน +4. **File Upload > Max File Size:** ปรับตามนโยบาย (Default 2MB อาจน้อยไป แนะนำ 50MB+) + * *หมายเหตุ: ต้องสัมพันธ์กับ `client_max_body_size` ใน NPM ด้วย* + +--- + +## 7. Maintenance + +### Backup Strategy +ข้อมูลสำคัญจะอยู่ที่ Path บน QNAP: +* `/share/np-dms/rocketchat/data/db` (Database) +* `/share/np-dms/rocketchat/uploads` (Files) + +ระบบ Backup (Restic บน ASUSTOR) ควรสั่ง backup folder `/share/np-dms/` ทั้งหมดอยู่แล้ว + +### Troubleshooting +หากเข้าเว็บไม่ได้ หรือขึ้น 502 Bad Gateway: +1. เช็ค Logs Rocket.Chat: `docker logs -f rocketchat` +2. เช็ค Logs MongoDB: `docker logs -f mongodb` (ดูว่า Replica Set init หรือยัง) +3. เช็ค NPM: มั่นใจว่า Forward Hostname ถูกต้อง (`rocketchat` ต้องอยู่ใน network เดียวกันคือ `lcbp3`) + +--- + +## 📦 Resource Summary + +| Service | Image | CPU Limit | Memory Limit | Port | +| :------------- | :-------------------------------------------- | :-------- | :----------- | :---- | +| **mongodb** | `mongo:7.0` | 1.0 | 1 GB | 27017 | +| **rocketchat** | `registry.rocket.chat/rocketchat/rocket.chat` | 1.0 | 1 GB | 3000 | \ No newline at end of file diff --git a/specs/08-infrastructure/NPM_setting.md b/specs/08-infrastructure/NPM_setting.md index 83a8a4b..c6f2905 100644 --- a/specs/08-infrastructure/NPM_setting.md +++ b/specs/08-infrastructure/NPM_setting.md @@ -26,6 +26,7 @@ setfacl -R -m u:0:rwx /share/Container/npm | db.np-dms.work | mariadb | 3306 | [x] | [x] | [x] | [x] | [x] | [ ] | | git.np-dms.work | gitea | 3000 | [x] | [x] | [x] | [x] | [x] | [ ] | | n8n.np-dms.work | n8n | 5678 | [x] | [x] | [x] | [x] | [x] | [ ] | +| chat.np-dms.work | rocketchat | 3000 | [x] | [x] | [x] | [x] | [x] | [ ] | | npm.np-dms.work | npm | 81 | [ ] | [x] | [x] | [x] | [x] | [ ] | | pma.np-dms.work | pma | 80 | [x] | [x] | [ ] | [x] | [x] | [ ] | | np-dms.work, [www.np-dms.work] | localhost | 80 | [x] | [x] | [ ] | [x] | [x] | [ ] | diff --git a/specs/08-infrastructure/README.md b/specs/08-infrastructure/README.md index 4712299..6c95013 100644 --- a/specs/08-infrastructure/README.md +++ b/specs/08-infrastructure/README.md @@ -48,6 +48,7 @@ | **API Gateway** | NPM (Nginx Proxy Manager) | SSL Termination | 1.0 CPU / 512MB RAM | | **Workflow** | n8n | Automation | 1.0 CPU / 1GB RAM | | **Code** | Gitea | Git Repository | 1.0 CPU / 1GB RAM | +| **Chat** | Rocket.Chat | **Standalone + MongoDB RS** | 2.0 CPU / 2GB RAM | #### ASUSTOR AS5403T (Infrastructure Stack) | Category | Service | Notes | @@ -208,14 +209,15 @@ graph TB ### Application Domains (QNAP) -| Domain | Service | Port | Host | Description | -| :-------------------- | :------- | :--- | :--- | :------------------------ | -| `lcbp3.np-dms.work` | frontend | 3000 | QNAP | Frontend Next.js | -| `backend.np-dms.work` | backend | 3000 | QNAP | Backend NestJS API | -| `pma.np-dms.work` | pma | 80 | QNAP | phpMyAdmin | -| `git.np-dms.work` | gitea | 3000 | QNAP | Gitea Git Server | -| `n8n.np-dms.work` | n8n | 5678 | QNAP | n8n Workflow Automation | -| `npm.np-dms.work` | npm | 81 | QNAP | Nginx Proxy Manager Admin | +| Domain | Service | Port | Host | Description | +| :-------------------- | :--------- | :--- | :--- | :------------------------ | +| `lcbp3.np-dms.work` | frontend | 3000 | QNAP | Frontend Next.js | +| `backend.np-dms.work` | backend | 3000 | QNAP | Backend NestJS API | +| `pma.np-dms.work` | pma | 80 | QNAP | phpMyAdmin | +| `git.np-dms.work` | gitea | 3000 | QNAP | Gitea Git Server | +| `n8n.np-dms.work` | n8n | 5678 | QNAP | n8n Workflow Automation | +| `chat.np-dms.work` | rocketchat | 3000 | QNAP | Rocket.Chat Service | +| `npm.np-dms.work` | npm | 81 | QNAP | Nginx Proxy Manager Admin | ### Infrastructure Domains (ASUSTOR) diff --git a/specs/08-infrastructure/docker-compose-app.yml b/specs/08-infrastructure/docker-compose-app.yml index c970dec..2aa17fc 100644 --- a/specs/08-infrastructure/docker-compose-app.yml +++ b/specs/08-infrastructure/docker-compose-app.yml @@ -112,11 +112,11 @@ services: networks: - lcbp3 healthcheck: - test: ['CMD-SHELL', 'wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1'] + test: ['CMD', 'curl', '-f', 'http://localhost:3000/'] interval: 30s timeout: 10s retries: 3 - start_period: 20s + start_period: 60s depends_on: backend: condition: service_healthy