Compare commits

..

No commits in common. "7b0873e0da9a528ece051e1c9e8b682c262343d5" and "e1a539325fa3d17c4b80bf4e0304b0bc50776037" have entirely different histories.

34 changed files with 241 additions and 2933 deletions

View File

@ -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;

View File

@ -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);

View File

@ -26,7 +26,6 @@ model pemberian_obat {
obat String @db.VarChar(100)
jumlah_obat Int
aturan_pakai String?
deleted_status String? @db.VarChar(25)
rekam_medis rekam_medis @relation(fields: [id_visit], references: [id_visit], onDelete: Cascade, onUpdate: NoAction, map: "fk_pemberian_obat_visit")
}
@ -36,7 +35,6 @@ model pemberian_tindakan {
tindakan String @db.VarChar(100)
kategori_tindakan String? @db.VarChar(50)
kelompok_tindakan String? @db.VarChar(50)
deleted_status String? @db.VarChar(25)
rekam_medis rekam_medis @relation(fields: [id_visit], references: [id_visit], onDelete: Cascade, onUpdate: NoAction, map: "fk_tindakan_visit")
}
@ -62,7 +60,6 @@ model rekam_medis {
berat_badan Decimal? @db.Decimal(10, 5)
jenis_kasus String? @db.VarChar(50)
tindak_lanjut String?
deleted_status String? @db.VarChar(25)
pemberian_obat pemberian_obat[]
pemberian_tindakan pemberian_tindakan[]
}

View File

@ -1,233 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
import { AuthGuard } from '../auth/guard/auth.guard';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
describe('AuditController', () => {
let controller: AuditController;
let auditService: jest.Mocked<AuditService>;
const mockAuditService = {
getAuditTrails: jest.fn(),
storeAuditTrail: jest.fn(),
getCountAuditTamperedData: jest.fn(),
};
const mockJwtService = {
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [AuditController],
providers: [
{ provide: AuditService, useValue: mockAuditService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
}).compile();
controller = module.get<AuditController>(AuditController);
auditService = module.get(AuditService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('getAuditTrail', () => {
const mockAuditLogs = {
0: {
id: 'REKAM_1',
event: 'rekam_medis_created',
result: 'non_tampered',
},
1: { id: 'OBAT_1', event: 'obat_created', result: 'tampered' },
totalCount: 2,
};
it('should return audit trails with default parameters', async () => {
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
const result = await controller.getAuditTrail(
'',
1,
10,
'',
'',
'',
'desc',
);
expect(result).toEqual(mockAuditLogs);
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
'',
1,
10,
'',
'',
'',
'desc',
);
});
it('should pass search parameter', async () => {
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
await controller.getAuditTrail('REKAM', 1, 10, '', '', '', 'desc');
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
'REKAM',
1,
10,
'',
'',
'',
'desc',
);
});
it('should pass type filter parameter', async () => {
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
await controller.getAuditTrail('', 1, 10, 'rekam_medis', '', '', 'desc');
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
'',
1,
10,
'rekam_medis',
'',
'',
'desc',
);
});
it('should pass tampered filter parameter', async () => {
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
await controller.getAuditTrail('', 1, 10, '', 'tampered', '', 'desc');
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
'',
1,
10,
'',
'tampered',
'',
'desc',
);
});
it('should pass orderBy and order parameters', async () => {
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
await controller.getAuditTrail('', 1, 10, '', '', 'last_sync', 'desc');
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
'',
1,
10,
'',
'',
'last_sync',
'desc',
);
});
it('should pass all parameters together', async () => {
mockAuditService.getAuditTrails.mockResolvedValue(mockAuditLogs);
await controller.getAuditTrail(
'search',
2,
25,
'obat',
'non_tampered',
'timestamp',
'asc',
);
expect(mockAuditService.getAuditTrails).toHaveBeenCalledWith(
'search',
2,
25,
'obat',
'non_tampered',
'timestamp',
'asc',
);
});
it('should handle empty results', async () => {
mockAuditService.getAuditTrails.mockResolvedValue({ totalCount: 0 });
const result = await controller.getAuditTrail(
'',
1,
10,
'',
'',
'',
'desc',
);
expect(result).toEqual({ totalCount: 0 });
});
it('should propagate service errors', async () => {
mockAuditService.getAuditTrails.mockRejectedValue(
new Error('Database error'),
);
await expect(
controller.getAuditTrail('', 1, 10, '', '', '', 'desc'),
).rejects.toThrow('Database error');
});
});
describe('createAuditTrail', () => {
it('should start audit trail process and return status', () => {
mockAuditService.storeAuditTrail.mockResolvedValue(undefined);
const result = controller.createAuditTrail();
expect(result).toEqual({
message: 'Proses audit trail dijalankan',
status: 'STARTED',
});
expect(mockAuditService.storeAuditTrail).toHaveBeenCalled();
});
it('should not wait for storeAuditTrail to complete', () => {
// storeAuditTrail is fire-and-forget (not awaited)
let resolved = false;
mockAuditService.storeAuditTrail.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 100));
resolved = true;
});
const result = controller.createAuditTrail();
expect(result.status).toBe('STARTED');
expect(resolved).toBe(false); // Should return before async completes
});
it('should call storeAuditTrail without parameters', () => {
controller.createAuditTrail();
expect(mockAuditService.storeAuditTrail).toHaveBeenCalledWith();
});
});
});

View File

@ -22,8 +22,6 @@ export class AuditController {
@Query('pageSize') pageSize: number,
@Query('type') type: string,
@Query('tampered') tampered: string,
@Query('orderBy') orderBy: string,
@Query('order') order: 'asc' | 'desc',
) {
const result = await this.auditService.getAuditTrails(
search,
@ -31,8 +29,6 @@ export class AuditController {
pageSize,
type,
tampered,
orderBy,
order,
);
return result;
}

View File

@ -1,198 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditGateway } from './audit.gateway';
import { Server } from 'socket.io';
import { WebsocketGuard } from '../auth/guard/websocket.guard';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
describe('AuditGateway', () => {
let gateway: AuditGateway;
let mockServer: jest.Mocked<Server>;
const mockJwtService = {
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuditGateway,
WebsocketGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
providers: [AuditGateway],
}).compile();
gateway = module.get<AuditGateway>(AuditGateway);
// Mock the WebSocket server
mockServer = {
emit: jest.fn(),
} as unknown as jest.Mocked<Server>;
// Inject mock server
gateway.server = mockServer;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(gateway).toBeDefined();
});
describe('sendProgress', () => {
it('should emit audit.progress event with progress data', () => {
const progressData = { status: 'RUNNING', progress_count: 50 };
gateway.sendProgress(progressData);
expect(mockServer.emit).toHaveBeenCalledWith(
'audit.progress',
progressData,
);
expect(mockServer.emit).toHaveBeenCalledTimes(1);
});
it('should emit progress with zero count', () => {
const progressData = { status: 'RUNNING', progress_count: 0 };
gateway.sendProgress(progressData);
expect(mockServer.emit).toHaveBeenCalledWith(
'audit.progress',
progressData,
);
});
it('should emit progress with large count', () => {
const progressData = { status: 'RUNNING', progress_count: 10000 };
gateway.sendProgress(progressData);
expect(mockServer.emit).toHaveBeenCalledWith(
'audit.progress',
progressData,
);
});
});
describe('sendComplete', () => {
it('should emit audit.complete event with complete data', () => {
const completeData = { status: 'COMPLETED' };
gateway.sendComplete(completeData);
expect(mockServer.emit).toHaveBeenCalledWith(
'audit.complete',
completeData,
);
expect(mockServer.emit).toHaveBeenCalledTimes(1);
});
it('should emit complete with additional metadata', () => {
const completeData = {
status: 'COMPLETED',
total_processed: 100,
duration_ms: 5000,
};
gateway.sendComplete(completeData);
expect(mockServer.emit).toHaveBeenCalledWith(
'audit.complete',
completeData,
);
});
});
describe('sendError', () => {
it('should emit audit.error event with error data', () => {
const errorData = {
message: 'Database connection failed',
code: 'DB_ERROR',
};
gateway.sendError(errorData);
expect(mockServer.emit).toHaveBeenCalledWith('audit.error', errorData);
expect(mockServer.emit).toHaveBeenCalledTimes(1);
});
it('should emit error with stack trace', () => {
const errorData = {
message: 'Unexpected error',
stack: 'Error: Unexpected error\n at AuditService...',
};
gateway.sendError(errorData);
expect(mockServer.emit).toHaveBeenCalledWith('audit.error', errorData);
});
});
describe('handleConnection', () => {
it('should log client connection', () => {
const mockClient = { id: 'test-client-123' } as any;
const loggerSpy = jest.spyOn(gateway['logger'], 'log');
gateway.handleConnection(mockClient);
expect(loggerSpy).toHaveBeenCalledWith(
'Klien terhubung: test-client-123',
);
});
});
describe('handleDisconnect', () => {
it('should log client disconnection', () => {
const mockClient = { id: 'test-client-456' } as any;
const loggerSpy = jest.spyOn(gateway['logger'], 'log');
gateway.handleDisconnect(mockClient);
expect(loggerSpy).toHaveBeenCalledWith('Klien terputus: test-client-456');
});
});
describe('multiple emissions', () => {
it('should handle multiple progress emissions', () => {
gateway.sendProgress({ status: 'RUNNING', progress_count: 10 });
gateway.sendProgress({ status: 'RUNNING', progress_count: 20 });
gateway.sendProgress({ status: 'RUNNING', progress_count: 30 });
expect(mockServer.emit).toHaveBeenCalledTimes(3);
});
it('should handle progress followed by complete', () => {
gateway.sendProgress({ status: 'RUNNING', progress_count: 100 });
gateway.sendComplete({ status: 'COMPLETED' });
expect(mockServer.emit).toHaveBeenNthCalledWith(1, 'audit.progress', {
status: 'RUNNING',
progress_count: 100,
});
expect(mockServer.emit).toHaveBeenNthCalledWith(2, 'audit.complete', {
status: 'COMPLETED',
});
});
it('should handle progress followed by error', () => {
gateway.sendProgress({ status: 'RUNNING', progress_count: 50 });
gateway.sendError({ message: 'Process failed' });
expect(mockServer.emit).toHaveBeenNthCalledWith(1, 'audit.progress', {
status: 'RUNNING',
progress_count: 50,
});
expect(mockServer.emit).toHaveBeenNthCalledWith(2, 'audit.error', {
message: 'Process failed',
});
});
});
});

View File

