260218:1712 20260218 TASK-BEFE-001n
All checks were successful
Build and Deploy / deploy (push) Successful in 4m55s

This commit is contained in:
admin
2026-02-18 17:12:11 +07:00
parent 01ce68acda
commit b84284f8a9
54 changed files with 1307 additions and 339 deletions

View File

@@ -10,6 +10,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthService } from './auth.service.js'; import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller.js'; import { AuthController } from './auth.controller.js';
import { SessionController } from './session.controller.js';
import { UserModule } from '../../modules/user/user.module.js'; import { UserModule } from '../../modules/user/user.module.js';
import { JwtStrategy } from './strategies/jwt.strategy.js'; import { JwtStrategy } from './strategies/jwt.strategy.js';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js'; import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
@@ -37,7 +38,7 @@ import { PermissionsGuard } from './guards/permissions.guard';
CaslModule, CaslModule,
], ],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy, PermissionsGuard], providers: [AuthService, JwtStrategy, JwtRefreshStrategy, PermissionsGuard],
controllers: [AuthController], controllers: [AuthController, SessionController],
exports: [AuthService, PermissionsGuard], exports: [AuthService, PermissionsGuard],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -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'
);
}
}
}

View File

@@ -40,6 +40,9 @@ export class Attachment {
@Column({ length: 64, nullable: true }) @Column({ length: 64, nullable: true })
checksum?: string; checksum?: string;
@Column({ name: 'reference_date', type: 'date', nullable: true })
referenceDate?: Date;
@Column({ name: 'uploaded_by_user_id' }) @Column({ name: 'uploaded_by_user_id' })
uploadedByUserId!: number; uploadedByUserId!: number;

View File

@@ -81,7 +81,15 @@ export class FileStorageService {
* Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent) * Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent)
* เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save * เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save
*/ */
async commit(tempIds: string[]): Promise<Attachment[]> { /**
* 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<Attachment[]> {
if (!tempIds || tempIds.length === 0) { if (!tempIds || tempIds.length === 0) {
return []; return [];
} }
@@ -100,12 +108,27 @@ export class FileStorageService {
} }
const committedAttachments: Attachment[] = []; const committedAttachments: Attachment[] = [];
const today = new Date(); // Use issueDate if provided, otherwise default to current date
const year = today.getFullYear().toString(); const refDate = options?.issueDate
const month = (today.getMonth() + 1).toString().padStart(2, '0'); ? new Date(options.issueDate)
: new Date();
// โฟลเดอร์ถาวรแยกตาม ปี/เดือน // Validate Date (in case invalid string passed)
const permanentDir = path.join(this.permanentDir, year, month); 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); await fs.ensureDir(permanentDir);
for (const att of attachments) { for (const att of attachments) {
@@ -122,6 +145,7 @@ export class FileStorageService {
att.isTemporary = false; att.isTemporary = false;
att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable) att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable)
att.expiresAt = null as any; // เคลียร์วันหมดอายุ att.expiresAt = null as any; // เคลียร์วันหมดอายุ
att.referenceDate = effectiveDate; // Save reference date
committedAttachments.push(await this.attachmentRepository.save(att)); committedAttachments.push(await this.attachmentRepository.save(att));
} else { } else {

View File

@@ -102,7 +102,8 @@ export class AsBuiltDrawingService {
// 5. Commit Files // 5. Commit Files
if (createDto.attachmentIds?.length) { if (createDto.attachmentIds?.length) {
await this.fileStorageService.commit( 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) { if (createDto.attachmentIds?.length) {
await this.fileStorageService.commit( await this.fileStorageService.commit(
createDto.attachmentIds.map(String) createDto.attachmentIds.map(String),
{ issueDate: revision.revisionDate, documentType: 'AsBuiltDrawing' }
); );
} }

View File

@@ -85,7 +85,8 @@ export class ContractDrawingService {
if (createDto.attachmentIds?.length) { if (createDto.attachmentIds?.length) {
// ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง // ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง
await this.fileStorageService.commit( await this.fileStorageService.commit(
createDto.attachmentIds.map(String) createDto.attachmentIds.map(String),
{ documentType: 'ContractDrawing' }
); );
} }
@@ -213,7 +214,8 @@ export class ContractDrawingService {
// Commit new files // Commit new files
// ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง // ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง
await this.fileStorageService.commit( await this.fileStorageService.commit(
updateDto.attachmentIds.map(String) updateDto.attachmentIds.map(String),
{ documentType: 'ContractDrawing' }
); );
} }

View File

@@ -101,7 +101,8 @@ export class ShopDrawingService {
// 5. Commit Files // 5. Commit Files
if (createDto.attachmentIds?.length) { if (createDto.attachmentIds?.length) {
await this.fileStorageService.commit( 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) { if (createDto.attachmentIds?.length) {
await this.fileStorageService.commit( await this.fileStorageService.commit(
createDto.attachmentIds.map(String) createDto.attachmentIds.map(String),
{ issueDate: revision.revisionDate, documentType: 'ShopDrawing' }
); );
} }

View File

@@ -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[];
}

View File

@@ -1,25 +1,29 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, DataSource } from 'typeorm';
import { UserAssignment } from './entities/user-assignment.entity'; // ต้องไปสร้าง Entity นี้ก่อน (ดูข้อ 3) import { UserAssignment } from './entities/user-assignment.entity';
import { AssignRoleDto } from './dto/assign-role.dto.js'; import { AssignRoleDto } from './dto/assign-role.dto.js';
import { BulkAssignmentDto, ActionType } from './dto/bulk-assignment.dto';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
@Injectable() @Injectable()
export class UserAssignmentService { export class UserAssignmentService {
private readonly logger = new Logger(UserAssignmentService.name);
constructor( constructor(
@InjectRepository(UserAssignment) @InjectRepository(UserAssignment)
private assignmentRepo: Repository<UserAssignment>, private assignmentRepo: Repository<UserAssignment>,
private dataSource: DataSource
) {} ) {}
async assignRole(dto: AssignRoleDto, assigner: User) { async assignRole(dto: AssignRoleDto, assigner: User) {
// Validation: ตรวจสอบกฎเหล็ก (เลือกได้แค่ Scope เดียว) // Validation: ตรวจสอบกฎเหล็ก (เลือกได้แค่ Scope เดียว)
const scopes = [dto.organizationId, dto.projectId, dto.contractId].filter( const scopes = [dto.organizationId, dto.projectId, dto.contractId].filter(
(v) => v != null, (v) => v != null
); );
if (scopes.length > 1) { if (scopes.length > 1) {
throw new BadRequestException( 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); 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();
}
}
} }

View File

@@ -27,6 +27,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { AssignRoleDto } from './dto/assign-role.dto'; import { AssignRoleDto } from './dto/assign-role.dto';
import { SearchUserDto } from './dto/search-user.dto'; import { SearchUserDto } from './dto/search-user.dto';
import { UpdatePreferenceDto } from './dto/update-preference.dto'; import { UpdatePreferenceDto } from './dto/update-preference.dto';
import { BulkAssignmentDto } from './dto/bulk-assignment.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; import { RbacGuard } from '../../common/guards/rbac.guard';
@@ -94,7 +95,7 @@ export class UserController {
} }
@Patch('roles/:id/permissions') @Patch('roles/:id/permissions')
@RequirePermission('permission.assign') @RequirePermission('role.assign_permissions')
@ApiOperation({ summary: 'Update role permissions' }) @ApiOperation({ summary: 'Update role permissions' })
async updateRolePermissions( async updateRolePermissions(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@@ -159,8 +160,21 @@ export class UserController {
@ApiOperation({ summary: 'Assign role to user' }) @ApiOperation({ summary: 'Assign role to user' })
@ApiBody({ type: AssignRoleDto }) @ApiBody({ type: AssignRoleDto })
@ApiResponse({ status: 201, description: 'Role assigned' }) @ApiResponse({ status: 201, description: 'Role assigned' })
@RequirePermission('permission.assign') @RequirePermission('user.manage_assignments')
assignRole(@Body() dto: AssignRoleDto, @CurrentUser() user: User) { assignRole(@Body() dto: AssignRoleDto, @CurrentUser() user: User) {
return this.assignmentService.assignRole(dto, 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);
}
} }

View File

@@ -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();

View File

@@ -63,6 +63,9 @@ ENV PORT=3000
RUN addgroup -g 1001 -S nextjs && \ RUN addgroup -g 1001 -S nextjs && \
adduser -S nextjs -u 1001 adduser -S nextjs -u 1001
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy standalone output from build # Copy standalone output from build
COPY --from=build --chown=nextjs:nextjs /app/frontend/.next/standalone ./ COPY --from=build --chown=nextjs:nextjs /app/frontend/.next/standalone ./
COPY --from=build --chown=nextjs:nextjs /app/frontend/.next/static ./frontend/.next/static COPY --from=build --chown=nextjs:nextjs /app/frontend/.next/static ./frontend/.next/static
@@ -71,7 +74,7 @@ USER nextjs
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=20s \ HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 CMD curl -f http://localhost:3000/ || exit 1
CMD ["node", "frontend/server.js"] CMD ["node", "frontend/server.js"]

View File

@@ -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 (
<div className="flex h-[400px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return <div className="p-8 text-center text-red-500">Failed to load sessions. Please try again.</div>;
}
return (
<div className="space-y-6 p-6">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold tracking-tight">Active Sessions</h1>
<p className="text-sm text-muted-foreground">Monitor and manage active user sessions across all devices.</p>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Device Info</TableHead>
<TableHead>Last Active</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions?.map((session: any) => (
<TableRow key={session.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{session.user.username}</span>
<span className="text-xs text-muted-foreground">
{session.user.firstName} {session.user.lastName}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm">
<Monitor className="h-3 w-3" />
{session.deviceName || 'Unknown Device'}
</div>
<span className="text-xs text-muted-foreground">{session.ipAddress || 'Unknown IP'}</span>
</div>
</TableCell>
<TableCell className="text-sm">
{session.lastActive ? format(new Date(session.lastActive), 'PP pp') : '-'}
</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
className="h-8"
onClick={() => handleRevoke(Number(session.id))}
disabled={revokeMutation.isPending}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Revoke
</Button>
</TableCell>
</TableRow>
))}
{(!sessions || sessions.length === 0) && (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No active sessions found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -1,21 +1,12 @@
"use client"; 'use client';
import { useOrganizations } from "@/hooks/use-master-data"; import { useOrganizations } from '@/hooks/use-master-data';
import { useUsers } from "@/hooks/use-users"; import { useUsers } from '@/hooks/use-users';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import { Users, Building2, FileText, Settings, Shield, Activity, ArrowRight, FileStack } from 'lucide-react';
Users, import Link from 'next/link';
Building2, import { Skeleton } from '@/components/ui/skeleton';
FileText, import { Button } from '@/components/ui/button';
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() { export default function AdminPage() {
const { data: organizations, isLoading: orgsLoading } = useOrganizations(); const { data: organizations, isLoading: orgsLoading } = useOrganizations();
@@ -23,66 +14,66 @@ export default function AdminPage() {
const stats = [ const stats = [
{ {
title: "Total Users", title: 'Total Users',
value: users?.length || 0, value: users?.length || 0,
icon: Users, icon: Users,
loading: usersLoading, loading: usersLoading,
href: "/admin/users", href: '/admin/access-control/users',
color: "text-blue-600", color: 'text-blue-600',
}, },
{ {
title: "Organizations", title: 'Organizations',
value: organizations?.length || 0, value: organizations?.length || 0,
icon: Building2, icon: Building2,
loading: orgsLoading, loading: orgsLoading,
href: "/admin/organizations", href: '/admin/access-control/organizations',
color: "text-green-600", color: 'text-green-600',
}, },
{ {
title: "System Logs", title: 'System Logs',
value: "View", value: 'View',
icon: Activity, icon: Activity,
loading: false, loading: false,
href: "/admin/system-logs", href: '/admin/monitoring/system-logs',
color: "text-orange-600", color: 'text-orange-600',
} },
]; ];
const quickLinks = [ const quickLinks = [
{ {
title: "User Management", title: 'User Management',
description: "Manage system users, roles, and permissions", description: 'Manage system users, roles, and permissions',
href: "/admin/users", href: '/admin/access-control/users',
icon: Users, icon: Users,
}, },
{ {
title: "Organizations", title: 'Organizations',
description: "Manage project organizations and companies", description: 'Manage project organizations and companies',
href: "/admin/organizations", href: '/admin/access-control/organizations',
icon: Building2, icon: Building2,
}, },
{ {
title: "Workflow Config", title: 'Workflow Config',
description: "Configure document approval workflows", description: 'Configure document approval workflows',
href: "/admin/workflows", href: '/admin/doc-control/workflows',
icon: FileText, icon: FileText,
}, },
{ {
title: "Security & RBAC", title: 'Security & RBAC',
description: "Configure roles, permissions, and security settings", description: 'Configure roles, permissions, and security settings',
href: "/admin/security/roles", href: '/admin/access-control/roles',
icon: Shield, icon: Shield,
}, },
{ {
title: "Numbering System", title: 'Numbering System',
description: "Setup document numbering templates", description: 'Setup document numbering templates',
href: "/admin/numbering", href: '/admin/doc-control/numbering',
icon: Settings, icon: Settings,
}, },
{ {
title: "Drawing Master Data", title: 'Drawing Master Data',
description: "Manage drawing categories, volumes, and classifications", description: 'Manage drawing categories, volumes, and classifications',
href: "/admin/drawings", href: '/admin/doc-control/drawings',
icon: FileStack, icon: FileStack,
}, },
]; ];
@@ -91,18 +82,14 @@ export default function AdminPage() {
<div className="space-y-8 p-8"> <div className="space-y-8 p-8">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1> <h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">System overview and quick access to administrative functions.</p>
System overview and quick access to administrative functions.
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<Card key={index} className="hover:shadow-md transition-shadow"> <Card key={index} className="hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
{stat.title}
</CardTitle>
<stat.icon className={`h-4 w-4 ${stat.color}`} /> <stat.icon className={`h-4 w-4 ${stat.color}`} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -112,10 +99,7 @@ export default function AdminPage() {
<div className="text-2xl font-bold">{stat.value}</div> <div className="text-2xl font-bold">{stat.value}</div>
)} )}
{stat.href && ( {stat.href && (
<Link <Link href={stat.href} className="text-xs text-muted-foreground hover:underline mt-1 inline-block">
href={stat.href}
className="text-xs text-muted-foreground hover:underline mt-1 inline-block"
>
View details View details
</Link> </Link>
)} )}
@@ -137,9 +121,7 @@ export default function AdminPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{link.description}</p>
{link.description}
</p>
<Button variant="ghost" className="mt-4 p-0 h-auto font-normal text-primary hover:no-underline group"> <Button variant="ghost" className="mt-4 p-0 h-auto font-normal text-primary hover:no-underline group">
Go to module <ArrowRight className="ml-1 h-3 w-3 group-hover:translate-x-1 transition-transform" /> Go to module <ArrowRight className="ml-1 h-3 w-3 group-hover:translate-x-1 transition-transform" />
</Button> </Button>

View File

@@ -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<Session[]>({
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<Session>[] = [
{
accessorKey: "user",
header: "User",
cell: ({ row }) => {
const user = row.original.user;
return (
<div className="flex flex-col">
<span className="font-medium">{user.username}</span>
<span className="text-xs text-muted-foreground">
{user.firstName} {user.lastName}
</span>
</div>
);
},
},
{
accessorKey: "deviceName",
header: "Device / IP",
cell: ({ row }) => (
<div className="flex items-center gap-2">
{row.original.deviceName.toLowerCase().includes("mobile") ? (
<Smartphone className="h-4 w-4 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 text-muted-foreground" />
)}
<div className="flex flex-col">
<span>{row.original.deviceName}</span>
<span className="text-xs text-muted-foreground">{row.original.ipAddress}</span>
</div>
</div>
),
},
{
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 ? <Badge>Current</Badge> : <Badge variant="secondary">Active</Badge>,
},
{
id: "actions",
cell: ({ row }) => (
<Button
variant="destructive"
size="sm"
disabled={row.original.isCurrent || revokeMutation.isPending}
onClick={() => revokeMutation.mutate(row.original.id)}
>
<LogOut className="h-4 w-4 mr-2" />
Revoke
</Button>
),
},
];
if (isLoading) {
return (
<div className="flex justify-center p-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Active Sessions</h1>
<p className="text-muted-foreground">Manage user sessions and force logout if needed</p>
</div>
</div>
<DataTable columns={columns} data={sessions} />
</div>
);
}

View File

@@ -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({ export default async function AdminLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const session = await auth(); const session = await auth();
// Temporary bypass for UI testing // Validate Admin or DC role
const isAdmin = true; // session?.user?.role === 'ADMIN'; const userRole = session?.user?.role;
const isAdmin = userRole === 'ADMIN' || userRole === 'DC';
if (!session || !isAdmin) { if (!session || !isAdmin) {
// redirect("/"); redirect('/dashboard'); // Redirect unauthorized users to dashboard
} }
return ( return (
<div className="flex h-screen w-full bg-background"> <div className="flex h-screen w-full bg-background">
<AdminSidebar /> <AdminSidebar />
<div className="flex-1 overflow-auto bg-muted/10 p-4"> <div className="flex-1 overflow-auto bg-muted/10 p-4">{children}</div>
{children}
</div>
</div> </div>
); );
} }

View File

@@ -1,9 +1,9 @@
"use client"; 'use client';
import Link from "next/link"; import Link from 'next/link';
import { usePathname } from "next/navigation"; import { usePathname } from 'next/navigation';
import { useState } from "react"; import { useState } from 'react';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { import {
Users, Users,
Building2, Building2,
@@ -16,7 +16,7 @@ import {
FileStack, FileStack,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
} from "lucide-react"; } from 'lucide-react';
interface MenuItem { interface MenuItem {
href?: string; href?: string;
@@ -26,29 +26,47 @@ interface MenuItem {
} }
const menuItems: 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, icon: FileStack,
children: [ children: [
{ href: "/admin/drawings/contract/volumes", label: "Contract: Volumes" }, { href: '/admin/doc-control/projects', label: 'Projects' },
{ href: "/admin/drawings/contract/categories", label: "Contract: Categories" }, { href: '/admin/doc-control/contracts', label: 'Contracts' },
{ href: "/admin/drawings/contract/sub-categories", label: "Contract: Sub-categories" }, { href: '/admin/doc-control/numbering', label: 'Numbering' },
{ href: "/admin/drawings/shop/main-categories", label: "Shop: Main Categories" }, { href: '/admin/doc-control/reference', label: 'Reference Data' },
{ href: "/admin/drawings/shop/sub-categories", label: "Shop: Sub-categories" }, { href: '/admin/doc-control/workflows', label: 'Workflows' },
] ],
}, },
{ href: "/admin/numbering", label: "Numbering", icon: FileText }, {
{ href: "/admin/workflows", label: "Workflows", icon: GitGraph }, label: 'Drawing Master',
{ href: "/admin/security/roles", label: "Security Roles", icon: Shield }, icon: FileStack, // Or another icon
{ href: "/admin/security/sessions", label: "Active Sessions", icon: Users }, children: [
{ href: "/admin/system-logs/numbering", label: "System Logs", icon: Activity }, { href: '/admin/doc-control/drawings/contract/volumes', label: 'Contract: Volumes' },
{ href: "/admin/audit-logs", label: "Audit Logs", icon: Activity }, { href: '/admin/doc-control/drawings/contract/categories', label: 'Contract: Categories' },
{ href: "/admin/settings", label: "Settings", icon: Settings }, { 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() { export function AdminSidebar() {
@@ -56,23 +74,19 @@ export function AdminSidebar() {
const [expandedMenus, setExpandedMenus] = useState<string[]>( const [expandedMenus, setExpandedMenus] = useState<string[]>(
// Auto-expand if current path matches a child // Auto-expand if current path matches a child
menuItems menuItems
.filter(item => item.children?.some(child => pathname.startsWith(child.href))) .filter((item) => item.children?.some((child) => pathname.startsWith(child.href)))
.map(item => item.label) .map((item) => item.label)
); );
const toggleMenu = (label: string) => { const toggleMenu = (label: string) => {
setExpandedMenus(prev => setExpandedMenus((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]));
prev.includes(label)
? prev.filter(l => l !== label)
: [...prev, label]
);
}; };
return ( return (
<aside className="w-64 border-r bg-card p-4 hidden md:block"> <aside className="w-64 border-r bg-card p-4 hidden md:block">
<div className="mb-8 px-2"> <div className="mb-8 px-2">
<h2 className="text-xl font-bold tracking-tight">Admin Console</h2> <h2 className="text-xl font-bold tracking-tight">Admin Console</h2>
<p className="text-sm text-muted-foreground">LCBP3 DMS</p> <p className="text-sm text-muted-foreground">LCBP3 DMS</p>
</div> </div>
<nav className="space-y-1"> <nav className="space-y-1">
@@ -82,28 +96,24 @@ export function AdminSidebar() {
// Has children - collapsible menu // Has children - collapsible menu
if (item.children) { if (item.children) {
const isExpanded = expandedMenus.includes(item.label); const isExpanded = expandedMenus.includes(item.label);
const hasActiveChild = item.children.some(child => pathname.startsWith(child.href)); const hasActiveChild = item.children.some((child) => pathname.startsWith(child.href));
return ( return (
<div key={item.label}> <div key={item.label}>
<button <button
onClick={() => toggleMenu(item.label)} onClick={() => toggleMenu(item.label)}
className={cn( className={cn(
"w-full flex items-center justify-between gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium", 'w-full flex items-center justify-between gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium',
hasActiveChild hasActiveChild
? "bg-primary/10 text-primary" ? 'bg-primary/10 text-primary'
: "text-muted-foreground hover:bg-muted hover:text-foreground" : 'text-muted-foreground hover:bg-muted hover:text-foreground'
)} )}
> >
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<span>{item.label}</span> <span>{item.label}</span>
</span> </span>
{isExpanded ? ( {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button> </button>
{isExpanded && ( {isExpanded && (
@@ -115,10 +125,10 @@ export function AdminSidebar() {
key={child.href} key={child.href}
href={child.href} href={child.href}
className={cn( className={cn(
"block px-3 py-1.5 rounded-lg transition-colors text-sm", 'block px-3 py-1.5 rounded-lg transition-colors text-sm',
isActive isActive
? "bg-primary text-primary-foreground shadow-sm" ? 'bg-primary text-primary-foreground shadow-sm'
: "text-muted-foreground hover:bg-muted hover:text-foreground" : 'text-muted-foreground hover:bg-muted hover:text-foreground'
)} )}
> >
{child.label} {child.label}
@@ -138,10 +148,10 @@ export function AdminSidebar() {
key={item.href} key={item.href}
href={item.href!} href={item.href!}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium", 'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium',
isActive isActive
? "bg-primary text-primary-foreground shadow-sm" ? 'bg-primary text-primary-foreground shadow-sm'
: "text-muted-foreground hover:bg-muted hover:text-foreground" : 'text-muted-foreground hover:bg-muted hover:text-foreground'
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -152,9 +162,9 @@ export function AdminSidebar() {
</nav> </nav>
<div className="mt-auto pt-8 px-2 fixed bottom-4 w-56"> <div className="mt-auto pt-8 px-2 fixed bottom-4 w-56">
<Link href="/dashboard" className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-2"> <Link href="/dashboard" className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-2">
Back to Dashboard Back to Dashboard
</Link> </Link>
</div> </div>
</aside> </aside>
); );

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
isLoading?: boolean;
}
export function ServerDataTable<TData, TValue>({
columns,
data,
pageCount,
pagination,
onPaginationChange,
sorting,
onSortingChange,
isLoading,
}: ServerDataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
pageCount,
state: {
pagination,
sorting,
},
onPaginationChange,
onSortingChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
});
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel && table.getFilteredSelectedRowModel().rows.length > 0 && (
<>
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</>
)}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<Drawing>[] = [
{
accessorKey: 'drawingNumber',
header: ({ column }) => {
return (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}>
Drawing No.
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(drawing.drawingNumber)}>
Copy Drawing No.
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View Details</DropdownMenuItem>
{/* Add download/view functionality later */}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];

