Compare commits
No commits in common. "7b0873e0da9a528ece051e1c9e8b682c262343d5" and "e1a539325fa3d17c4b80bf4e0304b0bc50776037" have entirely different histories.
7b0873e0da
...
e1a539325f
|
|
@ -0,0 +1,12 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "validation_queue" ADD COLUMN "integer_record_id" INTEGER DEFAULT 0,
|
||||
ADD COLUMN "string_record_id" VARCHAR(25) DEFAULT '';
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_rekam_medis" FOREIGN KEY ("string_record_id") REFERENCES "rekam_medis"("id_visit") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_pemberian_obat" FOREIGN KEY ("integer_record_id") REFERENCES "pemberian_obat"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_pemberian_tindakan" FOREIGN KEY ("integer_record_id") REFERENCES "pemberian_tindakan"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "pemberian_obat" ADD COLUMN "deleted_status" VARCHAR(25);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "pemberian_tindakan" ADD COLUMN "deleted_status" VARCHAR(25);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "rekam_medis" ADD COLUMN "deleted_status" VARCHAR(25);
|
||||
|
|
@ -26,7 +26,6 @@ model pemberian_obat {
|
|||
obat String @db.VarChar(100)
|
||||
jumlah_obat Int
|
||||
aturan_pakai String?
|
||||
deleted_status String? @db.VarChar(25)
|
||||
rekam_medis rekam_medis @relation(fields: [id_visit], references: [id_visit], onDelete: Cascade, onUpdate: NoAction, map: "fk_pemberian_obat_visit")
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +35,6 @@ model pemberian_tindakan {
|
|||
tindakan String @db.VarChar(100)
|
||||
kategori_tindakan String? @db.VarChar(50)
|
||||
kelompok_tindakan String? @db.VarChar(50)
|
||||
deleted_status String? @db.VarChar(25)
|
||||
rekam_medis rekam_medis @relation(fields: [id_visit], references: [id_visit], onDelete: Cascade, onUpdate: NoAction, map: "fk_tindakan_visit")
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +60,6 @@ model rekam_medis {
|
|||
berat_badan Decimal? @db.Decimal(10, 5)
|
||||
jenis_kasus String? @db.VarChar(50)
|
||||
tindak_lanjut String?
|
||||
deleted_status String? @db.VarChar(25)
|
||||
pemberian_obat pemberian_obat[]
|
||||
pemberian_tindakan pemberian_tindakan[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,233 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuthGuard } from '../auth/guard/auth.guard';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
describe('AuditController', () => {
|
||||
let controller: AuditController;
|
||||
let auditService: jest.Mocked<AuditService>;
|
||||
|
||||
const mockAuditService = {
|
||||
getAuditTrails: jest.fn(),
|
||||
storeAuditTrail: jest.fn(),
|
||||
getCountAuditTamperedData: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuditController],
|
||||
providers: [
|
||||
{ provide: AuditService, useValue: mockAuditService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuditController>(AuditController);
|
||||
auditService = module.get(AuditService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAuditTrail', () => {
|
||||
const mockAuditLogs = {
|
||||
0: {
|
||||
id: 'REKAM_1',
|
||||
event: 'rekam_medis_created',
|
||||
result: 'non_tampered',
|
||||
},
|
||||
1: { id: 'OBAT_1', event: 'obat_created', result: 'tampered' },
|
||||
totalCount: 2,
|
||||
};
|
||||
|
||||
it('should return audit trails with default parameters', async () => {
|
||||
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||
|
||||
const result = await controller.getAuditTrail(
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'desc',
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockAuditLogs);
|
||||
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'desc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass search parameter', async () => {
|
||||
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||
|
||||
await controller.getAuditTrail('REKAM', 1, 10, '', '', '', 'desc');
|
||||
|
||||
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||
'REKAM',
|
||||
1,
|
||||
10,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'desc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass type filter parameter', async () => {
|
||||
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||
|
||||
await controller.getAuditTrail('', 1, 10, 'rekam_medis', '', '', 'desc');
|
||||
|
||||
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
'rekam_medis',
|
||||
'',
|
||||
'',
|
||||
'desc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass tampered filter parameter', async () => {
|
||||
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||
|
||||
await controller.getAuditTrail('', 1, 10, '', 'tampered', '', 'desc');
|
||||
|
||||
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
'',
|
||||
'tampered',
|
||||
'',
|
||||
'desc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass orderBy and order parameters', async () => {
|
||||
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||
|
||||
await controller.getAuditTrail('', 1, 10, '', '', 'last_sync', 'desc');
|
||||
|
||||
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
'',
|
||||
'',
|
||||
'last_sync',
|
||||
'desc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass all parameters together', async () => {
|
||||
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
|
||||
|
||||
await controller.getAuditTrail(
|
||||
'search',
|
||||
2,
|
||||
25,
|
||||
'obat',
|
||||
'non_tampered',
|
||||
'timestamp',
|
||||
'asc',
|
||||
);
|
||||
|
||||
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
|
||||
'search',
|
||||
2,
|
||||
25,
|
||||
'obat',
|
||||
'non_tampered',
|
||||
'timestamp',
|
||||
'asc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockAuditService.getAuditTrails.mockResolvedValue({ totalCount: 0 });
|
||||
|
||||
const result = await controller.getAuditTrail(
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'desc',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ totalCount: 0 });
|
||||
});
|
||||
|
||||
it('should propagate service errors', async () => {
|
||||
mockAuditService.getAuditTrails.mockRejectedValue(
|
||||
new Error('Database error'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.getAuditTrail('', 1, 10, '', '', '', 'desc'),
|
||||
).rejects.toThrow('Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuditTrail', () => {
|
||||
it('should start audit trail process and return status', () => {
|
||||
mockAuditService.storeAuditTrail.mockResolvedValue(undefined);
|
||||
|
||||
const result = controller.createAuditTrail();
|
||||
|
||||
expect(result).toEqual({
|
||||
message: 'Proses audit trail dijalankan',
|
||||
status: 'STARTED',
|
||||
});
|
||||
expect(mockAuditService.storeAuditTrail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not wait for storeAuditTrail to complete', () => {
|
||||
// storeAuditTrail is fire-and-forget (not awaited)
|
||||
let resolved = false;
|
||||
mockAuditService.storeAuditTrail.mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
const result = controller.createAuditTrail();
|
||||
|
||||
expect(result.status).toBe('STARTED');
|
||||
expect(resolved).toBe(false); // Should return before async completes
|
||||
});
|
||||
|
||||
it('should call storeAuditTrail without parameters', () => {
|
||||
controller.createAuditTrail();
|
||||
|
||||
expect(mockAuditService.storeAuditTrail).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ export class AuditController {
|
|||
@Query('pageSize') pageSize: number,
|
||||
@Query('type') type: string,
|
||||
@Query('tampered') tampered: string,
|
||||
@Query('orderBy') orderBy: string,
|
||||
@Query('order') order: 'asc' | 'desc',
|
||||
) {
|
||||
const result = await this.auditService.getAuditTrails(
|
||||
search,
|
||||
|
|
@ -31,8 +29,6 @@ export class AuditController {
|
|||
pageSize,
|
||||
type,
|
||||
tampered,
|
||||
orderBy,
|
||||
order,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,198 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuditGateway } from './audit.gateway';
|
||||
import { Server } from 'socket.io';
|
||||
import { WebsocketGuard } from '../auth/guard/websocket.guard';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
describe('AuditGateway', () => {
|
||||
let gateway: AuditGateway;
|
||||
let mockServer: jest.Mocked<Server>;
|
||||
|
||||
const mockJwtService = {
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuditGateway,
|
||||
WebsocketGuard,
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
providers: [AuditGateway],
|
||||
}).compile();
|
||||
|
||||
gateway = module.get<AuditGateway>(AuditGateway);
|
||||
|
||||
// Mock the WebSocket server
|
||||
mockServer = {
|
||||
emit: jest.fn(),
|
||||
} as unknown as jest.Mocked<Server>;
|
||||
|
||||
// Inject mock server
|
||||
gateway.server = mockServer;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(gateway).toBeDefined();
|
||||
});
|
||||
|
||||
describe('sendProgress', () => {
|
||||
it('should emit audit.progress event with progress data', () => {
|
||||
const progressData = { status: 'RUNNING', progress_count: 50 };
|
||||
|
||||
gateway.sendProgress(progressData);
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||
'audit.progress',
|
||||
progressData,
|
||||
);
|
||||
expect(mockServer.emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit progress with zero count', () => {
|
||||
const progressData = { status: 'RUNNING', progress_count: 0 };
|
||||
|
||||
gateway.sendProgress(progressData);
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||
'audit.progress',
|
||||
progressData,
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit progress with large count', () => {
|
||||
const progressData = { status: 'RUNNING', progress_count: 10000 };
|
||||
|
||||
gateway.sendProgress(progressData);
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||
'audit.progress',
|
||||
progressData,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendComplete', () => {
|
||||
it('should emit audit.complete event with complete data', () => {
|
||||
const completeData = { status: 'COMPLETED' };
|
||||
|
||||
gateway.sendComplete(completeData);
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||
'audit.complete',
|
||||
completeData,
|
||||
);
|
||||
expect(mockServer.emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit complete with additional metadata', () => {
|
||||
const completeData = {
|
||||
status: 'COMPLETED',
|
||||
total_processed: 100,
|
||||
duration_ms: 5000,
|
||||
};
|
||||
|
||||
gateway.sendComplete(completeData);
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledWith(
|
||||
'audit.complete',
|
||||
completeData,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendError', () => {
|
||||
it('should emit audit.error event with error data', () => {
|
||||
const errorData = {
|
||||
message: 'Database connection failed',
|
||||
code: 'DB_ERROR',
|
||||
};
|
||||
|
||||
gateway.sendError(errorData);
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('audit.error', errorData);
|
||||
expect(mockServer.emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit error with stack trace', () => {
|
||||
const errorData = {
|
||||
message: 'Unexpected error',
|
||||
stack: 'Error: Unexpected error\n at AuditService...',
|
||||
};
|
||||
|
||||
gateway.sendError(errorData);
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledWith('audit.error', errorData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleConnection', () => {
|
||||
it('should log client connection', () => {
|
||||
const mockClient = { id: 'test-client-123' } as any;
|
||||
const loggerSpy = jest.spyOn(gateway['logger'], 'log');
|
||||
|
||||
gateway.handleConnection(mockClient);
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
'Klien terhubung: test-client-123',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisconnect', () => {
|
||||
it('should log client disconnection', () => {
|
||||
const mockClient = { id: 'test-client-456' } as any;
|
||||
const loggerSpy = jest.spyOn(gateway['logger'], 'log');
|
||||
|
||||
gateway.handleDisconnect(mockClient);
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith('Klien terputus: test-client-456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple emissions', () => {
|
||||
it('should handle multiple progress emissions', () => {
|
||||
gateway.sendProgress({ status: 'RUNNING', progress_count: 10 });
|
||||
gateway.sendProgress({ status: 'RUNNING', progress_count: 20 });
|
||||
gateway.sendProgress({ status: 'RUNNING', progress_count: 30 });
|
||||
|
||||
expect(mockServer.emit).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should handle progress followed by complete', () => {
|
||||
gateway.sendProgress({ status: 'RUNNING', progress_count: 100 });
|
||||
gateway.sendComplete({ status: 'COMPLETED' });
|
||||
|
||||
expect(mockServer.emit).toHaveBeenNthCalledWith(1, 'audit.progress', {
|
||||
status: 'RUNNING',
|
||||
progress_count: 100,
|
||||
});
|
||||
expect(mockServer.emit).toHaveBeenNthCalledWith(2, 'audit.complete', {
|
||||
status: 'COMPLETED',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle progress followed by error', () => {
|
||||
gateway.sendProgress({ status: 'RUNNING', progress_count: 50 });
|
||||
gateway.sendError({ message: 'Process failed' });
|
||||
|
||||
expect(mockServer.emit).toHaveBeenNthCalledWith(1, 'audit.progress', {
|
||||
status: 'RUNNING',
|
||||
progress_count: 50,
|
||||
});
|
||||
expect(mockServer.emit).toHaveBeenNthCalledWith(2, 'audit.error', {
|
||||
message: 'Process failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,653 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuditService } from './audit.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LogService } from '../log/log.service';
|
||||
import { ObatService } from '../obat/obat.service';
|
||||
import { RekammedisService } from '../rekammedis/rekammedis.service';
|
||||
import { TindakanDokterService } from '../tindakandokter/tindakandokter.service';
|
||||
import { AuditGateway } from './audit.gateway';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
describe('AuditService', () => {
|
||||
let service: AuditService;
|
||||
let prisma: jest.Mocked<PrismaService>;
|
||||
let logService: jest.Mocked<LogService>;
|
||||
let obatService: jest.Mocked<ObatService>;
|
||||
let rekamMedisService: jest.Mocked<RekammedisService>;
|
||||
let tindakanService: jest.Mocked<TindakanDokterService>;
|
||||
let auditGateway: jest.Mocked<AuditGateway>;
|
||||
|
||||
const mockPrisma = {
|
||||
audit: {
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLogService = {
|
||||
getLogsWithPagination: jest.fn(),
|
||||
};
|
||||
|
||||
const mockObatService = {
|
||||
getObatById: jest.fn(),
|
||||
createHashingPayload: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRekamMedisService = {
|
||||
getRekamMedisById: jest.fn(),
|
||||
createHashingPayload: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTindakanService = {
|
||||
getTindakanDokterById: jest.fn(),
|
||||
createHashingPayload: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAuditGateway = {
|
||||
sendProgress: jest.fn(),
|
||||
sendComplete: jest.fn(),
|
||||
sendError: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
// Suppress logger output during tests
|
||||
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuditService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: ObatService, useValue: mockObatService },
|
||||
{ provide: RekammedisService, useValue: mockRekamMedisService },
|
||||
{ provide: TindakanDokterService, useValue: mockTindakanService },
|
||||
{ provide: AuditGateway, useValue: mockAuditGateway },
|
||||
],
|
||||
providers: [AuditService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuditService>(AuditService);
|
||||
prisma = module.get(PrismaService);
|
||||
logService = module.get(LogService);
|
||||
obatService = module.get(ObatService);
|
||||
rekamMedisService = module.get(RekammedisService);
|
||||
tindakanService = module.get(TindakanDokterService);
|
||||
auditGateway = module.get(AuditGateway);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAuditTrails', () => {
|
||||
const mockAuditLogs = [
|
||||
{ id: 'REKAM_1', event: 'rekam_medis_created', result: 'non_tampered' },
|
||||
{ id: 'OBAT_1', event: 'obat_created', result: 'tampered' },
|
||||
];
|
||||
|
||||
it('should return paginated audit logs', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue(mockAuditLogs);
|
||||
mockPrisma.audit.count.mockResolvedValue(2);
|
||||
|
||||
const result = await service.getAuditTrails('', 1, 10);
|
||||
|
||||
expect(result.totalCount).toBe(2);
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith({
|
||||
take: 10,
|
||||
skip: 0,
|
||||
orderBy: { timestamp: 'desc' },
|
||||
where: {
|
||||
id: undefined,
|
||||
result: undefined,
|
||||
OR: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by rekam_medis type', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 1, 10, 'rekam_medis');
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: { startsWith: 'REKAM' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by tindakan type', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 1, 10, 'tindakan');
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: { startsWith: 'TINDAKAN' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by obat type', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 1, 10, 'obat');
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: { startsWith: 'OBAT' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by tampered status', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 1, 10, undefined, 'tampered');
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
result: 'tampered',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by non_tampered status', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 1, 10, undefined, 'non_tampered');
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
result: 'non_tampered',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore "all" type filter', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 1, 10, 'all');
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore "initial" type filter', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 1, 10, 'initial');
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should search by id', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('REKAM_123', 1, 10);
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [{ id: { contains: 'REKAM_123' } }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply custom orderBy and order', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails(
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
undefined,
|
||||
undefined,
|
||||
'last_sync',
|
||||
'asc',
|
||||
);
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orderBy: { last_sync: 'asc' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate correct skip for pagination', async () => {
|
||||
mockPrisma.audit.findMany.mockResolvedValue([]);
|
||||
mockPrisma.audit.count.mockResolvedValue(0);
|
||||
|
||||
await service.getAuditTrails('', 3, 10);
|
||||
|
||||
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 20, // (page - 1) * pageSize = (3 - 1) * 10
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCountAuditTamperedData', () => {
|
||||
it('should return all tampered counts', async () => {
|
||||
mockPrisma.audit.count
|
||||
.mockResolvedValueOnce(10) // auditTamperedCount
|
||||
.mockResolvedValueOnce(90) // auditNonTamperedCount
|
||||
.mockResolvedValueOnce(3) // rekamMedisTamperedCount
|
||||
.mockResolvedValueOnce(4) // tindakanDokterTamperedCount
|
||||
.mockResolvedValueOnce(3); // obatTamperedCount
|
||||
|
||||
const result = await service.getCountAuditTamperedData();
|
||||
|
||||
expect(result).toEqual({
|
||||
auditTamperedCount: 10,
|
||||
auditNonTamperedCount: 90,
|
||||
rekamMedisTamperedCount: 3,
|
||||
tindakanDokterTamperedCount: 4,
|
||||
obatTamperedCount: 3,
|
||||
});
|
||||
expect(mockPrisma.audit.count).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareData', () => {
|
||||
it('should return true when hashes match', async () => {
|
||||
const hash = 'abc123def456';
|
||||
const result = await service.compareData(hash, hash);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when hashes differ', async () => {
|
||||
const result = await service.compareData('hash1', 'hash2');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty strings comparison with non-empty', async () => {
|
||||
const result = await service.compareData('', 'somehash');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeAuditTrail', () => {
|
||||
it('should process logs and send complete when done', async () => {
|
||||
mockLogService.getLogsWithPagination.mockResolvedValue({
|
||||
logs: [],
|
||||
bookmark: '',
|
||||
});
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockLogService.getLogsWithPagination).toHaveBeenCalledWith(25, '');
|
||||
});
|
||||
|
||||
it('should process rekam_medis logs correctly', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'REKAM_123',
|
||||
event: 'rekam_medis_created',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user_id: 1,
|
||||
payload: 'blockchain_hash_123',
|
||||
},
|
||||
};
|
||||
|
||||
const mockRekamMedis = {
|
||||
id_visit: '123',
|
||||
anamnese: 'test',
|
||||
jenis_kasus: 'test',
|
||||
tindak_lanjut: 'test',
|
||||
deleted_status: null,
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
|
||||
mockRekamMedisService.createHashingPayload.mockReturnValue(
|
||||
'blockchain_hash_123',
|
||||
);
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockRekamMedisService.getRekamMedisById).toHaveBeenCalledWith(
|
||||
'123',
|
||||
);
|
||||
expect(mockAuditGateway.sendProgress).toHaveBeenCalled();
|
||||
expect(mockAuditGateway.sendComplete).toHaveBeenCalledWith({
|
||||
status: 'COMPLETED',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process obat logs correctly', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'OBAT_456',
|
||||
event: 'obat_created',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user_id: 1,
|
||||
payload: 'obat_hash',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObat = {
|
||||
id: 456,
|
||||
obat: 'Paracetamol',
|
||||
jumlah_obat: 10,
|
||||
aturan_pakai: '3x1',
|
||||
deleted_status: null,
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockObatService.getObatById.mockResolvedValue(mockObat);
|
||||
mockObatService.createHashingPayload.mockReturnValue('obat_hash');
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockObatService.getObatById).toHaveBeenCalledWith(456);
|
||||
});
|
||||
|
||||
it('should process tindakan logs correctly', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'TINDAKAN_789',
|
||||
event: 'tindakan_dokter_created',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user_id: 1,
|
||||
payload: 'tindakan_hash',
|
||||
},
|
||||
};
|
||||
|
||||
const mockTindakan = {
|
||||
id: 789,
|
||||
id_visit: '123',
|
||||
tindakan: 'Pemeriksaan',
|
||||
kategori_tindakan: 'Umum',
|
||||
kelompok_tindakan: 'Poliklinik',
|
||||
deleted_status: null,
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockTindakanService.getTindakanDokterById.mockResolvedValue(mockTindakan);
|
||||
mockTindakanService.createHashingPayload.mockReturnValue('tindakan_hash');
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockTindakanService.getTindakanDokterById).toHaveBeenCalledWith(
|
||||
789,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pagination with bookmark', async () => {
|
||||
mockLogService.getLogsWithPagination
|
||||
.mockResolvedValueOnce({
|
||||
logs: [{ value: null }],
|
||||
bookmark: 'next_page',
|
||||
})
|
||||
.mockResolvedValueOnce({ logs: [], bookmark: '' });
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockLogService.getLogsWithPagination).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogService.getLogsWithPagination).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
25,
|
||||
'',
|
||||
);
|
||||
expect(mockLogService.getLogsWithPagination).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
25,
|
||||
'next_page',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when blockchain service fails', async () => {
|
||||
mockLogService.getLogsWithPagination.mockRejectedValue(
|
||||
new Error('Blockchain error'),
|
||||
);
|
||||
|
||||
await expect(service.storeAuditTrail()).rejects.toThrow(
|
||||
'Failed to store audit trail',
|
||||
);
|
||||
|
||||
// Verify error was sent via WebSocket
|
||||
expect(mockAuditGateway.sendError).toHaveBeenCalledWith({
|
||||
status: 'ERROR',
|
||||
message: 'Blockchain error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tamper detection logic', () => {
|
||||
it('should mark as non_tampered when delete event and no DB row', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'REKAM_999',
|
||||
event: 'rekam_medis_deleted',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user_id: 1,
|
||||
payload: 'hash',
|
||||
},
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockRekamMedisService.getRekamMedisById.mockResolvedValue(null);
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
// When delete event and no DB row, should be non_tampered
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
const transactionCall = mockPrisma.$transaction.mock.calls[0][0];
|
||||
// Transaction is called with array of upsert promises
|
||||
expect(transactionCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should mark as tampered when no DB row and not delete event', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'REKAM_999',
|
||||
event: 'rekam_medis_created',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user_id: 1,
|
||||
payload: 'hash',
|
||||
},
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockRekamMedisService.getRekamMedisById.mockResolvedValue(null);
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
// Transaction should be called with tampered result
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark as non_tampered when delete event and deleted_status is DELETED', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'REKAM_123',
|
||||
event: 'rekam_medis_deleted',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user_id: 1,
|
||||
payload: 'hash',
|
||||
},
|
||||
};
|
||||
|
||||
const mockRekamMedis = {
|
||||
id_visit: '123',
|
||||
deleted_status: 'DELETED',
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark as tampered when hashes do not match', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'REKAM_123',
|
||||
event: 'rekam_medis_created',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
user_id: 1,
|
||||
payload: 'blockchain_hash',
|
||||
},
|
||||
};
|
||||
|
||||
const mockRekamMedis = {
|
||||
id_visit: '123',
|
||||
anamnese: 'modified',
|
||||
jenis_kasus: 'test',
|
||||
tindak_lanjut: 'test',
|
||||
deleted_status: null,
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
|
||||
mockRekamMedisService.createHashingPayload.mockReturnValue(
|
||||
'different_hash',
|
||||
);
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should skip log entries without value', async () => {
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [{ noValue: true }],
|
||||
bookmark: '',
|
||||
});
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(mockAuditGateway.sendComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip log entries without id', async () => {
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [{ value: { event: 'test' } }],
|
||||
bookmark: '',
|
||||
});
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip log entries without payload', async () => {
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [{ value: { id: 'REKAM_1', event: 'test' } }],
|
||||
bookmark: '',
|
||||
});
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip log entries with unknown prefix', async () => {
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [{ value: { id: 'UNKNOWN_1', event: 'test', payload: 'hash' } }],
|
||||
bookmark: '',
|
||||
});
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle invalid timestamp gracefully', async () => {
|
||||
const mockLog = {
|
||||
value: {
|
||||
id: 'REKAM_123',
|
||||
event: 'rekam_medis_created',
|
||||
timestamp: 'invalid-date',
|
||||
user_id: 1,
|
||||
payload: 'hash',
|
||||
},
|
||||
};
|
||||
|
||||
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
|
||||
logs: [mockLog],
|
||||
bookmark: '',
|
||||
});
|
||||
mockRekamMedisService.getRekamMedisById.mockResolvedValue({
|
||||
id_visit: '123',
|
||||
deleted_status: null,
|
||||
});
|
||||
mockRekamMedisService.createHashingPayload.mockReturnValue('hash');
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await service.storeAuditTrail();
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LogService } from '../log/log.service';
|
||||
import { ObatService } from '../obat/obat.service';
|
||||
|
|
@ -43,13 +39,7 @@ export class AuditService {
|
|||
pageSize: number,
|
||||
type?: string,
|
||||
tampered?: string,
|
||||
orderBy?: string,
|
||||
order?: 'asc' | 'desc',
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Fetching audit trails: page=${page}, pageSize=${pageSize}, type=${type}`,
|
||||
);
|
||||
|
||||
if (type === 'all' || type === 'initial') {
|
||||
type = undefined;
|
||||
} else if (type === 'rekam_medis') {
|
||||
|
|
@ -64,13 +54,10 @@ export class AuditService {
|
|||
tampered = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const auditLogs = await this.prisma.audit.findMany({
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
orderBy: orderBy
|
||||
? { [orderBy]: order || 'asc' }
|
||||
: { timestamp: 'desc' },
|
||||
orderBy: { timestamp: 'asc' },
|
||||
where: {
|
||||
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
||||
result: tampered ? (tampered as ResultStatus) : undefined,
|
||||
|
|
@ -82,7 +69,6 @@ export class AuditService {
|
|||
where: {
|
||||
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
||||
result: tampered ? (tampered as ResultStatus) : undefined,
|
||||
OR: search ? [{ id: { contains: search } }] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -90,10 +76,6 @@ export class AuditService {
|
|||
...auditLogs,
|
||||
totalCount: count,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch audit trails', error.stack);
|
||||
throw new InternalServerErrorException('Failed to fetch audit trails');
|
||||
}
|
||||
}
|
||||
|
||||
async storeAuditTrail() {
|
||||
|
|
@ -102,6 +84,30 @@ export class AuditService {
|
|||
let bookmark = '';
|
||||
let processedCount = 0;
|
||||
|
||||
// try {
|
||||
// const intervalId = setInterval(() => {
|
||||
// processedCount++;
|
||||
// const progressData = {
|
||||
// status: 'RUNNING',
|
||||
// progress_count: processedCount,
|
||||
// };
|
||||
|
||||
// this.logger.log('Mengirim progres via WebSocket:', progressData);
|
||||
// // PANGGIL FUNGSI GATEWAY
|
||||
// this.auditGateway.sendProgress(progressData);
|
||||
|
||||
// if (processedCount >= BATCH_SIZE) {
|
||||
// clearInterval(intervalId);
|
||||
// const completeData = { status: 'COMPLETED' };
|
||||
// this.logger.log('Mengirim selesai via WebSocket:', completeData);
|
||||
// // PANGGIL FUNGSI GATEWAY
|
||||
// this.auditGateway.sendComplete(completeData);
|
||||
// }
|
||||
// }, 500);
|
||||
// } catch (error) {
|
||||
// console.error('Tes streaming GAGAL', error);
|
||||
// }
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const pageResults = await this.logService.getLogsWithPagination(
|
||||
|
|
@ -129,8 +135,16 @@ export class AuditService {
|
|||
)
|
||||
).filter((record): record is AuditRecordPayload => record !== null);
|
||||
|
||||
// const records: AuditRecordPayload[] = [];
|
||||
// for (let index = 0; index < logs.length; index++) {
|
||||
// const record = await this.buildAuditRecord(logs[index], index);
|
||||
// if (record !== null) {
|
||||
// records.push(record);
|
||||
// }
|
||||
// await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
// }
|
||||
|
||||
if (records.length > 0) {
|
||||
this.logger.debug(`Processing ${records.length} audit records`);
|
||||
await this.prisma.$transaction(
|
||||
records.map((record) =>
|
||||
this.prisma.audit.upsert({
|
||||
|
|
@ -140,7 +154,7 @@ export class AuditService {
|
|||
event: record.event,
|
||||
payload: record.payload,
|
||||
timestamp: record.timestamp,
|
||||
user_id: BigInt(record.user_id),
|
||||
user_id: record.user_id,
|
||||
last_sync: record.last_sync,
|
||||
result: record.result,
|
||||
},
|
||||
|
|
@ -152,6 +166,7 @@ export class AuditService {
|
|||
|
||||
if (nextBookmark === '' || nextBookmark === bookmark) {
|
||||
const completeData = { status: 'COMPLETED' };
|
||||
this.logger.log('Mengirim selesai via WebSocket:', completeData);
|
||||
this.auditGateway.sendComplete(completeData);
|
||||
break;
|
||||
}
|
||||
|
|
@ -159,41 +174,50 @@ export class AuditService {
|
|||
bookmark = nextBookmark;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error storing audit trail', error.stack);
|
||||
this.auditGateway.sendError({
|
||||
status: 'ERROR',
|
||||
message: error.message || 'Failed to store audit trail',
|
||||
});
|
||||
throw new InternalServerErrorException('Failed to store audit trail');
|
||||
console.error('Error storing audit trail:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCountAuditTamperedData() {
|
||||
try {
|
||||
const [
|
||||
auditTamperedCount,
|
||||
auditNonTamperedCount,
|
||||
rekamMedisTamperedCount,
|
||||
tindakanDokterTamperedCount,
|
||||
obatTamperedCount,
|
||||
] = await Promise.all([
|
||||
this.prisma.audit.count({
|
||||
where: { result: 'tampered' },
|
||||
}),
|
||||
this.prisma.audit.count({
|
||||
where: { result: 'non_tampered' },
|
||||
}),
|
||||
this.prisma.audit.count({
|
||||
where: { result: 'tampered', id: { startsWith: 'REKAM' } },
|
||||
}),
|
||||
this.prisma.audit.count({
|
||||
where: { result: 'tampered', id: { startsWith: 'TINDAKAN' } },
|
||||
}),
|
||||
this.prisma.audit.count({
|
||||
where: { result: 'tampered', id: { startsWith: 'OBAT' } },
|
||||
}),
|
||||
]);
|
||||
const auditTamperedCount = await this.prisma.audit.count({
|
||||
where: {
|
||||
result: 'tampered',
|
||||
},
|
||||
});
|
||||
|
||||
const auditNonTamperedCount = await this.prisma.audit.count({
|
||||
where: {
|
||||
result: 'non_tampered',
|
||||
},
|
||||
});
|
||||
|
||||
const rekamMedisTamperedCount = await this.prisma.audit.count({
|
||||
where: {
|
||||
result: 'tampered',
|
||||
id: {
|
||||
startsWith: 'REKAM',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tindakanDokterTamperedCount = await this.prisma.audit.count({
|
||||
where: {
|
||||
result: 'tampered',
|
||||
id: {
|
||||
startsWith: 'TINDAKAN',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const obatTamperedCount = await this.prisma.audit.count({
|
||||
where: {
|
||||
result: 'tampered',
|
||||
id: {
|
||||
startsWith: 'OBAT',
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
auditTamperedCount,
|
||||
auditNonTamperedCount,
|
||||
|
|
@ -201,10 +225,6 @@ export class AuditService {
|
|||
tindakanDokterTamperedCount,
|
||||
obatTamperedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get audit tampered count', error.stack);
|
||||
throw new InternalServerErrorException('Failed to get audit statistics');
|
||||
}
|
||||
}
|
||||
|
||||
private async buildAuditRecord(
|
||||
|
|
@ -226,19 +246,18 @@ export class AuditService {
|
|||
const timestamp = this.parseTimestamp(value.timestamp) ?? now;
|
||||
const userId = value.user_id;
|
||||
const blockchainHash: string | undefined = value.payload;
|
||||
let data: any = null;
|
||||
|
||||
if (!blockchainHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let dbHash: string | null = null;
|
||||
//
|
||||
try {
|
||||
if (logId.startsWith('OBAT')) {
|
||||
if (logId.startsWith('OBAT_')) {
|
||||
const obatId = this.extractNumericId(logId);
|
||||
if (obatId !== null) {
|
||||
const obat = await this.obatService.getObatById(obatId);
|
||||
data = obat;
|
||||
if (obat) {
|
||||
dbHash = this.obatService.createHashingPayload({
|
||||
obat: obat.obat,
|
||||
|
|
@ -252,7 +271,6 @@ export class AuditService {
|
|||
if (rekamMedisId) {
|
||||
const rekamMedis =
|
||||
await this.rekamMedisService.getRekamMedisById(rekamMedisId);
|
||||
data = rekamMedis;
|
||||
if (rekamMedis) {
|
||||
dbHash = this.rekamMedisService.createHashingPayload({
|
||||
dokter_id: 123,
|
||||
|
|
@ -268,7 +286,6 @@ export class AuditService {
|
|||
if (tindakanId !== null) {
|
||||
const tindakanDokter =
|
||||
await this.tindakanDokterService.getTindakanDokterById(tindakanId);
|
||||
data = tindakanDokter;
|
||||
if (tindakanDokter) {
|
||||
dbHash = this.tindakanDokterService.createHashingPayload({
|
||||
id_visit: tindakanDokter.id_visit,
|
||||
|
|
@ -282,25 +299,12 @@ export class AuditService {
|
|||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to resolve related data for log ${logId}: ${err.message}`,
|
||||
);
|
||||
console.warn(`Failed to resolve related data for log ${logId}:`, err);
|
||||
}
|
||||
|
||||
let isNotTampered = false;
|
||||
const eventType = logEntry.value.event?.split('_').at(-1);
|
||||
const isDeleteEvent = eventType === 'deleted';
|
||||
const hasRow = Boolean(data);
|
||||
|
||||
if (!hasRow) {
|
||||
isNotTampered = isDeleteEvent;
|
||||
} else if (isDeleteEvent || data.deleted_status === 'DELETED') {
|
||||
isNotTampered = isDeleteEvent && data.deleted_status === 'DELETED';
|
||||
} else {
|
||||
const hashesMatch =
|
||||
dbHash && (await this.compareData(blockchainHash, dbHash));
|
||||
isNotTampered = Boolean(hashesMatch);
|
||||
}
|
||||
const isNotTampered = dbHash
|
||||
? await this.compareData(blockchainHash, dbHash)
|
||||
: false;
|
||||
|
||||
const result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered';
|
||||
|
||||
|
|
@ -309,6 +313,7 @@ export class AuditService {
|
|||
progress_count: index ?? 0,
|
||||
};
|
||||
|
||||
this.logger.log('Mengirim progres via WebSocket:', progressData);
|
||||
this.auditGateway.sendProgress(progressData);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,198 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UserRole } from './dto/auth.dto';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let authService: jest.Mocked<AuthService>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
|
||||
const mockAuthService = {
|
||||
registerUser: jest.fn(),
|
||||
signIn: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
signAsync: jest.fn(),
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
authService = module.get(AuthService);
|
||||
configService = module.get(ConfigService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('registerUser', () => {
|
||||
const createUserDto = {
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
role: UserRole.User,
|
||||
};
|
||||
|
||||
const expectedResponse = {
|
||||
id: BigInt(1),
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
role: UserRole.User,
|
||||
};
|
||||
|
||||
it('should register a new user', async () => {
|
||||
mockAuthService.registerUser.mockResolvedValue(expectedResponse);
|
||||
|
||||
const result = await controller.registerUser(createUserDto);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(mockAuthService.registerUser).toHaveBeenCalledWith(createUserDto);
|
||||
expect(mockAuthService.registerUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should propagate service errors', async () => {
|
||||
const error = new Error('Service error');
|
||||
mockAuthService.registerUser.mockRejectedValue(error);
|
||||
|
||||
await expect(controller.registerUser(createUserDto)).rejects.toThrow(
|
||||
'Service error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
const loginDto = {
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const mockSignInResponse = {
|
||||
accessToken: 'jwt-token',
|
||||
csrfToken: 'csrf-token',
|
||||
user: {
|
||||
id: BigInt(1),
|
||||
username: 'testuser',
|
||||
role: 'user',
|
||||
},
|
||||
};
|
||||
|
||||
it('should login user and set cookie in development mode', async () => {
|
||||
mockAuthService.signIn.mockResolvedValue(mockSignInResponse);
|
||||
mockConfigService.get.mockReturnValue('development');
|
||||
|
||||
const mockResponse = {
|
||||
cookie: jest.fn(),
|
||||
};
|
||||
|
||||
const result = await controller.login(loginDto, mockResponse as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: mockSignInResponse.user,
|
||||
csrfToken: mockSignInResponse.csrfToken,
|
||||
});
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'jwt-token',
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: false, // development mode
|
||||
sameSite: 'strict',
|
||||
maxAge: 3600000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should login user and set secure cookie in production mode', async () => {
|
||||
mockAuthService.signIn.mockResolvedValue(mockSignInResponse);
|
||||
mockConfigService.get.mockReturnValue('production');
|
||||
|
||||
const mockResponse = {
|
||||
cookie: jest.fn(),
|
||||
};
|
||||
|
||||
await controller.login(loginDto, mockResponse as any);
|
||||
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'jwt-token',
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: true, // production mode
|
||||
sameSite: 'strict',
|
||||
maxAge: 3600000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate authentication errors', async () => {
|
||||
mockAuthService.signIn.mockRejectedValue(
|
||||
new Error('Invalid credentials'),
|
||||
);
|
||||
|
||||
const mockResponse = {
|
||||
cookie: jest.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
controller.login(loginDto, mockResponse as any),
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
expect(mockResponse.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear access_token cookie in development mode', () => {
|
||||
mockConfigService.get.mockReturnValue('development');
|
||||
|
||||
const mockResponse = {
|
||||
clearCookie: jest.fn(),
|
||||
};
|
||||
|
||||
const result = controller.logout(mockResponse as any);
|
||||
|
||||
expect(result).toEqual({ message: 'Logout berhasil' });
|
||||
expect(mockResponse.clearCookie).toHaveBeenCalledWith('access_token', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear access_token cookie with secure flag in production mode', () => {
|
||||
mockConfigService.get.mockReturnValue('production');
|
||||
|
||||
const mockResponse = {
|
||||
clearCookie: jest.fn(),
|
||||
};
|
||||
|
||||
const result = controller.logout(mockResponse as any);
|
||||
|
||||
expect(result).toEqual({ message: 'Logout berhasil' });
|
||||
expect(mockResponse.clearCookie).toHaveBeenCalledWith('access_token', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
|
||||
import { AuthDto, UserRole } from './dto/auth.dto';
|
||||
import { AuthDto, AuthDtoResponse, UserRole } from './dto/auth.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthGuard } from './guard/auth.guard';
|
||||
import { RolesGuard } from './guard/roles.guard';
|
||||
|
|
@ -24,6 +24,7 @@ export class AuthController {
|
|||
) {}
|
||||
|
||||
@Post('/register')
|
||||
@Header('Content-Type', 'application/json')
|
||||
@HttpCode(201)
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles(UserRole.Admin)
|
||||
|
|
@ -45,24 +46,9 @@ export class AuthController {
|
|||
httpOnly: true,
|
||||
secure: this.configService.get<string>('NODE_ENV') !== 'development',
|
||||
sameSite: 'strict',
|
||||
maxAge: parseInt(
|
||||
this.configService.get<string>('COOKIE_MAX_AGE') || '7200000',
|
||||
10,
|
||||
),
|
||||
maxAge: 3600000,
|
||||
});
|
||||
|
||||
return { user, csrfToken };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
logout(@Res({ passthrough: true }) res: Response) {
|
||||
res.clearCookie('access_token', {
|
||||
httpOnly: true,
|
||||
secure: this.configService.get<string>('NODE_ENV') !== 'development',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
return { message: 'Logout berhasil' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { AuthService } from './auth.service';
|
|||
import { AuthController } from './auth.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||
import type { StringValue } from 'ms';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
exports: [AuthService],
|
||||
|
|
@ -15,13 +14,9 @@ import type { StringValue } from 'ms';
|
|||
global: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService): JwtModuleOptions => ({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn:
|
||||
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ??
|
||||
'120m',
|
||||
},
|
||||
signOptions: { expiresIn: '120m' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,47 +1,12 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Prisma } from '@dist/generated/prisma';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UserRole } from './dto/auth.dto';
|
||||
|
||||
// Mock bcrypt
|
||||
jest.mock('bcrypt', () => ({
|
||||
hash: jest.fn(),
|
||||
compare: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
const mockPrisma = {
|
||||
users: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
signAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
providers: [AuthService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
|
|
@ -50,250 +15,4 @@ describe('AuthService', () => {
|
|||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('registerUser', () => {
|
||||
const createUserDto = {
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
role: UserRole.User,
|
||||
};
|
||||
|
||||
const createdUser = {
|
||||
id: BigInt(1),
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
password_hash: 'hashedPassword',
|
||||
role: 'user',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
it('should register a new user successfully', async () => {
|
||||
mockConfigService.get.mockReturnValue(10);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
|
||||
mockPrisma.users.create.mockResolvedValue(createdUser);
|
||||
|
||||
const result = await service.registerUser(createUserDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: BigInt(1),
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
role: 'user',
|
||||
});
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
||||
expect(mockPrisma.users.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
password_hash: 'hashedPassword',
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* BUG TEST: This test SHOULD PASS but will FAIL
|
||||
*
|
||||
* Problem: configService.get() returns STRING from .env, not number
|
||||
* Current code: configService.get<number>('BCRYPT_SALT') ?? 10
|
||||
*
|
||||
* When BCRYPT_SALT=10 is in .env, it returns '10' (string), not 10 (number)
|
||||
* bcrypt.hash receives '10' instead of 10
|
||||
*
|
||||
* Fix needed in auth.service.ts:
|
||||
* const salt = parseInt(this.configService.get<string>('BCRYPT_SALT') || '10', 10);
|
||||
*/
|
||||
it('should pass NUMBER salt to bcrypt.hash (not string)', async () => {
|
||||
// Simulate real .env behavior: returns string '10'
|
||||
mockConfigService.get.mockReturnValue('10');
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
|
||||
mockPrisma.users.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.registerUser(createUserDto);
|
||||
|
||||
// CORRECT expectation: salt should be NUMBER 10, not STRING '10'
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
||||
});
|
||||
|
||||
it('should use default salt value 10 when BCRYPT_SALT is not configured', async () => {
|
||||
mockConfigService.get.mockReturnValue(undefined);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
|
||||
mockPrisma.users.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.registerUser(createUserDto);
|
||||
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
|
||||
});
|
||||
|
||||
it('should default role to "user" when not provided', async () => {
|
||||
const dtoWithoutRole = {
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
mockConfigService.get.mockReturnValue(10);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
|
||||
mockPrisma.users.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.registerUser(dtoWithoutRole as any);
|
||||
|
||||
expect(mockPrisma.users.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
password_hash: 'hashedPassword',
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException when username already exists (P2002)', async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
||||
'Unique constraint failed',
|
||||
{ code: 'P2002', clientVersion: '5.0.0' },
|
||||
);
|
||||
|
||||
mockConfigService.get.mockReturnValue(10);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
|
||||
mockPrisma.users.create.mockRejectedValue(prismaError);
|
||||
|
||||
await expect(service.registerUser(createUserDto)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should rethrow non-P2002 Prisma errors', async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
||||
'Foreign key constraint failed',
|
||||
{ code: 'P2003', clientVersion: '5.0.0' },
|
||||
);
|
||||
|
||||
mockConfigService.get.mockReturnValue(10);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
|
||||
mockPrisma.users.create.mockRejectedValue(prismaError);
|
||||
|
||||
await expect(service.registerUser(createUserDto)).rejects.toThrow(
|
||||
Prisma.PrismaClientKnownRequestError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should rethrow unknown errors without wrapping', async () => {
|
||||
const unknownError = new Error('Database connection failed');
|
||||
|
||||
mockConfigService.get.mockReturnValue(10);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
|
||||
mockPrisma.users.create.mockRejectedValue(unknownError);
|
||||
|
||||
await expect(service.registerUser(createUserDto)).rejects.toThrow(
|
||||
'Database connection failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signIn', () => {
|
||||
const mockUser = {
|
||||
id: BigInt(1),
|
||||
nama_lengkap: 'Test User',
|
||||
username: 'testuser',
|
||||
password_hash: 'hashedPassword',
|
||||
role: 'user',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
it('should sign in user successfully and return tokens', async () => {
|
||||
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
mockJwtService.signAsync.mockResolvedValue('jwt-token');
|
||||
|
||||
const result = await service.signIn('testuser', 'password123');
|
||||
|
||||
expect(result).toHaveProperty('accessToken', 'jwt-token');
|
||||
expect(result).toHaveProperty('csrfToken');
|
||||
expect(result.csrfToken).toHaveLength(64);
|
||||
expect(result.user).toEqual({
|
||||
id: BigInt(1),
|
||||
username: 'testuser',
|
||||
role: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include csrf token in JWT payload', async () => {
|
||||
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
mockJwtService.signAsync.mockResolvedValue('jwt-token');
|
||||
|
||||
await service.signIn('testuser', 'password123');
|
||||
|
||||
expect(mockJwtService.signAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sub: BigInt(1),
|
||||
username: 'testuser',
|
||||
role: 'user',
|
||||
csrf: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when user not found', async () => {
|
||||
mockPrisma.users.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.signIn('nonexistent', 'password')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(bcrypt.compare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when password is incorrect', async () => {
|
||||
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(service.signIn('testuser', 'wrongpassword')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use same error response for user-not-found and wrong-password (security)', async () => {
|
||||
mockPrisma.users.findUnique.mockResolvedValue(null);
|
||||
|
||||
let errorForNonexistent: UnauthorizedException | undefined;
|
||||
try {
|
||||
await service.signIn('nonexistent', 'password');
|
||||
} catch (error) {
|
||||
errorForNonexistent = error as UnauthorizedException;
|
||||
}
|
||||
|
||||
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
let errorForWrongPassword: UnauthorizedException | undefined;
|
||||
try {
|
||||
await service.signIn('testuser', 'wrongpassword');
|
||||
} catch (error) {
|
||||
errorForWrongPassword = error as UnauthorizedException;
|
||||
}
|
||||
|
||||
expect(errorForNonexistent).toBeInstanceOf(UnauthorizedException);
|
||||
expect(errorForWrongPassword).toBeInstanceOf(UnauthorizedException);
|
||||
expect(errorForNonexistent?.getResponse()).toEqual(
|
||||
errorForWrongPassword?.getResponse(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call bcrypt.compare with correct arguments', async () => {
|
||||
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
mockJwtService.signAsync.mockResolvedValue('jwt-token');
|
||||
|
||||
await service.signIn('testuser', 'mypassword');
|
||||
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith(
|
||||
'mypassword',
|
||||
'hashedPassword',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import { PrismaService } from '@api/modules/prisma/prisma.service';
|
|||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UserRole } from './dto/auth.dto';
|
||||
import { AuthDtoResponse, UserRole } from './dto/auth.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
|
||||
|
|
@ -21,28 +20,8 @@ export class AuthService {
|
|||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async isUserExisting(username: string): Promise<boolean> {
|
||||
let user;
|
||||
try {
|
||||
user = await this.prisma.users.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking if user exists:', error);
|
||||
user = null;
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
return !!user;
|
||||
}
|
||||
|
||||
async registerUser(data: CreateUserDto): Promise<CreateUserDtoResponse> {
|
||||
const saltEnv = this.configService.get<string>('BCRYPT_SALT');
|
||||
const salt = saltEnv ? parseInt(saltEnv, 10) : 10;
|
||||
|
||||
if (await this.isUserExisting(data.username)) {
|
||||
throw new ConflictException('Username ini sudah terdaftar');
|
||||
}
|
||||
|
||||
const salt = this.configService.get<number>('BCRYPT_SALT') ?? 10;
|
||||
const hashedPassword = await bcrypt.hash(data.password, salt);
|
||||
|
||||
try {
|
||||
|
|
@ -62,8 +41,12 @@ export class AuthService {
|
|||
role: userCreated.role as UserRole,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error registering user:', error);
|
||||
throw new InternalServerErrorException();
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2002') {
|
||||
throw new ConflictException('Username ini sudah terdaftar');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,3 +17,19 @@ export class AuthDto {
|
|||
@Length(6, undefined, { message: 'Password minimal 6 karakter' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class AuthDtoResponse {
|
||||
@Expose()
|
||||
@Transform(({ value }: { value: bigint }) => value.toString())
|
||||
id: bigint;
|
||||
|
||||
@Expose()
|
||||
username: string;
|
||||
|
||||
@Expose()
|
||||
@IsEnum(UserRole)
|
||||
role: UserRole;
|
||||
|
||||
@Expose()
|
||||
token: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,157 +1,9 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
let guard: AuthGuard;
|
||||
|
||||
const mockJwtService = {
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthGuard,
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<AuthGuard>(AuthGuard);
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
cookies?: any,
|
||||
headers?: any,
|
||||
): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
cookies: cookies,
|
||||
headers: headers || {},
|
||||
};
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
};
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(guard).toBeDefined();
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should return true when JWT and CSRF token are valid and match', async () => {
|
||||
const csrfToken = 'valid-csrf-token';
|
||||
const payload = {
|
||||
sub: 1,
|
||||
username: 'testuser',
|
||||
role: 'user',
|
||||
csrf: csrfToken,
|
||||
};
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
||||
|
||||
const context = createMockExecutionContext(
|
||||
{ access_token: 'valid-jwt-token' },
|
||||
{ 'x-csrf-token': csrfToken },
|
||||
);
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(request['user']).toEqual(payload);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when CSRF token is missing', async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ access_token: 'valid-jwt-token' },
|
||||
{}, // no CSRF header
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(mockJwtService.verifyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when CSRF token does not match JWT payload', async () => {
|
||||
const payload = {
|
||||
sub: 1,
|
||||
username: 'testuser',
|
||||
role: 'user',
|
||||
csrf: 'correct-csrf',
|
||||
};
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
||||
|
||||
const context = createMockExecutionContext(
|
||||
{ access_token: 'valid-jwt-token' },
|
||||
{ 'x-csrf-token': 'wrong-csrf-token' }, // doesn't match
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when no cookies present', async () => {
|
||||
const context = createMockExecutionContext(undefined, {
|
||||
'x-csrf-token': 'some-csrf',
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when access_token cookie is missing', async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ other_cookie: 'value' },
|
||||
{ 'x-csrf-token': 'some-csrf' },
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when JWT verification fails', async () => {
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token'));
|
||||
|
||||
const context = createMockExecutionContext(
|
||||
{ access_token: 'invalid-token' },
|
||||
{ 'x-csrf-token': 'some-csrf' },
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when JWT is expired', async () => {
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockRejectedValue(new Error('jwt expired'));
|
||||
|
||||
const context = createMockExecutionContext(
|
||||
{ access_token: 'expired-token' },
|
||||
{ 'x-csrf-token': 'some-csrf' },
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
expect(new AuthGuard(new JwtService(), new ConfigService())).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,23 +17,14 @@ export class AuthGuard implements CanActivate {
|
|||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const jwtToken = this.extractTokenFromCookie(request);
|
||||
const csrfToken = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!jwtToken || !csrfToken) {
|
||||
const token = this.extractTokenFromCookie(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(jwtToken, {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
});
|
||||
console.log(payload);
|
||||
|
||||
if (payload.csrf !== csrfToken) {
|
||||
throw new UnauthorizedException(['Invalid CSRF token']);
|
||||
}
|
||||
|
||||
request['user'] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
|
|
@ -42,8 +33,8 @@ export class AuthGuard implements CanActivate {
|
|||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const token = request.headers['x-csrf-token'];
|
||||
return token;
|
||||
const [type, token] = request.headers?.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
|
||||
private extractTokenFromCookie(request: Request): string | undefined {
|
||||
|
|
|
|||
|
|
@ -1,155 +1,8 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { UserRole } from '../dto/auth.dto';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
describe('RolesGuard', () => {
|
||||
let guard: RolesGuard;
|
||||
|
||||
const mockReflector = {
|
||||
getAllAndOverride: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [RolesGuard, { provide: Reflector, useValue: mockReflector }],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<RolesGuard>(RolesGuard);
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (user?: any): ExecutionContext => {
|
||||
const mockRequest = { user };
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
getHandler: () => jest.fn(),
|
||||
getClass: () => jest.fn(),
|
||||
} as unknown as ExecutionContext;
|
||||
};
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(guard).toBeDefined();
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should return true when no roles are required (undefined)', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
|
||||
const context = createMockExecutionContext({ role: 'user' });
|
||||
const result = guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when no roles are required (null)', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(null);
|
||||
|
||||
const context = createMockExecutionContext({ role: 'user' });
|
||||
const result = guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* BUG TEST: This test SHOULD PASS but will FAIL
|
||||
*
|
||||
* Problem: Empty array [] is truthy in JavaScript
|
||||
* Current code: if (!requiredRoles) { return true; }
|
||||
*
|
||||
* When @Roles() is used without arguments, requiredRoles = []
|
||||
* [] is truthy, so ![] is false, so the early return doesn't happen
|
||||
* Then .some([]) returns false, causing ForbiddenException
|
||||
*
|
||||
* Fix needed in roles.guard.ts:
|
||||
* if (!requiredRoles || requiredRoles.length === 0) { return true; }
|
||||
*/
|
||||
it('should return true when roles array is empty (no restrictions)', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([]);
|
||||
|
||||
const context = createMockExecutionContext({ role: 'user' });
|
||||
|
||||
// CORRECT expectation: empty roles = no restrictions = allow access
|
||||
const result = guard.canActivate(context);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when user has required role', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
|
||||
|
||||
const context = createMockExecutionContext({ role: UserRole.Admin });
|
||||
const result = guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when user has one of multiple required roles', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([
|
||||
UserRole.Admin,
|
||||
UserRole.User,
|
||||
]);
|
||||
|
||||
const context = createMockExecutionContext({ role: UserRole.User });
|
||||
const result = guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user object has no role property', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
|
||||
|
||||
const context = createMockExecutionContext({});
|
||||
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
expect(() => guard.canActivate(context)).toThrow(
|
||||
'Insufficient permissions (no role)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user is undefined', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
|
||||
|
||||
const context = createMockExecutionContext(undefined);
|
||||
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user does not have required role', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
|
||||
|
||||
const context = createMockExecutionContext({ role: UserRole.User });
|
||||
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
expect(() => guard.canActivate(context)).toThrow(
|
||||
'You do not have the required role',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user role is null', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
|
||||
|
||||
const context = createMockExecutionContext({ role: null });
|
||||
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
expect(() => guard.canActivate(context)).toThrow(
|
||||
'Insufficient permissions (no role)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user role is empty string', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
|
||||
|
||||
const context = createMockExecutionContext({ role: '' });
|
||||
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
expect(() => guard.canActivate(context)).toThrow(
|
||||
'Insufficient permissions (no role)',
|
||||
);
|
||||
});
|
||||
expect(new RolesGuard(new Reflector())).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ export class RolesGuard implements CanActivate {
|
|||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user?.role) {
|
||||
throw new ForbiddenException(['Insufficient permissions (no role)']);
|
||||
throw new ForbiddenException('Insufficient permissions (no role)');
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.role === role);
|
||||
|
|
@ -34,6 +34,6 @@ export class RolesGuard implements CanActivate {
|
|||
return true;
|
||||
}
|
||||
|
||||
throw new ForbiddenException(['You do not have the required role']);
|
||||
throw new ForbiddenException('You do not have the required role');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,147 +1,11 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WebsocketGuard } from './websocket.guard';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Socket } from 'socket.io';
|
||||
import { WebsocketGuard } from './websocket.guard';
|
||||
|
||||
describe('WebsocketGuard', () => {
|
||||
let guard: WebsocketGuard;
|
||||
let jwtService: jest.Mocked<JwtService>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
|
||||
const mockJwtService = {
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WebsocketGuard,
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<WebsocketGuard>(WebsocketGuard);
|
||||
jwtService = module.get(JwtService);
|
||||
configService = module.get(ConfigService);
|
||||
});
|
||||
|
||||
const createMockSocket = (cookieHeader?: string): Partial<Socket> => ({
|
||||
handshake: {
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
},
|
||||
} as any,
|
||||
data: {},
|
||||
disconnect: jest.fn(),
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
socket: Partial<Socket>,
|
||||
): ExecutionContext => {
|
||||
return {
|
||||
switchToWs: () => ({
|
||||
getClient: () => socket,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
};
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(guard).toBeDefined();
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should return true and attach user to socket data when token is valid', async () => {
|
||||
const payload = { sub: 1, username: 'testuser', role: 'user' };
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
||||
|
||||
const socket = createMockSocket('access_token=valid-token');
|
||||
const context = createMockExecutionContext(socket);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(socket.data?.user).toEqual(payload);
|
||||
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token', {
|
||||
secret: 'jwt-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('should disconnect and throw UnauthorizedException when no cookie header', async () => {
|
||||
const socket = createMockSocket(undefined);
|
||||
const context = createMockExecutionContext(socket);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
'No token provided',
|
||||
);
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disconnect and throw UnauthorizedException when access_token not in cookies', async () => {
|
||||
const socket = createMockSocket('other_cookie=value');
|
||||
const context = createMockExecutionContext(socket);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disconnect and throw UnauthorizedException when token is invalid', async () => {
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token'));
|
||||
|
||||
const socket = createMockSocket('access_token=invalid-token');
|
||||
const context = createMockExecutionContext(socket);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow('Invalid token');
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple cookies and extract access_token correctly', async () => {
|
||||
const payload = { sub: 1, username: 'testuser', role: 'user' };
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
||||
|
||||
const socket = createMockSocket(
|
||||
'session=abc123; access_token=valid-token; other=value',
|
||||
);
|
||||
const context = createMockExecutionContext(socket);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token', {
|
||||
secret: 'jwt-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('should disconnect and throw when token is expired', async () => {
|
||||
mockConfigService.get.mockReturnValue('jwt-secret');
|
||||
mockJwtService.verifyAsync.mockRejectedValue(new Error('jwt expired'));
|
||||
|
||||
const socket = createMockSocket('access_token=expired-token');
|
||||
const context = createMockExecutionContext(socket);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
expect(
|
||||
new WebsocketGuard(new JwtService(), new ConfigService()),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Socket } from 'socket.io';
|
|||
import * as cookie from 'cookie';
|
||||
|
||||
interface AuthPayload {
|
||||
sub: bigint;
|
||||
sub: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
|
|
@ -70,13 +69,4 @@ export class ObatController {
|
|||
async getObatLogs(@Param('id') id: string) {
|
||||
return await this.obatService.getLogObatById(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard)
|
||||
async deleteObatById(
|
||||
@Param('id') id: number,
|
||||
@CurrentUser() user: ActiveUserPayload,
|
||||
) {
|
||||
return await this.obatService.deleteObat(id, user);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,11 +56,6 @@ export class ObatService {
|
|||
take: take,
|
||||
where: {
|
||||
obat: obat ? { contains: obat } : undefined,
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
orderBy: orderBy
|
||||
? { [Object.keys(orderBy)[0]]: order || 'asc' }
|
||||
|
|
@ -70,11 +65,6 @@ export class ObatService {
|
|||
const count = await this.prisma.pemberian_obat.count({
|
||||
where: {
|
||||
obat: obat ? { contains: obat } : undefined,
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -283,98 +273,7 @@ export class ObatService {
|
|||
}
|
||||
}
|
||||
|
||||
async deleteObat(id: number, user: ActiveUserPayload) {
|
||||
const existingObat = await this.getObatById(id);
|
||||
if (!existingObat) {
|
||||
throw new BadRequestException(`Obat with id ${id} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.prisma.$transaction(async (tx) => {
|
||||
const createdValidationQueue =
|
||||
await this.prisma.validation_queue.create({
|
||||
data: {
|
||||
table_name: 'pemberian_obat',
|
||||
action: 'DELETE',
|
||||
dataPayload: {
|
||||
...existingObat,
|
||||
},
|
||||
record_id: id.toString(),
|
||||
user_id_request: Number(user.sub),
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
const updatedObat = await tx.pemberian_obat.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
deleted_status: 'DELETE_VALIDATION',
|
||||
},
|
||||
});
|
||||
|
||||
return createdValidationQueue;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error deleting Obat:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObatFromDBAndBlockchain(id: number, userId: number) {
|
||||
const obatId = Number(id);
|
||||
|
||||
if (isNaN(obatId)) {
|
||||
throw new BadRequestException('ID obat tidak valid');
|
||||
}
|
||||
|
||||
const existingObat = await this.getObatById(obatId);
|
||||
if (!existingObat) {
|
||||
throw new BadRequestException(`Obat dengan ID ${obatId} tidak ditemukan`);
|
||||
}
|
||||
|
||||
try {
|
||||
const deleteObat = await this.prisma.$transaction(async (tx) => {
|
||||
const deletedObat = await tx.pemberian_obat.update({
|
||||
where: { id: obatId },
|
||||
data: {
|
||||
deleted_status: 'DELETED',
|
||||
},
|
||||
});
|
||||
const logPayload = JSON.stringify({
|
||||
obat: existingObat.obat,
|
||||
jumlah_obat: existingObat.jumlah_obat,
|
||||
aturan_pakai: existingObat.aturan_pakai,
|
||||
});
|
||||
const payloadHash = sha256(logPayload);
|
||||
const data = {
|
||||
id: `OBAT_${deletedObat.id}`,
|
||||
event: 'obat_deleted',
|
||||
user_id: userId.toString(),
|
||||
payload: payloadHash,
|
||||
};
|
||||
const logResult = await this.logService.storeLog(data);
|
||||
return {
|
||||
...deletedObat,
|
||||
...logResult,
|
||||
};
|
||||
});
|
||||
return deleteObat;
|
||||
} catch (error) {
|
||||
console.error('Error deleting Obat:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async countObat() {
|
||||
return this.prisma.pemberian_obat.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
return this.prisma.pemberian_obat.count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,11 +195,6 @@ export class RekammedisService {
|
|||
: undefined,
|
||||
jenis_kelamin: jkCharacter ? { equals: jkCharacter } : undefined,
|
||||
kode_diagnosa: kode_diagnosa ? { contains: kode_diagnosa } : undefined,
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
...golDarahFilter,
|
||||
...tindakLanjutFilter,
|
||||
};
|
||||
|
|
@ -474,8 +469,7 @@ export class RekammedisService {
|
|||
throw new Error(`Rekam Medis with id_visit ${id_visit} not found`);
|
||||
}
|
||||
try {
|
||||
const response = await this.prisma.$transaction(async (tx) => {
|
||||
const createdQueue = await tx.validation_queue.create({
|
||||
const response = await this.prisma.validation_queue.create({
|
||||
data: {
|
||||
table_name: 'rekam_medis',
|
||||
action: 'DELETE',
|
||||
|
|
@ -485,18 +479,6 @@ export class RekammedisService {
|
|||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
const updatedRekamMedis = await tx.rekam_medis.update({
|
||||
where: { id_visit },
|
||||
data: {
|
||||
deleted_status: 'DELETE_VALIDATION',
|
||||
},
|
||||
});
|
||||
return {
|
||||
...createdQueue,
|
||||
rekam_medis: updatedRekamMedis,
|
||||
};
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error deleting validation queue:', error);
|
||||
|
|
@ -504,40 +486,11 @@ export class RekammedisService {
|
|||
}
|
||||
}
|
||||
|
||||
async deleteRekamMedisFromDBAndBlockchain(id_visit: string, userId: number) {
|
||||
const existing = await this.getRekamMedisById(id_visit);
|
||||
if (!existing) {
|
||||
throw new Error(`Rekam Medis with id_visit ${id_visit} not found`);
|
||||
}
|
||||
|
||||
async deleteRekamMedisFromDB(id_visit: string) {
|
||||
try {
|
||||
const deletedRekamMedis = await this.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.rekam_medis.update({
|
||||
data: {
|
||||
deleted_status: 'DELETED',
|
||||
},
|
||||
const deletedRekamMedis = await this.prisma.rekam_medis.delete({
|
||||
where: { id_visit },
|
||||
});
|
||||
|
||||
const logPayload = {
|
||||
dokter_id: 123,
|
||||
visit_id: id_visit,
|
||||
anamnese: deleted.anamnese,
|
||||
jenis_kasus: deleted.jenis_kasus,
|
||||
tindak_lanjut: deleted.tindak_lanjut,
|
||||
};
|
||||
const logPayloadString = JSON.stringify(logPayload);
|
||||
const payloadHash = sha256(logPayloadString);
|
||||
const logDto = {
|
||||
id: `REKAM_${id_visit}`,
|
||||
event: 'rekam_medis_deleted',
|
||||
user_id: userId.toString(),
|
||||
payload: payloadHash,
|
||||
};
|
||||
|
||||
await this.log.storeLog(logDto);
|
||||
return deleted;
|
||||
});
|
||||
return deletedRekamMedis;
|
||||
} catch (error) {
|
||||
console.error('Error deleting Rekam Medis:', error);
|
||||
|
|
@ -563,11 +516,6 @@ export class RekammedisService {
|
|||
waktu_visit: {
|
||||
gte: sevenDaysAgo,
|
||||
},
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
_count: {
|
||||
id_visit: true,
|
||||
|
|
@ -601,14 +549,6 @@ export class RekammedisService {
|
|||
}
|
||||
|
||||
async countRekamMedis() {
|
||||
return this.prisma.rekam_medis.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
return this.prisma.rekam_medis.count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,11 +79,6 @@ export class TindakanDokterService {
|
|||
kategori_tindakanArray.length > 0
|
||||
? { in: kategori_tindakanArray }
|
||||
: undefined,
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
orderBy: orderBy
|
||||
? { [Object.keys(orderBy)[0]]: order || 'asc' }
|
||||
|
|
@ -102,11 +97,6 @@ export class TindakanDokterService {
|
|||
kategori_tindakanArray.length > 0
|
||||
? { in: kategori_tindakanArray }
|
||||
: undefined,
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -341,9 +331,7 @@ export class TindakanDokterService {
|
|||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validationQueue = await this.prisma.$transaction(async (tx) => {
|
||||
const queue = await tx.validation_queue.create({
|
||||
return this.prisma.validation_queue.create({
|
||||
data: {
|
||||
table_name: 'pemberian_tindakan',
|
||||
action: 'DELETE',
|
||||
|
|
@ -353,24 +341,6 @@ export class TindakanDokterService {
|
|||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
const updatedTindakan = await tx.pemberian_tindakan.update({
|
||||
where: { id: tindakanId },
|
||||
data: {
|
||||
deleted_status: 'DELETE_VALIDATION',
|
||||
},
|
||||
});
|
||||
return {
|
||||
...queue,
|
||||
tindakan: updatedTindakan,
|
||||
};
|
||||
});
|
||||
|
||||
return validationQueue;
|
||||
} catch (error) {
|
||||
console.error('Error deleting Tindakan Dokter:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTindakanDokterFromDBAndBlockchain(id: number, userId: number) {
|
||||
|
|
@ -389,9 +359,8 @@ export class TindakanDokterService {
|
|||
|
||||
try {
|
||||
const deletedTindakan = await this.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.pemberian_tindakan.update({
|
||||
const deleted = await tx.pemberian_tindakan.delete({
|
||||
where: { id: tindakanId },
|
||||
data: { deleted_status: 'DELETED' },
|
||||
});
|
||||
const logPayload = JSON.stringify(deleted);
|
||||
const payloadHash = sha256(logPayload);
|
||||
|
|
@ -416,14 +385,6 @@ export class TindakanDokterService {
|
|||
}
|
||||
|
||||
async countTindakanDokter() {
|
||||
return this.prisma.pemberian_tindakan.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ deleted_status: null },
|
||||
{ deleted_status: 'DELETE_VALIDATION' },
|
||||
{ deleted_status: { not: 'DELETED' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
return this.prisma.pemberian_tindakan.count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/guard/auth.guard';
|
||||
import { ValidationService } from './validation.service';
|
||||
import { CurrentUser } from '../auth/decorator/current-user.decorator';
|
||||
|
|
@ -10,29 +10,8 @@ export class ValidationController {
|
|||
|
||||
@Get('/')
|
||||
@UseGuards(AuthGuard)
|
||||
async getValidationStatus(
|
||||
@Query('take') take: number,
|
||||
@Query('skip') skip: number,
|
||||
@Query('page') page: number,
|
||||
@Query('orderBy') orderBy: string,
|
||||
@Query('search') search: string,
|
||||
@Query('order') order: 'asc' | 'desc',
|
||||
@Query('kelompok_data') kelompok_data: string,
|
||||
@Query('aksi') aksi: string,
|
||||
@Query('status') status: string,
|
||||
) {
|
||||
const queryParams = {
|
||||
take,
|
||||
skip,
|
||||
page,
|
||||
orderBy,
|
||||
search,
|
||||
order,
|
||||
kelompok_data,
|
||||
aksi,
|
||||
status,
|
||||
};
|
||||
return this.validationService.getAllValidationsQueue(queryParams);
|
||||
async getValidationStatus() {
|
||||
return this.validationService.getAllValidationsQueue();
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
|
|
|
|||
|
|
@ -46,10 +46,7 @@ export class ValidationService {
|
|||
);
|
||||
},
|
||||
approveDelete: async (queue: any) => {
|
||||
return this.rekamMedisService.deleteRekamMedisFromDBAndBlockchain(
|
||||
queue.record_id,
|
||||
Number(queue.user_id_request),
|
||||
);
|
||||
return this.rekamMedisService.deleteRekamMedisFromDB(queue.record_id);
|
||||
},
|
||||
},
|
||||
pemberian_tindakan: {
|
||||
|
|
@ -100,65 +97,15 @@ export class ValidationService {
|
|||
Number(queue.user_id_request),
|
||||
);
|
||||
},
|
||||
approveDelete: async (queue: any) => {
|
||||
return this.obatService.deleteObatFromDBAndBlockchain(
|
||||
Number(queue.record_id),
|
||||
queue.user_id_request,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async getAllValidationsQueue(params: any) {
|
||||
const {
|
||||
take,
|
||||
skip,
|
||||
page,
|
||||
orderBy,
|
||||
order,
|
||||
search,
|
||||
kelompok_data,
|
||||
aksi,
|
||||
status,
|
||||
} = params;
|
||||
const skipValue = skip
|
||||
? parseInt(skip.toString())
|
||||
: page
|
||||
? (parseInt(page.toString()) - 1) * take
|
||||
: 0;
|
||||
console.log('Params', params);
|
||||
async getAllValidationsQueue() {
|
||||
const result = await this.prisma.validation_queue.findMany({
|
||||
take,
|
||||
skip: skipValue,
|
||||
orderBy: orderBy ? { [orderBy]: order || 'asc' } : { created_at: 'desc' },
|
||||
where: {
|
||||
record_id: search
|
||||
? {
|
||||
contains: search,
|
||||
}
|
||||
: undefined,
|
||||
table_name:
|
||||
kelompok_data && kelompok_data !== 'all'
|
||||
? kelompok_data.toLowerCase()
|
||||
: undefined,
|
||||
action: aksi && aksi !== 'all' ? aksi.toUpperCase() : undefined,
|
||||
status: status && status !== 'all' ? status.toUpperCase() : undefined,
|
||||
},
|
||||
where: { status: 'PENDING' },
|
||||
});
|
||||
const totalCount = await this.prisma.validation_queue.count({
|
||||
where: {
|
||||
record_id: search
|
||||
? {
|
||||
contains: search,
|
||||
}
|
||||
: undefined,
|
||||
table_name:
|
||||
kelompok_data && kelompok_data !== 'all'
|
||||
? kelompok_data.toLowerCase()
|
||||
: undefined,
|
||||
action: aksi && aksi !== 'all' ? aksi.toUpperCase() : undefined,
|
||||
status: status && status !== 'all' ? status.toUpperCase() : undefined,
|
||||
},
|
||||
where: { status: 'PENDING' },
|
||||
});
|
||||
return { data: result, totalCount };
|
||||
}
|
||||
|
|
@ -249,10 +196,7 @@ export class ValidationService {
|
|||
const updated = await this.prisma.validation_queue.update({
|
||||
where: { id: validationQueue.id },
|
||||
data: {
|
||||
record_id:
|
||||
validationQueue.table_name === 'rekam_medis'
|
||||
? approvalResult.id_visit
|
||||
: approvalResult.id.toString(),
|
||||
record_id: approvalResult.id.toString(),
|
||||
status: 'APPROVED',
|
||||
user_id_process: Number(user.sub),
|
||||
processed_at: new Date(),
|
||||
|
|
@ -265,65 +209,12 @@ export class ValidationService {
|
|||
}
|
||||
}
|
||||
|
||||
determineIdType(tableName: string, recordId: string) {
|
||||
if (tableName === 'rekam_medis') {
|
||||
return recordId;
|
||||
} else if (
|
||||
tableName === 'pemberian_tindakan' ||
|
||||
tableName === 'pemberian_obat'
|
||||
) {
|
||||
return Number(recordId); // numeric ID
|
||||
} else {
|
||||
throw new Error('Unsupported table for ID determination');
|
||||
}
|
||||
}
|
||||
|
||||
async rejectValidation(id: number, user: ActiveUserPayload) {
|
||||
const validationQueue = await this.getValidationQueueById(id);
|
||||
if (!validationQueue) {
|
||||
throw new Error('Validation queue not found');
|
||||
}
|
||||
|
||||
let recordId: number | string = '';
|
||||
|
||||
if (
|
||||
validationQueue.status === 'PENDING' &&
|
||||
validationQueue.action === 'DELETE'
|
||||
) {
|
||||
recordId = this.determineIdType(
|
||||
validationQueue.table_name,
|
||||
validationQueue.record_id,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const rejectedResponse = await this.prisma.$transaction(async (tx) => {
|
||||
let updatedDeleteStatus = null;
|
||||
if (validationQueue.action === 'DELETE') {
|
||||
switch (validationQueue.table_name) {
|
||||
case 'rekam_medis':
|
||||
updatedDeleteStatus = await tx.rekam_medis.update({
|
||||
where: { id_visit: recordId as string },
|
||||
data: { deleted_status: null },
|
||||
});
|
||||
break;
|
||||
case 'pemberian_tindakan':
|
||||
updatedDeleteStatus = await tx.pemberian_tindakan.update({
|
||||
where: { id: recordId as number },
|
||||
data: { deleted_status: null },
|
||||
});
|
||||
break;
|
||||
case 'pemberian_obat':
|
||||
updatedDeleteStatus = await tx.pemberian_obat.update({
|
||||
where: { id: recordId as number },
|
||||
data: { deleted_status: null },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported table for delete rejection');
|
||||
}
|
||||
}
|
||||
const updatedQueue = await tx.validation_queue.update({
|
||||
const updated = await this.prisma.validation_queue.update({
|
||||
where: { id: validationQueue.id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
|
|
@ -331,15 +222,6 @@ export class ValidationService {
|
|||
processed_at: new Date(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
...updatedQueue,
|
||||
updatedDeleteStatus,
|
||||
};
|
||||
});
|
||||
return rejectedResponse;
|
||||
} catch (error) {
|
||||
console.error('Error rejecting validation:', (error as Error).message);
|
||||
throw error;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,10 @@ const pendingDeleteItem = ref<T | null>(null);
|
|||
const hasStatusColumn = () => props.columns.some((col) => col.key === "status");
|
||||
const hasUserIdProcessColumn = () =>
|
||||
props.columns.some((col) => col.key === "user_id_process");
|
||||
const hasLastSyncColumn = () =>
|
||||
props.columns.some((col) => col.key === "last_sync");
|
||||
|
||||
const formatCellValue = (item: T, columnKey: keyof T) => {
|
||||
const value = item[columnKey];
|
||||
|
||||
if (columnKey === "event" && typeof value === "string") {
|
||||
const segments = value.split("_");
|
||||
|
||||
|
|
@ -43,10 +42,6 @@ const formatCellValue = (item: T, columnKey: keyof T) => {
|
|||
if (segments.length >= 2 && segments[segments.length - 1] === "updated") {
|
||||
return "UPDATE";
|
||||
}
|
||||
|
||||
if (segments.length >= 2 && segments[segments.length - 1] === "deleted") {
|
||||
return "DELETE";
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
|
|
@ -124,12 +119,10 @@ const handleDeleteCancel = () => {
|
|||
:key="item.id"
|
||||
:class="[
|
||||
'hover:bg-dark hover:text-light transition-colors',
|
||||
(item as Record<string, any>).isTampered ? 'bg-red-300 text-dark' : item.deleted_status ?
|
||||
item.deleted_status === 'DELETE_VALIDATION' ? 'bg-yellow-100 text-dark' : 'bg-gray-300 text-dark' : '',
|
||||
(item as Record<string, any>).isTampered ? 'bg-red-300 text-dark' : ''
|
||||
]"
|
||||
>
|
||||
<td
|
||||
v-if="item.deleted_status !== 'DELETE_VALIDATION'"
|
||||
v-for="column in columns"
|
||||
:key="String(column.key)"
|
||||
:class="[
|
||||
|
|
@ -150,125 +143,6 @@ const handleDeleteCancel = () => {
|
|||
Review
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td v-if="hasLastSyncColumn()">
|
||||
<RouterLink
|
||||
v-if="item.last_sync && item.id.split('_')[0] === 'REKAM'"
|
||||
:to="`rekam-medis/${item.id.split('_')[1]}`"
|
||||
class="text-dark hover:underline hover:text-white"
|
||||
>
|
||||
Review
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="item.last_sync && item.id.split('_')[0] === 'OBAT'"
|
||||
:to="`pemberian-obat/${item.id.split('_')[1]}`"
|
||||
class="text-dark hover:underline hover:text-white"
|
||||
>
|
||||
Review
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="item.last_sync && item.id.split('_')[0] === 'TINDAKAN'"
|
||||
:to="`pemberian-tindakan/${item.id.split('_')[1]}`"
|
||||
class="text-dark hover:underline hover:text-white"
|
||||
>
|
||||
Review
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td
|
||||
v-if="item.deleted_status === 'DELETE_VALIDATION'"
|
||||
v-for="column in columns"
|
||||
:key="String(column.key)"
|
||||
:class="[
|
||||
column.key === 'txId' || column.key === 'hash'
|
||||
? 'font-mono overflow-hidden text-ellipsis max-w-10 hover:max-w-150 transition-all duration-500 ease-out text-xs'
|
||||
: '',
|
||||
hasStatusColumn() ? 'text-xs' : '',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
column.key === 'id_visit' &&
|
||||
columns[2]?.key !== 'obat' &&
|
||||
columns[2]?.key !== 'tindakan'
|
||||
"
|
||||
:class="[
|
||||
column.key === 'id_visit' &&
|
||||
(columns[2]?.key !== 'obat' || columns[2]?.key !== 'tindakan')
|
||||
? 'tooltip tooltip-right flex items-center justify-center'
|
||||
: '',
|
||||
]"
|
||||
data-tip="Data ini sedang dalam proses validasi untuk dihapus"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
column.key === 'id_visit' ||
|
||||
columns[2]?.key !== 'obat' ||
|
||||
columns[2]?.key !== 'tindakan'
|
||||
? 'inline-flex items-center gap-1'
|
||||
: 'hidden',
|
||||
]"
|
||||
>
|
||||
{{ formatCellValue(item, column.key) }}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-3 h-3 mt-1"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
column.key === 'id' &&
|
||||
(columns[2]?.key === 'obat' || columns[2]?.key === 'tindakan')
|
||||
"
|
||||
:class="[
|
||||
column.key === 'id' &&
|
||||
(columns[2]?.key === 'obat' || columns[2]?.key === 'tindakan')
|
||||
? 'tooltip tooltip-right flex items-center'
|
||||
: '',
|
||||
]"
|
||||
data-tip="Data ini sedang dalam proses validasi untuk dihapus"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
column.key === 'id' ||
|
||||
columns[2]?.key === 'obat' ||
|
||||
columns[2]?.key === 'tindakan'
|
||||
? 'inline-flex items-center gap-1'
|
||||
: 'hidden',
|
||||
]"
|
||||
>
|
||||
{{ formatCellValue(item, column.key) }}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-3 h-3 mt-1"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
{{
|
||||
column.key !== columns[0]?.key
|
||||
? formatCellValue(item, column.key)
|
||||
: ""
|
||||
}}
|
||||
</td>
|
||||
<td v-if="!hasStatusColumn()">
|
||||
<div class="flex gap-2">
|
||||
<!-- Details Button -->
|
||||
|
|
|
|||
|
|
@ -2,24 +2,16 @@
|
|||
import { ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import DialogConfirm from "../DialogConfirm.vue";
|
||||
import { useApi } from "../../composables/useApi";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { post } = useApi();
|
||||
const logoutDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
|
||||
|
||||
const showLogoutDialog = () => {
|
||||
logoutDialog.value?.show();
|
||||
};
|
||||
|
||||
const handleLogoutConfirm = async () => {
|
||||
try {
|
||||
await post("/auth/logout", {});
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
|
||||
const handleLogoutConfirm = () => {
|
||||
localStorage.removeItem("csrf_token");
|
||||
router.push({ name: "login" });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ interface AuditLogEntry extends BlockchainLog {
|
|||
tamperedLabel: string;
|
||||
last_sync: string;
|
||||
isTampered: boolean;
|
||||
timestamp: string;
|
||||
txId?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,10 +102,6 @@ export const SORT_OPTIONS = {
|
|||
created_at: "Waktu Dibuat",
|
||||
processed_at: "Waktu Diproses",
|
||||
},
|
||||
AUDIT_TRAIL: {
|
||||
last_sync: "Last Sync",
|
||||
timestamp: "Timestamp",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const REKAM_MEDIS_TABLE_COLUMNS = [
|
||||
|
|
@ -306,7 +302,6 @@ export const AUDIT_TABLE_COLUMNS = [
|
|||
{ key: "last_sync", label: "Last Sync", class: "text-dark" },
|
||||
{ key: "userId", label: "User ID", class: "text-dark" },
|
||||
{ key: "status", label: "Status Data", class: "text-dark" },
|
||||
{ key: "timestamp", label: "Timestamp", class: "text-dark" },
|
||||
] satisfies Array<{
|
||||
key: keyof AuditLogEntry;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
DEBOUNCE_DELAY,
|
||||
ITEMS_PER_PAGE_OPTIONS,
|
||||
AUDIT_TABLE_COLUMNS,
|
||||
SORT_OPTIONS,
|
||||
} from "../../../constants/pagination";
|
||||
import ButtonDark from "../../../components/dashboard/ButtonDark.vue";
|
||||
import DialogConfirm from "../../../components/DialogConfirm.vue";
|
||||
|
|
@ -24,7 +23,6 @@ import type {
|
|||
AuditLogType,
|
||||
} from "../../../constants/interfaces";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
|
||||
|
||||
interface AuditLogResponse {
|
||||
data: AuditLogEntry[];
|
||||
|
|
@ -34,10 +32,6 @@ interface AuditLogResponse {
|
|||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const api = useApi();
|
||||
const sortBy = ref("last_sync");
|
||||
const sortOrder = ref<"asc" | "desc">(
|
||||
(route.query.order as "asc" | "desc") || "desc"
|
||||
);
|
||||
const { debounce } = useDebounce();
|
||||
|
||||
const pagination = usePagination({
|
||||
|
|
@ -53,17 +47,6 @@ const filters = ref({
|
|||
tampered: (route.query.tampered as string) || "initial",
|
||||
});
|
||||
|
||||
const handleSortChange = (newSortBy: string) => {
|
||||
sortBy.value = newSortBy;
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
console.log("Toggling sort order from", sortOrder.value);
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
};
|
||||
|
||||
const formatTimestamp = (rawValue?: string) => {
|
||||
if (!rawValue) {
|
||||
return "-";
|
||||
|
|
@ -201,16 +184,10 @@ const updateQueryParams = () => {
|
|||
query.search = searchId.value;
|
||||
}
|
||||
|
||||
if (sortBy.value !== "initial") {
|
||||
query.sortBy = sortBy.value;
|
||||
}
|
||||
|
||||
if (filters.value.type !== "all") {
|
||||
query.type = filters.value.type;
|
||||
}
|
||||
|
||||
query.order = sortOrder.value;
|
||||
|
||||
if (filters.value.tampered !== "all") {
|
||||
query.tampered = filters.value.tampered;
|
||||
}
|
||||
|
|
@ -223,8 +200,6 @@ const fetchData = async () => {
|
|||
const params = new URLSearchParams({
|
||||
page: pagination.page.value.toString(),
|
||||
pageSize: pageSize.value.toString(),
|
||||
orderBy: sortBy.value,
|
||||
order: sortOrder.value,
|
||||
});
|
||||
|
||||
if (searchId.value) {
|
||||
|
|
@ -243,8 +218,6 @@ const fetchData = async () => {
|
|||
`/audit/trail${params.toString() ? `?${params.toString()}` : ""}`
|
||||
);
|
||||
|
||||
console.log("Audit Log Response:", response);
|
||||
|
||||
const apiResponse = response as any;
|
||||
pagination.totalCount.value = apiResponse.totalCount;
|
||||
|
||||
|
|
@ -289,12 +262,6 @@ const handleResetFilters = () => {
|
|||
pagination.reset();
|
||||
fetchData();
|
||||
};
|
||||
|
||||
watch(sortOrder, () => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pagination.page.value,
|
||||
() => {
|
||||
|
|
@ -445,40 +412,11 @@ onBeforeUnmount(() => {
|
|||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
|
||||
>
|
||||
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
|
||||
<SearchInput
|
||||
v-model="searchId"
|
||||
placeholder="Cari berdasarkan ID Log"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<div class="flex items-center gap-2 md:ml-4">
|
||||
<SortDropdown
|
||||
v-model="sortBy"
|
||||
:options="SORT_OPTIONS.AUDIT_TRAIL"
|
||||
label="Urut berdasarkan:"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
|
||||
@click="toggleSortOrder"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
class="inline-block size-4"
|
||||
>
|
||||
<path d="M7 7l3 -3l3 3"></path>
|
||||
<path d="M10 4v16"></path>
|
||||
<path d="M17 17l-3 3l-3 -3"></path>
|
||||
<path d="M14 20v-16"></path>
|
||||
</svg>
|
||||
<span class="ml-2 uppercase">{{ sortOrder }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogConfirm
|
||||
ref="auditDialog"
|
||||
title="Konfirmasi"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ interface ApiResponse {
|
|||
const data = ref<ObatData[]>([]);
|
||||
const searchObat = ref("");
|
||||
const sortBy = ref("id");
|
||||
const isDeleteSuccess = ref<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
|
@ -156,16 +155,15 @@ const handleUpdate = (item: ObatData) => {
|
|||
};
|
||||
|
||||
const handleDelete = async (item: ObatData) => {
|
||||
if (confirm(`Apakah Anda yakin ingin menghapus obat "${item.obat}"?`)) {
|
||||
try {
|
||||
const result = await api.delete(`/obat/${item.id}`);
|
||||
if (result) {
|
||||
isDeleteSuccess.value = true;
|
||||
}
|
||||
await api.delete(`/obat/${item.id}`);
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.error("Error deleting obat:", error);
|
||||
alert("Gagal menghapus data obat");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch([() => pagination.page.value], () => {
|
||||
|
|
@ -254,27 +252,6 @@ onMounted(async () => {
|
|||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="alert"
|
||||
class="alert alert-success m-4 shadow-md"
|
||||
v-if="isDeleteSuccess"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Data obat berhasil dikirim untuk validasi penghapusan</span>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<DataTable
|
||||
:data="data"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ interface ApiResponse {
|
|||
const data = ref<RekamMedis[]>([]);
|
||||
const searchRekamMedis = ref("");
|
||||
const sortBy = ref("waktu_visit");
|
||||
const isDeleteSuccess = ref<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
|
@ -276,10 +275,7 @@ const handleUpdate = (item: RekamMedis) => {
|
|||
|
||||
const handleDelete = async (item: RekamMedis) => {
|
||||
try {
|
||||
const result = await api.delete(`/rekammedis/${item.id_visit}`);
|
||||
if (result) {
|
||||
isDeleteSuccess.value = true;
|
||||
}
|
||||
await api.delete(`/rekammedis/${item.id_visit}`);
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.error("Error deleting rekam medis:", error);
|
||||
|
|
@ -606,30 +602,6 @@ onBeforeUnmount(() => {
|
|||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="alert"
|
||||
class="alert alert-success m-4 shadow-md"
|
||||
v-if="isDeleteSuccess"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
>Data rekam medis berhasil dikirim untuk validasi
|
||||
penghapusan</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<DataTable
|
||||
:data="data"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
|
|||
import DataTable from "../../../components/dashboard/DataTable.vue";
|
||||
import PaginationControls from "../../../components/dashboard/PaginationControls.vue";
|
||||
import type { ValidationLog } from "../../../constants/interfaces";
|
||||
import Footer from "../../../components/dashboard/Footer.vue";
|
||||
|
||||
interface ApiResponse {
|
||||
data: ValidationLog[];
|
||||
|
|
@ -26,7 +25,8 @@ interface ApiResponse {
|
|||
}
|
||||
|
||||
const data = ref<ValidationLog[]>([]);
|
||||
const sortBy = ref("created_at");
|
||||
const searchValidation = ref("");
|
||||
const sortBy = ref("id");
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const pagination = usePagination({
|
||||
|
|
@ -34,16 +34,10 @@ const pagination = usePagination({
|
|||
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const sortOrder = ref<"asc" | "desc">(
|
||||
(route.query.order as "asc" | "desc") || "desc"
|
||||
(route.query.order as "asc" | "desc") || "asc"
|
||||
);
|
||||
const api = useApi();
|
||||
const { debounce } = useDebounce();
|
||||
const searchId = ref("");
|
||||
const filters = ref({
|
||||
kelompok_data: (route.query.kelompok_data as string) || "initial",
|
||||
aksi: (route.query.aksi as string) || "initial",
|
||||
status: (route.query.status as string) || "PENDING",
|
||||
});
|
||||
|
||||
const updateQueryParams = () => {
|
||||
const query: Record<string, string> = {
|
||||
|
|
@ -51,29 +45,14 @@ const updateQueryParams = () => {
|
|||
pageSize: pagination.pageSize.value.toString(),
|
||||
};
|
||||
|
||||
if (searchId.value) {
|
||||
query.search = searchId.value;
|
||||
if (searchValidation.value) {
|
||||
query.search = searchValidation.value;
|
||||
}
|
||||
|
||||
if (sortBy.value !== "id") {
|
||||
query.sortBy = sortBy.value;
|
||||
}
|
||||
|
||||
if (filters.value.status !== "all") {
|
||||
query.status = filters.value.status;
|
||||
}
|
||||
|
||||
if (filters.value.aksi !== "all" && filters.value.aksi !== "initial") {
|
||||
query.aksi = filters.value.aksi;
|
||||
}
|
||||
|
||||
if (
|
||||
filters.value.kelompok_data !== "all" &&
|
||||
filters.value.kelompok_data !== "initial"
|
||||
) {
|
||||
query.kelompok_data = filters.value.kelompok_data;
|
||||
}
|
||||
|
||||
query.order = sortOrder.value;
|
||||
|
||||
router.replace({ query });
|
||||
|
|
@ -158,14 +137,6 @@ const normalizedData = (rawData: any[]): ValidationLog[] => {
|
|||
}));
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
filters.value.kelompok_data = "all";
|
||||
filters.value.aksi = "all";
|
||||
searchId.value = "";
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
|
|
@ -173,13 +144,7 @@ const fetchData = async () => {
|
|||
page: pagination.page.value.toString(),
|
||||
orderBy: sortBy.value,
|
||||
order: sortOrder.value,
|
||||
kelompok_data:
|
||||
filters.value.kelompok_data !== "initial"
|
||||
? filters.value.kelompok_data
|
||||
: "",
|
||||
aksi: filters.value.aksi !== "initial" ? filters.value.aksi : "",
|
||||
status: filters.value.status !== "initial" ? filters.value.status : "",
|
||||
...(searchId.value && { search: searchId.value }),
|
||||
...(searchValidation.value && { validation: searchValidation.value }),
|
||||
});
|
||||
|
||||
const result = await api.get<ApiResponse>(
|
||||
|
|
@ -248,40 +213,16 @@ watch(sortOrder, () => {
|
|||
fetchData();
|
||||
});
|
||||
|
||||
watch(searchId, (newValue, oldValue) => {
|
||||
watch(searchValidation, (newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => filters.value.kelompok_data,
|
||||
() => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => filters.value.aksi,
|
||||
() => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => filters.value.status,
|
||||
() => {
|
||||
pagination.reset();
|
||||
fetchData();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.query.search) {
|
||||
searchId.value = route.query.search as string;
|
||||
searchValidation.value = route.query.search as string;
|
||||
}
|
||||
if (route.query.sortBy) {
|
||||
sortBy.value = route.query.sortBy as string;
|
||||
|
|
@ -303,95 +244,14 @@ onMounted(async () => {
|
|||
<div class="flex h-full p-2">
|
||||
<Sidebar>
|
||||
<PageHeader title="Validasi" subtitle="Manajemen Validasi" />
|
||||
|
||||
<div
|
||||
class="collapse collapse-arrow bg-white border-white border shadow-sm mb-2"
|
||||
>
|
||||
<input type="checkbox" />
|
||||
<div
|
||||
class="collapse-title font-semibold after:start-5 after:end-auto pe-4 ps-12"
|
||||
>
|
||||
Filter
|
||||
</div>
|
||||
<div class="collapse-content text-sm flex flex-col gap-4">
|
||||
<div class="flex gap-x-4">
|
||||
<div class="flex gap-x-4 items-end">
|
||||
<div class="h-full">
|
||||
<label for="jenis_kelamin" class="font-bold"
|
||||
>Kelompok Data</label
|
||||
>
|
||||
<select
|
||||
v-model="filters.kelompok_data"
|
||||
class="select bg-white border border-gray-300 mt-1"
|
||||
>
|
||||
<option disabled selected value="initial">
|
||||
Pilih Kelompok Data
|
||||
</option>
|
||||
<option value="rekam_medis">Rekam Medis</option>
|
||||
<option value="pemberian_tindakan">Tindakan</option>
|
||||
<option value="pemberian_obat">Obat</option>
|
||||
<option value="all">Semua Tipe</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-x-4 items-end">
|
||||
<div class="h-full">
|
||||
<label for="jenis_kelamin" class="font-bold"
|
||||
>Jenis Aksi</label
|
||||
>
|
||||
<select
|
||||
v-model="filters.aksi"
|
||||
class="select bg-white border border-gray-300 mt-1"
|
||||
>
|
||||
<option disabled selected value="initial">
|
||||
Pilih Jenis Aksi
|
||||
</option>
|
||||
<option value="CREATE">Create</option>
|
||||
<option value="UPDATE">Update</option>
|
||||
<option value="DELETE">Delete</option>
|
||||
<option value="all">Semua</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-x-4 items-end">
|
||||
<div class="h-full">
|
||||
<label for="jenis_kelamin" class="font-bold"
|
||||
>Status Validasi</label
|
||||
>
|
||||
<select
|
||||
v-model="filters.status"
|
||||
class="select bg-white border border-gray-300 mt-1"
|
||||
>
|
||||
<option disabled selected value="initial">
|
||||
Pilih Status Validasi
|
||||
</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
<option value="all">Semua</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="handleResetFilters"
|
||||
class="btn btn-sm btn-outline btn-dark hover:bg-dark hover:text-light"
|
||||
>
|
||||
Reset Filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
|
||||
>
|
||||
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
|
||||
<SearchInput
|
||||
v-model="searchId"
|
||||
placeholder="Cari berdasarkan ID Record"
|
||||
v-model="searchValidation"
|
||||
placeholder="Cari berdasarkan Validation"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<div class="flex items-center gap-2 md:ml-4">
|
||||
|
|
@ -455,7 +315,6 @@ onMounted(async () => {
|
|||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user