Compare commits
10 Commits
e1a539325f
...
7b0873e0da
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b0873e0da | |||
| 74d5da7475 | |||
| fda1d5d92a | |||
| 5388ef03bc | |||
| b78cbc4fe6 | |||
| ac39d8df2e | |||
| 12e190961c | |||
| cbcf4ad897 | |||
| 4d4565299d | |||
| 3bf3c9e1eb |
|
|
@ -1,12 +0,0 @@
|
||||||
-- 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;
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- 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,6 +26,7 @@ model pemberian_obat {
|
||||||
obat String @db.VarChar(100)
|
obat String @db.VarChar(100)
|
||||||
jumlah_obat Int
|
jumlah_obat Int
|
||||||
aturan_pakai String?
|
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")
|
rekam_medis rekam_medis @relation(fields: [id_visit], references: [id_visit], onDelete: Cascade, onUpdate: NoAction, map: "fk_pemberian_obat_visit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ model pemberian_tindakan {
|
||||||
tindakan String @db.VarChar(100)
|
tindakan String @db.VarChar(100)
|
||||||
kategori_tindakan String? @db.VarChar(50)
|
kategori_tindakan String? @db.VarChar(50)
|
||||||
kelompok_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")
|
rekam_medis rekam_medis @relation(fields: [id_visit], references: [id_visit], onDelete: Cascade, onUpdate: NoAction, map: "fk_tindakan_visit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +62,7 @@ model rekam_medis {
|
||||||
berat_badan Decimal? @db.Decimal(10, 5)
|
berat_badan Decimal? @db.Decimal(10, 5)
|
||||||
jenis_kasus String? @db.VarChar(50)
|
jenis_kasus String? @db.VarChar(50)
|
||||||
tindak_lanjut String?
|
tindak_lanjut String?
|
||||||
|
deleted_status String? @db.VarChar(25)
|
||||||
pemberian_obat pemberian_obat[]
|
pemberian_obat pemberian_obat[]
|
||||||
pemberian_tindakan pemberian_tindakan[]
|
pemberian_tindakan pemberian_tindakan[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,233 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditController } from './audit.controller';
|
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', () => {
|
describe('AuditController', () => {
|
||||||
let controller: 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 () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AuditController],
|
controllers: [AuditController],
|
||||||
}).compile();
|
providers: [
|
||||||
|
{ provide: AuditService, useValue: mockAuditService },
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
controller = module.get<AuditController>(AuditController);
|
controller = module.get<AuditController>(AuditController);
|
||||||
|
auditService = module.get(AuditService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
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,6 +22,8 @@ export class AuditController {
|
||||||
@Query('pageSize') pageSize: number,
|
@Query('pageSize') pageSize: number,
|
||||||
@Query('type') type: string,
|
@Query('type') type: string,
|
||||||
@Query('tampered') tampered: string,
|
@Query('tampered') tampered: string,
|
||||||
|
@Query('orderBy') orderBy: string,
|
||||||
|
@Query('order') order: 'asc' | 'desc',
|
||||||
) {
|
) {
|
||||||
const result = await this.auditService.getAuditTrails(
|
const result = await this.auditService.getAuditTrails(
|
||||||
search,
|
search,
|
||||||
|
|
@ -29,6 +31,8 @@ export class AuditController {
|
||||||
pageSize,
|
pageSize,
|
||||||
type,
|
type,
|
||||||
tampered,
|
tampered,
|
||||||
|
orderBy,
|
||||||
|
order,
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,198 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditGateway } from './audit.gateway';
|
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', () => {
|
describe('AuditGateway', () => {
|
||||||
let gateway: AuditGateway;
|
let gateway: AuditGateway;
|
||||||
|
let mockServer: jest.Mocked<Server>;
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
verifyAsync: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [AuditGateway],
|
providers: [
|
||||||
|
AuditGateway,
|
||||||
|
WebsocketGuard,
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
gateway = module.get<AuditGateway>(AuditGateway);
|
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', () => {
|
it('should be defined', () => {
|
||||||
expect(gateway).toBeDefined();
|
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,18 +1,653 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuditService } from './audit.service';
|
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', () => {
|
describe('AuditService', () => {
|
||||||
let service: 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 () => {
|
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({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [AuditService],
|
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 },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AuditService>(AuditService);
|
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', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
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,4 +1,8 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { LogService } from '../log/log.service';
|
import { LogService } from '../log/log.service';
|
||||||
import { ObatService } from '../obat/obat.service';
|
import { ObatService } from '../obat/obat.service';
|
||||||
|
|
@ -39,7 +43,13 @@ export class AuditService {
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
type?: string,
|
type?: string,
|
||||||
tampered?: 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') {
|
if (type === 'all' || type === 'initial') {
|
||||||
type = undefined;
|
type = undefined;
|
||||||
} else if (type === 'rekam_medis') {
|
} else if (type === 'rekam_medis') {
|
||||||
|
|
@ -54,28 +64,36 @@ export class AuditService {
|
||||||
tampered = undefined;
|
tampered = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auditLogs = await this.prisma.audit.findMany({
|
try {
|
||||||
take: pageSize,
|
const auditLogs = await this.prisma.audit.findMany({
|
||||||
skip: (page - 1) * pageSize,
|
take: pageSize,
|
||||||
orderBy: { timestamp: 'asc' },
|
skip: (page - 1) * pageSize,
|
||||||
where: {
|
orderBy: orderBy
|
||||||
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
? { [orderBy]: order || 'asc' }
|
||||||
result: tampered ? (tampered as ResultStatus) : undefined,
|
: { timestamp: 'desc' },
|
||||||
OR: search ? [{ id: { contains: search } }] : undefined,
|
where: {
|
||||||
},
|
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
||||||
});
|
result: tampered ? (tampered as ResultStatus) : undefined,
|
||||||
|
OR: search ? [{ id: { contains: search } }] : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const count = await this.prisma.audit.count({
|
const count = await this.prisma.audit.count({
|
||||||
where: {
|
where: {
|
||||||
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
id: type && type !== 'all' ? { startsWith: type } : undefined,
|
||||||
result: tampered ? (tampered as ResultStatus) : undefined,
|
result: tampered ? (tampered as ResultStatus) : undefined,
|
||||||
},
|
OR: search ? [{ id: { contains: search } }] : undefined,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...auditLogs,
|
...auditLogs,
|
||||||
totalCount: count,
|
totalCount: count,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to fetch audit trails', error.stack);
|
||||||
|
throw new InternalServerErrorException('Failed to fetch audit trails');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async storeAuditTrail() {
|
async storeAuditTrail() {
|
||||||
|
|
@ -84,30 +102,6 @@ export class AuditService {
|
||||||
let bookmark = '';
|
let bookmark = '';
|
||||||
let processedCount = 0;
|
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 {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const pageResults = await this.logService.getLogsWithPagination(
|
const pageResults = await this.logService.getLogsWithPagination(
|
||||||
|
|
@ -135,16 +129,8 @@ export class AuditService {
|
||||||
)
|
)
|
||||||
).filter((record): record is AuditRecordPayload => record !== null);
|
).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) {
|
if (records.length > 0) {
|
||||||
|
this.logger.debug(`Processing ${records.length} audit records`);
|
||||||
await this.prisma.$transaction(
|
await this.prisma.$transaction(
|
||||||
records.map((record) =>
|
records.map((record) =>
|
||||||
this.prisma.audit.upsert({
|
this.prisma.audit.upsert({
|
||||||
|
|
@ -154,7 +140,7 @@ export class AuditService {
|
||||||
event: record.event,
|
event: record.event,
|
||||||
payload: record.payload,
|
payload: record.payload,
|
||||||
timestamp: record.timestamp,
|
timestamp: record.timestamp,
|
||||||
user_id: record.user_id,
|
user_id: BigInt(record.user_id),
|
||||||
last_sync: record.last_sync,
|
last_sync: record.last_sync,
|
||||||
result: record.result,
|
result: record.result,
|
||||||
},
|
},
|
||||||
|
|
@ -166,7 +152,6 @@ export class AuditService {
|
||||||
|
|
||||||
if (nextBookmark === '' || nextBookmark === bookmark) {
|
if (nextBookmark === '' || nextBookmark === bookmark) {
|
||||||
const completeData = { status: 'COMPLETED' };
|
const completeData = { status: 'COMPLETED' };
|
||||||
this.logger.log('Mengirim selesai via WebSocket:', completeData);
|
|
||||||
this.auditGateway.sendComplete(completeData);
|
this.auditGateway.sendComplete(completeData);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -174,57 +159,52 @@ export class AuditService {
|
||||||
bookmark = nextBookmark;
|
bookmark = nextBookmark;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error storing audit trail:', error);
|
this.logger.error('Error storing audit trail', error.stack);
|
||||||
throw error;
|
this.auditGateway.sendError({
|
||||||
|
status: 'ERROR',
|
||||||
|
message: error.message || 'Failed to store audit trail',
|
||||||
|
});
|
||||||
|
throw new InternalServerErrorException('Failed to store audit trail');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCountAuditTamperedData() {
|
async getCountAuditTamperedData() {
|
||||||
const auditTamperedCount = await this.prisma.audit.count({
|
try {
|
||||||
where: {
|
const [
|
||||||
result: 'tampered',
|
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 auditNonTamperedCount = await this.prisma.audit.count({
|
return {
|
||||||
where: {
|
auditTamperedCount,
|
||||||
result: 'non_tampered',
|
auditNonTamperedCount,
|
||||||
},
|
rekamMedisTamperedCount,
|
||||||
});
|
tindakanDokterTamperedCount,
|
||||||
|
obatTamperedCount,
|
||||||
const rekamMedisTamperedCount = await this.prisma.audit.count({
|
};
|
||||||
where: {
|
} catch (error) {
|
||||||
result: 'tampered',
|
this.logger.error('Failed to get audit tampered count', error.stack);
|
||||||
id: {
|
throw new InternalServerErrorException('Failed to get audit statistics');
|
||||||
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,
|
|
||||||
rekamMedisTamperedCount,
|
|
||||||
tindakanDokterTamperedCount,
|
|
||||||
obatTamperedCount,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildAuditRecord(
|
private async buildAuditRecord(
|
||||||
|
|
@ -246,18 +226,19 @@ export class AuditService {
|
||||||
const timestamp = this.parseTimestamp(value.timestamp) ?? now;
|
const timestamp = this.parseTimestamp(value.timestamp) ?? now;
|
||||||
const userId = value.user_id;
|
const userId = value.user_id;
|
||||||
const blockchainHash: string | undefined = value.payload;
|
const blockchainHash: string | undefined = value.payload;
|
||||||
|
let data: any = null;
|
||||||
|
|
||||||
if (!blockchainHash) {
|
if (!blockchainHash) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dbHash: string | null = null;
|
let dbHash: string | null = null;
|
||||||
//
|
|
||||||
try {
|
try {
|
||||||
if (logId.startsWith('OBAT_')) {
|
if (logId.startsWith('OBAT')) {
|
||||||
const obatId = this.extractNumericId(logId);
|
const obatId = this.extractNumericId(logId);
|
||||||
if (obatId !== null) {
|
if (obatId !== null) {
|
||||||
const obat = await this.obatService.getObatById(obatId);
|
const obat = await this.obatService.getObatById(obatId);
|
||||||
|
data = obat;
|
||||||
if (obat) {
|
if (obat) {
|
||||||
dbHash = this.obatService.createHashingPayload({
|
dbHash = this.obatService.createHashingPayload({
|
||||||
obat: obat.obat,
|
obat: obat.obat,
|
||||||
|
|
@ -271,6 +252,7 @@ export class AuditService {
|
||||||
if (rekamMedisId) {
|
if (rekamMedisId) {
|
||||||
const rekamMedis =
|
const rekamMedis =
|
||||||
await this.rekamMedisService.getRekamMedisById(rekamMedisId);
|
await this.rekamMedisService.getRekamMedisById(rekamMedisId);
|
||||||
|
data = rekamMedis;
|
||||||
if (rekamMedis) {
|
if (rekamMedis) {
|
||||||
dbHash = this.rekamMedisService.createHashingPayload({
|
dbHash = this.rekamMedisService.createHashingPayload({
|
||||||
dokter_id: 123,
|
dokter_id: 123,
|
||||||
|
|
@ -286,6 +268,7 @@ export class AuditService {
|
||||||
if (tindakanId !== null) {
|
if (tindakanId !== null) {
|
||||||
const tindakanDokter =
|
const tindakanDokter =
|
||||||
await this.tindakanDokterService.getTindakanDokterById(tindakanId);
|
await this.tindakanDokterService.getTindakanDokterById(tindakanId);
|
||||||
|
data = tindakanDokter;
|
||||||
if (tindakanDokter) {
|
if (tindakanDokter) {
|
||||||
dbHash = this.tindakanDokterService.createHashingPayload({
|
dbHash = this.tindakanDokterService.createHashingPayload({
|
||||||
id_visit: tindakanDokter.id_visit,
|
id_visit: tindakanDokter.id_visit,
|
||||||
|
|
@ -299,12 +282,25 @@ export class AuditService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Failed to resolve related data for log ${logId}:`, err);
|
this.logger.warn(
|
||||||
|
`Failed to resolve related data for log ${logId}: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNotTampered = dbHash
|
let isNotTampered = false;
|
||||||
? await this.compareData(blockchainHash, dbHash)
|
const eventType = logEntry.value.event?.split('_').at(-1);
|
||||||
: false;
|
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 result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered';
|
const result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered';
|
||||||
|
|
||||||
|
|
@ -313,7 +309,6 @@ export class AuditService {
|
||||||
progress_count: index ?? 0,
|
progress_count: index ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log('Mengirim progres via WebSocket:', progressData);
|
|
||||||
this.auditGateway.sendProgress(progressData);
|
this.auditGateway.sendProgress(progressData);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,198 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuthController } from './auth.controller';
|
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', () => {
|
describe('AuthController', () => {
|
||||||
let controller: 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 () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<AuthController>(AuthController);
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
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';
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
|
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
|
||||||
import { AuthDto, AuthDtoResponse, UserRole } from './dto/auth.dto';
|
import { AuthDto, UserRole } from './dto/auth.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthGuard } from './guard/auth.guard';
|
import { AuthGuard } from './guard/auth.guard';
|
||||||
import { RolesGuard } from './guard/roles.guard';
|
import { RolesGuard } from './guard/roles.guard';
|
||||||
|
|
@ -24,7 +24,6 @@ export class AuthController {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/register')
|
@Post('/register')
|
||||||
@Header('Content-Type', 'application/json')
|
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.Admin)
|
@Roles(UserRole.Admin)
|
||||||
|
|
@ -46,9 +45,24 @@ export class AuthController {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: this.configService.get<string>('NODE_ENV') !== 'development',
|
secure: this.configService.get<string>('NODE_ENV') !== 'development',
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
maxAge: 3600000,
|
maxAge: parseInt(
|
||||||
|
this.configService.get<string>('COOKIE_MAX_AGE') || '7200000',
|
||||||
|
10,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user, csrfToken };
|
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,7 +3,8 @@ import { AuthService } from './auth.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||||
|
import type { StringValue } from 'ms';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
|
|
@ -14,9 +15,13 @@ import { JwtModule } from '@nestjs/jwt';
|
||||||
global: true,
|
global: true,
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService): JwtModuleOptions => ({
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
signOptions: { expiresIn: '120m' },
|
signOptions: {
|
||||||
|
expiresIn:
|
||||||
|
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ??
|
||||||
|
'120m',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,47 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuthService } from './auth.service';
|
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', () => {
|
describe('AuthService', () => {
|
||||||
let service: 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 () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [AuthService],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AuthService>(AuthService);
|
service = module.get<AuthService>(AuthService);
|
||||||
|
|
@ -15,4 +50,250 @@ describe('AuthService', () => {
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
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,9 +2,10 @@ import { PrismaService } from '@api/modules/prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthDtoResponse, UserRole } from './dto/auth.dto';
|
import { UserRole } from './dto/auth.dto';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
|
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
|
||||||
|
|
@ -20,8 +21,28 @@ export class AuthService {
|
||||||
private configService: ConfigService,
|
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> {
|
async registerUser(data: CreateUserDto): Promise<CreateUserDtoResponse> {
|
||||||
const salt = this.configService.get<number>('BCRYPT_SALT') ?? 10;
|
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 hashedPassword = await bcrypt.hash(data.password, salt);
|
const hashedPassword = await bcrypt.hash(data.password, salt);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -41,12 +62,8 @@ export class AuthService {
|
||||||
role: userCreated.role as UserRole,
|
role: userCreated.role as UserRole,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
console.error('Error registering user:', error);
|
||||||
if (error.code === 'P2002') {
|
throw new InternalServerErrorException();
|
||||||
throw new ConflictException('Username ini sudah terdaftar');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,3 @@ export class AuthDto {
|
||||||
@Length(6, undefined, { message: 'Password minimal 6 karakter' })
|
@Length(6, undefined, { message: 'Password minimal 6 karakter' })
|
||||||
password: string;
|
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,9 +1,157 @@
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AuthGuard } from './auth.guard';
|
import { AuthGuard } from './auth.guard';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
describe('AuthGuard', () => {
|
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', () => {
|
it('should be defined', () => {
|
||||||
expect(new AuthGuard(new JwtService(), new ConfigService())).toBeDefined();
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,23 @@ export class AuthGuard implements CanActivate {
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const token = this.extractTokenFromCookie(request);
|
const jwtToken = this.extractTokenFromCookie(request);
|
||||||
if (!token) {
|
const csrfToken = this.extractTokenFromHeader(request);
|
||||||
|
|
||||||
|
if (!jwtToken || !csrfToken) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(jwtToken, {
|
||||||
secret: this.configService.get<string>('JWT_SECRET'),
|
secret: this.configService.get<string>('JWT_SECRET'),
|
||||||
});
|
});
|
||||||
|
console.log(payload);
|
||||||
|
|
||||||
|
if (payload.csrf !== csrfToken) {
|
||||||
|
throw new UnauthorizedException(['Invalid CSRF token']);
|
||||||
|
}
|
||||||
|
|
||||||
request['user'] = payload;
|
request['user'] = payload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
|
|
@ -33,8 +42,8 @@ export class AuthGuard implements CanActivate {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractTokenFromHeader(request: any): string | undefined {
|
private extractTokenFromHeader(request: any): string | undefined {
|
||||||
const [type, token] = request.headers?.authorization?.split(' ') ?? [];
|
const token = request.headers['x-csrf-token'];
|
||||||
return type === 'Bearer' ? token : undefined;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractTokenFromCookie(request: Request): string | undefined {
|
private extractTokenFromCookie(request: Request): string | undefined {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,155 @@
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { RolesGuard } from './roles.guard';
|
import { RolesGuard } from './roles.guard';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { UserRole } from '../dto/auth.dto';
|
||||||
|
|
||||||
describe('RolesGuard', () => {
|
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', () => {
|
it('should be defined', () => {
|
||||||
expect(new RolesGuard(new Reflector())).toBeDefined();
|
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)',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@ export class RolesGuard implements CanActivate {
|
||||||
[context.getHandler(), context.getClass()],
|
[context.getHandler(), context.getClass()],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!requiredRoles) {
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = context.switchToHttp().getRequest();
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
if (!user?.role) {
|
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);
|
const hasRole = requiredRoles.some((role) => user.role === role);
|
||||||
|
|
@ -34,6 +34,6 @@ export class RolesGuard implements CanActivate {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ForbiddenException('You do not have the required role');
|
throw new ForbiddenException(['You do not have the required role']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,147 @@
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { WebsocketGuard } from './websocket.guard';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { WebsocketGuard } from './websocket.guard';
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Socket } from 'socket.io';
|
||||||
|
|
||||||
describe('WebsocketGuard', () => {
|
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', () => {
|
it('should be defined', () => {
|
||||||
expect(
|
expect(guard).toBeDefined();
|
||||||
new WebsocketGuard(new JwtService(), new ConfigService()),
|
});
|
||||||
).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Socket } from 'socket.io';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
|
|
||||||
interface AuthPayload {
|
interface AuthPayload {
|
||||||
sub: number;
|
sub: bigint;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
|
@ -69,4 +70,13 @@ export class ObatController {
|
||||||
async getObatLogs(@Param('id') id: string) {
|
async getObatLogs(@Param('id') id: string) {
|
||||||
return await this.obatService.getLogObatById(id);
|
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,6 +56,11 @@ export class ObatService {
|
||||||
take: take,
|
take: take,
|
||||||
where: {
|
where: {
|
||||||
obat: obat ? { contains: obat } : undefined,
|
obat: obat ? { contains: obat } : undefined,
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
orderBy: orderBy
|
orderBy: orderBy
|
||||||
? { [Object.keys(orderBy)[0]]: order || 'asc' }
|
? { [Object.keys(orderBy)[0]]: order || 'asc' }
|
||||||
|
|
@ -65,6 +70,11 @@ export class ObatService {
|
||||||
const count = await this.prisma.pemberian_obat.count({
|
const count = await this.prisma.pemberian_obat.count({
|
||||||
where: {
|
where: {
|
||||||
obat: obat ? { contains: obat } : undefined,
|
obat: obat ? { contains: obat } : undefined,
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -273,7 +283,98 @@ 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() {
|
async countObat() {
|
||||||
return this.prisma.pemberian_obat.count();
|
return this.prisma.pemberian_obat.count({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,11 @@ export class RekammedisService {
|
||||||
: undefined,
|
: undefined,
|
||||||
jenis_kelamin: jkCharacter ? { equals: jkCharacter } : undefined,
|
jenis_kelamin: jkCharacter ? { equals: jkCharacter } : undefined,
|
||||||
kode_diagnosa: kode_diagnosa ? { contains: kode_diagnosa } : undefined,
|
kode_diagnosa: kode_diagnosa ? { contains: kode_diagnosa } : undefined,
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
...golDarahFilter,
|
...golDarahFilter,
|
||||||
...tindakLanjutFilter,
|
...tindakLanjutFilter,
|
||||||
};
|
};
|
||||||
|
|
@ -469,15 +474,28 @@ export class RekammedisService {
|
||||||
throw new Error(`Rekam Medis with id_visit ${id_visit} not found`);
|
throw new Error(`Rekam Medis with id_visit ${id_visit} not found`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await this.prisma.validation_queue.create({
|
const response = await this.prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const createdQueue = await tx.validation_queue.create({
|
||||||
table_name: 'rekam_medis',
|
data: {
|
||||||
action: 'DELETE',
|
table_name: 'rekam_medis',
|
||||||
record_id: id_visit,
|
action: 'DELETE',
|
||||||
dataPayload: data,
|
record_id: id_visit,
|
||||||
user_id_request: user.sub,
|
dataPayload: data,
|
||||||
status: 'PENDING',
|
user_id_request: user.sub,
|
||||||
},
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedRekamMedis = await tx.rekam_medis.update({
|
||||||
|
where: { id_visit },
|
||||||
|
data: {
|
||||||
|
deleted_status: 'DELETE_VALIDATION',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...createdQueue,
|
||||||
|
rekam_medis: updatedRekamMedis,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -486,10 +504,39 @@ export class RekammedisService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRekamMedisFromDB(id_visit: string) {
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedRekamMedis = await this.prisma.rekam_medis.delete({
|
const deletedRekamMedis = await this.prisma.$transaction(async (tx) => {
|
||||||
where: { id_visit },
|
const deleted = await tx.rekam_medis.update({
|
||||||
|
data: {
|
||||||
|
deleted_status: 'DELETED',
|
||||||
|
},
|
||||||
|
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;
|
return deletedRekamMedis;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -516,6 +563,11 @@ export class RekammedisService {
|
||||||
waktu_visit: {
|
waktu_visit: {
|
||||||
gte: sevenDaysAgo,
|
gte: sevenDaysAgo,
|
||||||
},
|
},
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
id_visit: true,
|
id_visit: true,
|
||||||
|
|
@ -549,6 +601,14 @@ export class RekammedisService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async countRekamMedis() {
|
async countRekamMedis() {
|
||||||
return this.prisma.rekam_medis.count();
|
return this.prisma.rekam_medis.count({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ export class TindakanDokterService {
|
||||||
kategori_tindakanArray.length > 0
|
kategori_tindakanArray.length > 0
|
||||||
? { in: kategori_tindakanArray }
|
? { in: kategori_tindakanArray }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
orderBy: orderBy
|
orderBy: orderBy
|
||||||
? { [Object.keys(orderBy)[0]]: order || 'asc' }
|
? { [Object.keys(orderBy)[0]]: order || 'asc' }
|
||||||
|
|
@ -97,6 +102,11 @@ export class TindakanDokterService {
|
||||||
kategori_tindakanArray.length > 0
|
kategori_tindakanArray.length > 0
|
||||||
? { in: kategori_tindakanArray }
|
? { in: kategori_tindakanArray }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -331,16 +341,36 @@ export class TindakanDokterService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.validation_queue.create({
|
try {
|
||||||
data: {
|
const validationQueue = await this.prisma.$transaction(async (tx) => {
|
||||||
table_name: 'pemberian_tindakan',
|
const queue = await tx.validation_queue.create({
|
||||||
action: 'DELETE',
|
data: {
|
||||||
dataPayload: existingTindakan,
|
table_name: 'pemberian_tindakan',
|
||||||
record_id: tindakanId.toString(),
|
action: 'DELETE',
|
||||||
user_id_request: user.sub,
|
dataPayload: existingTindakan,
|
||||||
status: 'PENDING',
|
record_id: tindakanId.toString(),
|
||||||
},
|
user_id_request: user.sub,
|
||||||
});
|
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) {
|
async deleteTindakanDokterFromDBAndBlockchain(id: number, userId: number) {
|
||||||
|
|
@ -359,8 +389,9 @@ export class TindakanDokterService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedTindakan = await this.prisma.$transaction(async (tx) => {
|
const deletedTindakan = await this.prisma.$transaction(async (tx) => {
|
||||||
const deleted = await tx.pemberian_tindakan.delete({
|
const deleted = await tx.pemberian_tindakan.update({
|
||||||
where: { id: tindakanId },
|
where: { id: tindakanId },
|
||||||
|
data: { deleted_status: 'DELETED' },
|
||||||
});
|
});
|
||||||
const logPayload = JSON.stringify(deleted);
|
const logPayload = JSON.stringify(deleted);
|
||||||
const payloadHash = sha256(logPayload);
|
const payloadHash = sha256(logPayload);
|
||||||
|
|
@ -385,6 +416,14 @@ export class TindakanDokterService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async countTindakanDokter() {
|
async countTindakanDokter() {
|
||||||
return this.prisma.pemberian_tindakan.count();
|
return this.prisma.pemberian_tindakan.count({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ deleted_status: null },
|
||||||
|
{ deleted_status: 'DELETE_VALIDATION' },
|
||||||
|
{ deleted_status: { not: 'DELETED' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/guard/auth.guard';
|
import { AuthGuard } from '../auth/guard/auth.guard';
|
||||||
import { ValidationService } from './validation.service';
|
import { ValidationService } from './validation.service';
|
||||||
import { CurrentUser } from '../auth/decorator/current-user.decorator';
|
import { CurrentUser } from '../auth/decorator/current-user.decorator';
|
||||||
|
|
@ -10,8 +10,29 @@ export class ValidationController {
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
async getValidationStatus() {
|
async getValidationStatus(
|
||||||
return this.validationService.getAllValidationsQueue();
|
@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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/:id')
|
@Get('/:id')
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,10 @@ export class ValidationService {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
approveDelete: async (queue: any) => {
|
approveDelete: async (queue: any) => {
|
||||||
return this.rekamMedisService.deleteRekamMedisFromDB(queue.record_id);
|
return this.rekamMedisService.deleteRekamMedisFromDBAndBlockchain(
|
||||||
|
queue.record_id,
|
||||||
|
Number(queue.user_id_request),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pemberian_tindakan: {
|
pemberian_tindakan: {
|
||||||
|
|
@ -97,15 +100,65 @@ export class ValidationService {
|
||||||
Number(queue.user_id_request),
|
Number(queue.user_id_request),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
approveDelete: async (queue: any) => {
|
||||||
|
return this.obatService.deleteObatFromDBAndBlockchain(
|
||||||
|
Number(queue.record_id),
|
||||||
|
queue.user_id_request,
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async getAllValidationsQueue() {
|
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);
|
||||||
const result = await this.prisma.validation_queue.findMany({
|
const result = await this.prisma.validation_queue.findMany({
|
||||||
where: { status: 'PENDING' },
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const totalCount = await this.prisma.validation_queue.count({
|
const totalCount = await this.prisma.validation_queue.count({
|
||||||
where: { status: 'PENDING' },
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return { data: result, totalCount };
|
return { data: result, totalCount };
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +249,10 @@ export class ValidationService {
|
||||||
const updated = await this.prisma.validation_queue.update({
|
const updated = await this.prisma.validation_queue.update({
|
||||||
where: { id: validationQueue.id },
|
where: { id: validationQueue.id },
|
||||||
data: {
|
data: {
|
||||||
record_id: approvalResult.id.toString(),
|
record_id:
|
||||||
|
validationQueue.table_name === 'rekam_medis'
|
||||||
|
? approvalResult.id_visit
|
||||||
|
: approvalResult.id.toString(),
|
||||||
status: 'APPROVED',
|
status: 'APPROVED',
|
||||||
user_id_process: Number(user.sub),
|
user_id_process: Number(user.sub),
|
||||||
processed_at: new Date(),
|
processed_at: new Date(),
|
||||||
|
|
@ -209,19 +265,81 @@ 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) {
|
async rejectValidation(id: number, user: ActiveUserPayload) {
|
||||||
const validationQueue = await this.getValidationQueueById(id);
|
const validationQueue = await this.getValidationQueueById(id);
|
||||||
if (!validationQueue) {
|
if (!validationQueue) {
|
||||||
throw new Error('Validation queue not found');
|
throw new Error('Validation queue not found');
|
||||||
}
|
}
|
||||||
const updated = await this.prisma.validation_queue.update({
|
|
||||||
where: { id: validationQueue.id },
|
let recordId: number | string = '';
|
||||||
data: {
|
|
||||||
status: 'REJECTED',
|
if (
|
||||||
user_id_process: Number(user.sub),
|
validationQueue.status === 'PENDING' &&
|
||||||
processed_at: new Date(),
|
validationQueue.action === 'DELETE'
|
||||||
},
|
) {
|
||||||
});
|
recordId = this.determineIdType(
|
||||||
return updated;
|
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({
|
||||||
|
where: { id: validationQueue.id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
user_id_process: Number(user.sub),
|
||||||
|
processed_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...updatedQueue,
|
||||||
|
updatedDeleteStatus,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return rejectedResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rejecting validation:', (error as Error).message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,11 @@ const pendingDeleteItem = ref<T | null>(null);
|
||||||
const hasStatusColumn = () => props.columns.some((col) => col.key === "status");
|
const hasStatusColumn = () => props.columns.some((col) => col.key === "status");
|
||||||
const hasUserIdProcessColumn = () =>
|
const hasUserIdProcessColumn = () =>
|
||||||
props.columns.some((col) => col.key === "user_id_process");
|
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 formatCellValue = (item: T, columnKey: keyof T) => {
|
||||||
const value = item[columnKey];
|
const value = item[columnKey];
|
||||||
|
|
||||||
if (columnKey === "event" && typeof value === "string") {
|
if (columnKey === "event" && typeof value === "string") {
|
||||||
const segments = value.split("_");
|
const segments = value.split("_");
|
||||||
|
|
||||||
|
|
@ -42,6 +43,10 @@ const formatCellValue = (item: T, columnKey: keyof T) => {
|
||||||
if (segments.length >= 2 && segments[segments.length - 1] === "updated") {
|
if (segments.length >= 2 && segments[segments.length - 1] === "updated") {
|
||||||
return "UPDATE";
|
return "UPDATE";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (segments.length >= 2 && segments[segments.length - 1] === "deleted") {
|
||||||
|
return "DELETE";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|
@ -119,10 +124,12 @@ const handleDeleteCancel = () => {
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:class="[
|
:class="[
|
||||||
'hover:bg-dark hover:text-light transition-colors',
|
'hover:bg-dark hover:text-light transition-colors',
|
||||||
(item as Record<string, any>).isTampered ? 'bg-red-300 text-dark' : ''
|
(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' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
|
v-if="item.deleted_status !== 'DELETE_VALIDATION'"
|
||||||
v-for="column in columns"
|
v-for="column in columns"
|
||||||
:key="String(column.key)"
|
:key="String(column.key)"
|
||||||
:class="[
|
:class="[
|
||||||
|
|
@ -143,6 +150,125 @@ const handleDeleteCancel = () => {
|
||||||
Review
|
Review
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</td>
|
</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()">
|
<td v-if="!hasStatusColumn()">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<!-- Details Button -->
|
<!-- Details Button -->
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,24 @@
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import DialogConfirm from "../DialogConfirm.vue";
|
import DialogConfirm from "../DialogConfirm.vue";
|
||||||
|
import { useApi } from "../../composables/useApi";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { post } = useApi();
|
||||||
const logoutDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
|
const logoutDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
|
||||||
|
|
||||||
const showLogoutDialog = () => {
|
const showLogoutDialog = () => {
|
||||||
logoutDialog.value?.show();
|
logoutDialog.value?.show();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogoutConfirm = () => {
|
const handleLogoutConfirm = async () => {
|
||||||
|
try {
|
||||||
|
await post("/auth/logout", {});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.removeItem("csrf_token");
|
localStorage.removeItem("csrf_token");
|
||||||
router.push({ name: "login" });
|
router.push({ name: "login" });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ interface AuditLogEntry extends BlockchainLog {
|
||||||
tamperedLabel: string;
|
tamperedLabel: string;
|
||||||
last_sync: string;
|
last_sync: string;
|
||||||
isTampered: boolean;
|
isTampered: boolean;
|
||||||
|
timestamp: string;
|
||||||
txId?: string;
|
txId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ export const SORT_OPTIONS = {
|
||||||
created_at: "Waktu Dibuat",
|
created_at: "Waktu Dibuat",
|
||||||
processed_at: "Waktu Diproses",
|
processed_at: "Waktu Diproses",
|
||||||
},
|
},
|
||||||
|
AUDIT_TRAIL: {
|
||||||
|
last_sync: "Last Sync",
|
||||||
|
timestamp: "Timestamp",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const REKAM_MEDIS_TABLE_COLUMNS = [
|
export const REKAM_MEDIS_TABLE_COLUMNS = [
|
||||||
|
|
@ -302,6 +306,7 @@ export const AUDIT_TABLE_COLUMNS = [
|
||||||
{ key: "last_sync", label: "Last Sync", class: "text-dark" },
|
{ key: "last_sync", label: "Last Sync", class: "text-dark" },
|
||||||
{ key: "userId", label: "User ID", class: "text-dark" },
|
{ key: "userId", label: "User ID", class: "text-dark" },
|
||||||
{ key: "status", label: "Status Data", class: "text-dark" },
|
{ key: "status", label: "Status Data", class: "text-dark" },
|
||||||
|
{ key: "timestamp", label: "Timestamp", class: "text-dark" },
|
||||||
] satisfies Array<{
|
] satisfies Array<{
|
||||||
key: keyof AuditLogEntry;
|
key: keyof AuditLogEntry;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DEBOUNCE_DELAY,
|
DEBOUNCE_DELAY,
|
||||||
ITEMS_PER_PAGE_OPTIONS,
|
ITEMS_PER_PAGE_OPTIONS,
|
||||||
AUDIT_TABLE_COLUMNS,
|
AUDIT_TABLE_COLUMNS,
|
||||||
|
SORT_OPTIONS,
|
||||||
} from "../../../constants/pagination";
|
} from "../../../constants/pagination";
|
||||||
import ButtonDark from "../../../components/dashboard/ButtonDark.vue";
|
import ButtonDark from "../../../components/dashboard/ButtonDark.vue";
|
||||||
import DialogConfirm from "../../../components/DialogConfirm.vue";
|
import DialogConfirm from "../../../components/DialogConfirm.vue";
|
||||||
|
|
@ -23,6 +24,7 @@ import type {
|
||||||
AuditLogType,
|
AuditLogType,
|
||||||
} from "../../../constants/interfaces";
|
} from "../../../constants/interfaces";
|
||||||
import { io, Socket } from "socket.io-client";
|
import { io, Socket } from "socket.io-client";
|
||||||
|
import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
|
||||||
|
|
||||||
interface AuditLogResponse {
|
interface AuditLogResponse {
|
||||||
data: AuditLogEntry[];
|
data: AuditLogEntry[];
|
||||||
|
|
@ -32,6 +34,10 @@ interface AuditLogResponse {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const sortBy = ref("last_sync");
|
||||||
|
const sortOrder = ref<"asc" | "desc">(
|
||||||
|
(route.query.order as "asc" | "desc") || "desc"
|
||||||
|
);
|
||||||
const { debounce } = useDebounce();
|
const { debounce } = useDebounce();
|
||||||
|
|
||||||
const pagination = usePagination({
|
const pagination = usePagination({
|
||||||
|
|
@ -47,6 +53,17 @@ const filters = ref({
|
||||||
tampered: (route.query.tampered as string) || "initial",
|
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) => {
|
const formatTimestamp = (rawValue?: string) => {
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return "-";
|
return "-";
|
||||||
|
|
@ -184,10 +201,16 @@ const updateQueryParams = () => {
|
||||||
query.search = searchId.value;
|
query.search = searchId.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortBy.value !== "initial") {
|
||||||
|
query.sortBy = sortBy.value;
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.value.type !== "all") {
|
if (filters.value.type !== "all") {
|
||||||
query.type = filters.value.type;
|
query.type = filters.value.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query.order = sortOrder.value;
|
||||||
|
|
||||||
if (filters.value.tampered !== "all") {
|
if (filters.value.tampered !== "all") {
|
||||||
query.tampered = filters.value.tampered;
|
query.tampered = filters.value.tampered;
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +223,8 @@ const fetchData = async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: pagination.page.value.toString(),
|
page: pagination.page.value.toString(),
|
||||||
pageSize: pageSize.value.toString(),
|
pageSize: pageSize.value.toString(),
|
||||||
|
orderBy: sortBy.value,
|
||||||
|
order: sortOrder.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (searchId.value) {
|
if (searchId.value) {
|
||||||
|
|
@ -218,6 +243,8 @@ const fetchData = async () => {
|
||||||
`/audit/trail${params.toString() ? `?${params.toString()}` : ""}`
|
`/audit/trail${params.toString() ? `?${params.toString()}` : ""}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("Audit Log Response:", response);
|
||||||
|
|
||||||
const apiResponse = response as any;
|
const apiResponse = response as any;
|
||||||
pagination.totalCount.value = apiResponse.totalCount;
|
pagination.totalCount.value = apiResponse.totalCount;
|
||||||
|
|
||||||
|
|
@ -262,6 +289,12 @@ const handleResetFilters = () => {
|
||||||
pagination.reset();
|
pagination.reset();
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(sortOrder, () => {
|
||||||
|
pagination.reset();
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => pagination.page.value,
|
() => pagination.page.value,
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -412,11 +445,40 @@ onBeforeUnmount(() => {
|
||||||
<div
|
<div
|
||||||
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
|
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
|
||||||
>
|
>
|
||||||
<SearchInput
|
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
|
||||||
v-model="searchId"
|
<SearchInput
|
||||||
placeholder="Cari berdasarkan ID Log"
|
v-model="searchId"
|
||||||
@search="handleSearch"
|
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
|
<DialogConfirm
|
||||||
ref="auditDialog"
|
ref="auditDialog"
|
||||||
title="Konfirmasi"
|
title="Konfirmasi"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface ApiResponse {
|
||||||
const data = ref<ObatData[]>([]);
|
const data = ref<ObatData[]>([]);
|
||||||
const searchObat = ref("");
|
const searchObat = ref("");
|
||||||
const sortBy = ref("id");
|
const sortBy = ref("id");
|
||||||
|
const isDeleteSuccess = ref<boolean>(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
@ -155,14 +156,15 @@ const handleUpdate = (item: ObatData) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (item: ObatData) => {
|
const handleDelete = async (item: ObatData) => {
|
||||||
if (confirm(`Apakah Anda yakin ingin menghapus obat "${item.obat}"?`)) {
|
try {
|
||||||
try {
|
const result = await api.delete(`/obat/${item.id}`);
|
||||||
await api.delete(`/obat/${item.id}`);
|
if (result) {
|
||||||
await fetchData();
|
isDeleteSuccess.value = true;
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting obat:", error);
|
|
||||||
alert("Gagal menghapus data obat");
|
|
||||||
}
|
}
|
||||||
|
await fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting obat:", error);
|
||||||
|
alert("Gagal menghapus data obat");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -252,6 +254,27 @@ onMounted(async () => {
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</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 -->
|
<!-- Data Table -->
|
||||||
<DataTable
|
<DataTable
|
||||||
:data="data"
|
:data="data"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface ApiResponse {
|
||||||
const data = ref<RekamMedis[]>([]);
|
const data = ref<RekamMedis[]>([]);
|
||||||
const searchRekamMedis = ref("");
|
const searchRekamMedis = ref("");
|
||||||
const sortBy = ref("waktu_visit");
|
const sortBy = ref("waktu_visit");
|
||||||
|
const isDeleteSuccess = ref<boolean>(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
@ -275,7 +276,10 @@ const handleUpdate = (item: RekamMedis) => {
|
||||||
|
|
||||||
const handleDelete = async (item: RekamMedis) => {
|
const handleDelete = async (item: RekamMedis) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/rekammedis/${item.id_visit}`);
|
const result = await api.delete(`/rekammedis/${item.id_visit}`);
|
||||||
|
if (result) {
|
||||||
|
isDeleteSuccess.value = true;
|
||||||
|
}
|
||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting rekam medis:", error);
|
console.error("Error deleting rekam medis:", error);
|
||||||
|
|
@ -602,6 +606,30 @@ onBeforeUnmount(() => {
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</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 -->
|
<!-- Data Table -->
|
||||||
<DataTable
|
<DataTable
|
||||||
:data="data"
|
:data="data"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
|
||||||
import DataTable from "../../../components/dashboard/DataTable.vue";
|
import DataTable from "../../../components/dashboard/DataTable.vue";
|
||||||
import PaginationControls from "../../../components/dashboard/PaginationControls.vue";
|
import PaginationControls from "../../../components/dashboard/PaginationControls.vue";
|
||||||
import type { ValidationLog } from "../../../constants/interfaces";
|
import type { ValidationLog } from "../../../constants/interfaces";
|
||||||
|
import Footer from "../../../components/dashboard/Footer.vue";
|
||||||
|
|
||||||
interface ApiResponse {
|
interface ApiResponse {
|
||||||
data: ValidationLog[];
|
data: ValidationLog[];
|
||||||
|
|
@ -25,8 +26,7 @@ interface ApiResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = ref<ValidationLog[]>([]);
|
const data = ref<ValidationLog[]>([]);
|
||||||
const searchValidation = ref("");
|
const sortBy = ref("created_at");
|
||||||
const sortBy = ref("id");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const pagination = usePagination({
|
const pagination = usePagination({
|
||||||
|
|
@ -34,10 +34,16 @@ const pagination = usePagination({
|
||||||
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
|
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const sortOrder = ref<"asc" | "desc">(
|
const sortOrder = ref<"asc" | "desc">(
|
||||||
(route.query.order as "asc" | "desc") || "asc"
|
(route.query.order as "asc" | "desc") || "desc"
|
||||||
);
|
);
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const { debounce } = useDebounce();
|
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 updateQueryParams = () => {
|
||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
|
|
@ -45,14 +51,29 @@ const updateQueryParams = () => {
|
||||||
pageSize: pagination.pageSize.value.toString(),
|
pageSize: pagination.pageSize.value.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (searchValidation.value) {
|
if (searchId.value) {
|
||||||
query.search = searchValidation.value;
|
query.search = searchId.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortBy.value !== "id") {
|
if (sortBy.value !== "id") {
|
||||||
query.sortBy = sortBy.value;
|
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;
|
query.order = sortOrder.value;
|
||||||
|
|
||||||
router.replace({ query });
|
router.replace({ query });
|
||||||
|
|
@ -137,6 +158,14 @@ const normalizedData = (rawData: any[]): ValidationLog[] => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
filters.value.kelompok_data = "all";
|
||||||
|
filters.value.aksi = "all";
|
||||||
|
searchId.value = "";
|
||||||
|
pagination.reset();
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
|
|
@ -144,7 +173,13 @@ const fetchData = async () => {
|
||||||
page: pagination.page.value.toString(),
|
page: pagination.page.value.toString(),
|
||||||
orderBy: sortBy.value,
|
orderBy: sortBy.value,
|
||||||
order: sortOrder.value,
|
order: sortOrder.value,
|
||||||
...(searchValidation.value && { validation: searchValidation.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 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await api.get<ApiResponse>(
|
const result = await api.get<ApiResponse>(
|
||||||
|
|
@ -213,16 +248,40 @@ watch(sortOrder, () => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(searchValidation, (newValue, oldValue) => {
|
watch(searchId, (newValue, oldValue) => {
|
||||||
if (oldValue && !newValue) {
|
if (oldValue && !newValue) {
|
||||||
pagination.reset();
|
pagination.reset();
|
||||||
fetchData();
|
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 () => {
|
onMounted(async () => {
|
||||||
if (route.query.search) {
|
if (route.query.search) {
|
||||||
searchValidation.value = route.query.search as string;
|
searchId.value = route.query.search as string;
|
||||||
}
|
}
|
||||||
if (route.query.sortBy) {
|
if (route.query.sortBy) {
|
||||||
sortBy.value = route.query.sortBy as string;
|
sortBy.value = route.query.sortBy as string;
|
||||||
|
|
@ -244,14 +303,95 @@ onMounted(async () => {
|
||||||
<div class="flex h-full p-2">
|
<div class="flex h-full p-2">
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<PageHeader title="Validasi" subtitle="Manajemen Validasi" />
|
<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="bg-white rounded-xl shadow-md">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
|
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">
|
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
v-model="searchValidation"
|
v-model="searchId"
|
||||||
placeholder="Cari berdasarkan Validation"
|
placeholder="Cari berdasarkan ID Record"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-2 md:ml-4">
|
<div class="flex items-center gap-2 md:ml-4">
|
||||||
|
|
@ -315,6 +455,7 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user