251209:0000 Backend Test stagenot finish & Frontend add Task 013-015
This commit is contained in:
30
backend/docker-compose.test.yml
Normal file
30
backend/docker-compose.test.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
mariadb_test:
|
||||||
|
image: mariadb:11.8
|
||||||
|
container_name: mariadb-test
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: Center#2025
|
||||||
|
MYSQL_DATABASE: lcbp3_test
|
||||||
|
MYSQL_USER: admin
|
||||||
|
MYSQL_PASSWORD: Center2025
|
||||||
|
ports:
|
||||||
|
- '3307:3306'
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- lcbp3-test-net
|
||||||
|
|
||||||
|
redis_test:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis-test
|
||||||
|
restart: always
|
||||||
|
command: redis-server --requirepass "Center2025"
|
||||||
|
ports:
|
||||||
|
- '6380:6379'
|
||||||
|
networks:
|
||||||
|
- lcbp3-test-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lcbp3-test-net:
|
||||||
|
driver: bridge
|
||||||
@@ -7,13 +7,15 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
"doc": "npx @compodoc/compodoc -p tsconfig.doc.json -s",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest --forceExit",
|
||||||
|
"test:debug-handles": "jest --detectOpenHandles",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
@@ -72,11 +74,12 @@
|
|||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.27",
|
"typeorm": "^0.3.27",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.18.3",
|
"winston": "^3.18.3",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@compodoc/compodoc": "^1.1.32",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
@@ -94,7 +97,7 @@
|
|||||||
"@types/opossum": "^8.1.9",
|
"@types/opossum": "^8.1.9",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^9.0.8",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
|||||||
@@ -102,7 +102,16 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Logout (Revoke Tokens)' })
|
@ApiOperation({ summary: 'Logout (Revoke Tokens)' })
|
||||||
@ApiResponse({ status: 200, description: 'Logged out successfully' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Logged out successfully',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: { type: 'string', example: 'Logged out successfully' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async logout(@Req() req: RequestWithUser) {
|
async logout(@Req() req: RequestWithUser) {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -1,18 +1,202 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { UserService } from '../../modules/user/user.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { User } from '../../modules/user/entities/user.entity';
|
||||||
|
import { RefreshToken } from './entities/refresh-token.entity';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
|
let userService: UserService;
|
||||||
|
let jwtService: JwtService;
|
||||||
|
let tokenRepo: Repository<RefreshToken>;
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
user_id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'hashedpassword',
|
||||||
|
primaryOrganizationId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
getOne: jest.fn().mockResolvedValue(mockUser),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTokenRepo = {
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [AuthService],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: UserService,
|
||||||
|
useValue: {
|
||||||
|
findOneByUsername: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: JwtService,
|
||||||
|
useValue: {
|
||||||
|
signAsync: jest.fn().mockResolvedValue('jwt_token'),
|
||||||
|
decode: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn().mockImplementation((key) => {
|
||||||
|
if (key.includes('EXPIRATION')) return '1h';
|
||||||
|
return 'secret';
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CACHE_MANAGER,
|
||||||
|
useValue: {
|
||||||
|
set: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(User),
|
||||||
|
useValue: mockUserRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(RefreshToken),
|
||||||
|
useValue: mockTokenRepo,
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AuthService>(AuthService);
|
service = module.get<AuthService>(AuthService);
|
||||||
|
userService = module.get<UserService>(UserService);
|
||||||
|
jwtService = module.get<JwtService>(JwtService);
|
||||||
|
tokenRepo = module.get(getRepositoryToken(RefreshToken));
|
||||||
|
|
||||||
|
// Mock bcrypt
|
||||||
|
jest
|
||||||
|
.spyOn(bcrypt, 'compare')
|
||||||
|
.mockImplementation(() => Promise.resolve(true));
|
||||||
|
jest
|
||||||
|
.spyOn(bcrypt, 'hash')
|
||||||
|
.mockImplementation(() => Promise.resolve('hashedpassword'));
|
||||||
|
jest
|
||||||
|
.spyOn(bcrypt, 'genSalt')
|
||||||
|
.mockImplementation(() => Promise.resolve('salt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateUser', () => {
|
||||||
|
it('should return user without password if validation succeeds', async () => {
|
||||||
|
const result = await service.validateUser('testuser', 'password');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result).not.toHaveProperty('password');
|
||||||
|
expect(result.username).toBe('testuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if user not found', async () => {
|
||||||
|
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
|
||||||
|
const result = await service.validateUser('unknown', 'password');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if password mismatch', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(bcrypt, 'compare')
|
||||||
|
.mockImplementation(() => Promise.resolve(false));
|
||||||
|
const result = await service.validateUser('testuser', 'wrongpassword');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should return access and refresh tokens', async () => {
|
||||||
|
mockTokenRepo.create.mockReturnValue({ id: 1 });
|
||||||
|
mockTokenRepo.save.mockResolvedValue({ id: 1 });
|
||||||
|
|
||||||
|
const result = await service.login(mockUser);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('access_token');
|
||||||
|
expect(result).toHaveProperty('refresh_token');
|
||||||
|
expect(mockTokenRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('should register a new user', async () => {
|
||||||
|
(userService.findOneByUsername as jest.Mock).mockResolvedValue(null);
|
||||||
|
(userService.create as jest.Mock).mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
username: 'newuser',
|
||||||
|
password: 'password',
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.register(dto);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(userService.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshToken', () => {
|
||||||
|
it('should return new tokens if valid', async () => {
|
||||||
|
const mockStoredToken = {
|
||||||
|
tokenHash: 'somehash',
|
||||||
|
isRevoked: false,
|
||||||
|
expiresAt: new Date(Date.now() + 10000),
|
||||||
|
};
|
||||||
|
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
|
||||||
|
(userService.findOne as jest.Mock).mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.refreshToken(1, 'valid_refresh_token');
|
||||||
|
|
||||||
|
expect(result.access_token).toBeDefined();
|
||||||
|
expect(result.refresh_token).toBeDefined();
|
||||||
|
// Should mark old token as revoked
|
||||||
|
expect(mockTokenRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ isRevoked: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException if token revoked', async () => {
|
||||||
|
const mockStoredToken = {
|
||||||
|
tokenHash: 'somehash',
|
||||||
|
isRevoked: true,
|
||||||
|
expiresAt: new Date(Date.now() + 10000),
|
||||||
|
};
|
||||||
|
mockTokenRepo.findOne.mockResolvedValue(mockStoredToken);
|
||||||
|
|
||||||
|
await expect(service.refreshToken(1, 'revoked_token')).rejects.toThrow(
|
||||||
|
UnauthorizedException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,142 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { FileStorageService } from './file-storage.service';
|
import { FileStorageService } from './file-storage.service';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Attachment } from './entities/attachment.entity';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
// Mock fs-extra
|
||||||
|
jest.mock('fs-extra');
|
||||||
|
|
||||||
describe('FileStorageService', () => {
|
describe('FileStorageService', () => {
|
||||||
let service: FileStorageService;
|
let service: FileStorageService;
|
||||||
|
let attachmentRepo: Repository<Attachment>;
|
||||||
|
|
||||||
|
const mockAttachment = {
|
||||||
|
id: 1,
|
||||||
|
originalFilename: 'test.pdf',
|
||||||
|
storedFilename: 'uuid.pdf',
|
||||||
|
filePath: '/permanent/2024/12/uuid.pdf',
|
||||||
|
fileSize: 1024,
|
||||||
|
uploadedByUserId: 1,
|
||||||
|
} as Attachment;
|
||||||
|
|
||||||
|
const mockFile = {
|
||||||
|
originalname: 'test.pdf',
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
size: 1024,
|
||||||
|
buffer: Buffer.from('test-content'),
|
||||||
|
} as Express.Multer.File;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [FileStorageService],
|
providers: [
|
||||||
|
FileStorageService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Attachment),
|
||||||
|
useValue: {
|
||||||
|
create: jest.fn().mockReturnValue(mockAttachment),
|
||||||
|
save: jest.fn().mockResolvedValue(mockAttachment),
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn((key) => {
|
||||||
|
if (key === 'NODE_ENV') return 'test';
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<FileStorageService>(FileStorageService);
|
service = module.get<FileStorageService>(FileStorageService);
|
||||||
|
attachmentRepo = module.get(getRepositoryToken(Attachment));
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(fs.ensureDirSync as jest.Mock).mockReturnValue(true);
|
||||||
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
||||||
|
(fs.move as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.remove as jest.Mock).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('upload', () => {
|
||||||
|
it('should save file to temp and create DB record', async () => {
|
||||||
|
const result = await service.upload(mockFile, 1);
|
||||||
|
|
||||||
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
|
expect(attachmentRepo.create).toHaveBeenCalled();
|
||||||
|
expect(attachmentRepo.save).toHaveBeenCalled();
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException if write fails', async () => {
|
||||||
|
(fs.writeFile as jest.Mock).mockRejectedValueOnce(
|
||||||
|
new Error('Write error')
|
||||||
|
);
|
||||||
|
await expect(service.upload(mockFile, 1)).rejects.toThrow(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commit', () => {
|
||||||
|
it('should move files to permanent storage', async () => {
|
||||||
|
const tempIds = ['uuid-1'];
|
||||||
|
const mockAttachments = [
|
||||||
|
{
|
||||||
|
...mockAttachment,
|
||||||
|
isTemporary: true,
|
||||||
|
tempId: 'uuid-1',
|
||||||
|
filePath: '/temp/uuid.pdf',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(attachmentRepo.find as jest.Mock).mockResolvedValue(mockAttachments);
|
||||||
|
|
||||||
|
await service.commit(tempIds);
|
||||||
|
|
||||||
|
expect(fs.ensureDir).toHaveBeenCalled();
|
||||||
|
expect(fs.move).toHaveBeenCalled();
|
||||||
|
expect(attachmentRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show warning if file counts mismatch', async () => {
|
||||||
|
(attachmentRepo.find as jest.Mock).mockResolvedValue([]);
|
||||||
|
await expect(service.commit(['uuid-1'])).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete file if user owns it', async () => {
|
||||||
|
(attachmentRepo.findOne as jest.Mock).mockResolvedValue(mockAttachment);
|
||||||
|
|
||||||
|
await service.delete(1, 1);
|
||||||
|
|
||||||
|
expect(fs.remove).toHaveBeenCalled();
|
||||||
|
expect(attachmentRepo.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException if user does not own file', async () => {
|
||||||
|
(attachmentRepo.findOne as jest.Mock).mockResolvedValue(mockAttachment);
|
||||||
|
await expect(service.delete(1, 999)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { CorrespondenceController } from './correspondence.controller';
|
import { CorrespondenceController } from './correspondence.controller';
|
||||||
|
import { CorrespondenceService } from './correspondence.service';
|
||||||
|
|
||||||
describe('CorrespondenceController', () => {
|
describe('CorrespondenceController', () => {
|
||||||
let controller: CorrespondenceController;
|
let controller: CorrespondenceController;
|
||||||
@@ -7,6 +8,20 @@ describe('CorrespondenceController', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [CorrespondenceController],
|
controllers: [CorrespondenceController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: CorrespondenceService,
|
||||||
|
useValue: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
submit: jest.fn(),
|
||||||
|
processAction: jest.fn(),
|
||||||
|
getReferences: jest.fn(),
|
||||||
|
addReference: jest.fn(),
|
||||||
|
removeReference: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<CorrespondenceController>(CorrespondenceController);
|
controller = module.get<CorrespondenceController>(CorrespondenceController);
|
||||||
|
|||||||
@@ -5,91 +5,118 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
Param, // <--- ✅ 1. เพิ่ม Param
|
Param,
|
||||||
ParseIntPipe, // <--- ✅ 2. เพิ่ม ParseIntPipe
|
ParseIntPipe,
|
||||||
|
Query,
|
||||||
|
Delete,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CorrespondenceService } from './correspondence.service.js';
|
import {
|
||||||
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js';
|
ApiTags,
|
||||||
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto.js'; // <--- ✅ 3. เพิ่ม Import DTO นี้
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CorrespondenceService } from './correspondence.service';
|
||||||
|
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
|
||||||
|
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto';
|
||||||
|
import { WorkflowActionDto } from './dto/workflow-action.dto';
|
||||||
|
import { AddReferenceDto } from './dto/add-reference.dto';
|
||||||
|
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
|
||||||
|
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { RbacGuard } from '../../common/guards/rbac.guard.js';
|
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { Audit } from '../../common/decorators/audit.decorator';
|
||||||
import { WorkflowActionDto } from './dto/workflow-action.dto.js';
|
|
||||||
// ... imports ...
|
|
||||||
import { AddReferenceDto } from './dto/add-reference.dto.js';
|
|
||||||
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto.js';
|
|
||||||
import { Query, Delete } from '@nestjs/common'; // เพิ่ม Query, Delete
|
|
||||||
import { Audit } from '../../common/decorators/audit.decorator'; // Import
|
|
||||||
|
|
||||||
|
@ApiTags('Correspondences')
|
||||||
@Controller('correspondences')
|
@Controller('correspondences')
|
||||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
export class CorrespondenceController {
|
export class CorrespondenceController {
|
||||||
constructor(private readonly correspondenceService: CorrespondenceService) {}
|
constructor(private readonly correspondenceService: CorrespondenceService) {}
|
||||||
|
|
||||||
@Post(':id/workflow/action')
|
@Post(':id/workflow/action')
|
||||||
@RequirePermission('workflow.action_review') // สิทธิ์ในการกดอนุมัติ/ตรวจสอบ
|
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
|
||||||
|
@RequirePermission('workflow.action_review')
|
||||||
processAction(
|
processAction(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() actionDto: WorkflowActionDto,
|
@Body() actionDto: WorkflowActionDto,
|
||||||
@Request() req: any,
|
@Request() req: any
|
||||||
) {
|
) {
|
||||||
return this.correspondenceService.processAction(id, actionDto, req.user);
|
return this.correspondenceService.processAction(id, actionDto, req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('correspondence.create') // 🔒 ต้องมีสิทธิ์สร้าง
|
@ApiOperation({ summary: 'Create new correspondence' })
|
||||||
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Correspondence created successfully.',
|
||||||
|
type: CreateCorrespondenceDto,
|
||||||
|
})
|
||||||
|
@RequirePermission('correspondence.create')
|
||||||
|
@Audit('correspondence.create', 'correspondence')
|
||||||
create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) {
|
create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) {
|
||||||
return this.correspondenceService.create(createDto, req.user);
|
return this.correspondenceService.create(createDto, req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ ปรับปรุง findAll ให้รับ Query Params
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Search correspondences' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Return list of correspondences.' })
|
||||||
@RequirePermission('document.view')
|
@RequirePermission('document.view')
|
||||||
findAll(@Query() searchDto: SearchCorrespondenceDto) {
|
findAll(@Query() searchDto: SearchCorrespondenceDto) {
|
||||||
return this.correspondenceService.findAll(searchDto);
|
return this.correspondenceService.findAll(searchDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ เพิ่ม Endpoint นี้ครับ
|
|
||||||
@Post(':id/submit')
|
@Post(':id/submit')
|
||||||
@RequirePermission('correspondence.create') // หรือจะสร้าง Permission ใหม่ 'workflow.submit' ก็ได้
|
@ApiOperation({ summary: 'Submit correspondence to workflow' })
|
||||||
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Correspondence submitted successfully.',
|
||||||
|
})
|
||||||
|
@RequirePermission('correspondence.create')
|
||||||
|
@Audit('correspondence.create', 'correspondence')
|
||||||
submit(
|
submit(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() submitDto: SubmitCorrespondenceDto,
|
@Body() submitDto: SubmitCorrespondenceDto,
|
||||||
@Request() req: any,
|
@Request() req: any
|
||||||
) {
|
) {
|
||||||
return this.correspondenceService.submit(
|
return this.correspondenceService.submit(
|
||||||
id,
|
id,
|
||||||
submitDto.templateId,
|
submitDto.templateId,
|
||||||
req.user,
|
req.user
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- REFERENCES ---
|
|
||||||
|
|
||||||
@Get(':id/references')
|
@Get(':id/references')
|
||||||
|
@ApiOperation({ summary: 'Get referenced documents' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Return list of referenced documents.',
|
||||||
|
})
|
||||||
@RequirePermission('document.view')
|
@RequirePermission('document.view')
|
||||||
getReferences(@Param('id', ParseIntPipe) id: number) {
|
getReferences(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.correspondenceService.getReferences(id);
|
return this.correspondenceService.getReferences(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/references')
|
@Post(':id/references')
|
||||||
@RequirePermission('document.edit') // ต้องมีสิทธิ์แก้ไขถึงจะเพิ่ม Ref ได้
|
@ApiOperation({ summary: 'Add reference to another document' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Reference added successfully.' })
|
||||||
|
@RequirePermission('document.edit')
|
||||||
addReference(
|
addReference(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: AddReferenceDto,
|
@Body() dto: AddReferenceDto
|
||||||
) {
|
) {
|
||||||
return this.correspondenceService.addReference(id, dto);
|
return this.correspondenceService.addReference(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/references/:targetId')
|
@Delete(':id/references/:targetId')
|
||||||
|
@ApiOperation({ summary: 'Remove reference' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Reference removed successfully.' })
|
||||||
@RequirePermission('document.edit')
|
@RequirePermission('document.edit')
|
||||||
removeReference(
|
removeReference(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Param('targetId', ParseIntPipe) targetId: number,
|
@Param('targetId', ParseIntPipe) targetId: number
|
||||||
) {
|
) {
|
||||||
return this.correspondenceService.removeReference(id, targetId);
|
return this.correspondenceService.removeReference(id, targetId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { IsInt, IsNotEmpty } from 'class-validator';
|
import { IsInt, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class AddReferenceDto {
|
export class AddReferenceDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Target Correspondence ID to reference',
|
||||||
|
example: 20,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
targetId!: number;
|
targetId!: number;
|
||||||
|
|||||||
@@ -1,33 +1,43 @@
|
|||||||
import { IsOptional, IsString, IsInt } from 'class-validator';
|
import { IsOptional, IsString, IsInt } from 'class-validator';
|
||||||
import { Type } from 'class-transformer'; // <--- ✅ Import จาก class-transformer
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class SearchCorrespondenceDto {
|
export class SearchCorrespondenceDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Search term (Title or Document Number)',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string; // ค้นหาจาก Title หรือ Number
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by Document Type ID' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
typeId?: number;
|
typeId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by Project ID' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
projectId?: number;
|
projectId?: number;
|
||||||
|
|
||||||
// status อาจจะซับซ้อนหน่อยเพราะอยู่ที่ Revision แต่ใส่ไว้ก่อน
|
@ApiPropertyOptional({ description: 'Filter by Status ID' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
statusId?: number;
|
statusId?: number;
|
||||||
|
|
||||||
// Pagination
|
@ApiPropertyOptional({ description: 'Page number (default 1)', default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Items per page (default 10)',
|
||||||
|
default: 10,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { IsInt, IsNotEmpty } from 'class-validator';
|
import { IsInt, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class SubmitCorrespondenceDto {
|
export class SubmitCorrespondenceDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'ID of the Workflow Template to start',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
templateId!: number;
|
templateId!: number;
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { IsEnum, IsString, IsOptional, IsInt } from 'class-validator';
|
import { IsEnum, IsString, IsOptional, IsInt } from 'class-validator';
|
||||||
import { WorkflowAction } from '../../workflow-engine/interfaces/workflow.interface.js';
|
import { WorkflowAction } from '../../workflow-engine/interfaces/workflow.interface';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class WorkflowActionDto {
|
export class WorkflowActionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Workflow Action',
|
||||||
|
enum: ['APPROVE', 'REJECT', 'RETURN', 'CANCEL', 'ACKNOWLEDGE'],
|
||||||
|
})
|
||||||
@IsEnum(WorkflowAction)
|
@IsEnum(WorkflowAction)
|
||||||
action!: WorkflowAction; // APPROVE, REJECT, RETURN, ACKNOWLEDGE
|
action!: WorkflowAction; // APPROVE, REJECT, RETURN, ACKNOWLEDGE
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Review comments',
|
||||||
|
example: 'Approved with note...',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
comments?: string;
|
comments?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sequence to return to (only for RETURN action)',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
returnToSequence?: number; // ใช้กรณี action = RETURN
|
returnToSequence?: number; // ใช้กรณี action = RETURN
|
||||||
|
|||||||
@@ -1,18 +1,181 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { DocumentNumberingService } from './document-numbering.service';
|
import { DocumentNumberingService } from './document-numbering.service';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Repository, OptimisticLockVersionMismatchError } from 'typeorm';
|
||||||
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
|
||||||
|
import { DocumentNumberFormat } from './entities/document-number-format.entity';
|
||||||
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
import { Organization } from '../project/entities/organization.entity';
|
||||||
|
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||||
|
import { Discipline } from '../master/entities/discipline.entity';
|
||||||
|
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
|
||||||
|
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
|
||||||
|
import { DocumentNumberError } from './entities/document-number-error.entity';
|
||||||
|
|
||||||
|
// Mock Redis and Redlock
|
||||||
|
const mockRedis = {
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockRedlock = {
|
||||||
|
acquire: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockLock = {
|
||||||
|
release: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('ioredis', () => {
|
||||||
|
return jest.fn().mockImplementation(() => mockRedis);
|
||||||
|
});
|
||||||
|
jest.mock('redlock', () => {
|
||||||
|
return jest.fn().mockImplementation(() => {
|
||||||
|
return mockRedlock;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('DocumentNumberingService', () => {
|
describe('DocumentNumberingService', () => {
|
||||||
let service: DocumentNumberingService;
|
let service: DocumentNumberingService;
|
||||||
|
let module: TestingModule;
|
||||||
|
let counterRepo: Repository<DocumentNumberCounter>;
|
||||||
|
let formatRepo: Repository<DocumentNumberFormat>;
|
||||||
|
let auditRepo: Repository<DocumentNumberAudit>;
|
||||||
|
|
||||||
|
const mockProject = { id: 1, projectCode: 'LCBP3' };
|
||||||
|
const mockOrg = { id: 1, name: 'Google' };
|
||||||
|
const mockType = { id: 1, typeCode: 'COR' };
|
||||||
|
const mockDiscipline = { id: 1, code: 'CIV' };
|
||||||
|
|
||||||
|
const mockContext = {
|
||||||
|
projectId: 1,
|
||||||
|
originatorId: 1,
|
||||||
|
typeId: 1,
|
||||||
|
disciplineId: 1,
|
||||||
|
year: 2025,
|
||||||
|
customTokens: { TYPE_CODE: 'COR', ORG_CODE: 'GGL' },
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
mockRedlock.acquire.mockResolvedValue(mockLock);
|
||||||
providers: [DocumentNumberingService],
|
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DocumentNumberingService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: { get: jest.fn().mockReturnValue('localhost') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(DocumentNumberCounter),
|
||||||
|
useValue: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
create: jest.fn().mockReturnValue({ lastNumber: 0 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(DocumentNumberFormat),
|
||||||
|
useValue: { findOne: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(DocumentNumberAudit),
|
||||||
|
useValue: { save: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(DocumentNumberError),
|
||||||
|
useValue: { save: jest.fn() },
|
||||||
|
},
|
||||||
|
// Mock other dependencies used inside generateNextNumber lookups
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Project),
|
||||||
|
useValue: { findOne: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Organization),
|
||||||
|
useValue: { findOne: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(CorrespondenceType),
|
||||||
|
useValue: { findOne: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Discipline),
|
||||||
|
useValue: { findOne: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(CorrespondenceSubType),
|
||||||
|
useValue: { findOne: jest.fn() },
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<DocumentNumberingService>(DocumentNumberingService);
|
service = module.get<DocumentNumberingService>(DocumentNumberingService);
|
||||||
|
counterRepo = module.get(getRepositoryToken(DocumentNumberCounter));
|
||||||
|
formatRepo = module.get(getRepositoryToken(DocumentNumberFormat));
|
||||||
|
auditRepo = module.get(getRepositoryToken(DocumentNumberAudit));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service.onModuleDestroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('generateNextNumber', () => {
|
||||||
|
it('should generate a new number successfully', async () => {
|
||||||
|
const projectRepo = module.get(getRepositoryToken(Project));
|
||||||
|
const orgRepo = module.get(getRepositoryToken(Organization));
|
||||||
|
const typeRepo = module.get(getRepositoryToken(CorrespondenceType));
|
||||||
|
const disciplineRepo = module.get(getRepositoryToken(Discipline));
|
||||||
|
|
||||||
|
(projectRepo.findOne as jest.Mock).mockResolvedValue(mockProject);
|
||||||
|
(orgRepo.findOne as jest.Mock).mockResolvedValue(mockOrg);
|
||||||
|
(typeRepo.findOne as jest.Mock).mockResolvedValue(mockType);
|
||||||
|
(disciplineRepo.findOne as jest.Mock).mockResolvedValue(mockDiscipline);
|
||||||
|
(formatRepo.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
formatTemplate: '{SEQ}',
|
||||||
|
});
|
||||||
|
(counterRepo.findOne as jest.Mock).mockResolvedValue(null); // First time
|
||||||
|
(counterRepo.save as jest.Mock).mockResolvedValue({ lastNumber: 1 });
|
||||||
|
|
||||||
|
service.onModuleInit();
|
||||||
|
|
||||||
|
const result = await service.generateNextNumber(mockContext);
|
||||||
|
|
||||||
|
expect(result).toBe('000001'); // Default padding 6
|
||||||
|
expect(counterRepo.save).toHaveBeenCalled();
|
||||||
|
expect(auditRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw InternalServerErrorException if max retries exceeded', async () => {
|
||||||
|
const projectRepo = module.get(getRepositoryToken(Project));
|
||||||
|
const orgRepo = module.get(getRepositoryToken(Organization));
|
||||||
|
const typeRepo = module.get(getRepositoryToken(CorrespondenceType));
|
||||||
|
const disciplineRepo = module.get(getRepositoryToken(Discipline));
|
||||||
|
|
||||||
|
(projectRepo.findOne as jest.Mock).mockResolvedValue(mockProject);
|
||||||
|
(orgRepo.findOne as jest.Mock).mockResolvedValue(mockOrg);
|
||||||
|
(typeRepo.findOne as jest.Mock).mockResolvedValue(mockType);
|
||||||
|
(disciplineRepo.findOne as jest.Mock).mockResolvedValue(mockDiscipline);
|
||||||
|
(formatRepo.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
formatTemplate: '{SEQ}',
|
||||||
|
});
|
||||||
|
(counterRepo.findOne as jest.Mock).mockResolvedValue({ lastNumber: 1 });
|
||||||
|
|
||||||
|
// Always fail
|
||||||
|
(counterRepo.save as jest.Mock).mockRejectedValue(
|
||||||
|
new OptimisticLockVersionMismatchError('Counter', 1, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
service.onModuleInit();
|
||||||
|
|
||||||
|
await expect(service.generateNextNumber(mockContext)).rejects.toThrow(
|
||||||
|
InternalServerErrorException
|
||||||
|
);
|
||||||
|
expect(counterRepo.save).toHaveBeenCalledTimes(3); // Max retries
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,66 +1,204 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { WorkflowEngineService } from './workflow-engine.service';
|
import { WorkflowEngineService } from './workflow-engine.service';
|
||||||
import { WorkflowAction } from './interfaces/workflow.interface';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||||
|
import {
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowStatus,
|
||||||
|
} from './entities/workflow-instance.entity';
|
||||||
|
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||||
|
import { WorkflowDslService } from './workflow-dsl.service';
|
||||||
|
import { WorkflowEventService } from './workflow-event.service';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
||||||
|
|
||||||
describe('WorkflowEngineService', () => {
|
describe('WorkflowEngineService', () => {
|
||||||
let service: WorkflowEngineService;
|
let service: WorkflowEngineService;
|
||||||
|
let defRepo: Repository<WorkflowDefinition>;
|
||||||
|
let instanceRepo: Repository<WorkflowInstance>;
|
||||||
|
let dslService: WorkflowDslService;
|
||||||
|
let eventService: WorkflowEventService;
|
||||||
|
|
||||||
|
// Mock Objects
|
||||||
|
const mockQueryRunner = {
|
||||||
|
connect: jest.fn(),
|
||||||
|
startTransaction: jest.fn(),
|
||||||
|
commitTransaction: jest.fn(),
|
||||||
|
rollbackTransaction: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
manager: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDataSource = {
|
||||||
|
createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDslService = {
|
||||||
|
compile: jest.fn(),
|
||||||
|
evaluate: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEventService = {
|
||||||
|
dispatchEvents: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCompiledWorkflow = {
|
||||||
|
initialState: 'START',
|
||||||
|
states: {
|
||||||
|
START: { transitions: { SUBMIT: 'PENDING' } },
|
||||||
|
PENDING: { transitions: { APPROVE: 'APPROVED', REJECT: 'REJECTED' } },
|
||||||
|
APPROVED: { terminal: true },
|
||||||
|
REJECTED: { terminal: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [WorkflowEngineService],
|
providers: [
|
||||||
|
WorkflowEngineService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(WorkflowDefinition),
|
||||||
|
useValue: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(WorkflowInstance),
|
||||||
|
useValue: {
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(WorkflowHistory),
|
||||||
|
useValue: {
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: WorkflowDslService, useValue: mockDslService },
|
||||||
|
{ provide: WorkflowEventService, useValue: mockEventService },
|
||||||
|
{ provide: DataSource, useValue: mockDataSource },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<WorkflowEngineService>(WorkflowEngineService);
|
service = module.get<WorkflowEngineService>(WorkflowEngineService);
|
||||||
|
defRepo = module.get(getRepositoryToken(WorkflowDefinition));
|
||||||
|
instanceRepo = module.get(getRepositoryToken(WorkflowInstance));
|
||||||
|
dslService = module.get(WorkflowDslService);
|
||||||
|
eventService = module.get(WorkflowEventService);
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('processAction', () => {
|
describe('createDefinition', () => {
|
||||||
// 🟢 กรณี: อนุมัติทั่วไป (ไปขั้นต่อไป)
|
it('should create a new definition version', async () => {
|
||||||
it('should move to next step on APPROVE', () => {
|
const dto = {
|
||||||
const result = service.processAction(1, 3, WorkflowAction.APPROVE);
|
workflow_code: 'WF01',
|
||||||
expect(result.nextStepSequence).toBe(2);
|
dsl: {},
|
||||||
expect(result.shouldUpdateStatus).toBe(false);
|
} as CreateWorkflowDefinitionDto;
|
||||||
|
mockDslService.compile.mockReturnValue(mockCompiledWorkflow);
|
||||||
|
(defRepo.findOne as jest.Mock).mockResolvedValue({ version: 1 });
|
||||||
|
(defRepo.create as jest.Mock).mockReturnValue({ version: 2 });
|
||||||
|
(defRepo.save as jest.Mock).mockResolvedValue({
|
||||||
|
version: 2,
|
||||||
|
workflow_code: 'WF01',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🟢 กรณี: อนุมัติขั้นตอนสุดท้าย (จบงาน)
|
const result = await service.createDefinition(dto);
|
||||||
it('should complete workflow on APPROVE at last step', () => {
|
|
||||||
const result = service.processAction(3, 3, WorkflowAction.APPROVE);
|
expect(dslService.compile).toHaveBeenCalledWith(dto.dsl);
|
||||||
expect(result.nextStepSequence).toBeNull(); // ไม่มีขั้นต่อไป
|
expect(defRepo.create).toHaveBeenCalledWith(
|
||||||
expect(result.shouldUpdateStatus).toBe(true);
|
expect.objectContaining({ version: 2 })
|
||||||
expect(result.documentStatus).toBe('COMPLETED');
|
);
|
||||||
|
expect(result).toEqual(expect.objectContaining({ version: 2 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔴 กรณี: ปฏิเสธ (จบงานทันที)
|
describe('createInstance', () => {
|
||||||
it('should stop workflow on REJECT', () => {
|
it('should create a new instance with initial state', async () => {
|
||||||
const result = service.processAction(1, 3, WorkflowAction.REJECT);
|
const mockDef = {
|
||||||
expect(result.nextStepSequence).toBeNull();
|
id: 'def-1',
|
||||||
expect(result.shouldUpdateStatus).toBe(true);
|
compiled: mockCompiledWorkflow,
|
||||||
expect(result.documentStatus).toBe('REJECTED');
|
};
|
||||||
|
|
||||||
|
(defRepo.findOne as jest.Mock).mockResolvedValue(mockDef);
|
||||||
|
(instanceRepo.create as jest.Mock).mockReturnValue({
|
||||||
|
id: 'inst-1',
|
||||||
|
currentState: 'START',
|
||||||
|
});
|
||||||
|
(instanceRepo.save as jest.Mock).mockResolvedValue({ id: 'inst-1' });
|
||||||
|
|
||||||
|
const result = await service.createInstance('WF01', 'DOC', '101');
|
||||||
|
|
||||||
|
expect(instanceRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
currentState: 'START',
|
||||||
|
entityId: '101',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🟠 กรณี: ส่งกลับ (ย้อนกลับ 1 ขั้น)
|
it('should throw NotFoundException if definition not found', async () => {
|
||||||
it('should return to previous step on RETURN', () => {
|
(defRepo.findOne as jest.Mock).mockResolvedValue(null);
|
||||||
const result = service.processAction(2, 3, WorkflowAction.RETURN);
|
await expect(
|
||||||
expect(result.nextStepSequence).toBe(1);
|
service.createInstance('WF01', 'DOC', '101')
|
||||||
expect(result.shouldUpdateStatus).toBe(true);
|
).rejects.toThrow(NotFoundException);
|
||||||
expect(result.documentStatus).toBe('REVISE_REQUIRED');
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🟠 กรณี: ส่งกลับ (ระบุขั้น)
|
describe('processTransition', () => {
|
||||||
it('should return to specific step on RETURN', () => {
|
it('should process transition successfully and commit transaction', async () => {
|
||||||
const result = service.processAction(3, 5, WorkflowAction.RETURN, 1);
|
const instanceId = 'inst-1';
|
||||||
expect(result.nextStepSequence).toBe(1);
|
const mockInstance = {
|
||||||
|
id: instanceId,
|
||||||
|
currentState: 'PENDING',
|
||||||
|
status: WorkflowStatus.ACTIVE,
|
||||||
|
definition: { compiled: mockCompiledWorkflow },
|
||||||
|
context: { some: 'data' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Pessimistic Lock Find
|
||||||
|
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
||||||
|
|
||||||
|
// Mock DSL Evaluation
|
||||||
|
mockDslService.evaluate.mockReturnValue({
|
||||||
|
nextState: 'APPROVED',
|
||||||
|
events: [{ type: 'NOTIFY' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ❌ กรณี: Error (ส่งกลับต่ำกว่า 1)
|
const result = await service.processTransition(instanceId, 'APPROVE', 1);
|
||||||
it('should throw error if return step is invalid', () => {
|
|
||||||
expect(() => {
|
expect(mockQueryRunner.startTransaction).toHaveBeenCalled();
|
||||||
service.processAction(1, 3, WorkflowAction.RETURN);
|
expect(mockDslService.evaluate).toHaveBeenCalled();
|
||||||
}).toThrow(BadRequestException);
|
expect(mockQueryRunner.manager.save).toHaveBeenCalledTimes(2); // Instance + History
|
||||||
|
expect(mockQueryRunner.commitTransaction).toHaveBeenCalled();
|
||||||
|
expect(eventService.dispatchEvents).toHaveBeenCalled(); // Should dispatch events
|
||||||
|
expect(result.nextState).toBe('APPROVED');
|
||||||
|
expect(result.isCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback transaction on error', async () => {
|
||||||
|
mockQueryRunner.manager.findOne.mockRejectedValue(new Error('DB Error'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.processTransition('inst-1', 'APPROVE', 1)
|
||||||
|
).rejects.toThrow('DB Error');
|
||||||
|
|
||||||
|
expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled();
|
||||||
|
expect(mockQueryRunner.release).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
4
backend/tsconfig.doc.json
Normal file
4
backend/tsconfig.doc.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "**/*spec.ts"]
|
||||||
|
}
|
||||||
16
frontend/app/(dashboard)/circulation/page.tsx
Normal file
16
frontend/app/(dashboard)/circulation/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// File: e:/np-dms/lcbp3/frontend/app/(dashboard)/circulation/page.tsx
|
||||||
|
// Change Log: Added circulation page under dashboard layout
|
||||||
|
|
||||||
|
import CirculationList from "@/components/CirculationList";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* หน้าแสดงรายการการหมุนเวียนเอกสาร (อยู่ใน Dashboard)
|
||||||
|
*/
|
||||||
|
export default function CirculationPage() {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Circulation</h1>
|
||||||
|
<CirculationList />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
"doc": "docs",
|
"doc": "docs",
|
||||||
"example": "examples"
|
"example": "examples"
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
|
|||||||
2283
pnpm-lock.yaml
generated
2283
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -127,14 +127,14 @@ graph TB
|
|||||||
### Phase 1: Foundation (2-3 weeks)
|
### Phase 1: Foundation (2-3 weeks)
|
||||||
|
|
||||||
| ID | Task | Priority | Effort | Status | Dependencies |
|
| ID | Task | Priority | Effort | Status | Dependencies |
|
||||||
| ---------------------------------------------- | --------------------------- | -------- | -------- | -------------- | ------------ |
|
| ---------------------------------------------- | --------------------------- | -------- | -------- | ------------- | ------------ |
|
||||||
| [BE-001](./TASK-BE-001-database-migrations.md) | Database Setup & Migrations | P0 | 2-3 days | 🔴 Not Started | None |
|
| [BE-001](./TASK-BE-001-database-migrations.md) | Database Setup & Migrations | P0 | 2-3 days | 🔴 Not Started | None |
|
||||||
| [BE-002](./TASK-BE-002-auth-rbac.md) | Auth & RBAC Module | P0 | 5-7 days | 🔴 Not Started | BE-001 |
|
| [BE-002](./TASK-BE-002-auth-rbac.md) | Auth & RBAC Module | P0 | 5-7 days | 🔴 Not Started | BE-001 |
|
||||||
|
|
||||||
### Phase 2: Core Infrastructure (3-4 weeks)
|
### Phase 2: Core Infrastructure (3-4 weeks)
|
||||||
|
|
||||||
| ID | Task | Priority | Effort | Status | Dependencies |
|
| ID | Task | Priority | Effort | Status | Dependencies |
|
||||||
| ------------------------------------------------- | -------------------------- | -------- | ---------- | -------------- | -------------- |
|
| ------------------------------------------------- | -------------------------- | -------- | ---------- | ------------- | -------------- |
|
||||||
| [BE-013](./TASK-BE-013-user-management.md) | User Management | P1 | 5-7 days | 🔴 Not Started | BE-001, BE-002 |
|
| [BE-013](./TASK-BE-013-user-management.md) | User Management | P1 | 5-7 days | 🔴 Not Started | BE-001, BE-002 |
|
||||||
| [BE-012](./TASK-BE-012-master-data-management.md) | Master Data Management | P1 | 6-8 days | 🔴 Not Started | BE-001, BE-002 |
|
| [BE-012](./TASK-BE-012-master-data-management.md) | Master Data Management | P1 | 6-8 days | 🔴 Not Started | BE-001, BE-002 |
|
||||||
| [BE-003](./TASK-BE-003-file-storage.md) | File Storage (Two-Phase) | P1 | 4-5 days | 🔴 Not Started | BE-001, BE-002 |
|
| [BE-003](./TASK-BE-003-file-storage.md) | File Storage (Two-Phase) | P1 | 4-5 days | 🔴 Not Started | BE-001, BE-002 |
|
||||||
@@ -144,14 +144,14 @@ graph TB
|
|||||||
### Phase 3: Business Modules (4-5 weeks)
|
### Phase 3: Business Modules (4-5 weeks)
|
||||||
|
|
||||||
| ID | Task | Priority | Effort | Status | Dependencies |
|
| ID | Task | Priority | Effort | Status | Dependencies |
|
||||||
| ------------------------------------------------ | --------------------- | -------- | --------- | -------------- | ---------------------------------- |
|
| ------------------------------------------------ | --------------------- | -------- | --------- | ------------- | ---------------------------------- |
|
||||||
| [BE-005](./TASK-BE-005-correspondence-module.md) | Correspondence Module | P1 | 7-10 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
|
| [BE-005](./TASK-BE-005-correspondence-module.md) | Correspondence Module | P1 | 7-10 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
|
||||||
| [BE-007](./TASK-BE-007-rfa-module.md) | RFA Module | P1 | 8-12 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
|
| [BE-007](./TASK-BE-007-rfa-module.md) | RFA Module | P1 | 8-12 days | 🔴 Not Started | BE-001~004, BE-006, BE-012, BE-013 |
|
||||||
|
|
||||||
### Phase 4: Supporting Modules (2-3 weeks)
|
### Phase 4: Supporting Modules (2-3 weeks)
|
||||||
|
|
||||||
| ID | Task | Priority | Effort | Status | Dependencies |
|
| ID | Task | Priority | Effort | Status | Dependencies |
|
||||||
| -------------------------------------------------- | ------------------------- | -------- | -------- | -------------- | -------------------------- |
|
| -------------------------------------------------- | ------------------------- | -------- | -------- | ------------- | -------------------------- |
|
||||||
| [BE-008](./TASK-BE-008-drawing-module.md) | Drawing Module | P2 | 6-8 days | 🔴 Not Started | BE-001~004, BE-012 |
|
| [BE-008](./TASK-BE-008-drawing-module.md) | Drawing Module | P2 | 6-8 days | 🔴 Not Started | BE-001~004, BE-012 |
|
||||||
| [BE-009](./TASK-BE-009-circulation-transmittal.md) | Circulation & Transmittal | P2 | 5-7 days | 🔴 Not Started | BE-001~003, BE-006, BE-012 |
|
| [BE-009](./TASK-BE-009-circulation-transmittal.md) | Circulation & Transmittal | P2 | 5-7 days | 🔴 Not Started | BE-001~003, BE-006, BE-012 |
|
||||||
| [BE-010](./TASK-BE-010-search-elasticsearch.md) | Search & Elasticsearch | P2 | 4-6 days | 🔴 Not Started | BE-001, BE-005, BE-007 |
|
| [BE-010](./TASK-BE-010-search-elasticsearch.md) | Search & Elasticsearch | P2 | 4-6 days | 🔴 Not Started | BE-001, BE-005, BE-007 |
|
||||||
@@ -159,7 +159,7 @@ graph TB
|
|||||||
### Phase 5: Supporting Services (1 week)
|
### Phase 5: Supporting Services (1 week)
|
||||||
|
|
||||||
| ID | Task | Priority | Effort | Status | Dependencies |
|
| ID | Task | Priority | Effort | Status | Dependencies |
|
||||||
| --------------------------------------------- | ------------------------ | -------- | -------- | -------------- | -------------- |
|
| --------------------------------------------- | ------------------------ | -------- | -------- | ------------- | -------------- |
|
||||||
| [BE-011](./TASK-BE-011-notification-audit.md) | Notification & Audit Log | P3 | 3-5 days | 🔴 Not Started | BE-001, BE-002 |
|
| [BE-011](./TASK-BE-011-notification-audit.md) | Notification & Audit Log | P3 | 3-5 days | 🔴 Not Started | BE-001, BE-002 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -191,9 +191,10 @@ graph TB
|
|||||||
### Phase 4: Supporting Features (Week 9)
|
### Phase 4: Supporting Features (Week 9)
|
||||||
|
|
||||||
| Task | Title | Priority | Effort | Dependencies |
|
| Task | Title | Priority | Effort | Dependencies |
|
||||||
| ------------------------------------------------------- | ---------------------------- | -------- | -------- | -------------- |
|
| ---------------------------------------------------------- | ---------------------------- | -------- | -------- | -------------- |
|
||||||
| [TASK-FE-008](./TASK-FE-008-search-ui.md) | Search & Global Filters | P2 | 3-4 days | FE-003, BE-010 |
|
| [TASK-FE-008](./TASK-FE-008-search-ui.md) | Search & Global Filters | P2 | 3-4 days | FE-003, BE-010 |
|
||||||
| [TASK-FE-009](./TASK-FE-009-dashboard-notifications.md) | Dashboard & Notifications UI | P3 | 3-4 days | FE-003, BE-011 |
|
| [TASK-FE-009](./TASK-FE-009-dashboard-notifications.md) | Dashboard & Notifications UI | P3 | 3-4 days | FE-003, BE-011 |
|
||||||
|
| [TASK-FE-013](./TASK-FE-013-circulation-transmittal-ui.md) | Circulation & Transmittal UI | P2 | 5-7 days | FE-005, BE-009 |
|
||||||
|
|
||||||
### Phase 5: Administration (Weeks 10-11)
|
### Phase 5: Administration (Weeks 10-11)
|
||||||
|
|
||||||
@@ -202,6 +203,8 @@ graph TB
|
|||||||
| [TASK-FE-010](./TASK-FE-010-admin-panel.md) | Admin Panel & Settings UI | P2 | 5-7 days | FE-002, FE-005, BE-012, BE-013 |
|
| [TASK-FE-010](./TASK-FE-010-admin-panel.md) | Admin Panel & Settings UI | P2 | 5-7 days | FE-002, FE-005, BE-012, BE-013 |
|
||||||
| [TASK-FE-011](./TASK-FE-011-workflow-config-ui.md) | Workflow Configuration UI | P2 | 5-7 days | FE-010, BE-006 |
|
| [TASK-FE-011](./TASK-FE-011-workflow-config-ui.md) | Workflow Configuration UI | P2 | 5-7 days | FE-010, BE-006 |
|
||||||
| [TASK-FE-012](./TASK-FE-012-numbering-config-ui.md) | Document Numbering Config UI | P2 | 3-4 days | FE-010, BE-004 |
|
| [TASK-FE-012](./TASK-FE-012-numbering-config-ui.md) | Document Numbering Config UI | P2 | 3-4 days | FE-010, BE-004 |
|
||||||
|
| [TASK-FE-014](./TASK-FE-014-reference-data-ui.md) | Reference Data & Lookups UI | P3 | 3-5 days | FE-010, BE-012 |
|
||||||
|
| [TASK-FE-015](./TASK-FE-015-security-admin-ui.md) | Security & System Admin UI | P2 | 5-7 days | FE-010, BE-002, BE-011 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -231,7 +234,7 @@ graph TB
|
|||||||
#### Sprint 9-10: Supporting Modules (4 weeks)
|
#### Sprint 9-10: Supporting Modules (4 weeks)
|
||||||
|
|
||||||
- Week 18-19: Drawing Module (BE-008)
|
- Week 18-19: Drawing Module (BE-008)
|
||||||
- Week 20: Circulation & Transmittal (BE-009)
|
- Week 20: Circulation & Transmittal (BE-009, FE-013)
|
||||||
- Week 21: Search & Elasticsearch (BE-010)
|
- Week 21: Search & Elasticsearch (BE-010)
|
||||||
- _Milestone:_ Complete document ecosystem
|
- _Milestone:_ Complete document ecosystem
|
||||||
|
|
||||||
@@ -313,16 +316,6 @@ graph TB
|
|||||||
- Search & filter
|
- Search & filter
|
||||||
- **Why Critical:** Core business document type
|
- **Why Critical:** Core business document type
|
||||||
|
|
||||||
### TASK-BE-006: Workflow Engine
|
|
||||||
|
|
||||||
- **Type:** Core Infrastructure
|
|
||||||
- **Key Deliverables:**
|
|
||||||
- DSL parser and validator
|
|
||||||
- State machine management
|
|
||||||
- Guard and effect executors
|
|
||||||
- History tracking
|
|
||||||
- **Related ADR:** [ADR-001](../05-decisions/ADR-001-unified-workflow-engine.md)
|
|
||||||
|
|
||||||
### TASK-BE-007: RFA Module
|
### TASK-BE-007: RFA Module
|
||||||
|
|
||||||
- **Type:** Business Module
|
- **Type:** Business Module
|
||||||
@@ -377,7 +370,7 @@ graph TB
|
|||||||
|
|
||||||
## 🔗 Dependencies Graph
|
## 🔗 Dependencies Graph
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
BE-001 (Database)
|
BE-001 (Database)
|
||||||
├── BE-002 (Auth)
|
├── BE-002 (Auth)
|
||||||
│ ├── BE-004 (Doc Numbering)
|
│ ├── BE-004 (Doc Numbering)
|
||||||
|
|||||||
71
specs/06-tasks/TASK-BE-014-testing-documentation.md
Normal file
71
specs/06-tasks/TASK-BE-014-testing-documentation.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# TASK-BE-014: Backend Testing & Documentation Strategy
|
||||||
|
|
||||||
|
**Status:** Draft
|
||||||
|
**Owner:** TBD
|
||||||
|
**Priority:** High
|
||||||
|
**Related:**
|
||||||
|
|
||||||
|
- `specs/03-implementation/testing-strategy.md`
|
||||||
|
- `specs/03-implementation/backend-guidelines.md`
|
||||||
|
|
||||||
|
## 🎯 Objective
|
||||||
|
|
||||||
|
Establish a robust safety net and comprehensive documentation for the Backend (NestJS).
|
||||||
|
Goal: **Quality First, Self-Documenting Code.**
|
||||||
|
|
||||||
|
## 📋 Scope
|
||||||
|
|
||||||
|
### 1. Unit Testing (Target: 80% Coverage on Services)
|
||||||
|
|
||||||
|
Focus on Business Logic, not framework glue code.
|
||||||
|
|
||||||
|
- [/] **Unit Testing (Service Level):** <!-- In Progress -->
|
||||||
|
- [x] `DocumentNumberingService` (Mock Redis/Redlock, Test Optimistic Lock).
|
||||||
|
- [x] `FileStorageService` (Test Local Storage fs-extra).
|
||||||
|
- [x] `WorkflowEngineService` (Test state transitions/Guard validation).
|
||||||
|
- [x] `AuthService` (Critical: RBAC)
|
||||||
|
|
||||||
|
- [ ] **Feature Modules:**
|
||||||
|
- [ ] `CorrespondenceService` & `CorrespondenceWorkflowService`
|
||||||
|
- [ ] `RfaService` & `RfaWorkflowService`
|
||||||
|
- [ ] `TransmittalService` & `CirculationService`
|
||||||
|
|
||||||
|
### 2. Integration / E2E Testing (Target: Critical User Journeys)
|
||||||
|
|
||||||
|
Verify end-to-end flows using a Test Database (Dockerized MariaDB).
|
||||||
|
|
||||||
|
- [ ] **Infrastructure:**
|
||||||
|
- [ ] Ensure `docker-compose.test.yml` exists for isolated DB testing.
|
||||||
|
- [ ] Setup Global Setup/Teardown for Jest E2E.
|
||||||
|
- [ ] **Scenarios:**
|
||||||
|
- [ ] **Auth Flow:** Login -> JWT -> RBAC Rejection.
|
||||||
|
- [ ] **Document Lifecycle:** Create -> Upload -> Submit -> Approve -> Complete.
|
||||||
|
- [ ] **Search:** Create Doc -> Wait -> Search (Elasticsearch Mock/Real).
|
||||||
|
|
||||||
|
### 3. Documentation
|
||||||
|
|
||||||
|
- [/] **API Documentation (Swagger/OpenAPI):** <!-- In Progress -->
|
||||||
|
- [x] Ensure all DTOs have `@ApiProperty()` (Verified in CreateCorrespondenceDto and others).
|
||||||
|
- [x] Ensure all Controllers have `@ApiOperation()` and `@ApiResponse()` (Done for Auth & Correspondence).
|
||||||
|
- [ ] Verify `http://localhost:3000/docs` covers 100% of endpoints.
|
||||||
|
- [/] **Code Documentation (Compodoc):** <!-- In Progress -->
|
||||||
|
- [x] Install `@compodoc/compodoc`.
|
||||||
|
- [x] Configure `tsconfig.doc.json`.
|
||||||
|
- [x] Add `npm run doc` script.
|
||||||
|
- [ ] Generate static HTML documentation.
|
||||||
|
|
||||||
|
## 🛠Implementation Details
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
- **Unit/Integration:** `jest`, `ts-jest`, `@nestjs/testing`
|
||||||
|
- **E2E:** `supertest`
|
||||||
|
- **Docs:** `@nestjs/swagger`, `@compodoc/compodoc`
|
||||||
|
|
||||||
|
## ✅ Definition of Done
|
||||||
|
|
||||||
|
1. [ ] `npm run test` passes (Unit Tests).
|
||||||
|
2. [ ] `npm run test:e2e` passes (E2E Tests).
|
||||||
|
3. [ ] `npm run doc` generates valid HTML.
|
||||||
|
4. [ ] Swagger UI (`/docs`) is complete and usable.
|
||||||
|
5. [ ] **Testing Strategy Guide** is updated if new patterns emerge.
|
||||||
159
specs/06-tasks/TASK-FE-013-circulation-transmittal-ui.md
Normal file
159
specs/06-tasks/TASK-FE-013-circulation-transmittal-ui.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Task: Circulation & Transmittal UI
|
||||||
|
|
||||||
|
**Status:** Not Started
|
||||||
|
**Priority:** P2 (Medium)
|
||||||
|
**Estimated Effort:** 5-7 days
|
||||||
|
**Dependencies:** TASK-FE-005, TASK-BE-009
|
||||||
|
**Owner:** Frontend Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
Implement the **Circulation** (Internal Distribution) and **Transmittal** (External Submission) modules in the Frontend. These interfaces will allow users to manage document distribution, track assignees, and generate transmittal slips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Objectives
|
||||||
|
|
||||||
|
- ✅ **Circulation UI:** Create, View, and Track internal circulations.
|
||||||
|
- ✅ **Transmittal UI:** Create Transmittals, Manage Items, and Print/Export PDF.
|
||||||
|
- ✅ **Integration:** Connect with Backend APIs for data persistence and workflow actions.
|
||||||
|
- ✅ **UX/UI:** User-friendly document selection and assignee management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Acceptance Criteria
|
||||||
|
|
||||||
|
### 1. Circulation Module
|
||||||
|
|
||||||
|
- [ ] **List View:** Display circulations with status, due date, and progress indicators.
|
||||||
|
- [ ] **Create Form:**
|
||||||
|
- [ ] Select Subject/Title.
|
||||||
|
- [ ] **Assignee Selector:** Multi-select users for Main/Action/Info roles.
|
||||||
|
- [ ] **Document Linker:** Search and select existing Correspondence/RFAs to attach.
|
||||||
|
- [ ] **Detail View:**
|
||||||
|
- [ ] Show overall status.
|
||||||
|
- [ ] List of assignees with their individual status (Pending/Completed).
|
||||||
|
- [ ] Action button for Assignee to "Complete" their task with remarks.
|
||||||
|
|
||||||
|
### 2. Transmittal Module
|
||||||
|
|
||||||
|
- [ ] **List View:** Display transmittals with transmittal number, recipient, and date.
|
||||||
|
- [ ] **Create Form:**
|
||||||
|
- [ ] Header info (Attention To, Organization, Date).
|
||||||
|
- [ ] **Item Manager:** Add/Remove documents (Correspondence/RFA/Drawing) to the transmittal list.
|
||||||
|
- [ ] Specify "Number of Copies" for each item.
|
||||||
|
- [ ] **Detail View:** Read-only view of the transmittal slip.
|
||||||
|
- [ ] **PDF Export:** Button to download the generated Transmittal PDF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Implementation Steps
|
||||||
|
|
||||||
|
### 1. API Services & Types
|
||||||
|
|
||||||
|
Create TypeScript interfaces and API service methods.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types/circulation.ts
|
||||||
|
export interface Circulation {
|
||||||
|
id: number;
|
||||||
|
circulation_number: string;
|
||||||
|
subject: string;
|
||||||
|
due_date: string;
|
||||||
|
status: 'active' | 'completed';
|
||||||
|
assignees: CirculationAssignee[];
|
||||||
|
correspondences: Correspondence[]; // Linked docs
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CirculationAssignee {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string; // Mapped from User entity
|
||||||
|
status: 'pending' | 'completed';
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// services/circulation-service.ts
|
||||||
|
// - getCirculations(params)
|
||||||
|
// - getCirculationById(id)
|
||||||
|
// - createCirculation(data)
|
||||||
|
// - completeAssignment(id, assigneeId, data)
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types/transmittal.ts
|
||||||
|
export interface Transmittal {
|
||||||
|
id: number;
|
||||||
|
transmittal_number: string;
|
||||||
|
attention_to: string;
|
||||||
|
transmittal_date: string;
|
||||||
|
items: TransmittalItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransmittalItem {
|
||||||
|
document_type: 'correspondence' | 'rfa' | 'drawing';
|
||||||
|
document_id: number;
|
||||||
|
document_number: string;
|
||||||
|
document_title: string;
|
||||||
|
number_of_copies: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// services/transmittal-service.ts
|
||||||
|
// - getTransmittals(params)
|
||||||
|
// - getTransmittalById(id)
|
||||||
|
// - createTransmittal(data)
|
||||||
|
// - downloadTransmittalPDF(id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. UI Components
|
||||||
|
|
||||||
|
#### Circulation
|
||||||
|
|
||||||
|
- **`components/circulation/circulation-list.tsx`**: DataTable with custom columns.
|
||||||
|
- **`components/circulation/circulation-form.tsx`**:
|
||||||
|
- Use `Combobox` for searching Users.
|
||||||
|
- Use `DocumentSelector` (shared component) for linking Correspondence/RFAs.
|
||||||
|
- **`components/circulation/assignee-status-card.tsx`**: Component to show assignee progress.
|
||||||
|
|
||||||
|
#### Transmittal
|
||||||
|
|
||||||
|
- **`components/transmittal/transmittal-list.tsx`**: Standard DataTable.
|
||||||
|
- **`components/transmittal/transmittal-form.tsx`**:
|
||||||
|
- Header fields (Recipient, Date, etc.)
|
||||||
|
- **Items Table**: Dynamic rows to add documents.
|
||||||
|
- Column 1: Document Type (Select).
|
||||||
|
- Column 2: Document Search (AsyncSelect).
|
||||||
|
- Column 3: Copies (Input Number).
|
||||||
|
- Action: Remove Row.
|
||||||
|
|
||||||
|
### 3. Pages & Routing
|
||||||
|
|
||||||
|
- `app/(dashboard)/circulation/page.tsx`: List View
|
||||||
|
- `app/(dashboard)/circulation/new/page.tsx`: Create View
|
||||||
|
- `app/(dashboard)/circulation/[id]/page.tsx`: Detail View
|
||||||
|
- `app/(dashboard)/transmittals/page.tsx`: List View
|
||||||
|
- `app/(dashboard)/transmittals/new/page.tsx`: Create View
|
||||||
|
- `app/(dashboard)/transmittals/[id]/page.tsx`: Detail View
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Strategy
|
||||||
|
|
||||||
|
- **Unit Tests:** Test form validation logic (e.g., at least one assignee required).
|
||||||
|
- **Integration Tests:** Mock API calls to verify data loading and submission.
|
||||||
|
- **E2E Tests:**
|
||||||
|
1. Login as User A.
|
||||||
|
2. Create a Circulation and assign to User B.
|
||||||
|
3. Logout and Login as User B.
|
||||||
|
4. Verify notification/dashboard task.
|
||||||
|
5. Complete the assignment.
|
||||||
|
6. Verify Circulation status updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Resources
|
||||||
|
|
||||||
|
- [Figma Design - Circulation](https://figma.com/...) (Internal Link)
|
||||||
|
- [Backend Task: BE-009](../TASK-BE-009-circulation-transmittal.md)
|
||||||
116
specs/06-tasks/TASK-FE-014-reference-data-ui.md
Normal file
116
specs/06-tasks/TASK-FE-014-reference-data-ui.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# TASK-FE-014: Reference Data & Lookups UI
|
||||||
|
|
||||||
|
**ID:** TASK-FE-014
|
||||||
|
**Title:** Reference Data & Lookups Management UI
|
||||||
|
**Category:** Administration
|
||||||
|
**Priority:** P3 (Low)
|
||||||
|
**Effort:** 3-5 days
|
||||||
|
**Dependencies:** TASK-FE-010, TASK-BE-012
|
||||||
|
**Assigned To:** Frontend Developer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
Build a generic or specific UI for managing various system lookup tables (Master Data) that are essential for the application but change infrequently. This includes Disciplines, Drawing Categories, RFA Types, and Tags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Objectives
|
||||||
|
|
||||||
|
1. Manage **Correspondence Types** (and Sub-types)
|
||||||
|
2. Manage **RFA Types** and associated **Approve Codes**
|
||||||
|
3. Manage **Drawing Categories** (Main & Sub-categories)
|
||||||
|
4. Manage **Disciplines** (System-wide codes)
|
||||||
|
5. Manage **Tags** and other minor lookups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Admin can create/edit/delete Correspondence Types
|
||||||
|
- [ ] Admin can manage RFA Types and their Approve Codes
|
||||||
|
- [ ] Admin can configure Drawing Categories (Main/Sub)
|
||||||
|
- [ ] Admin can manage Disciplines (Code & Name)
|
||||||
|
- [ ] UI supports "Soft Delete" (Active/Inactive toggle)
|
||||||
|
- [ ] Updates reflect immediately in dropdowns across the system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Specific Lookup Pages vs Generic Table
|
||||||
|
|
||||||
|
Since these tables have similar structures (Code, Name, Description, IsActive), you can either build:
|
||||||
|
A. **Generic Master Data Component** (Recommended for simple tables)
|
||||||
|
B. **Dedicated Pages** for complex relations (like Categories -> Sub-categories)
|
||||||
|
|
||||||
|
#### Recommended Approach
|
||||||
|
|
||||||
|
- **Dedicated Page:** for RFA Types (due to relationship with Approve Codes)
|
||||||
|
- **Dedicated Page:** for Drawing Categories (Hierarchical)
|
||||||
|
- **Generic/Shared Page:** for Disciplines, Tags, Correspondence Types
|
||||||
|
|
||||||
|
### Step 2: RFA Configuration Page
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// File: src/app/(admin)/admin/reference/rfa-types/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DataTable } from '@/components/common/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
// ... imports
|
||||||
|
|
||||||
|
export default function RfaConfigPage() {
|
||||||
|
const [types, setTypes] = useState([]);
|
||||||
|
|
||||||
|
// Columns: Code, Name, Contract, Active Status, Actions
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">RFA Types Configuration</h1>
|
||||||
|
<Button>Add Type</Button>
|
||||||
|
</div>
|
||||||
|
<DataTable data={types} columns={/*...*/} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Disciplines Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// File: src/app/(admin)/admin/reference/disciplines/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
// Simple table to manage 'disciplines'
|
||||||
|
// Fields: discipline_code, code_name_th, code_name_en
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Drawing Categories (Hierarchy)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// File: src/app/(admin)/admin/reference/drawing-categories/page.tsx
|
||||||
|
// Needs to handle Main Category -> Sub Category relationship
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Deliverables
|
||||||
|
|
||||||
|
- [ ] RFA Types Management Page
|
||||||
|
- [ ] Drawing Categories Management Page
|
||||||
|
- [ ] Disciplines Management Page
|
||||||
|
- [ ] Correspondence Types Management Page
|
||||||
|
- [ ] Unified "Reference Data" Sidebar Group
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Documents
|
||||||
|
|
||||||
|
- [TASK-BE-012: Master Data Management](./TASK-BE-012-master-data-management.md)
|
||||||
99
specs/06-tasks/TASK-FE-015-security-admin-ui.md
Normal file
99
specs/06-tasks/TASK-FE-015-security-admin-ui.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# TASK-FE-015: Security & System Administration UI
|
||||||
|
|
||||||
|
**ID:** TASK-FE-015
|
||||||
|
**Title:** Security & System Administration UI
|
||||||
|
**Category:** Administration
|
||||||
|
**Priority:** P2 (High)
|
||||||
|
**Effort:** 5-7 days
|
||||||
|
**Dependencies:** TASK-FE-010, TASK-BE-002, TASK-BE-011
|
||||||
|
**Assigned To:** Frontend Developer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
Provide advanced administrative tools for managing system security (RBAC), monitoring active user sessions, and viewing system-level error logs (specifically for critical features like Document Numbering).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Objectives
|
||||||
|
|
||||||
|
1. **RBAC Matrix Editor:** Visual interface to assign permissions to roles.
|
||||||
|
2. **Session Management:** View and revoke active user sessions/tokens.
|
||||||
|
3. **System Logs:** View specific error logs (e.g., `document_number_errors`) and Audit Logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] **RBAC Matrix:** Grid view showing Roles (Columns) vs Permissions (Rows) with toggle switches.
|
||||||
|
- [ ] **Session Monitor:** List active users/sessions with "Force Logout" capability.
|
||||||
|
- [ ] **Numbering Logs:** Specific view for `document_number_audit` and `document_number_errors`.
|
||||||
|
- [ ] **Security:** These pages must be restricted to Super Admin only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: RBAC Matrix Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// File: src/components/admin/security/rbac-matrix.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Matrix layout:
|
||||||
|
// | Permission | Admin | User | Approver |
|
||||||
|
// |------------|-------|------|----------|
|
||||||
|
// | rfa.view | [x] | [x] | [x] |
|
||||||
|
// | rfa.create | [x] | [ ] | [ ] |
|
||||||
|
|
||||||
|
export function RbacMatrix({ roles, permissions, matrix }) {
|
||||||
|
const handleToggle = (roleId, permId) => {
|
||||||
|
// Call API to toggle permission
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
{/* ... Render Matrix ... */}
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Active Sessions Page
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// File: src/app/(admin)/admin/security/sessions/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// List active refresh tokens or sessions from backend
|
||||||
|
// Columns: User, IP, Last Active, Device, Actions (Revoke)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Document Numbering Logs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// File: src/app/(admin)/admin/logs/numbering/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// specific table for 'document_number_errors' and 'document_number_audit'
|
||||||
|
// Critical for diagnosing failed number generation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Deliverables
|
||||||
|
|
||||||
|
- [ ] RBAC Configuration Page
|
||||||
|
- [ ] Active Sessions / Security Page
|
||||||
|
- [ ] Document Numbering Diagnostics Page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Documents
|
||||||
|
|
||||||
|
- [TASK-BE-002: Auth & RBAC](./TASK-BE-002-auth-rbac.md)
|
||||||
|
- [TASK-BE-011: Notification & Audit](./TASK-BE-011-notification-audit.md)
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
| **TASK-FE-010** | Admin Panel | ✅ **Done** | 100% | Layout, Users, Audit Logs, Organizations implemented. |
|
| **TASK-FE-010** | Admin Panel | ✅ **Done** | 100% | Layout, Users, Audit Logs, Organizations implemented. |
|
||||||
| **TASK-FE-011** | Workflow Config UI | 🚧 **In Progress** | 30% | Workflow builder UI needed. |
|
| **TASK-FE-011** | Workflow Config UI | 🚧 **In Progress** | 30% | Workflow builder UI needed. |
|
||||||
| **TASK-FE-012** | Numbering Config UI | 🚧 **In Progress** | 30% | Configuration forms needed. |
|
| **TASK-FE-012** | Numbering Config UI | 🚧 **In Progress** | 30% | Configuration forms needed. |
|
||||||
|
| **TASK-FE-013** | Circulation & Transmittal | 🔴 **Not Started** | 0% | Pending implementation. |
|
||||||
|
| **TASK-FE-014** | Reference Data UI | 🔴 **Not Started** | 0% | Specs created. |
|
||||||
|
| **TASK-FE-015** | Security Admin UI | 🔴 **Not Started** | 0% | Specs created. |
|
||||||
|
|
||||||
## 🛠 Detailed Status by Component
|
## 🛠 Detailed Status by Component
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user