260218:1712 20260218 TASK-BEFE-001n
All checks were successful
Build and Deploy / deploy (push) Successful in 4m55s
All checks were successful
Build and Deploy / deploy (push) Successful in 4m55s
This commit is contained in:
@@ -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 {}
|
||||
|
||||
59
backend/src/common/auth/session.controller.ts
Normal file
59
backend/src/common/auth/session.controller.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -81,7 +81,15 @@ export class FileStorageService {
|
||||
* Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent)
|
||||
* เมธอดนี้จะถูกเรียกโดย 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) {
|
||||
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 {
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
41
backend/src/modules/user/dto/bulk-assignment.dto.ts
Normal file
41
backend/src/modules/user/dto/bulk-assignment.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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<UserAssignment>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
119
backend/src/scripts/migrate-storage-v2.ts
Normal file
119
backend/src/scripts/migrate-storage-v2.ts
Normal 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();
|
||||
Reference in New Issue
Block a user