Fix report draft save and microphone startup
- Allow draft reports to be saved without patient name or hospital ID while keeping completed reports strictly validated. - Preserve completed-report identity validation when updating existing reports by checking merged old and new values. - Show real API save errors in the report editor and send expired sessions back to login instead of reporting a generic backend outage. - Guard speech startup for missing getUserMedia or AudioContext support and explain localhost/HTTPS microphone requirements. - Add report schema tests covering draft identity fields and completed-report validation. - Update AGENTS and docs for report editor behavior, feature status, progress, and testing coverage.
This commit is contained in:
43
server/src/reports/reports.schemas.test.ts
Normal file
43
server/src/reports/reports.schemas.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createReportSchema,
|
||||
getCompletedReportIdentityIssues,
|
||||
updateReportSchema,
|
||||
} from './reports.schemas';
|
||||
|
||||
describe('reports schemas', () => {
|
||||
it('allows drafts without patient identity fields', () => {
|
||||
const parsed = createReportSchema.parse({
|
||||
title: '腹腔镜胆囊切除术报告',
|
||||
patientName: '',
|
||||
hospitalId: '',
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
expect(parsed.patientName).toBe('');
|
||||
expect(parsed.hospitalId).toBe('');
|
||||
expect(parsed.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('requires patient identity fields when completing reports', () => {
|
||||
expect(() => createReportSchema.parse({
|
||||
title: '腹腔镜胆囊切除术报告',
|
||||
patientName: '',
|
||||
hospitalId: '',
|
||||
status: 'completed',
|
||||
})).toThrow(/患者姓名不能为空/);
|
||||
|
||||
expect(() => updateReportSchema.parse({
|
||||
patientName: '张三',
|
||||
hospitalId: '',
|
||||
status: 'completed',
|
||||
})).toThrow(/住院号不能为空/);
|
||||
});
|
||||
|
||||
it('reports missing identity fields for merged completion validation', () => {
|
||||
expect(getCompletedReportIdentityIssues('completed', '张三', '')).toEqual([
|
||||
{ path: 'hospitalId', message: '住院号不能为空' },
|
||||
]);
|
||||
expect(getCompletedReportIdentityIssues('draft', '', '')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,55 @@ import { z } from 'zod';
|
||||
|
||||
export const reportStatusSchema = z.enum(['draft', 'completed']);
|
||||
|
||||
type CompletedIdentityIssue = {
|
||||
path: 'patientName' | 'hospitalId';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function getCompletedReportIdentityIssues(
|
||||
status: z.infer<typeof reportStatusSchema> | undefined,
|
||||
patientName: string | undefined,
|
||||
hospitalId: string | undefined,
|
||||
): CompletedIdentityIssue[] {
|
||||
if (status !== 'completed') return [];
|
||||
|
||||
const issues: CompletedIdentityIssue[] = [];
|
||||
if (!patientName?.trim()) {
|
||||
issues.push({ path: 'patientName', message: '患者姓名不能为空' });
|
||||
}
|
||||
if (!hospitalId?.trim()) {
|
||||
issues.push({ path: 'hospitalId', message: '住院号不能为空' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
const requireCompletedIdentity = (
|
||||
value: { status?: z.infer<typeof reportStatusSchema>; patientName?: string; hospitalId?: string },
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
for (const issue of getCompletedReportIdentityIssues(value.status, value.patientName, value.hospitalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [issue.path],
|
||||
message: issue.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const reportBaseSchema = z.object({
|
||||
title: z.string().trim().min(1, '报告标题不能为空'),
|
||||
patientName: z.string().trim().min(1, '患者姓名不能为空'),
|
||||
hospitalId: z.string().trim().min(1, '住院号不能为空'),
|
||||
patientName: z.string().trim().default(''),
|
||||
hospitalId: z.string().trim().default(''),
|
||||
content: z.string().default(''),
|
||||
status: reportStatusSchema.default('draft'),
|
||||
templateId: z.string().trim().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const createReportSchema = reportBaseSchema;
|
||||
export const createReportSchema = reportBaseSchema.superRefine(requireCompletedIdentity);
|
||||
|
||||
export const updateReportSchema = reportBaseSchema.partial().extend({
|
||||
status: reportStatusSchema.optional(),
|
||||
}).passthrough();
|
||||
}).passthrough().superRefine(requireCompletedIdentity);
|
||||
|
||||
export const listReportsQuerySchema = z.object({
|
||||
q: z.string().trim().optional(),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './report.mapper.js';
|
||||
import {
|
||||
createReportSchema,
|
||||
getCompletedReportIdentityIssues,
|
||||
listReportsQuerySchema,
|
||||
updateReportSchema,
|
||||
type ListReportsQuery,
|
||||
@@ -129,6 +130,15 @@ export class ReportsService {
|
||||
|
||||
const input = result.data;
|
||||
const nextStatus = input.status ? this.toDbStatus(input.status) : old.status;
|
||||
const completionIssues = getCompletedReportIdentityIssues(
|
||||
nextStatus === 'COMPLETED' ? 'completed' : 'draft',
|
||||
input.patientName ?? old.patientName,
|
||||
input.hospitalId ?? old.hospitalId,
|
||||
);
|
||||
if (completionIssues.length > 0) {
|
||||
throw new BadRequestException(completionIssues.map((issue) => issue.message).join(';'));
|
||||
}
|
||||
|
||||
const nextRevision = old.status === 'COMPLETED' ? old.revision + 1 : old.revision;
|
||||
const media = extractReportMedia(input);
|
||||
const content = input.content === undefined ? old.content : sanitizeReportHtml(input.content);
|
||||
|
||||
Reference in New Issue
Block a user