View File

@@ -1,58 +1,60 @@
"use client"; 'use client';
import { DrawingCard } from "@/components/drawings/card"; import { DrawingCard } from '@/components/drawings/card';
import { useDrawings } from "@/hooks/use-drawing"; import { useDrawings } from '@/hooks/use-drawing';
import { Drawing } from "@/types/drawing"; import { Drawing } from '@/types/drawing';
import { Loader2 } from "lucide-react"; 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 { SearchContractDrawingDto } from '@/types/dto/drawing/contract-drawing.dto';
import { SearchShopDrawingDto } from "@/types/dto/drawing/shop-drawing.dto"; import { SearchShopDrawingDto } from '@/types/dto/drawing/shop-drawing.dto';
import { SearchAsBuiltDrawingDto } from "@/types/dto/drawing/asbuilt-drawing.dto"; import { SearchAsBuiltDrawingDto } from '@/types/dto/drawing/asbuilt-drawing.dto';
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto; type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto;
interface DrawingListProps { interface DrawingListProps {
type: "CONTRACT" | "SHOP" | "AS_BUILT"; type: 'CONTRACT' | 'SHOP' | 'AS_BUILT';
projectId: number; projectId: number;
filters?: Partial<DrawingSearchParams>; filters?: Partial<DrawingSearchParams>;
} }
export function DrawingList({ type, projectId, filters }: DrawingListProps) { export function DrawingList({ type, projectId, filters }: DrawingListProps) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
const [sorting, setSorting] = useState<SortingState>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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. const drawings = response?.data || [];
// The params { type } might be redundant if getAll doesn't use it, but safe to pass. const meta = response?.meta || { total: 0, page: 1, limit: 20, totalPages: 0 };
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (isError) {
return (
<div className="text-center py-12 text-red-500">
Failed to load drawings.
</div>
);
}
if (!drawings?.data || drawings.data.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
No drawings found.
</div>
);
}
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6"> <div>
{drawings.data.map((drawing: Drawing) => ( <ServerDataTable
<DrawingCard key={drawing.drawingId} drawing={drawing} /> columns={columns}
))} data={drawings}
pageCount={meta.totalPages}
pagination={pagination}
onPaginationChange={setPagination}
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
/>
</div> </div>
); );
} }