@ -1,653 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditService } from './audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { LogService } from '../log/log.service';
import { ObatService } from '../obat/obat.service';
import { RekammedisService } from '../rekammedis/rekammedis.service';
import { TindakanDokterService } from '../tindakandokter/tindakandokter.service';
import { AuditGateway } from './audit.gateway';
import { Logger } from '@nestjs/common';
describe('AuditService', () => {
let service: AuditService;
let prisma: jest.Mocked<PrismaService>;
let logService: jest.Mocked<LogService>;
let obatService: jest.Mocked<ObatService>;
let rekamMedisService: jest.Mocked<RekammedisService>;
let tindakanService: jest.Mocked<TindakanDokterService>;
let auditGateway: jest.Mocked<AuditGateway>;
const mockPrisma = {
audit: {
findMany: jest.fn(),
count: jest.fn(),
upsert: jest.fn(),
},
$transaction: jest.fn(),
};
const mockLogService = {
getLogsWithPagination: jest.fn(),
};
const mockObatService = {
getObatById: jest.fn(),
createHashingPayload: jest.fn(),
};
const mockRekamMedisService = {
getRekamMedisById: jest.fn(),
createHashingPayload: jest.fn(),
};
const mockTindakanService = {
getTindakanDokterById: jest.fn(),
createHashingPayload: jest.fn(),
};
const mockAuditGateway = {
sendProgress: jest.fn(),
sendComplete: jest.fn(),
sendError: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
// Suppress logger output during tests
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
jest.spyOn(Logger.prototype, 'error').mockImplementation();
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const module: TestingModule = await Test.createTestingModule({
providers: [
AuditService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: LogService, useValue: mockLogService },
{ provide: ObatService, useValue: mockObatService },
{ provide: RekammedisService, useValue: mockRekamMedisService },
{ provide: TindakanDokterService, useValue: mockTindakanService },
{ provide: AuditGateway, useValue: mockAuditGateway },
],
providers: [AuditService],
}).compile();
service = module.get<AuditService>(AuditService);
prisma = module.get(PrismaService);
logService = module.get(LogService);
obatService = module.get(ObatService);
rekamMedisService = module.get(RekammedisService);
tindakanService = module.get(TindakanDokterService);
auditGateway = module.get(AuditGateway);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getAuditTrails', () => {
const mockAuditLogs = [
{ id: 'REKAM_1', event: 'rekam_medis_created', result: 'non_tampered' },
{ id: 'OBAT_1', event: 'obat_created', result: 'tampered' },
];
it('should return paginated audit logs', async () => {
mockPrisma.audit.findMany.mockResolvedValue(mockAuditLogs);
mockPrisma.audit.count.mockResolvedValue(2);
const result = await service.getAuditTrails('', 1, 10);
expect(result.totalCount).toBe(2);
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith({
take: 10,
skip: 0,
orderBy: { timestamp: 'desc' },
where: {
id: undefined,
result: undefined,
OR: undefined,
},
});
});
it('should filter by rekam_medis type', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 1, 10, 'rekam_medis');
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: { startsWith: 'REKAM' },
}),
}),
);
});
it('should filter by tindakan type', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 1, 10, 'tindakan');
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: { startsWith: 'TINDAKAN' },
}),
}),
);
});
it('should filter by obat type', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 1, 10, 'obat');
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: { startsWith: 'OBAT' },
}),
}),
);
});
it('should filter by tampered status', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 1, 10, undefined, 'tampered');
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
result: 'tampered',
}),
}),
);
});
it('should filter by non_tampered status', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 1, 10, undefined, 'non_tampered');
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
result: 'non_tampered',
}),
}),
);
});
it('should ignore "all" type filter', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 1, 10, 'all');
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: undefined,
}),
}),
);
});
it('should ignore "initial" type filter', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 1, 10, 'initial');
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: undefined,
}),
}),
);
});
it('should search by id', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('REKAM_123', 1, 10);
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [{ id: { contains: 'REKAM_123' } }],
}),
}),
);
});
it('should apply custom orderBy and order', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails(
'',
1,
10,
undefined,
undefined,
'last_sync',
'asc',
);
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { last_sync: 'asc' },
}),
);
});
it('should calculate correct skip for pagination', async () => {
mockPrisma.audit.findMany.mockResolvedValue([]);
mockPrisma.audit.count.mockResolvedValue(0);
await service.getAuditTrails('', 3, 10);
expect(mockPrisma.audit.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 20, // (page - 1) * pageSize = (3 - 1) * 10
}),
);
});
});
describe('getCountAuditTamperedData', () => {
it('should return all tampered counts', async () => {
mockPrisma.audit.count
.mockResolvedValueOnce(10) // auditTamperedCount
.mockResolvedValueOnce(90) // auditNonTamperedCount
.mockResolvedValueOnce(3) // rekamMedisTamperedCount
.mockResolvedValueOnce(4) // tindakanDokterTamperedCount
.mockResolvedValueOnce(3); // obatTamperedCount
const result = await service.getCountAuditTamperedData();
expect(result).toEqual({
auditTamperedCount: 10,
auditNonTamperedCount: 90,
rekamMedisTamperedCount: 3,
tindakanDokterTamperedCount: 4,
obatTamperedCount: 3,
});
expect(mockPrisma.audit.count).toHaveBeenCalledTimes(5);
});
});
describe('compareData', () => {
it('should return true when hashes match', async () => {
const hash = 'abc123def456';
const result = await service.compareData(hash, hash);
expect(result).toBe(true);
});
it('should return false when hashes differ', async () => {
const result = await service.compareData('hash1', 'hash2');
expect(result).toBe(false);
});
it('should return false for empty strings comparison with non-empty', async () => {
const result = await service.compareData('', 'somehash');
expect(result).toBe(false);
});
});
describe('storeAuditTrail', () => {
it('should process logs and send complete when done', async () => {
mockLogService.getLogsWithPagination.mockResolvedValue({
logs: [],
bookmark: '',
});
await service.storeAuditTrail();
expect(mockLogService.getLogsWithPagination).toHaveBeenCalledWith(25, '');
});
it('should process rekam_medis logs correctly', async () => {
const mockLog = {
value: {
id: 'REKAM_123',
event: 'rekam_medis_created',
timestamp: '2024-01-01T00:00:00Z',
user_id: 1,
payload: 'blockchain_hash_123',
},
};
const mockRekamMedis = {
id_visit: '123',
anamnese: 'test',
jenis_kasus: 'test',
tindak_lanjut: 'test',
deleted_status: null,
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
mockRekamMedisService.createHashingPayload.mockReturnValue(
'blockchain_hash_123',
);
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
expect(mockRekamMedisService.getRekamMedisById).toHaveBeenCalledWith(
'123',
);
expect(mockAuditGateway.sendProgress).toHaveBeenCalled();
expect(mockAuditGateway.sendComplete).toHaveBeenCalledWith({
status: 'COMPLETED',
});
});
it('should process obat logs correctly', async () => {
const mockLog = {
value: {
id: 'OBAT_456',
event: 'obat_created',
timestamp: '2024-01-01T00:00:00Z',
user_id: 1,
payload: 'obat_hash',
},
};
const mockObat = {
id: 456,
obat: 'Paracetamol',
jumlah_obat: 10,
aturan_pakai: '3x1',
deleted_status: null,
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockObatService.getObatById.mockResolvedValue(mockObat);
mockObatService.createHashingPayload.mockReturnValue('obat_hash');
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
expect(mockObatService.getObatById).toHaveBeenCalledWith(456);
});
it('should process tindakan logs correctly', async () => {
const mockLog = {
value: {
id: 'TINDAKAN_789',
event: 'tindakan_dokter_created',
timestamp: '2024-01-01T00:00:00Z',
user_id: 1,
payload: 'tindakan_hash',
},
};
const mockTindakan = {
id: 789,
id_visit: '123',
tindakan: 'Pemeriksaan',
kategori_tindakan: 'Umum',
kelompok_tindakan: 'Poliklinik',
deleted_status: null,
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockTindakanService.getTindakanDokterById.mockResolvedValue(mockTindakan);
mockTindakanService.createHashingPayload.mockReturnValue('tindakan_hash');
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
expect(mockTindakanService.getTindakanDokterById).toHaveBeenCalledWith(
789,
);
});
it('should handle pagination with bookmark', async () => {
mockLogService.getLogsWithPagination
.mockResolvedValueOnce({
logs: [{ value: null }],
bookmark: 'next_page',
})
.mockResolvedValueOnce({ logs: [], bookmark: '' });
await service.storeAuditTrail();
expect(mockLogService.getLogsWithPagination).toHaveBeenCalledTimes(2);
expect(mockLogService.getLogsWithPagination).toHaveBeenNthCalledWith(
1,
25,
'',
);
expect(mockLogService.getLogsWithPagination).toHaveBeenNthCalledWith(
2,
25,
'next_page',
);
});
it('should throw error when blockchain service fails', async () => {
mockLogService.getLogsWithPagination.mockRejectedValue(
new Error('Blockchain error'),
);
await expect(service.storeAuditTrail()).rejects.toThrow(
'Failed to store audit trail',
);
// Verify error was sent via WebSocket
expect(mockAuditGateway.sendError).toHaveBeenCalledWith({
status: 'ERROR',
message: 'Blockchain error',
});
});
});
describe('tamper detection logic', () => {
it('should mark as non_tampered when delete event and no DB row', async () => {
const mockLog = {
value: {
id: 'REKAM_999',
event: 'rekam_medis_deleted',
timestamp: '2024-01-01T00:00:00Z',
user_id: 1,
payload: 'hash',
},
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockRekamMedisService.getRekamMedisById.mockResolvedValue(null);
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
// When delete event and no DB row, should be non_tampered
expect(mockPrisma.$transaction).toHaveBeenCalled();
const transactionCall = mockPrisma.$transaction.mock.calls[0][0];
// Transaction is called with array of upsert promises
expect(transactionCall).toBeDefined();
});
it('should mark as tampered when no DB row and not delete event', async () => {
const mockLog = {
value: {
id: 'REKAM_999',
event: 'rekam_medis_created',
timestamp: '2024-01-01T00:00:00Z',
user_id: 1,
payload: 'hash',
},
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockRekamMedisService.getRekamMedisById.mockResolvedValue(null);
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
// Transaction should be called with tampered result
expect(mockPrisma.$transaction).toHaveBeenCalled();
});
it('should mark as non_tampered when delete event and deleted_status is DELETED', async () => {
const mockLog = {
value: {
id: 'REKAM_123',
event: 'rekam_medis_deleted',
timestamp: '2024-01-01T00:00:00Z',
user_id: 1,
payload: 'hash',
},
};
const mockRekamMedis = {
id_visit: '123',
deleted_status: 'DELETED',
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
expect(mockPrisma.$transaction).toHaveBeenCalled();
});
it('should mark as tampered when hashes do not match', async () => {
const mockLog = {
value: {
id: 'REKAM_123',
event: 'rekam_medis_created',
timestamp: '2024-01-01T00:00:00Z',
user_id: 1,
payload: 'blockchain_hash',
},
};
const mockRekamMedis = {
id_visit: '123',
anamnese: 'modified',
jenis_kasus: 'test',
tindak_lanjut: 'test',
deleted_status: null,
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockRekamMedisService.getRekamMedisById.mockResolvedValue(mockRekamMedis);
mockRekamMedisService.createHashingPayload.mockReturnValue(
'different_hash',
);
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
expect(mockPrisma.$transaction).toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should skip log entries without value', async () => {
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [{ noValue: true }],
bookmark: '',
});
await service.storeAuditTrail();
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
expect(mockAuditGateway.sendComplete).toHaveBeenCalled();
});
it('should skip log entries without id', async () => {
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [{ value: { event: 'test' } }],
bookmark: '',
});
await service.storeAuditTrail();
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
});
it('should skip log entries without payload', async () => {
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [{ value: { id: 'REKAM_1', event: 'test' } }],
bookmark: '',
});
await service.storeAuditTrail();
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
});
it('should skip log entries with unknown prefix', async () => {
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [{ value: { id: 'UNKNOWN_1', event: 'test', payload: 'hash' } }],
bookmark: '',
});
await service.storeAuditTrail();
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
});
it('should handle invalid timestamp gracefully', async () => {
const mockLog = {
value: {
id: 'REKAM_123',
event: 'rekam_medis_created',
timestamp: 'invalid-date',
user_id: 1,
payload: 'hash',
},
};
mockLogService.getLogsWithPagination.mockResolvedValueOnce({
logs: [mockLog],
bookmark: '',
});
mockRekamMedisService.getRekamMedisById.mockResolvedValue({
id_visit: '123',
deleted_status: null,
});
mockRekamMedisService.createHashingPayload.mockReturnValue('hash');
mockPrisma.$transaction.mockResolvedValue([]);
await service.storeAuditTrail();
expect(mockPrisma.$transaction).toHaveBeenCalled();
});
});
});

