Compare commits
No commits in common. "7b0873e0da9a528ece051e1c9e8b682c262343d5" and "e1a539325fa3d17c4b80bf4e0304b0bc50776037" have entirely different histories.
7b0873e0da
...
e1a539325f
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "validation_queue" ADD COLUMN "integer_record_id" INTEGER DEFAULT 0,
|
||||||
|
ADD COLUMN "string_record_id" VARCHAR(25) DEFAULT '';
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_rekam_medis" FOREIGN KEY ("string_record_id") REFERENCES "rekam_medis"("id_visit") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_pemberian_obat" FOREIGN KEY ("integer_record_id") REFERENCES "pemberian_obat"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "validation_queue" ADD CONSTRAINT "fk_validation_pemberian_tindakan" FOREIGN KEY ("integer_record_id") REFERENCES "pemberian_tindakan"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "pemberian_obat" ADD COLUMN "deleted_status" VARCHAR(25);
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "pemberian_tindakan" ADD COLUMN "deleted_status" VARCHAR(25);
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "rekam_medis" ADD COLUMN "deleted_status" VARCHAR(25);
|
|
||||||
|
|
@ -26,7 +26,6 @@ model pemberian_obat {
|
||||||
obat String @db.VarChar(100)
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +35,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +60,6 @@ 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,233 +1,18 @@
|
||||||
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],
|
||||||
providers: [
|
}).compile();
|
||||||
{ 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,8 +22,6 @@ 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,
|
||||||
|
|
@ -31,8 +29,6 @@ export class AuditController {
|
||||||
pageSize,
|
pageSize,
|
||||||
type,
|
type,
|
||||||
tampered,
|
tampered,
|
||||||
orderBy,
|
|
||||||
order,
|
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,198 +1,18 @@
|
||||||
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: [
|
providers: [AuditGateway],
|
||||||
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,653 +1,18 @@
|
||||||
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: [
|
providers: [AuditService],
|
||||||
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,8 +1,4 @@
|
||||||
import {
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
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';
|
||||||
|
|
@ -43,13 +39,7 @@ 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') {
|
||||||
|
|
@ -64,13 +54,10 @@ export class AuditService {
|
||||||
tampered = undefined;
|
tampered = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const auditLogs = await this.prisma.audit.findMany({
|
const auditLogs = await this.prisma.audit.findMany({
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
orderBy: orderBy
|
orderBy: { timestamp: 'asc' },
|
||||||
? { [orderBy]: order || 'asc' }
|
|
||||||
: { timestamp: 'desc' },
|
|
||||||
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,
|
||||||
|
|
@ -82,7 +69,6 @@ export class AuditService {
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -90,10 +76,6 @@ export class AuditService {
|
||||||
...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() {
|
||||||
|
|
@ -102,6 +84,30 @@ 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(
|
||||||
|
|
@ -129,8 +135,16 @@ 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({
|
||||||
|
|
@ -140,7 +154,7 @@ export class AuditService {
|
||||||
event: record.event,
|
event: record.event,
|
||||||
payload: record.payload,
|
payload: record.payload,
|
||||||
timestamp: record.timestamp,
|
timestamp: record.timestamp,
|
||||||
user_id: BigInt(record.user_id),
|
user_id: record.user_id,
|
||||||
last_sync: record.last_sync,
|
last_sync: record.last_sync,
|
||||||
result: record.result,
|
result: record.result,
|
||||||
},
|
},
|
||||||
|
|
@ -152,6 +166,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -159,41 +174,50 @@ export class AuditService {
|
||||||
bookmark = nextBookmark;
|
bookmark = nextBookmark;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error storing audit trail', error.stack);
|
console.error('Error storing audit trail:', error);
|
||||||
this.auditGateway.sendError({
|
throw error;
|
||||||
status: 'ERROR',
|
|
||||||
message: error.message || 'Failed to store audit trail',
|
|
||||||
});
|
|
||||||
throw new InternalServerErrorException('Failed to store audit trail');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCountAuditTamperedData() {
|
async getCountAuditTamperedData() {
|
||||||
try {
|
const auditTamperedCount = await this.prisma.audit.count({
|
||||||
const [
|
where: {
|
||||||
auditTamperedCount,
|
result: 'tampered',
|
||||||
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({
|
||||||
|
where: {
|
||||||
|
result: 'non_tampered',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rekamMedisTamperedCount = await this.prisma.audit.count({
|
||||||
|
where: {
|
||||||
|
result: 'tampered',
|
||||||
|
id: {
|
||||||
|
startsWith: 'REKAM',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tindakanDokterTamperedCount = await this.prisma.audit.count({
|
||||||
|
where: {
|
||||||
|
result: 'tampered',
|
||||||
|
id: {
|
||||||
|
startsWith: 'TINDAKAN',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const obatTamperedCount = await this.prisma.audit.count({
|
||||||
|
where: {
|
||||||
|
result: 'tampered',
|
||||||
|
id: {
|
||||||
|
startsWith: 'OBAT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
auditTamperedCount,
|
auditTamperedCount,
|
||||||
auditNonTamperedCount,
|
auditNonTamperedCount,
|
||||||
|
|
@ -201,10 +225,6 @@ export class AuditService {
|
||||||
tindakanDokterTamperedCount,
|
tindakanDokterTamperedCount,
|
||||||
obatTamperedCount,
|
obatTamperedCount,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get audit tampered count', error.stack);
|
|
||||||
throw new InternalServerErrorException('Failed to get audit statistics');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildAuditRecord(
|
private async buildAuditRecord(
|
||||||
|
|
@ -226,19 +246,18 @@ 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,
|
||||||
|
|
@ -252,7 +271,6 @@ 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,
|
||||||
|
|
@ -268,7 +286,6 @@ 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,
|
||||||
|
|
@ -282,25 +299,12 @@ export class AuditService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
console.warn(`Failed to resolve related data for log ${logId}:`, err);
|
||||||
`Failed to resolve related data for log ${logId}: ${err.message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let isNotTampered = false;
|
const isNotTampered = dbHash
|
||||||
const eventType = logEntry.value.event?.split('_').at(-1);
|
? await this.compareData(blockchainHash, dbHash)
|
||||||
const isDeleteEvent = eventType === 'deleted';
|
: false;
|
||||||
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';
|
||||||
|
|
||||||
|
|
@ -309,6 +313,7 @@ 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,198 +1,18 @@
|
||||||
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, UserRole } from './dto/auth.dto';
|
import { AuthDto, AuthDtoResponse, 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,6 +24,7 @@ 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)
|
||||||
|
|
@ -45,24 +46,9 @@ 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: parseInt(
|
maxAge: 3600000,
|
||||||
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,8 +3,7 @@ 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, JwtModuleOptions } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import type { StringValue } from 'ms';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
|
|
@ -15,13 +14,9 @@ import type { StringValue } from 'ms';
|
||||||
global: true,
|
global: true,
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService): JwtModuleOptions => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
signOptions: {
|
signOptions: { expiresIn: '120m' },
|
||||||
expiresIn:
|
|
||||||
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ??
|
|
||||||
'120m',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,12 @@
|
||||||
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: [
|
providers: [AuthService],
|
||||||
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);
|
||||||
|
|
@ -50,250 +15,4 @@ 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,10 +2,9 @@ import { PrismaService } from '@api/modules/prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserRole } from './dto/auth.dto';
|
import { AuthDtoResponse, 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';
|
||||||
|
|
@ -21,28 +20,8 @@ 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 saltEnv = this.configService.get<string>('BCRYPT_SALT');
|
const salt = this.configService.get<number>('BCRYPT_SALT') ?? 10;
|
||||||
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 {
|
||||||
|
|
@ -62,8 +41,12 @@ export class AuthService {
|
||||||
role: userCreated.role as UserRole,
|
role: userCreated.role as UserRole,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error registering user:', error);
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new InternalServerErrorException();
|
if (error.code === 'P2002') {
|
||||||
|
throw new ConflictException('Username ini sudah terdaftar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,19 @@ export class AuthDto {
|
||||||
@Length(6, undefined, { message: 'Password minimal 6 karakter' })
|
@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,157 +1,9 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { AuthGuard } from './auth.guard';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { AuthGuard } from './auth.guard';
|
||||||
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(guard).toBeDefined();
|
expect(new AuthGuard(new JwtService(), new ConfigService())).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,23 +17,14 @@ 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 jwtToken = this.extractTokenFromCookie(request);
|
const token = this.extractTokenFromCookie(request);
|
||||||
const csrfToken = this.extractTokenFromHeader(request);
|
if (!token) {
|
||||||
|
|
||||||
if (!jwtToken || !csrfToken) {
|
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(jwtToken, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
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();
|
||||||
|
|
@ -42,8 +33,8 @@ export class AuthGuard implements CanActivate {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractTokenFromHeader(request: any): string | undefined {
|
private extractTokenFromHeader(request: any): string | undefined {
|
||||||
const token = request.headers['x-csrf-token'];
|
const [type, token] = request.headers?.authorization?.split(' ') ?? [];
|
||||||
return token;
|
return type === 'Bearer' ? token : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractTokenFromCookie(request: Request): string | undefined {
|
private extractTokenFromCookie(request: Request): string | undefined {
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,8 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { RolesGuard } from './roles.guard';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
import { RolesGuard } from './roles.guard';
|
||||||
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(guard).toBeDefined();
|
expect(new RolesGuard(new Reflector())).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 || requiredRoles.length === 0) {
|
if (!requiredRoles) {
|
||||||
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,147 +1,11 @@
|
||||||
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 { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
import { WebsocketGuard } from './websocket.guard';
|
||||||
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(guard).toBeDefined();
|
expect(
|
||||||
});
|
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: bigint;
|
sub: number;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
|
@ -70,13 +69,4 @@ 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,11 +56,6 @@ 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' }
|
||||||
|
|
@ -70,11 +65,6 @@ 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' } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -283,98 +273,7 @@ export class ObatService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteObat(id: number, user: ActiveUserPayload) {
|
|
||||||
const existingObat = await this.getObatById(id);
|
|
||||||
if (!existingObat) {
|
|
||||||
throw new BadRequestException(`Obat with id ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const createdValidationQueue =
|
|
||||||
await this.prisma.validation_queue.create({
|
|
||||||
data: {
|
|
||||||
table_name: 'pemberian_obat',
|
|
||||||
action: 'DELETE',
|
|
||||||
dataPayload: {
|
|
||||||
...existingObat,
|
|
||||||
},
|
|
||||||
record_id: id.toString(),
|
|
||||||
user_id_request: Number(user.sub),
|
|
||||||
status: 'PENDING',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedObat = await tx.pemberian_obat.update({
|
|
||||||
where: { id: id },
|
|
||||||
data: {
|
|
||||||
deleted_status: 'DELETE_VALIDATION',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return createdValidationQueue;
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting Obat:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteObatFromDBAndBlockchain(id: number, userId: number) {
|
|
||||||
const obatId = Number(id);
|
|
||||||
|
|
||||||
if (isNaN(obatId)) {
|
|
||||||
throw new BadRequestException('ID obat tidak valid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingObat = await this.getObatById(obatId);
|
|
||||||
if (!existingObat) {
|
|
||||||
throw new BadRequestException(`Obat dengan ID ${obatId} tidak ditemukan`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deleteObat = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const deletedObat = await tx.pemberian_obat.update({
|
|
||||||
where: { id: obatId },
|
|
||||||
data: {
|
|
||||||
deleted_status: 'DELETED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const logPayload = JSON.stringify({
|
|
||||||
obat: existingObat.obat,
|
|
||||||
jumlah_obat: existingObat.jumlah_obat,
|
|
||||||
aturan_pakai: existingObat.aturan_pakai,
|
|
||||||
});
|
|
||||||
const payloadHash = sha256(logPayload);
|
|
||||||
const data = {
|
|
||||||
id: `OBAT_${deletedObat.id}`,
|
|
||||||
event: 'obat_deleted',
|
|
||||||
user_id: userId.toString(),
|
|
||||||
payload: payloadHash,
|
|
||||||
};
|
|
||||||
const logResult = await this.logService.storeLog(data);
|
|
||||||
return {
|
|
||||||
...deletedObat,
|
|
||||||
...logResult,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return deleteObat;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting Obat:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async countObat() {
|
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,11 +195,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
@ -474,8 +469,7 @@ 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.$transaction(async (tx) => {
|
const response = await this.prisma.validation_queue.create({
|
||||||
const createdQueue = await tx.validation_queue.create({
|
|
||||||
data: {
|
data: {
|
||||||
table_name: 'rekam_medis',
|
table_name: 'rekam_medis',
|
||||||
action: 'DELETE',
|
action: 'DELETE',
|
||||||
|
|
@ -485,18 +479,6 @@ export class RekammedisService {
|
||||||
status: 'PENDING',
|
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) {
|
||||||
console.error('Error deleting validation queue:', error);
|
console.error('Error deleting validation queue:', error);
|
||||||
|
|
@ -504,40 +486,11 @@ export class RekammedisService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRekamMedisFromDBAndBlockchain(id_visit: string, userId: number) {
|
async deleteRekamMedisFromDB(id_visit: string) {
|
||||||
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.$transaction(async (tx) => {
|
const deletedRekamMedis = await this.prisma.rekam_medis.delete({
|
||||||
const deleted = await tx.rekam_medis.update({
|
|
||||||
data: {
|
|
||||||
deleted_status: 'DELETED',
|
|
||||||
},
|
|
||||||
where: { id_visit },
|
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) {
|
||||||
console.error('Error deleting Rekam Medis:', error);
|
console.error('Error deleting Rekam Medis:', error);
|
||||||
|
|
@ -563,11 +516,6 @@ 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,
|
||||||
|
|
@ -601,14 +549,6 @@ 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,11 +79,6 @@ 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' }
|
||||||
|
|
@ -102,11 +97,6 @@ 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' } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -341,9 +331,7 @@ export class TindakanDokterService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return this.prisma.validation_queue.create({
|
||||||
const validationQueue = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const queue = await tx.validation_queue.create({
|
|
||||||
data: {
|
data: {
|
||||||
table_name: 'pemberian_tindakan',
|
table_name: 'pemberian_tindakan',
|
||||||
action: 'DELETE',
|
action: 'DELETE',
|
||||||
|
|
@ -353,24 +341,6 @@ export class TindakanDokterService {
|
||||||
status: 'PENDING',
|
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) {
|
||||||
|
|
@ -389,9 +359,8 @@ 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.update({
|
const deleted = await tx.pemberian_tindakan.delete({
|
||||||
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);
|
||||||
|
|
@ -416,14 +385,6 @@ 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, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Post, 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,29 +10,8 @@ export class ValidationController {
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
async getValidationStatus(
|
async getValidationStatus() {
|
||||||
@Query('take') take: number,
|
return this.validationService.getAllValidationsQueue();
|
||||||
@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,10 +46,7 @@ export class ValidationService {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
approveDelete: async (queue: any) => {
|
approveDelete: async (queue: any) => {
|
||||||
return this.rekamMedisService.deleteRekamMedisFromDBAndBlockchain(
|
return this.rekamMedisService.deleteRekamMedisFromDB(queue.record_id);
|
||||||
queue.record_id,
|
|
||||||
Number(queue.user_id_request),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pemberian_tindakan: {
|
pemberian_tindakan: {
|
||||||
|
|
@ -100,65 +97,15 @@ 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(params: any) {
|
async getAllValidationsQueue() {
|
||||||
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({
|
||||||
take,
|
where: { status: 'PENDING' },
|
||||||
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: {
|
where: { status: 'PENDING' },
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
@ -249,10 +196,7 @@ 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:
|
record_id: approvalResult.id.toString(),
|
||||||
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(),
|
||||||
|
|
@ -265,65 +209,12 @@ export class ValidationService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
determineIdType(tableName: string, recordId: string) {
|
|
||||||
if (tableName === 'rekam_medis') {
|
|
||||||
return recordId;
|
|
||||||
} else if (
|
|
||||||
tableName === 'pemberian_tindakan' ||
|
|
||||||
tableName === 'pemberian_obat'
|
|
||||||
) {
|
|
||||||
return Number(recordId); // numeric ID
|
|
||||||
} else {
|
|
||||||
throw new Error('Unsupported table for ID determination');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rejectValidation(id: number, user: ActiveUserPayload) {
|
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({
|
||||||
let recordId: number | string = '';
|
|
||||||
|
|
||||||
if (
|
|
||||||
validationQueue.status === 'PENDING' &&
|
|
||||||
validationQueue.action === 'DELETE'
|
|
||||||
) {
|
|
||||||
recordId = this.determineIdType(
|
|
||||||
validationQueue.table_name,
|
|
||||||
validationQueue.record_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rejectedResponse = await this.prisma.$transaction(async (tx) => {
|
|
||||||
let updatedDeleteStatus = null;
|
|
||||||
if (validationQueue.action === 'DELETE') {
|
|
||||||
switch (validationQueue.table_name) {
|
|
||||||
case 'rekam_medis':
|
|
||||||
updatedDeleteStatus = await tx.rekam_medis.update({
|
|
||||||
where: { id_visit: recordId as string },
|
|
||||||
data: { deleted_status: null },
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'pemberian_tindakan':
|
|
||||||
updatedDeleteStatus = await tx.pemberian_tindakan.update({
|
|
||||||
where: { id: recordId as number },
|
|
||||||
data: { deleted_status: null },
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'pemberian_obat':
|
|
||||||
updatedDeleteStatus = await tx.pemberian_obat.update({
|
|
||||||
where: { id: recordId as number },
|
|
||||||
data: { deleted_status: null },
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('Unsupported table for delete rejection');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updatedQueue = await tx.validation_queue.update({
|
|
||||||
where: { id: validationQueue.id },
|
where: { id: validationQueue.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
|
|
@ -331,15 +222,6 @@ export class ValidationService {
|
||||||
processed_at: new Date(),
|
processed_at: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return updated;
|
||||||
...updatedQueue,
|
|
||||||
updatedDeleteStatus,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return rejectedResponse;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error rejecting validation:', (error as Error).message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,10 @@ 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("_");
|
||||||
|
|
||||||
|
|
@ -43,10 +42,6 @@ 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;
|
||||||
|
|
@ -124,12 +119,10 @@ 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.deleted_status ?
|
(item as Record<string, any>).isTampered ? 'bg-red-300 text-dark' : ''
|
||||||
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="[
|
||||||
|
|
@ -150,125 +143,6 @@ 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,24 +2,16 @@
|
||||||
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 = async () => {
|
const handleLogoutConfirm = () => {
|
||||||
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,7 +78,6 @@ 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,10 +102,6 @@ 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 = [
|
||||||
|
|
@ -306,7 +302,6 @@ 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,7 +14,6 @@ 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";
|
||||||
|
|
@ -24,7 +23,6 @@ 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[];
|
||||||
|
|
@ -34,10 +32,6 @@ 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({
|
||||||
|
|
@ -53,17 +47,6 @@ 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 "-";
|
||||||
|
|
@ -201,16 +184,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -223,8 +200,6 @@ 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) {
|
||||||
|
|
@ -243,8 +218,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -289,12 +262,6 @@ const handleResetFilters = () => {
|
||||||
pagination.reset();
|
pagination.reset();
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(sortOrder, () => {
|
|
||||||
pagination.reset();
|
|
||||||
fetchData();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => pagination.page.value,
|
() => pagination.page.value,
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -445,40 +412,11 @@ 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"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
|
|
||||||
<SearchInput
|
<SearchInput
|
||||||
v-model="searchId"
|
v-model="searchId"
|
||||||
placeholder="Cari berdasarkan ID Log"
|
placeholder="Cari berdasarkan ID Log"
|
||||||
@search="handleSearch"
|
@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,7 +34,6 @@ 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();
|
||||||
|
|
@ -156,16 +155,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) {
|
|
||||||
isDeleteSuccess.value = true;
|
|
||||||
}
|
|
||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting obat:", error);
|
console.error("Error deleting obat:", error);
|
||||||
alert("Gagal menghapus data obat");
|
alert("Gagal menghapus data obat");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch([() => pagination.page.value], () => {
|
watch([() => pagination.page.value], () => {
|
||||||
|
|
@ -254,27 +252,6 @@ 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,7 +36,6 @@ 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();
|
||||||
|
|
@ -276,10 +275,7 @@ const handleUpdate = (item: RekamMedis) => {
|
||||||
|
|
||||||
const handleDelete = async (item: RekamMedis) => {
|
const handleDelete = async (item: RekamMedis) => {
|
||||||
try {
|
try {
|
||||||
const result = await api.delete(`/rekammedis/${item.id_visit}`);
|
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);
|
||||||
|
|
@ -606,30 +602,6 @@ 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,7 +18,6 @@ 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[];
|
||||||
|
|
@ -26,7 +25,8 @@ interface ApiResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = ref<ValidationLog[]>([]);
|
const data = ref<ValidationLog[]>([]);
|
||||||
const sortBy = ref("created_at");
|
const searchValidation = ref("");
|
||||||
|
const sortBy = ref("id");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const pagination = usePagination({
|
const pagination = usePagination({
|
||||||
|
|
@ -34,16 +34,10 @@ 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") || "desc"
|
(route.query.order as "asc" | "desc") || "asc"
|
||||||
);
|
);
|
||||||
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> = {
|
||||||
|
|
@ -51,29 +45,14 @@ const updateQueryParams = () => {
|
||||||
pageSize: pagination.pageSize.value.toString(),
|
pageSize: pagination.pageSize.value.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (searchId.value) {
|
if (searchValidation.value) {
|
||||||
query.search = searchId.value;
|
query.search = searchValidation.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 });
|
||||||
|
|
@ -158,14 +137,6 @@ const normalizedData = (rawData: any[]): ValidationLog[] => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
|
||||||
filters.value.kelompok_data = "all";
|
|
||||||
filters.value.aksi = "all";
|
|
||||||
searchId.value = "";
|
|
||||||
pagination.reset();
|
|
||||||
fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
|
|
@ -173,13 +144,7 @@ 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,
|
||||||
kelompok_data:
|
...(searchValidation.value && { validation: searchValidation.value }),
|
||||||
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>(
|
||||||
|
|
@ -248,40 +213,16 @@ watch(sortOrder, () => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(searchId, (newValue, oldValue) => {
|
watch(searchValidation, (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) {
|
||||||
searchId.value = route.query.search as string;
|
searchValidation.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;
|
||||||
|
|
@ -303,95 +244,14 @@ 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="searchId"
|
v-model="searchValidation"
|
||||||
placeholder="Cari berdasarkan ID Record"
|
placeholder="Cari berdasarkan Validation"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-2 md:ml-4">
|
<div class="flex items-center gap-2 md:ml-4">
|
||||||
|
|
@ -455,7 +315,6 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user