View File

@@ -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<any>('/auth/sessions');
return response.data.data || response.data;
},
revokeSession: async (sessionId: number) => {
const response = await apiClient.delete(`/auth/sessions/${sessionId}`);
return response.data;
},
};

View File

@@ -1,17 +1,17 @@
// File: middleware.ts // File: middleware.ts
import { NextResponse } from "next/server"; import { NextResponse } from 'next/server';
import type { NextRequest } from "next/server"; import type { NextRequest } from 'next/server';
import { auth } from "@/lib/auth"; import { auth } from '@/lib/auth';
// รายการ Route ที่ไม่ต้อง Login ก็เข้าได้ (Public Routes) // รายการ Route ที่ไม่ต้อง Login ก็เข้าได้ (Public Routes)
const publicRoutes = ["/login", "/register", "/"]; const publicRoutes = ['/login', '/register', '/'];
export default auth((req) => { export default auth((req) => {
const isLoggedIn = !!req.auth; const isLoggedIn = !!req.auth;
const { nextUrl } = req; const { nextUrl } = req;
const isPublicRoute = publicRoutes.includes(nextUrl.pathname); const isPublicRoute = publicRoutes.includes(nextUrl.pathname);
const isAuthRoute = nextUrl.pathname.startsWith("/api/auth"); const isAuthRoute = nextUrl.pathname.startsWith('/api/auth');
// 1. ถ้าเป็น API Auth routes ให้ผ่านไปเลย // 1. ถ้าเป็น API Auth routes ให้ผ่านไปเลย
if (isAuthRoute) { if (isAuthRoute) {
@@ -19,8 +19,8 @@ export default auth((req) => {
} }
// 2. ถ้า Login อยู่แล้ว แต่พยายามเข้าหน้า Login -> ให้ไป Dashboard // 2. ถ้า Login อยู่แล้ว แต่พยายามเข้าหน้า Login -> ให้ไป Dashboard
if (isLoggedIn && nextUrl.pathname === "/login") { if (isLoggedIn && nextUrl.pathname === '/login') {
return Response.redirect(new URL("/dashboard", nextUrl)); return Response.redirect(new URL('/dashboard', nextUrl));
} }
// 3. ถ้ายังไม่ Login และพยายามเข้า Private Route -> ให้ไป Login // 3. ถ้ายังไม่ Login และพยายามเข้า Private Route -> ให้ไป Login
@@ -30,11 +30,19 @@ export default auth((req) => {
if (nextUrl.search) { if (nextUrl.search) {
callbackUrl += nextUrl.search; callbackUrl += nextUrl.search;
} }
const encodedCallbackUrl = encodeURIComponent(callbackUrl); const encodedCallbackUrl = encodeURIComponent(callbackUrl);
return Response.redirect(new URL(`/login?callbackUrl=${encodedCallbackUrl}`, nextUrl)); 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 return NextResponse.next(); // แก้ไขจาก null
}); });
@@ -51,4 +59,4 @@ export const config = {
*/ */
'/((?!api|_next/static|_next/image|favicon.ico|images).*)', '/((?!api|_next/static|_next/image|favicon.ico|images).*)',
], ],
}; };

View File

@@ -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).

