Add audit log UI and backend API seeded E2E
- Add Auth Context route role guards so doctors cannot directly enter template management, user management, or audit logs. - Add Audit Logs page, sidebar entry, frontend audit API client, and API client test. - Add backend audit log query endpoint with super/admin visibility rules and query filtering. - Extend PostgreSQL integration tests to cover audit log query permissions. - Move Playwright E2E away from localStorage seed data to real backend API login and seed helpers. - Add E2E coverage for route guards and audit log visibility. - Run Playwright backend on port 3100 and proxy Vite API requests there to avoid local port conflicts. - Make server:dev use the compiled NestJS server path, avoiding tsx parameter-property injection issues. - Update README, AGENTS, feature, testing, security, deployment, progress, API, backendization, and auth/user module docs.
This commit is contained in:
19
server/src/audit/audit.controller.ts
Normal file
19
server/src/audit/audit.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AuthService } from '../auth/auth.service.js';
|
||||
import { getSessionUser } from '../auth/session-user.js';
|
||||
import { AuditService } from './audit.service.js';
|
||||
|
||||
@Controller('audit-logs')
|
||||
export class AuditController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Req() request: Request, @Query() query: unknown) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
return { data: await this.auditService.list(actor, query) };
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module.js';
|
||||
import { AuthModule } from '../auth/auth.module.js';
|
||||
import { AuditController } from './audit.controller.js';
|
||||
import { AuditService } from './audit.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
|
||||
11
server/src/audit/audit.schemas.ts
Normal file
11
server/src/audit/audit.schemas.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const listAuditLogsQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().positive().max(100).default(50),
|
||||
action: z.string().trim().optional(),
|
||||
targetType: z.string().trim().optional(),
|
||||
actor: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type ListAuditLogsQuery = z.infer<typeof listAuditLogsQuerySchema>;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { SafeUser } from '../auth/auth.types.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { listAuditLogsQuerySchema } from './audit.schemas.js';
|
||||
|
||||
export interface AuditInput {
|
||||
actor?: SafeUser | null;
|
||||
@@ -18,6 +19,84 @@ export interface AuditInput {
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(actor: SafeUser, rawQuery: unknown) {
|
||||
if (actor.role === 'doctor') {
|
||||
throw new ForbiddenException('无权查看审计日志');
|
||||
}
|
||||
|
||||
const parsed = listAuditLogsQuerySchema.safeParse(rawQuery);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestException(parsed.error.issues.map((issue) => issue.message).join(';'));
|
||||
}
|
||||
const query = parsed.data;
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
tenantId: actor.tenantId,
|
||||
};
|
||||
|
||||
if (actor.role === 'admin') {
|
||||
where.OR = [
|
||||
{ departmentId: actor.departmentId },
|
||||
{ actorUserId: actor.id },
|
||||
];
|
||||
}
|
||||
|
||||
if (query.action) {
|
||||
where.action = query.action;
|
||||
}
|
||||
if (query.targetType) {
|
||||
where.targetType = query.targetType;
|
||||
}
|
||||
if (query.actor) {
|
||||
where.actor = {
|
||||
OR: [
|
||||
{ username: { contains: query.actor, mode: 'insensitive' } },
|
||||
{ name: { contains: query.actor, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const skip = (query.page - 1) * query.pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
actor: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
actorUserId: item.actorUserId,
|
||||
actorUsername: item.actor?.username ?? null,
|
||||
actorName: item.actor?.name ?? null,
|
||||
actorRole: item.actorRole,
|
||||
action: item.action,
|
||||
targetType: item.targetType,
|
||||
targetId: item.targetId,
|
||||
departmentId: item.departmentId,
|
||||
ip: item.ip,
|
||||
userAgent: item.userAgent,
|
||||
metadata: item.metadata,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async record(input: AuditInput) {
|
||||
const actor = input.actor || null;
|
||||
const tenantId = actor?.tenantId;
|
||||
|
||||
@@ -171,6 +171,10 @@ describe('Prisma-backed service integration', () => {
|
||||
expect(managerStats.totalCount).toBeGreaterThanOrEqual(1);
|
||||
expect(managerStats.trend).toHaveLength(7);
|
||||
expect(managerStats.templateCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const auditList = await auditService.list(superActor, { action: 'report.complete', page: 1, pageSize: 10 });
|
||||
expect(auditList.items.map((item) => item.targetId)).toContain(ownReport.id);
|
||||
await expect(auditService.list(doctorActor, {})).rejects.toThrow('无权查看审计日志');
|
||||
});
|
||||
|
||||
it('stores department and personal templates with real permission filtering', async () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Pool } from 'pg';
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
const connectionString = configService.get<string>('DATABASE_URL');
|
||||
constructor(configService?: ConfigService) {
|
||||
const connectionString = configService?.get<string>('DATABASE_URL') ?? process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL is required to start the API server');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user