View File

@ -1,8 +1,4 @@
import {
Injectable,
Logger,
InternalServerErrorException,
} from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { LogService } from '../log/log.service';
import { ObatService } from '../obat/obat.service';
@ -43,13 +39,7 @@ export class AuditService {
pageSize: number,
type?: string,
tampered?: string,
orderBy?: string,
order?: 'asc' | 'desc',
) {
this.logger.debug(
`Fetching audit trails: page=${page}, pageSize=${pageSize}, type=${type}`,
);
if (type === 'all' || type === 'initial') {
type = undefined;
} else if (type === 'rekam_medis') {
@ -64,36 +54,28 @@ export class AuditService {
tampered = undefined;
}
try {
const auditLogs = await this.prisma.audit.findMany({
take: pageSize,
skip: (page - 1) * pageSize,
orderBy: orderBy
? { [orderBy]: order || 'asc' }
: { timestamp: 'desc' },
where: {
id: type && type !== 'all' ? { startsWith: type } : undefined,
result: tampered ? (tampered as ResultStatus) : undefined,
OR: search ? [{ id: { contains: search } }] : undefined,
},
});
const auditLogs = await this.prisma.audit.findMany({
take: pageSize,
skip: (page - 1) * pageSize,
orderBy: { timestamp: 'asc' },
where: {
id: type && type !== 'all' ? { startsWith: type } : undefined,
result: tampered ? (tampered as ResultStatus) : undefined,
OR: search ? [{ id: { contains: search } }] : undefined,
},
});
const count = await this.prisma.audit.count({
where: {
id: type && type !== 'all' ? { startsWith: type } : undefined,
result: tampered ? (tampered as ResultStatus) : undefined,
OR: search ? [{ id: { contains: search } }] : undefined,
},
});
const count = await this.prisma.audit.count({
where: {
id: type && type !== 'all' ? { startsWith: type } : undefined,
result: tampered ? (tampered as ResultStatus) : undefined,
},
});
return {
...auditLogs,
totalCount: count,
};
} catch (error) {
this.logger.error('Failed to fetch audit trails', error.stack);
throw new InternalServerErrorException('Failed to fetch audit trails');
}
return {
...auditLogs,
totalCount: count,
};
}
async storeAuditTrail() {
@ -102,6 +84,30 @@ export class AuditService {
let bookmark = '';
let processedCount = 0;
// try {
// const intervalId = setInterval(() => {
// processedCount++;
// const progressData = {
// status: 'RUNNING',
// progress_count: processedCount,
// };
// this.logger.log('Mengirim progres via WebSocket:', progressData);
// // PANGGIL FUNGSI GATEWAY
// this.auditGateway.sendProgress(progressData);
// if (processedCount >= BATCH_SIZE) {
// clearInterval(intervalId);
// const completeData = { status: 'COMPLETED' };
// this.logger.log('Mengirim selesai via WebSocket:', completeData);
// // PANGGIL FUNGSI GATEWAY
// this.auditGateway.sendComplete(completeData);
// }
// }, 500);
// } catch (error) {
// console.error('Tes streaming GAGAL', error);
// }
try {
while (true) {
const pageResults = await this.logService.getLogsWithPagination(
@ -129,8 +135,16 @@ export class AuditService {
)
).filter((record): record is AuditRecordPayload => record !== null);
// const records: AuditRecordPayload[] = [];
// for (let index = 0; index < logs.length; index++) {
// const record = await this.buildAuditRecord(logs[index], index);
// if (record !== null) {
// records.push(record);
// }
// await new Promise((resolve) => setTimeout(resolve, 250));
// }
if (records.length > 0) {
this.logger.debug(`Processing ${records.length} audit records`);
await this.prisma.$transaction(
records.map((record) =>
this.prisma.audit.upsert({
@ -140,7 +154,7 @@ export class AuditService {
event: record.event,
payload: record.payload,
timestamp: record.timestamp,
user_id: BigInt(record.user_id),
user_id: record.user_id,
last_sync: record.last_sync,
result: record.result,
},
@ -152,6 +166,7 @@ export class AuditService {
if (nextBookmark === '' || nextBookmark === bookmark) {
const completeData = { status: 'COMPLETED' };
this.logger.log('Mengirim selesai via WebSocket:', completeData);
this.auditGateway.sendComplete(completeData);
break;
}
@ -159,52 +174,57 @@ export class AuditService {
bookmark = nextBookmark;
}
} catch (error) {
this.logger.error('Error storing audit trail', error.stack);
this.auditGateway.sendError({
status: 'ERROR',
message: error.message || 'Failed to store audit trail',
});
throw new InternalServerErrorException('Failed to store audit trail');
console.error('Error storing audit trail:', error);
throw error;
}
}
async getCountAuditTamperedData() {
try {
const [
auditTamperedCount,
auditNonTamperedCount,
rekamMedisTamperedCount,
tindakanDokterTamperedCount,
obatTamperedCount,
] = await Promise.all([
this.prisma.audit.count({
where: { result: 'tampered' },
}),
this.prisma.audit.count({
where: { result: 'non_tampered' },
}),
this.prisma.audit.count({
where: { result: 'tampered', id: { startsWith: 'REKAM' } },
}),
this.prisma.audit.count({
where: { result: 'tampered', id: { startsWith: 'TINDAKAN' } },
}),
this.prisma.audit.count({
where: { result: 'tampered', id: { startsWith: 'OBAT' } },
}),
]);
const auditTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
},
});
return {
auditTamperedCount,
auditNonTamperedCount,
rekamMedisTamperedCount,
tindakanDokterTamperedCount,
obatTamperedCount,
};
} catch (error) {
this.logger.error('Failed to get audit tampered count', error.stack);
throw new InternalServerErrorException('Failed to get audit statistics');
}
const auditNonTamperedCount = await this.prisma.audit.count({
where: {
result: 'non_tampered',
},
});
const rekamMedisTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
id: {
startsWith: 'REKAM',
},
},
});
const tindakanDokterTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
id: {
startsWith: 'TINDAKAN',
},
},
});
const obatTamperedCount = await this.prisma.audit.count({
where: {
result: 'tampered',
id: {
startsWith: 'OBAT',
},
},
});
return {
auditTamperedCount,
auditNonTamperedCount,
rekamMedisTamperedCount,
tindakanDokterTamperedCount,
obatTamperedCount,
};
}
private async buildAuditRecord(
@ -226,19 +246,18 @@ export class AuditService {
const timestamp = this.parseTimestamp(value.timestamp) ?? now;
const userId = value.user_id;
const blockchainHash: string | undefined = value.payload;
let data: any = null;
if (!blockchainHash) {
return null;
}
let dbHash: string | null = null;
//
try {
if (logId.startsWith('OBAT')) {
if (logId.startsWith('OBAT_')) {
const obatId = this.extractNumericId(logId);
if (obatId !== null) {
const obat = await this.obatService.getObatById(obatId);
data = obat;
if (obat) {
dbHash = this.obatService.createHashingPayload({
obat: obat.obat,
@ -252,7 +271,6 @@ export class AuditService {
if (rekamMedisId) {
const rekamMedis =
await this.rekamMedisService.getRekamMedisById(rekamMedisId);
data = rekamMedis;
if (rekamMedis) {
dbHash = this.rekamMedisService.createHashingPayload({
dokter_id: 123,
@ -268,7 +286,6 @@ export class AuditService {
if (tindakanId !== null) {
const tindakanDokter =
await this.tindakanDokterService.getTindakanDokterById(tindakanId);
data = tindakanDokter;
if (tindakanDokter) {
dbHash = this.tindakanDokterService.createHashingPayload({
id_visit: tindakanDokter.id_visit,
@ -282,25 +299,12 @@ export class AuditService {
return null;
}
} catch (err) {
this.logger.warn(
`Failed to resolve related data for log ${logId}: ${err.message}`,
);
console.warn(`Failed to resolve related data for log ${logId}:`, err);
}
let isNotTampered = false;
const eventType = logEntry.value.event?.split('_').at(-1);
const isDeleteEvent = eventType === 'deleted';
const hasRow = Boolean(data);
if (!hasRow) {
isNotTampered = isDeleteEvent;
} else if (isDeleteEvent || data.deleted_status === 'DELETED') {
isNotTampered = isDeleteEvent && data.deleted_status === 'DELETED';
} else {
const hashesMatch =
dbHash && (await this.compareData(blockchainHash, dbHash));
isNotTampered = Boolean(hashesMatch);
}
const isNotTampered = dbHash
? await this.compareData(blockchainHash, dbHash)
: false;
const result: ResultStatus = isNotTampered ? 'non_tampered' : 'tampered';
@ -309,6 +313,7 @@ export class AuditService {
progress_count: index ?? 0,
};
this.logger.log('Mengirim progres via WebSocket:', progressData);
this.auditGateway.sendProgress(progressData);
return {

View File

@ -1,198 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { UserRole } from './dto/auth.dto';
describe('AuthController', () => {
let controller: AuthController;
let authService: jest.Mocked<AuthService>;
let configService: jest.Mocked<ConfigService>;
const mockAuthService = {
registerUser: jest.fn(),
signIn: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
verifyAsync: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
controller = module.get<AuthController>(AuthController);
authService = module.get(AuthService);
configService = module.get(ConfigService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('registerUser', () => {
const createUserDto = {
nama_lengkap: 'Test User',
username: 'testuser',
password: 'password123',
role: UserRole.User,
};
const expectedResponse = {
id: BigInt(1),
nama_lengkap: 'Test User',
username: 'testuser',
role: UserRole.User,
};
it('should register a new user', async () => {
mockAuthService.registerUser.mockResolvedValue(expectedResponse);
const result = await controller.registerUser(createUserDto);
expect(result).toEqual(expectedResponse);
expect(mockAuthService.registerUser).toHaveBeenCalledWith(createUserDto);
expect(mockAuthService.registerUser).toHaveBeenCalledTimes(1);
});
it('should propagate service errors', async () => {
const error = new Error('Service error');
mockAuthService.registerUser.mockRejectedValue(error);
await expect(controller.registerUser(createUserDto)).rejects.toThrow(
'Service error',
);
});
});
describe('login', () => {
const loginDto = {
username: 'testuser',
password: 'password123',
};
const mockSignInResponse = {
accessToken: 'jwt-token',
csrfToken: 'csrf-token',
user: {
id: BigInt(1),
username: 'testuser',
role: 'user',
},
};
it('should login user and set cookie in development mode', async () => {
mockAuthService.signIn.mockResolvedValue(mockSignInResponse);
mockConfigService.get.mockReturnValue('development');
const mockResponse = {
cookie: jest.fn(),
};
const result = await controller.login(loginDto, mockResponse as any);
expect(result).toEqual({
user: mockSignInResponse.user,
csrfToken: mockSignInResponse.csrfToken,
});
expect(mockResponse.cookie).toHaveBeenCalledWith(
'access_token',
'jwt-token',
{
httpOnly: true,
secure: false, // development mode
sameSite: 'strict',
maxAge: 3600000,
},
);
});
it('should login user and set secure cookie in production mode', async () => {
mockAuthService.signIn.mockResolvedValue(mockSignInResponse);
mockConfigService.get.mockReturnValue('production');
const mockResponse = {
cookie: jest.fn(),
};
await controller.login(loginDto, mockResponse as any);
expect(mockResponse.cookie).toHaveBeenCalledWith(
'access_token',
'jwt-token',
{
httpOnly: true,
secure: true, // production mode
sameSite: 'strict',
maxAge: 3600000,
},
);
});
it('should propagate authentication errors', async () => {
mockAuthService.signIn.mockRejectedValue(
new Error('Invalid credentials'),
);
const mockResponse = {
cookie: jest.fn(),
};
await expect(
controller.login(loginDto, mockResponse as any),
).rejects.toThrow('Invalid credentials');
expect(mockResponse.cookie).not.toHaveBeenCalled();
});
});
describe('logout', () => {
it('should clear access_token cookie in development mode', () => {
mockConfigService.get.mockReturnValue('development');
const mockResponse = {
clearCookie: jest.fn(),
};
const result = controller.logout(mockResponse as any);
expect(result).toEqual({ message: 'Logout berhasil' });
expect(mockResponse.clearCookie).toHaveBeenCalledWith('access_token', {
httpOnly: true,
secure: false,
sameSite: 'strict',
});
});
it('should clear access_token cookie with secure flag in production mode', () => {
mockConfigService.get.mockReturnValue('production');
const mockResponse = {
clearCookie: jest.fn(),
};
const result = controller.logout(mockResponse as any);
expect(result).toEqual({ message: 'Logout berhasil' });
expect(mockResponse.clearCookie).toHaveBeenCalledWith('access_token', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
});
});
});

View File

@ -9,7 +9,7 @@ import {
} from '@nestjs/common';
import type { Response } from 'express';
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
import { AuthDto, UserRole } from './dto/auth.dto';
import { AuthDto, AuthDtoResponse, UserRole } from './dto/auth.dto';
import { AuthService } from './auth.service';
import { AuthGuard } from './guard/auth.guard';
import { RolesGuard } from './guard/roles.guard';
@ -24,6 +24,7 @@ export class AuthController {
) {}
@Post('/register')
@Header('Content-Type', 'application/json')
@HttpCode(201)
@UseGuards(AuthGuard, RolesGuard)
@Roles(UserRole.Admin)
@ -45,24 +46,9 @@ export class AuthController {
httpOnly: true,
secure: this.configService.get<string>('NODE_ENV') !== 'development',
sameSite: 'strict',
maxAge: parseInt(
this.configService.get<string>('COOKIE_MAX_AGE') || '7200000',
10,
),
maxAge: 3600000,
});
return { user, csrfToken };
}
@Post('logout')
@HttpCode(200)
logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie('access_token', {
httpOnly: true,
secure: this.configService.get<string>('NODE_ENV') !== 'development',
sameSite: 'strict',
});
return { message: 'Logout berhasil' };
}
}

View File

@ -3,8 +3,7 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { JwtModule } from '@nestjs/jwt';
@Module({
exports: [AuthService],
@ -15,13 +14,9 @@ import type { StringValue } from 'ms';
global: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService): JwtModuleOptions => ({
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn:
(configService.get<string>('JWT_EXPIRES_IN') as StringValue) ??
'120m',
},
signOptions: { expiresIn: '120m' },
}),
}),
],

View File

@ -1,47 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@dist/generated/prisma';
import * as bcrypt from 'bcrypt';
import { UserRole } from './dto/auth.dto';
// Mock bcrypt
jest.mock('bcrypt', () => ({
hash: jest.fn(),
compare: jest.fn(),
}));
describe('AuthService', () => {
let service: AuthService;
const mockPrisma = {
users: {
create: jest.fn(),
findUnique: jest.fn(),
},
};
const mockJwtService = {
signAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
providers: [AuthService],
}).compile();
service = module.get<AuthService>(AuthService);
@ -50,250 +15,4 @@ describe('AuthService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('registerUser', () => {
const createUserDto = {
nama_lengkap: 'Test User',
username: 'testuser',
password: 'password123',
role: UserRole.User,
};
const createdUser = {
id: BigInt(1),
nama_lengkap: 'Test User',
username: 'testuser',
password_hash: 'hashedPassword',
role: 'user',
created_at: new Date(),
updated_at: new Date(),
};
it('should register a new user successfully', async () => {
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockResolvedValue(createdUser);
const result = await service.registerUser(createUserDto);
expect(result).toEqual({
id: BigInt(1),
nama_lengkap: 'Test User',
username: 'testuser',
role: 'user',
});
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
expect(mockPrisma.users.create).toHaveBeenCalledWith({
data: {
nama_lengkap: 'Test User',
username: 'testuser',
password_hash: 'hashedPassword',
role: 'user',
},
});
});
/**
* BUG TEST: This test SHOULD PASS but will FAIL
*
* Problem: configService.get() returns STRING from .env, not number
* Current code: configService.get<number>('BCRYPT_SALT') ?? 10
*
* When BCRYPT_SALT=10 is in .env, it returns '10' (string), not 10 (number)
* bcrypt.hash receives '10' instead of 10
*
* Fix needed in auth.service.ts:
* const salt = parseInt(this.configService.get<string>('BCRYPT_SALT') || '10', 10);
*/
it('should pass NUMBER salt to bcrypt.hash (not string)', async () => {
// Simulate real .env behavior: returns string '10'
mockConfigService.get.mockReturnValue('10');
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockResolvedValue(createdUser);
await service.registerUser(createUserDto);
// CORRECT expectation: salt should be NUMBER 10, not STRING '10'
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
});
it('should use default salt value 10 when BCRYPT_SALT is not configured', async () => {
mockConfigService.get.mockReturnValue(undefined);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockResolvedValue(createdUser);
await service.registerUser(createUserDto);
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
});
it('should default role to "user" when not provided', async () => {
const dtoWithoutRole = {
nama_lengkap: 'Test User',
username: 'testuser',
password: 'password123',
};
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockResolvedValue(createdUser);
await service.registerUser(dtoWithoutRole as any);
expect(mockPrisma.users.create).toHaveBeenCalledWith({
data: {
nama_lengkap: 'Test User',
username: 'testuser',
password_hash: 'hashedPassword',
role: 'user',
},
});
});
it('should throw ConflictException when username already exists (P2002)', async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed',
{ code: 'P2002', clientVersion: '5.0.0' },
);
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockRejectedValue(prismaError);
await expect(service.registerUser(createUserDto)).rejects.toThrow(
ConflictException,
);
});
it('should rethrow non-P2002 Prisma errors', async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError(
'Foreign key constraint failed',
{ code: 'P2003', clientVersion: '5.0.0' },
);
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockRejectedValue(prismaError);
await expect(service.registerUser(createUserDto)).rejects.toThrow(
Prisma.PrismaClientKnownRequestError,
);
});
it('should rethrow unknown errors without wrapping', async () => {
const unknownError = new Error('Database connection failed');
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockRejectedValue(unknownError);
await expect(service.registerUser(createUserDto)).rejects.toThrow(
'Database connection failed',
);
});
});
describe('signIn', () => {
const mockUser = {
id: BigInt(1),
nama_lengkap: 'Test User',
username: 'testuser',
password_hash: 'hashedPassword',
role: 'user',
created_at: new Date(),
updated_at: new Date(),
};
it('should sign in user successfully and return tokens', async () => {
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
mockJwtService.signAsync.mockResolvedValue('jwt-token');
const result = await service.signIn('testuser', 'password123');
expect(result).toHaveProperty('accessToken', 'jwt-token');
expect(result).toHaveProperty('csrfToken');
expect(result.csrfToken).toHaveLength(64);
expect(result.user).toEqual({
id: BigInt(1),
username: 'testuser',
role: 'user',
});
});
it('should include csrf token in JWT payload', async () => {
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
mockJwtService.signAsync.mockResolvedValue('jwt-token');
await service.signIn('testuser', 'password123');
expect(mockJwtService.signAsync).toHaveBeenCalledWith(
expect.objectContaining({
sub: BigInt(1),
username: 'testuser',
role: 'user',
csrf: expect.any(String),
}),
);
});
it('should throw UnauthorizedException when user not found', async () => {
mockPrisma.users.findUnique.mockResolvedValue(null);
await expect(service.signIn('nonexistent', 'password')).rejects.toThrow(
UnauthorizedException,
);
expect(bcrypt.compare).not.toHaveBeenCalled();
});
it('should throw UnauthorizedException when password is incorrect', async () => {
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
await expect(service.signIn('testuser', 'wrongpassword')).rejects.toThrow(
UnauthorizedException,
);
});
it('should use same error response for user-not-found and wrong-password (security)', async () => {
mockPrisma.users.findUnique.mockResolvedValue(null);
let errorForNonexistent: UnauthorizedException | undefined;
try {
await service.signIn('nonexistent', 'password');
} catch (error) {
errorForNonexistent = error as UnauthorizedException;
}
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
let errorForWrongPassword: UnauthorizedException | undefined;
try {
await service.signIn('testuser', 'wrongpassword');
} catch (error) {
errorForWrongPassword = error as UnauthorizedException;
}
expect(errorForNonexistent).toBeInstanceOf(UnauthorizedException);
expect(errorForWrongPassword).toBeInstanceOf(UnauthorizedException);
expect(errorForNonexistent?.getResponse()).toEqual(
errorForWrongPassword?.getResponse(),
);
});
it('should call bcrypt.compare with correct arguments', async () => {
mockPrisma.users.findUnique.mockResolvedValue(mockUser);
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
mockJwtService.signAsync.mockResolvedValue('jwt-token');
await service.signIn('testuser', 'mypassword');
expect(bcrypt.compare).toHaveBeenCalledWith(
'mypassword',
'hashedPassword',
);
});
});
});

View File

@ -2,10 +2,9 @@ import { PrismaService } from '@api/modules/prisma/prisma.service';
import {
ConflictException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { UserRole } from './dto/auth.dto';
import { AuthDtoResponse, UserRole } from './dto/auth.dto';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
import { CreateUserDto, CreateUserDtoResponse } from './dto/create-user.dto';
@ -21,28 +20,8 @@ export class AuthService {
private configService: ConfigService,
) {}
async isUserExisting(username: string): Promise<boolean> {
let user;
try {
user = await this.prisma.users.findUnique({
where: { username },
});
} catch (error) {
console.error('Error checking if user exists:', error);
user = null;
throw new InternalServerErrorException();
}
return !!user;
}
async registerUser(data: CreateUserDto): Promise<CreateUserDtoResponse> {
const saltEnv = this.configService.get<string>('BCRYPT_SALT');
const salt = saltEnv ? parseInt(saltEnv, 10) : 10;
if (await this.isUserExisting(data.username)) {
throw new ConflictException('Username ini sudah terdaftar');
}
const salt = this.configService.get<number>('BCRYPT_SALT') ?? 10;
const hashedPassword = await bcrypt.hash(data.password, salt);
try {
@ -62,8 +41,12 @@ export class AuthService {
role: userCreated.role as UserRole,
};
} catch (error) {
console.error('Error registering user:', error);
throw new InternalServerErrorException();
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new ConflictException('Username ini sudah terdaftar');
}
}
throw error;
}
}

View File

@ -17,3 +17,19 @@ export class AuthDto {
@Length(6, undefined, { message: 'Password minimal 6 karakter' })
password: string;
}
export class AuthDtoResponse {
@Expose()
@Transform(({ value }: { value: bigint }) => value.toString())
id: bigint;
@Expose()
username: string;
@Expose()
@IsEnum(UserRole)
role: UserRole;
@Expose()
token: string;
}

View File

@ -1,157 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthGuard } from './auth.guard';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from './auth.guard';
import { ConfigService } from '@nestjs/config';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
describe('AuthGuard', () => {
let guard: AuthGuard;
const mockJwtService = {
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<AuthGuard>(AuthGuard);
});
const createMockExecutionContext = (
cookies?: any,
headers?: any,
): ExecutionContext => {
const mockRequest = {
cookies: cookies,
headers: headers || {},
};
return {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
};
it('should be defined', () => {
expect(guard).toBeDefined();
});
describe('canActivate', () => {
it('should return true when JWT and CSRF token are valid and match', async () => {
const csrfToken = 'valid-csrf-token';
const payload = {
sub: 1,
username: 'testuser',
role: 'user',
csrf: csrfToken,
};
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = createMockExecutionContext(
{ access_token: 'valid-jwt-token' },
{ 'x-csrf-token': csrfToken },
);
const request = context.switchToHttp().getRequest();
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(request['user']).toEqual(payload);
});
it('should throw UnauthorizedException when CSRF token is missing', async () => {
const context = createMockExecutionContext(
{ access_token: 'valid-jwt-token' },
{}, // no CSRF header
);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
expect(mockJwtService.verifyAsync).not.toHaveBeenCalled();
});
it('should throw UnauthorizedException when CSRF token does not match JWT payload', async () => {
const payload = {
sub: 1,
username: 'testuser',
role: 'user',
csrf: 'correct-csrf',
};
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = createMockExecutionContext(
{ access_token: 'valid-jwt-token' },
{ 'x-csrf-token': 'wrong-csrf-token' }, // doesn't match
);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it('should throw UnauthorizedException when no cookies present', async () => {
const context = createMockExecutionContext(undefined, {
'x-csrf-token': 'some-csrf',
});
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it('should throw UnauthorizedException when access_token cookie is missing', async () => {
const context = createMockExecutionContext(
{ other_cookie: 'value' },
{ 'x-csrf-token': 'some-csrf' },
);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it('should throw UnauthorizedException when JWT verification fails', async () => {
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token'));
const context = createMockExecutionContext(
{ access_token: 'invalid-token' },
{ 'x-csrf-token': 'some-csrf' },
);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
it('should throw UnauthorizedException when JWT is expired', async () => {
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockRejectedValue(new Error('jwt expired'));
const context = createMockExecutionContext(
{ access_token: 'expired-token' },
{ 'x-csrf-token': 'some-csrf' },
);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
});
expect(new AuthGuard(new JwtService(), new ConfigService())).toBeDefined();
});
});

View File

@ -17,23 +17,14 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const jwtToken = this.extractTokenFromCookie(request);
const csrfToken = this.extractTokenFromHeader(request);
if (!jwtToken || !csrfToken) {
const token = this.extractTokenFromCookie(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(jwtToken, {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_SECRET'),
});
console.log(payload);
if (payload.csrf !== csrfToken) {
throw new UnauthorizedException(['Invalid CSRF token']);
}
request['user'] = payload;
} catch {
throw new UnauthorizedException();
@ -42,8 +33,8 @@ export class AuthGuard implements CanActivate {
}
private extractTokenFromHeader(request: any): string | undefined {
const token = request.headers['x-csrf-token'];
return token;
const [type, token] = request.headers?.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
private extractTokenFromCookie(request: Request): string | undefined {

View File

@ -1,155 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RolesGuard } from './roles.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { UserRole } from '../dto/auth.dto';
import { RolesGuard } from './roles.guard';
describe('RolesGuard', () => {
let guard: RolesGuard;
const mockReflector = {
getAllAndOverride: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [RolesGuard, { provide: Reflector, useValue: mockReflector }],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
});
const createMockExecutionContext = (user?: any): ExecutionContext => {
const mockRequest = { user };
return {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as unknown as ExecutionContext;
};
it('should be defined', () => {
expect(guard).toBeDefined();
});
describe('canActivate', () => {
it('should return true when no roles are required (undefined)', () => {
mockReflector.getAllAndOverride.mockReturnValue(undefined);
const context = createMockExecutionContext({ role: 'user' });
const result = guard.canActivate(context);
expect(result).toBe(true);
});
it('should return true when no roles are required (null)', () => {
mockReflector.getAllAndOverride.mockReturnValue(null);
const context = createMockExecutionContext({ role: 'user' });
const result = guard.canActivate(context);
expect(result).toBe(true);
});
/**
* BUG TEST: This test SHOULD PASS but will FAIL
*
* Problem: Empty array [] is truthy in JavaScript
* Current code: if (!requiredRoles) { return true; }
*
* When @Roles() is used without arguments, requiredRoles = []
* [] is truthy, so ![] is false, so the early return doesn't happen
* Then .some([]) returns false, causing ForbiddenException
*
* Fix needed in roles.guard.ts:
* if (!requiredRoles || requiredRoles.length === 0) { return true; }
*/
it('should return true when roles array is empty (no restrictions)', () => {
mockReflector.getAllAndOverride.mockReturnValue([]);
const context = createMockExecutionContext({ role: 'user' });
// CORRECT expectation: empty roles = no restrictions = allow access
const result = guard.canActivate(context);
expect(result).toBe(true);
});
it('should return true when user has required role', () => {
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
const context = createMockExecutionContext({ role: UserRole.Admin });
const result = guard.canActivate(context);
expect(result).toBe(true);
});
it('should return true when user has one of multiple required roles', () => {
mockReflector.getAllAndOverride.mockReturnValue([
UserRole.Admin,
UserRole.User,
]);
const context = createMockExecutionContext({ role: UserRole.User });
const result = guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw ForbiddenException when user object has no role property', () => {
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
const context = createMockExecutionContext({});
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow(
'Insufficient permissions (no role)',
);
});
it('should throw ForbiddenException when user is undefined', () => {
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
const context = createMockExecutionContext(undefined);
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
it('should throw ForbiddenException when user does not have required role', () => {
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
const context = createMockExecutionContext({ role: UserRole.User });
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow(
'You do not have the required role',
);
});
it('should throw ForbiddenException when user role is null', () => {
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
const context = createMockExecutionContext({ role: null });
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow(
'Insufficient permissions (no role)',
);
});
it('should throw ForbiddenException when user role is empty string', () => {
mockReflector.getAllAndOverride.mockReturnValue([UserRole.Admin]);
const context = createMockExecutionContext({ role: '' });
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow(
'Insufficient permissions (no role)',
);
});
expect(new RolesGuard(new Reflector())).toBeDefined();
});
});

View File

@ -18,14 +18,14 @@ export class RolesGuard implements CanActivate {
[context.getHandler(), context.getClass()],
);
if (!requiredRoles || requiredRoles.length === 0) {
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user?.role) {
throw new ForbiddenException(['Insufficient permissions (no role)']);
throw new ForbiddenException('Insufficient permissions (no role)');
}
const hasRole = requiredRoles.some((role) => user.role === role);
@ -34,6 +34,6 @@ export class RolesGuard implements CanActivate {
return true;
}
throw new ForbiddenException(['You do not have the required role']);
throw new ForbiddenException('You do not have the required role');
}
}

View File

@ -1,147 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WebsocketGuard } from './websocket.guard';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Socket } from 'socket.io';
import { WebsocketGuard } from './websocket.guard';
describe('WebsocketGuard', () => {
let guard: WebsocketGuard;
let jwtService: jest.Mocked<JwtService>;
let configService: jest.Mocked<ConfigService>;
const mockJwtService = {
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
WebsocketGuard,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
guard = module.get<WebsocketGuard>(WebsocketGuard);
jwtService = module.get(JwtService);
configService = module.get(ConfigService);
});
const createMockSocket = (cookieHeader?: string): Partial<Socket> => ({
handshake: {
headers: {
cookie: cookieHeader,
},
} as any,
data: {},
disconnect: jest.fn(),
});
const createMockExecutionContext = (
socket: Partial<Socket>,
): ExecutionContext => {
return {
switchToWs: () => ({
getClient: () => socket,
}),
} as unknown as ExecutionContext;
};
it('should be defined', () => {
expect(guard).toBeDefined();
});
describe('canActivate', () => {
it('should return true and attach user to socket data when token is valid', async () => {
const payload = { sub: 1, username: 'testuser', role: 'user' };
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockResolvedValue(payload);
const socket = createMockSocket('access_token=valid-token');
const context = createMockExecutionContext(socket);
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(socket.data?.user).toEqual(payload);
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token', {
secret: 'jwt-secret',
});
});
it('should disconnect and throw UnauthorizedException when no cookie header', async () => {
const socket = createMockSocket(undefined);
const context = createMockExecutionContext(socket);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
await expect(guard.canActivate(context)).rejects.toThrow(
'No token provided',
);
expect(socket.disconnect).toHaveBeenCalled();
});
it('should disconnect and throw UnauthorizedException when access_token not in cookies', async () => {
const socket = createMockSocket('other_cookie=value');
const context = createMockExecutionContext(socket);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
expect(socket.disconnect).toHaveBeenCalled();
});
it('should disconnect and throw UnauthorizedException when token is invalid', async () => {
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockRejectedValue(new Error('Invalid token'));
const socket = createMockSocket('access_token=invalid-token');
const context = createMockExecutionContext(socket);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
await expect(guard.canActivate(context)).rejects.toThrow('Invalid token');
expect(socket.disconnect).toHaveBeenCalled();
});
it('should handle multiple cookies and extract access_token correctly', async () => {
const payload = { sub: 1, username: 'testuser', role: 'user' };
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockResolvedValue(payload);
const socket = createMockSocket(
'session=abc123; access_token=valid-token; other=value',
);
const context = createMockExecutionContext(socket);
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token', {
secret: 'jwt-secret',
});
});
it('should disconnect and throw when token is expired', async () => {
mockConfigService.get.mockReturnValue('jwt-secret');
mockJwtService.verifyAsync.mockRejectedValue(new Error('jwt expired'));
const socket = createMockSocket('access_token=expired-token');
const context = createMockExecutionContext(socket);
await expect(guard.canActivate(context)).rejects.toThrow(
UnauthorizedException,
);
expect(socket.disconnect).toHaveBeenCalled();
});
expect(
new WebsocketGuard(new JwtService(), new ConfigService()),
).toBeDefined();
});
});

View File

@ -10,7 +10,7 @@ import { Socket } from 'socket.io';
import * as cookie from 'cookie';
interface AuthPayload {
sub: bigint;
sub: number;
username: string;
role: string;
}

View File

@ -1,7 +1,6 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
@ -70,13 +69,4 @@ export class ObatController {
async getObatLogs(@Param('id') id: string) {
return await this.obatService.getLogObatById(id);
}
@Delete(':id')
@UseGuards(AuthGuard)
async deleteObatById(
@Param('id') id: number,
@CurrentUser() user: ActiveUserPayload,
) {
return await this.obatService.deleteObat(id, user);
}
}

View File

@ -56,11 +56,6 @@ export class ObatService {
take: take,
where: {
obat: obat ? { contains: obat } : undefined,
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
orderBy: orderBy
? { [Object.keys(orderBy)[0]]: order || 'asc' }
@ -70,11 +65,6 @@ export class ObatService {
const count = await this.prisma.pemberian_obat.count({
where: {
obat: obat ? { contains: obat } : undefined,
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
});
@ -283,98 +273,7 @@ export class ObatService {
}
}
async deleteObat(id: number, user: ActiveUserPayload) {
const existingObat = await this.getObatById(id);
if (!existingObat) {
throw new BadRequestException(`Obat with id ${id} not found`);
}
try {
const response = await this.prisma.$transaction(async (tx) => {
const createdValidationQueue =
await this.prisma.validation_queue.create({
data: {
table_name: 'pemberian_obat',
action: 'DELETE',
dataPayload: {
...existingObat,
},
record_id: id.toString(),
user_id_request: Number(user.sub),
status: 'PENDING',
},
});
const updatedObat = await tx.pemberian_obat.update({
where: { id: id },
data: {
deleted_status: 'DELETE_VALIDATION',
},
});
return createdValidationQueue;
});
return response;
} catch (error) {
console.error('Error deleting Obat:', error);
throw error;
}
}
async deleteObatFromDBAndBlockchain(id: number, userId: number) {
const obatId = Number(id);
if (isNaN(obatId)) {
throw new BadRequestException('ID obat tidak valid');
}
const existingObat = await this.getObatById(obatId);
if (!existingObat) {
throw new BadRequestException(`Obat dengan ID ${obatId} tidak ditemukan`);
}
try {
const deleteObat = await this.prisma.$transaction(async (tx) => {
const deletedObat = await tx.pemberian_obat.update({
where: { id: obatId },
data: {
deleted_status: 'DELETED',
},
});
const logPayload = JSON.stringify({
obat: existingObat.obat,
jumlah_obat: existingObat.jumlah_obat,
aturan_pakai: existingObat.aturan_pakai,
});
const payloadHash = sha256(logPayload);
const data = {
id: `OBAT_${deletedObat.id}`,
event: 'obat_deleted',
user_id: userId.toString(),
payload: payloadHash,
};
const logResult = await this.logService.storeLog(data);
return {
...deletedObat,
...logResult,
};
});
return deleteObat;
} catch (error) {
console.error('Error deleting Obat:', error);
throw error;
}
}
async countObat() {
return this.prisma.pemberian_obat.count({
where: {
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
});
return this.prisma.pemberian_obat.count();
}
}

View File

@ -195,11 +195,6 @@ export class RekammedisService {
: undefined,
jenis_kelamin: jkCharacter ? { equals: jkCharacter } : undefined,
kode_diagnosa: kode_diagnosa ? { contains: kode_diagnosa } : undefined,
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
...golDarahFilter,
...tindakLanjutFilter,
};
@ -474,28 +469,15 @@ export class RekammedisService {
throw new Error(`Rekam Medis with id_visit ${id_visit} not found`);
}
try {
const response = await this.prisma.$transaction(async (tx) => {
const createdQueue = await tx.validation_queue.create({
data: {
table_name: 'rekam_medis',
action: 'DELETE',
record_id: id_visit,
dataPayload: data,
user_id_request: user.sub,
status: 'PENDING',
},
});
const updatedRekamMedis = await tx.rekam_medis.update({
where: { id_visit },
data: {
deleted_status: 'DELETE_VALIDATION',
},
});
return {
...createdQueue,
rekam_medis: updatedRekamMedis,
};
const response = await this.prisma.validation_queue.create({
data: {
table_name: 'rekam_medis',
action: 'DELETE',
record_id: id_visit,
dataPayload: data,
user_id_request: user.sub,
status: 'PENDING',
},
});
return response;
} catch (error) {
@ -504,39 +486,10 @@ export class RekammedisService {
}
}
async deleteRekamMedisFromDBAndBlockchain(id_visit: string, userId: number) {
const existing = await this.getRekamMedisById(id_visit);
if (!existing) {
throw new Error(`Rekam Medis with id_visit ${id_visit} not found`);
}
async deleteRekamMedisFromDB(id_visit: string) {
try {
const deletedRekamMedis = await this.prisma.$transaction(async (tx) => {
const deleted = await tx.rekam_medis.update({
data: {
deleted_status: 'DELETED',
},
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;
const deletedRekamMedis = await this.prisma.rekam_medis.delete({
where: { id_visit },
});
return deletedRekamMedis;
} catch (error) {
@ -563,11 +516,6 @@ export class RekammedisService {
waktu_visit: {
gte: sevenDaysAgo,
},
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
_count: {
id_visit: true,
@ -601,14 +549,6 @@ export class RekammedisService {
}
async countRekamMedis() {
return this.prisma.rekam_medis.count({
where: {
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
});
return this.prisma.rekam_medis.count();
}
}

View File

@ -79,11 +79,6 @@ export class TindakanDokterService {
kategori_tindakanArray.length > 0
? { in: kategori_tindakanArray }
: undefined,
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
orderBy: orderBy
? { [Object.keys(orderBy)[0]]: order || 'asc' }
@ -102,11 +97,6 @@ export class TindakanDokterService {
kategori_tindakanArray.length > 0
? { in: kategori_tindakanArray }
: undefined,
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
});
@ -341,36 +331,16 @@ export class TindakanDokterService {
);
}
try {
const validationQueue = await this.prisma.$transaction(async (tx) => {
const queue = await tx.validation_queue.create({
data: {
table_name: 'pemberian_tindakan',
action: 'DELETE',
dataPayload: existingTindakan,
record_id: tindakanId.toString(),
user_id_request: user.sub,
status: 'PENDING',
},
});
const updatedTindakan = await tx.pemberian_tindakan.update({
where: { id: tindakanId },
data: {
deleted_status: 'DELETE_VALIDATION',
},
});
return {
...queue,
tindakan: updatedTindakan,
};
});
return validationQueue;
} catch (error) {
console.error('Error deleting Tindakan Dokter:', error);
throw error;
}
return this.prisma.validation_queue.create({
data: {
table_name: 'pemberian_tindakan',
action: 'DELETE',
dataPayload: existingTindakan,
record_id: tindakanId.toString(),
user_id_request: user.sub,
status: 'PENDING',
},
});
}
async deleteTindakanDokterFromDBAndBlockchain(id: number, userId: number) {
@ -389,9 +359,8 @@ export class TindakanDokterService {
try {
const deletedTindakan = await this.prisma.$transaction(async (tx) => {
const deleted = await tx.pemberian_tindakan.update({
const deleted = await tx.pemberian_tindakan.delete({
where: { id: tindakanId },
data: { deleted_status: 'DELETED' },
});
const logPayload = JSON.stringify(deleted);
const payloadHash = sha256(logPayload);
@ -416,14 +385,6 @@ export class TindakanDokterService {
}
async countTindakanDokter() {
return this.prisma.pemberian_tindakan.count({
where: {
OR: [
{ deleted_status: null },
{ deleted_status: 'DELETE_VALIDATION' },
{ deleted_status: { not: 'DELETED' } },
],
},
});
return this.prisma.pemberian_tindakan.count();
}
}

View File

@ -1,4 +1,4 @@
import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/guard/auth.guard';
import { ValidationService } from './validation.service';
import { CurrentUser } from '../auth/decorator/current-user.decorator';
@ -10,29 +10,8 @@ export class ValidationController {
@Get('/')
@UseGuards(AuthGuard)
async getValidationStatus(
@Query('take') take: number,
@Query('skip') skip: number,
@Query('page') page: number,
@Query('orderBy') orderBy: string,
@Query('search') search: string,
@Query('order') order: 'asc' | 'desc',
@Query('kelompok_data') kelompok_data: string,
@Query('aksi') aksi: string,
@Query('status') status: string,
) {
const queryParams = {
take,
skip,
page,
orderBy,
search,
order,
kelompok_data,
aksi,
status,
};
return this.validationService.getAllValidationsQueue(queryParams);
async getValidationStatus() {
return this.validationService.getAllValidationsQueue();
}
@Get('/:id')

View File

@ -46,10 +46,7 @@ export class ValidationService {
);
},
approveDelete: async (queue: any) => {
return this.rekamMedisService.deleteRekamMedisFromDBAndBlockchain(
queue.record_id,
Number(queue.user_id_request),
);
return this.rekamMedisService.deleteRekamMedisFromDB(queue.record_id);
},
},
pemberian_tindakan: {
@ -100,65 +97,15 @@ export class ValidationService {
Number(queue.user_id_request),
);
},
approveDelete: async (queue: any) => {
return this.obatService.deleteObatFromDBAndBlockchain(
Number(queue.record_id),
queue.user_id_request,
);
},
},
};
async getAllValidationsQueue(params: any) {
const {
take,
skip,
page,
orderBy,
order,
search,
kelompok_data,
aksi,
status,
} = params;
const skipValue = skip
? parseInt(skip.toString())
: page
? (parseInt(page.toString()) - 1) * take
: 0;
console.log('Params', params);
async getAllValidationsQueue() {
const result = await this.prisma.validation_queue.findMany({
take,
skip: skipValue,
orderBy: orderBy ? { [orderBy]: order || 'asc' } : { created_at: 'desc' },
where: {
record_id: search
? {
contains: search,
}
: undefined,
table_name:
kelompok_data && kelompok_data !== 'all'
? kelompok_data.toLowerCase()
: undefined,
action: aksi && aksi !== 'all' ? aksi.toUpperCase() : undefined,
status: status && status !== 'all' ? status.toUpperCase() : undefined,
},
where: { status: 'PENDING' },
});
const totalCount = await this.prisma.validation_queue.count({
where: {
record_id: search
? {
contains: search,
}
: undefined,
table_name:
kelompok_data && kelompok_data !== 'all'
? kelompok_data.toLowerCase()
: undefined,
action: aksi && aksi !== 'all' ? aksi.toUpperCase() : undefined,
status: status && status !== 'all' ? status.toUpperCase() : undefined,
},
where: { status: 'PENDING' },
});
return { data: result, totalCount };
}
@ -249,10 +196,7 @@ export class ValidationService {
const updated = await this.prisma.validation_queue.update({
where: { id: validationQueue.id },
data: {
record_id:
validationQueue.table_name === 'rekam_medis'
? approvalResult.id_visit
: approvalResult.id.toString(),
record_id: approvalResult.id.toString(),
status: 'APPROVED',
user_id_process: Number(user.sub),
processed_at: new Date(),
@ -265,81 +209,19 @@ export class ValidationService {
}
}
determineIdType(tableName: string, recordId: string) {
if (tableName === 'rekam_medis') {
return recordId;
} else if (
tableName === 'pemberian_tindakan' ||
tableName === 'pemberian_obat'
) {
return Number(recordId); // numeric ID
} else {
throw new Error('Unsupported table for ID determination');
}
}
async rejectValidation(id: number, user: ActiveUserPayload) {
const validationQueue = await this.getValidationQueueById(id);
if (!validationQueue) {
throw new Error('Validation queue not found');
}
let recordId: number | string = '';
if (
validationQueue.status === 'PENDING' &&
validationQueue.action === 'DELETE'
) {
recordId = this.determineIdType(
validationQueue.table_name,
validationQueue.record_id,
);
}
try {
const rejectedResponse = await this.prisma.$transaction(async (tx) => {
let updatedDeleteStatus = null;
if (validationQueue.action === 'DELETE') {
switch (validationQueue.table_name) {
case 'rekam_medis':
updatedDeleteStatus = await tx.rekam_medis.update({
where: { id_visit: recordId as string },
data: { deleted_status: null },
});
break;
case 'pemberian_tindakan':
updatedDeleteStatus = await tx.pemberian_tindakan.update({
where: { id: recordId as number },
data: { deleted_status: null },
});
break;
case 'pemberian_obat':
updatedDeleteStatus = await tx.pemberian_obat.update({
where: { id: recordId as number },
data: { deleted_status: null },
});
break;
default:
throw new Error('Unsupported table for delete rejection');
}
}
const updatedQueue = await tx.validation_queue.update({
where: { id: validationQueue.id },
data: {
status: 'REJECTED',
user_id_process: Number(user.sub),
processed_at: new Date(),
},
});
return {
...updatedQueue,
updatedDeleteStatus,
};
});
return rejectedResponse;
} catch (error) {
console.error('Error rejecting validation:', (error as Error).message);
throw error;
}
const updated = await this.prisma.validation_queue.update({
where: { id: validationQueue.id },
data: {
status: 'REJECTED',
user_id_process: Number(user.sub),
processed_at: new Date(),
},
});
return updated;
}
}

View File

@ -28,11 +28,10 @@ const pendingDeleteItem = ref<T | null>(null);
const hasStatusColumn = () => props.columns.some((col) => col.key === "status");
const hasUserIdProcessColumn = () =>
props.columns.some((col) => col.key === "user_id_process");
const hasLastSyncColumn = () =>
props.columns.some((col) => col.key === "last_sync");
const formatCellValue = (item: T, columnKey: keyof T) => {
const value = item[columnKey];
if (columnKey === "event" && typeof value === "string") {
const segments = value.split("_");
@ -43,10 +42,6 @@ const formatCellValue = (item: T, columnKey: keyof T) => {
if (segments.length >= 2 && segments[segments.length - 1] === "updated") {
return "UPDATE";
}
if (segments.length >= 2 && segments[segments.length - 1] === "deleted") {
return "DELETE";
}
}
return value;
@ -124,12 +119,10 @@ const handleDeleteCancel = () => {
:key="item.id"
:class="[
'hover:bg-dark hover:text-light transition-colors',
(item as Record<string, any>).isTampered ? 'bg-red-300 text-dark' : item.deleted_status ?
item.deleted_status === 'DELETE_VALIDATION' ? 'bg-yellow-100 text-dark' : 'bg-gray-300 text-dark' : '',
(item as Record<string, any>).isTampered ? 'bg-red-300 text-dark' : ''
]"
>
<td
v-if="item.deleted_status !== 'DELETE_VALIDATION'"
v-for="column in columns"
:key="String(column.key)"
:class="[
@ -150,125 +143,6 @@ const handleDeleteCancel = () => {
Review
</RouterLink>
</td>
<td v-if="hasLastSyncColumn()">
<RouterLink
v-if="item.last_sync && item.id.split('_')[0] === 'REKAM'"
:to="`rekam-medis/${item.id.split('_')[1]}`"
class="text-dark hover:underline hover:text-white"
>
Review
</RouterLink>
<RouterLink
v-if="item.last_sync && item.id.split('_')[0] === 'OBAT'"
:to="`pemberian-obat/${item.id.split('_')[1]}`"
class="text-dark hover:underline hover:text-white"
>
Review
</RouterLink>
<RouterLink
v-if="item.last_sync && item.id.split('_')[0] === 'TINDAKAN'"
:to="`pemberian-tindakan/${item.id.split('_')[1]}`"
class="text-dark hover:underline hover:text-white"
>
Review
</RouterLink>
</td>
<td
v-if="item.deleted_status === 'DELETE_VALIDATION'"
v-for="column in columns"
:key="String(column.key)"
:class="[
column.key === 'txId' || column.key === 'hash'
? 'font-mono overflow-hidden text-ellipsis max-w-10 hover:max-w-150 transition-all duration-500 ease-out text-xs'
: '',
hasStatusColumn() ? 'text-xs' : '',
]"
>
<div
v-if="
column.key === 'id_visit' &&
columns[2]?.key !== 'obat' &&
columns[2]?.key !== 'tindakan'
"
:class="[
column.key === 'id_visit' &&
(columns[2]?.key !== 'obat' || columns[2]?.key !== 'tindakan')
? 'tooltip tooltip-right flex items-center justify-center'
: '',
]"
data-tip="Data ini sedang dalam proses validasi untuk dihapus"
>
<span
:class="[
column.key === 'id_visit' ||
columns[2]?.key !== 'obat' ||
columns[2]?.key !== 'tindakan'
? 'inline-flex items-center gap-1'
: 'hidden',
]"
>
{{ formatCellValue(item, column.key) }}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-3 h-3 mt-1"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
</span>
</div>
<div
v-if="
column.key === 'id' &&
(columns[2]?.key === 'obat' || columns[2]?.key === 'tindakan')
"
:class="[
column.key === 'id' &&
(columns[2]?.key === 'obat' || columns[2]?.key === 'tindakan')
? 'tooltip tooltip-right flex items-center'
: '',
]"
data-tip="Data ini sedang dalam proses validasi untuk dihapus"
>
<span
:class="[
column.key === 'id' ||
columns[2]?.key === 'obat' ||
columns[2]?.key === 'tindakan'
? 'inline-flex items-center gap-1'
: 'hidden',
]"
>
{{ formatCellValue(item, column.key) }}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-3 h-3 mt-1"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
</span>
</div>
{{
column.key !== columns[0]?.key
? formatCellValue(item, column.key)
: ""
}}
</td>
<td v-if="!hasStatusColumn()">
<div class="flex gap-2">
<!-- Details Button -->

View File

@ -2,24 +2,16 @@
import { ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import DialogConfirm from "../DialogConfirm.vue";
import { useApi } from "../../composables/useApi";
const router = useRouter();
const route = useRoute();
const { post } = useApi();
const logoutDialog = ref<InstanceType<typeof DialogConfirm> | null>(null);
const showLogoutDialog = () => {
logoutDialog.value?.show();
};
const handleLogoutConfirm = async () => {
try {
await post("/auth/logout", {});
} catch (error) {
console.error("Logout error:", error);
}
const handleLogoutConfirm = () => {
localStorage.removeItem("csrf_token");
router.push({ name: "login" });
};

View File

@ -78,7 +78,6 @@ interface AuditLogEntry extends BlockchainLog {
tamperedLabel: string;
last_sync: string;
isTampered: boolean;
timestamp: string;
txId?: string;
}

View File

@ -102,10 +102,6 @@ export const SORT_OPTIONS = {
created_at: "Waktu Dibuat",
processed_at: "Waktu Diproses",
},
AUDIT_TRAIL: {
last_sync: "Last Sync",
timestamp: "Timestamp",
},
} as const;
export const REKAM_MEDIS_TABLE_COLUMNS = [
@ -306,7 +302,6 @@ export const AUDIT_TABLE_COLUMNS = [
{ key: "last_sync", label: "Last Sync", class: "text-dark" },
{ key: "userId", label: "User ID", class: "text-dark" },
{ key: "status", label: "Status Data", class: "text-dark" },
{ key: "timestamp", label: "Timestamp", class: "text-dark" },
] satisfies Array<{
key: keyof AuditLogEntry;
label: string;

View File

@ -14,7 +14,6 @@ import {
DEBOUNCE_DELAY,
ITEMS_PER_PAGE_OPTIONS,
AUDIT_TABLE_COLUMNS,
SORT_OPTIONS,
} from "../../../constants/pagination";
import ButtonDark from "../../../components/dashboard/ButtonDark.vue";
import DialogConfirm from "../../../components/DialogConfirm.vue";
@ -24,7 +23,6 @@ import type {
AuditLogType,
} from "../../../constants/interfaces";
import { io, Socket } from "socket.io-client";
import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
interface AuditLogResponse {
data: AuditLogEntry[];
@ -34,10 +32,6 @@ interface AuditLogResponse {
const router = useRouter();
const route = useRoute();
const api = useApi();
const sortBy = ref("last_sync");
const sortOrder = ref<"asc" | "desc">(
(route.query.order as "asc" | "desc") || "desc"
);
const { debounce } = useDebounce();
const pagination = usePagination({
@ -53,17 +47,6 @@ const filters = ref({
tampered: (route.query.tampered as string) || "initial",
});
const handleSortChange = (newSortBy: string) => {
sortBy.value = newSortBy;
pagination.reset();
fetchData();
};
const toggleSortOrder = () => {
console.log("Toggling sort order from", sortOrder.value);
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
};
const formatTimestamp = (rawValue?: string) => {
if (!rawValue) {
return "-";
@ -201,16 +184,10 @@ const updateQueryParams = () => {
query.search = searchId.value;
}
if (sortBy.value !== "initial") {
query.sortBy = sortBy.value;
}
if (filters.value.type !== "all") {
query.type = filters.value.type;
}
query.order = sortOrder.value;
if (filters.value.tampered !== "all") {
query.tampered = filters.value.tampered;
}
@ -223,8 +200,6 @@ const fetchData = async () => {
const params = new URLSearchParams({
page: pagination.page.value.toString(),
pageSize: pageSize.value.toString(),
orderBy: sortBy.value,
order: sortOrder.value,
});
if (searchId.value) {
@ -243,8 +218,6 @@ const fetchData = async () => {
`/audit/trail${params.toString() ? `?${params.toString()}` : ""}`
);
console.log("Audit Log Response:", response);
const apiResponse = response as any;
pagination.totalCount.value = apiResponse.totalCount;
@ -289,12 +262,6 @@ const handleResetFilters = () => {
pagination.reset();
fetchData();
};
watch(sortOrder, () => {
pagination.reset();
fetchData();
});
watch(
() => pagination.page.value,
() => {
@ -445,40 +412,11 @@ onBeforeUnmount(() => {
<div
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
>
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
<SearchInput
v-model="searchId"
placeholder="Cari berdasarkan ID Log"
@search="handleSearch"
/>
<div class="flex items-center gap-2 md:ml-4">
<SortDropdown
v-model="sortBy"
:options="SORT_OPTIONS.AUDIT_TRAIL"
label="Urut berdasarkan:"
@change="handleSortChange"
/>
<button
class="btn btn-sm bg-dark text-light hover:bg-light hover:text-dark active:inset-shadow-sm active:inset-shadow-black/50"
@click="toggleSortOrder"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="2"
fill="none"
stroke="currentColor"
class="inline-block size-4"
>
<path d="M7 7l3 -3l3 3"></path>
<path d="M10 4v16"></path>
<path d="M17 17l-3 3l-3 -3"></path>
<path d="M14 20v-16"></path>
</svg>
<span class="ml-2 uppercase">{{ sortOrder }}</span>
</button>
</div>
</div>
<SearchInput
v-model="searchId"
placeholder="Cari berdasarkan ID Log"
@search="handleSearch"
/>
<DialogConfirm
ref="auditDialog"
title="Konfirmasi"

View File

@ -34,7 +34,6 @@ interface ApiResponse {
const data = ref<ObatData[]>([]);
const searchObat = ref("");
const sortBy = ref("id");
const isDeleteSuccess = ref<boolean>(false);
const router = useRouter();
const route = useRoute();
@ -156,15 +155,14 @@ const handleUpdate = (item: ObatData) => {
};
const handleDelete = async (item: ObatData) => {
try {
const result = await api.delete(`/obat/${item.id}`);
if (result) {
isDeleteSuccess.value = true;
if (confirm(`Apakah Anda yakin ingin menghapus obat "${item.obat}"?`)) {
try {
await api.delete(`/obat/${item.id}`);
await fetchData();
} catch (error) {
console.error("Error deleting obat:", error);
alert("Gagal menghapus data obat");
}
await fetchData();
} catch (error) {
console.error("Error deleting obat:", error);
alert("Gagal menghapus data obat");
}
};
@ -254,27 +252,6 @@ onMounted(async () => {
</RouterLink>
</div>
<div
role="alert"
class="alert alert-success m-4 shadow-md"
v-if="isDeleteSuccess"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Data obat berhasil dikirim untuk validasi penghapusan</span>
</div>
<!-- Data Table -->
<DataTable
:data="data"

View File

@ -36,7 +36,6 @@ interface ApiResponse {
const data = ref<RekamMedis[]>([]);
const searchRekamMedis = ref("");
const sortBy = ref("waktu_visit");
const isDeleteSuccess = ref<boolean>(false);
const router = useRouter();
const route = useRoute();
@ -276,10 +275,7 @@ const handleUpdate = (item: RekamMedis) => {
const handleDelete = async (item: RekamMedis) => {
try {
const result = await api.delete(`/rekammedis/${item.id_visit}`);
if (result) {
isDeleteSuccess.value = true;
}
await api.delete(`/rekammedis/${item.id_visit}`);
await fetchData();
} catch (error) {
console.error("Error deleting rekam medis:", error);
@ -606,30 +602,6 @@ onBeforeUnmount(() => {
</RouterLink>
</div>
<div
role="alert"
class="alert alert-success m-4 shadow-md"
v-if="isDeleteSuccess"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
>Data rekam medis berhasil dikirim untuk validasi
penghapusan</span
>
</div>
<!-- Data Table -->
<DataTable
:data="data"

View File

@ -18,7 +18,6 @@ import SortDropdown from "../../../components/dashboard/SortDropdown.vue";
import DataTable from "../../../components/dashboard/DataTable.vue";
import PaginationControls from "../../../components/dashboard/PaginationControls.vue";
import type { ValidationLog } from "../../../constants/interfaces";
import Footer from "../../../components/dashboard/Footer.vue";
interface ApiResponse {
data: ValidationLog[];
@ -26,7 +25,8 @@ interface ApiResponse {
}
const data = ref<ValidationLog[]>([]);
const sortBy = ref("created_at");
const searchValidation = ref("");
const sortBy = ref("id");
const router = useRouter();
const route = useRoute();
const pagination = usePagination({
@ -34,16 +34,10 @@ const pagination = usePagination({
initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
});
const sortOrder = ref<"asc" | "desc">(
(route.query.order as "asc" | "desc") || "desc"
(route.query.order as "asc" | "desc") || "asc"
);
const api = useApi();
const { debounce } = useDebounce();
const searchId = ref("");
const filters = ref({
kelompok_data: (route.query.kelompok_data as string) || "initial",
aksi: (route.query.aksi as string) || "initial",
status: (route.query.status as string) || "PENDING",
});
const updateQueryParams = () => {
const query: Record<string, string> = {
@ -51,29 +45,14 @@ const updateQueryParams = () => {
pageSize: pagination.pageSize.value.toString(),
};
if (searchId.value) {
query.search = searchId.value;
if (searchValidation.value) {
query.search = searchValidation.value;
}
if (sortBy.value !== "id") {
query.sortBy = sortBy.value;
}
if (filters.value.status !== "all") {
query.status = filters.value.status;
}
if (filters.value.aksi !== "all" && filters.value.aksi !== "initial") {
query.aksi = filters.value.aksi;
}
if (
filters.value.kelompok_data !== "all" &&
filters.value.kelompok_data !== "initial"
) {
query.kelompok_data = filters.value.kelompok_data;
}
query.order = sortOrder.value;
router.replace({ query });
@ -158,14 +137,6 @@ const normalizedData = (rawData: any[]): ValidationLog[] => {
}));
};
const handleResetFilters = () => {
filters.value.kelompok_data = "all";
filters.value.aksi = "all";
searchId.value = "";
pagination.reset();
fetchData();
};
const fetchData = async () => {
try {
const queryParams = new URLSearchParams({
@ -173,13 +144,7 @@ const fetchData = async () => {
page: pagination.page.value.toString(),
orderBy: sortBy.value,
order: sortOrder.value,
kelompok_data:
filters.value.kelompok_data !== "initial"
? filters.value.kelompok_data
: "",
aksi: filters.value.aksi !== "initial" ? filters.value.aksi : "",
status: filters.value.status !== "initial" ? filters.value.status : "",
...(searchId.value && { search: searchId.value }),
...(searchValidation.value && { validation: searchValidation.value }),
});
const result = await api.get<ApiResponse>(
@ -248,40 +213,16 @@ watch(sortOrder, () => {
fetchData();
});
watch(searchId, (newValue, oldValue) => {
watch(searchValidation, (newValue, oldValue) => {
if (oldValue && !newValue) {
pagination.reset();
fetchData();
}
});
watch(
() => filters.value.kelompok_data,
() => {
pagination.reset();
fetchData();
}
);
watch(
() => filters.value.aksi,
() => {
pagination.reset();
fetchData();
}
);
watch(
() => filters.value.status,
() => {
pagination.reset();
fetchData();
}
);
onMounted(async () => {
if (route.query.search) {
searchId.value = route.query.search as string;
searchValidation.value = route.query.search as string;
}
if (route.query.sortBy) {
sortBy.value = route.query.sortBy as string;
@ -303,95 +244,14 @@ onMounted(async () => {
<div class="flex h-full p-2">
<Sidebar>
<PageHeader title="Validasi" subtitle="Manajemen Validasi" />
<div
class="collapse collapse-arrow bg-white border-white border shadow-sm mb-2"
>
<input type="checkbox" />
<div
class="collapse-title font-semibold after:start-5 after:end-auto pe-4 ps-12"
>
Filter
</div>
<div class="collapse-content text-sm flex flex-col gap-4">
<div class="flex gap-x-4">
<div class="flex gap-x-4 items-end">
<div class="h-full">
<label for="jenis_kelamin" class="font-bold"
>Kelompok Data</label
>
<select
v-model="filters.kelompok_data"
class="select bg-white border border-gray-300 mt-1"
>
<option disabled selected value="initial">
Pilih Kelompok Data
</option>
<option value="rekam_medis">Rekam Medis</option>
<option value="pemberian_tindakan">Tindakan</option>
<option value="pemberian_obat">Obat</option>
<option value="all">Semua Tipe</option>
</select>
</div>
</div>
<div class="flex gap-x-4 items-end">
<div class="h-full">
<label for="jenis_kelamin" class="font-bold"
>Jenis Aksi</label
>
<select
v-model="filters.aksi"
class="select bg-white border border-gray-300 mt-1"
>
<option disabled selected value="initial">
Pilih Jenis Aksi
</option>
<option value="CREATE">Create</option>
<option value="UPDATE">Update</option>
<option value="DELETE">Delete</option>
<option value="all">Semua</option>
</select>
</div>
</div>
<div class="flex gap-x-4 items-end">
<div class="h-full">
<label for="jenis_kelamin" class="font-bold"
>Status Validasi</label
>
<select
v-model="filters.status"
class="select bg-white border border-gray-300 mt-1"
>
<option disabled selected value="initial">
Pilih Status Validasi
</option>
<option value="PENDING">Pending</option>
<option value="APPROVED">Approved</option>
<option value="REJECTED">Rejected</option>
<option value="all">Semua</option>
</select>
</div>
</div>
</div>
<div class="flex justify-end">
<button
@click="handleResetFilters"
class="btn btn-sm btn-outline btn-dark hover:bg-dark hover:text-light"
>
Reset Filter
</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-md">
<div
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 px-4 pt-4 pb-2"
>
<div class="flex flex-col w-full gap-2 md:flex-row md:items-center">
<SearchInput
v-model="searchId"
placeholder="Cari berdasarkan ID Record"
v-model="searchValidation"
placeholder="Cari berdasarkan Validation"
@search="handleSearch"
/>
<div class="flex items-center gap-2 md:ml-4">
@ -455,7 +315,6 @@ onMounted(async () => {
</div>
</Sidebar>
</div>
<Footer />
</div>
</template>