View File

@@ -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`);

View File

@@ -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';

View File

@@ -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 |

View File

@@ -26,6 +26,7 @@ setfacl -R -m u:0:rwx /share/Container/npm
| db.np-dms.work | mariadb | 3306 | [x] | [x] | [x] | [x] | [x] | [ ] | | db.np-dms.work | mariadb | 3306 | [x] | [x] | [x] | [x] | [x] | [ ] |
| git.np-dms.work | gitea | 3000 | [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] | [ ] | | 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] | [ ] | | npm.np-dms.work | npm | 81 | [ ] | [x] | [x] | [x] | [x] | [ ] |
| pma.np-dms.work | pma | 80 | [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] | [ ] | | np-dms.work, [www.np-dms.work] | localhost | 80 | [x] | [x] | [ ] | [x] | [x] | [ ] |

View File

@@ -48,6 +48,7 @@
| **API Gateway** | NPM (Nginx Proxy Manager) | SSL Termination | 1.0 CPU / 512MB RAM | | **API Gateway** | NPM (Nginx Proxy Manager) | SSL Termination | 1.0 CPU / 512MB RAM |
| **Workflow** | n8n | Automation | 1.0 CPU / 1GB RAM | | **Workflow** | n8n | Automation | 1.0 CPU / 1GB RAM |
| **Code** | Gitea | Git Repository | 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) #### ASUSTOR AS5403T (Infrastructure Stack)
| Category | Service | Notes | | Category | Service | Notes |
@@ -208,14 +209,15 @@ graph TB
### Application Domains (QNAP) ### Application Domains (QNAP)
| Domain | Service | Port | Host | Description | | Domain | Service | Port | Host | Description |
| :-------------------- | :------- | :--- | :--- | :------------------------ | | :-------------------- | :--------- | :--- | :--- | :------------------------ |
| `lcbp3.np-dms.work` | frontend | 3000 | QNAP | Frontend Next.js | | `lcbp3.np-dms.work` | frontend | 3000 | QNAP | Frontend Next.js |
| `backend.np-dms.work` | backend | 3000 | QNAP | Backend NestJS API | | `backend.np-dms.work` | backend | 3000 | QNAP | Backend NestJS API |
| `pma.np-dms.work` | pma | 80 | QNAP | phpMyAdmin | | `pma.np-dms.work` | pma | 80 | QNAP | phpMyAdmin |
| `git.np-dms.work` | gitea | 3000 | QNAP | Gitea Git Server | | `git.np-dms.work` | gitea | 3000 | QNAP | Gitea Git Server |
| `n8n.np-dms.work` | n8n | 5678 | QNAP | n8n Workflow Automation | | `n8n.np-dms.work` | n8n | 5678 | QNAP | n8n Workflow Automation |
| `npm.np-dms.work` | npm | 81 | QNAP | Nginx Proxy Manager Admin | | `chat.np-dms.work` | rocketchat | 3000 | QNAP | Rocket.Chat Service |
| `npm.np-dms.work` | npm | 81 | QNAP | Nginx Proxy Manager Admin |
### Infrastructure Domains (ASUSTOR) ### Infrastructure Domains (ASUSTOR)

View File

@@ -112,11 +112,11 @@ services:
networks: networks:
- lcbp3 - lcbp3
healthcheck: healthcheck:
test: ['CMD-SHELL', 'wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1'] test: ['CMD', 'curl', '-f', 'http://localhost:3000/']
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 20s start_period: 60s
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy