Initialize backendized SurClaw report system
- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows. - Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization. - Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration. - Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress. - Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
58
server/src/templates/template.mapper.test.ts
Normal file
58
server/src/templates/template.mapper.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toFrontendTemplate, toTemplateResource } from './template.mapper';
|
||||
|
||||
const now = new Date('2026-05-01T00:00:00.000Z');
|
||||
|
||||
const baseTemplate = {
|
||||
id: 'tpl1',
|
||||
tenantId: 'tenant-a',
|
||||
name: '外科模板',
|
||||
description: '标准模板',
|
||||
content: '<p>模板</p>',
|
||||
fields: [{ key: 'patientName' }],
|
||||
scope: 'DEPARTMENT' as const,
|
||||
ownerDepartmentId: 'dept-surgery',
|
||||
ownerUserId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
ownerDepartment: {
|
||||
id: 'dept-surgery',
|
||||
tenantId: 'tenant-a',
|
||||
name: '外科',
|
||||
code: 'surgery',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
ownerUser: null,
|
||||
permissions: [
|
||||
{
|
||||
id: 'perm1',
|
||||
templateId: 'tpl1',
|
||||
departmentId: 'dept-surgery',
|
||||
canUse: true,
|
||||
canManage: true,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('template mapper', () => {
|
||||
it('maps Prisma templates to frontend template objects and policy resources', () => {
|
||||
expect(toFrontendTemplate(baseTemplate)).toMatchObject({
|
||||
id: 'tpl1',
|
||||
name: '外科模板',
|
||||
desc: '标准模板',
|
||||
scope: 'department',
|
||||
department: '外科',
|
||||
fields: [{ key: 'patientName' }],
|
||||
});
|
||||
|
||||
expect(toTemplateResource(baseTemplate)).toMatchObject({
|
||||
tenantId: 'tenant-a',
|
||||
scope: 'department',
|
||||
ownerDepartmentId: 'dept-surgery',
|
||||
permittedDepartmentIds: ['dept-surgery'],
|
||||
manageableDepartmentIds: ['dept-surgery'],
|
||||
});
|
||||
});
|
||||
});
|
||||
36
server/src/templates/template.mapper.ts
Normal file
36
server/src/templates/template.mapper.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
export const templateInclude = {
|
||||
ownerDepartment: true,
|
||||
ownerUser: true,
|
||||
permissions: true,
|
||||
} satisfies Prisma.TemplateInclude;
|
||||
|
||||
export type TemplateWithRelations = Prisma.TemplateGetPayload<{ include: typeof templateInclude }>;
|
||||
|
||||
export const toTemplateResource = (template: TemplateWithRelations) => ({
|
||||
tenantId: template.tenantId,
|
||||
scope: template.scope.toLowerCase() as 'department' | 'personal',
|
||||
ownerDepartmentId: template.ownerDepartmentId,
|
||||
ownerUserId: template.ownerUserId,
|
||||
permittedDepartmentIds: template.permissions
|
||||
.filter((permission) => permission.canUse)
|
||||
.map((permission) => permission.departmentId),
|
||||
manageableDepartmentIds: template.permissions
|
||||
.filter((permission) => permission.canManage)
|
||||
.map((permission) => permission.departmentId),
|
||||
});
|
||||
|
||||
export const toFrontendTemplate = (template: TemplateWithRelations) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
desc: template.description ?? '',
|
||||
content: template.content,
|
||||
createdAt: template.createdAt.toISOString(),
|
||||
updatedAt: template.updatedAt.toISOString(),
|
||||
author: template.ownerUser?.username || 'admin',
|
||||
fields: Array.isArray(template.fields) ? template.fields : [],
|
||||
scope: template.scope.toLowerCase(),
|
||||
ownerUser: template.ownerUser?.username,
|
||||
department: template.ownerDepartment?.name ?? '',
|
||||
});
|
||||
44
server/src/templates/templates.controller.ts
Normal file
44
server/src/templates/templates.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, 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 { TemplatesService } from './templates.service.js';
|
||||
|
||||
@Controller('templates')
|
||||
export class TemplatesController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly templatesService: TemplatesService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Req() request: Request, @Query() query: unknown) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
return { data: await this.templatesService.list(actor, query) };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async get(@Req() request: Request, @Param('id') id: string) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
return { data: { template: await this.templatesService.get(actor, id) } };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Req() request: Request, @Body() body: unknown) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
return { data: { template: await this.templatesService.create(actor, body) } };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Req() request: Request, @Param('id') id: string, @Body() body: unknown) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
return { data: { template: await this.templatesService.update(actor, id, body) } };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Req() request: Request, @Param('id') id: string) {
|
||||
const actor = await getSessionUser(request, this.authService);
|
||||
await this.templatesService.remove(actor, id);
|
||||
return { data: null };
|
||||
}
|
||||
}
|
||||
11
server/src/templates/templates.module.ts
Normal file
11
server/src/templates/templates.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module.js';
|
||||
import { TemplatesController } from './templates.controller.js';
|
||||
import { TemplatesService } from './templates.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [TemplatesController],
|
||||
providers: [TemplatesService],
|
||||
})
|
||||
export class TemplatesModule {}
|
||||
24
server/src/templates/templates.schemas.ts
Normal file
24
server/src/templates/templates.schemas.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const templateScopeSchema = z.enum(['department', 'personal']);
|
||||
|
||||
export const listTemplatesQuerySchema = z.object({
|
||||
access: z.enum(['use', 'manage']).default('use'),
|
||||
});
|
||||
|
||||
export const templateInputSchema = z.object({
|
||||
name: z.string().trim().min(1, '模板名称不能为空'),
|
||||
desc: z.string().trim().optional(),
|
||||
content: z.string().default(''),
|
||||
fields: z.array(z.unknown()).default([]),
|
||||
scope: templateScopeSchema.default('department'),
|
||||
department: z.string().trim().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const templateUpdateSchema = templateInputSchema.partial().extend({
|
||||
scope: templateScopeSchema.optional(),
|
||||
});
|
||||
|
||||
export type ListTemplatesQuery = z.infer<typeof listTemplatesQuerySchema>;
|
||||
export type TemplateInput = z.infer<typeof templateInputSchema>;
|
||||
export type TemplateUpdateInput = z.infer<typeof templateUpdateSchema>;
|
||||
224
server/src/templates/templates.service.ts
Normal file
224
server/src/templates/templates.service.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { Prisma, TemplateScope } from '@prisma/client';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
import type { SafeUser } from '../auth/auth.types.js';
|
||||
import { sanitizeReportHtml } from '../common/html-sanitizer.js';
|
||||
import { canManageTemplate, canUseTemplate, isDoctor } from '../permissions/permissions.policy.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { templateInclude, toFrontendTemplate, toTemplateResource } from './template.mapper.js';
|
||||
import {
|
||||
listTemplatesQuerySchema,
|
||||
templateInputSchema,
|
||||
templateUpdateSchema,
|
||||
type ListTemplatesQuery,
|
||||
} from './templates.schemas.js';
|
||||
|
||||
@Injectable()
|
||||
export class TemplatesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit?: AuditService,
|
||||
) {}
|
||||
|
||||
async list(actor: SafeUser, rawQuery: unknown) {
|
||||
const query = this.parseListQuery(rawQuery);
|
||||
const templates = await this.prisma.template.findMany({
|
||||
where: { tenantId: actor.tenantId },
|
||||
include: templateInclude,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
const policyActor = this.actorToPolicy(actor);
|
||||
const filtered = templates.filter((template) => {
|
||||
const resource = toTemplateResource(template);
|
||||
return query.access === 'manage'
|
||||
? canManageTemplate(policyActor, resource)
|
||||
: canUseTemplate(policyActor, resource);
|
||||
});
|
||||
|
||||
return {
|
||||
items: filtered.map(toFrontendTemplate),
|
||||
total: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
async get(actor: SafeUser, id: string) {
|
||||
const template = await this.findTemplate(actor.tenantId, id);
|
||||
if (!canUseTemplate(this.actorToPolicy(actor), toTemplateResource(template))) {
|
||||
throw new ForbiddenException('无权查看此模板');
|
||||
}
|
||||
return toFrontendTemplate(template);
|
||||
}
|
||||
|
||||
async create(actor: SafeUser, rawInput: unknown) {
|
||||
const result = templateInputSchema.safeParse(rawInput);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(';'));
|
||||
}
|
||||
|
||||
const input = result.data;
|
||||
if (input.scope === 'department' && isDoctor(this.actorToPolicy(actor))) {
|
||||
throw new ForbiddenException('医生不能创建部门模板');
|
||||
}
|
||||
|
||||
const ownerDepartmentId =
|
||||
input.scope === 'department'
|
||||
? await this.resolveOwnerDepartmentId(actor, input.department)
|
||||
: null;
|
||||
const ownerUserId = input.scope === 'personal' ? actor.id : null;
|
||||
|
||||
const template = await this.prisma.template.create({
|
||||
data: {
|
||||
tenantId: actor.tenantId,
|
||||
name: input.name,
|
||||
description: input.desc || null,
|
||||
content: sanitizeReportHtml(input.content),
|
||||
fields: input.fields as Prisma.InputJsonValue,
|
||||
scope: this.toDbScope(input.scope),
|
||||
ownerDepartmentId,
|
||||
ownerUserId,
|
||||
permissions: ownerDepartmentId
|
||||
? {
|
||||
create: {
|
||||
departmentId: ownerDepartmentId,
|
||||
canUse: true,
|
||||
canManage: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: templateInclude,
|
||||
});
|
||||
|
||||
await this.audit?.record({
|
||||
actor,
|
||||
action: 'template.create',
|
||||
targetType: 'Template',
|
||||
targetId: template.id,
|
||||
departmentId: ownerDepartmentId,
|
||||
metadata: { scope: template.scope, name: template.name },
|
||||
});
|
||||
|
||||
return toFrontendTemplate(template);
|
||||
}
|
||||
|
||||
async update(actor: SafeUser, id: string, rawInput: unknown) {
|
||||
const result = templateUpdateSchema.safeParse(rawInput);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(';'));
|
||||
}
|
||||
|
||||
const existing = await this.findTemplate(actor.tenantId, id);
|
||||
if (!canManageTemplate(this.actorToPolicy(actor), toTemplateResource(existing))) {
|
||||
throw new ForbiddenException('无权修改此模板');
|
||||
}
|
||||
|
||||
const input = result.data;
|
||||
const updated = await this.prisma.template.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name ?? existing.name,
|
||||
description: input.desc ?? existing.description,
|
||||
content: input.content === undefined ? existing.content : sanitizeReportHtml(input.content),
|
||||
fields: input.fields ? (input.fields as Prisma.InputJsonValue) : existing.fields,
|
||||
},
|
||||
include: templateInclude,
|
||||
});
|
||||
|
||||
await this.audit?.record({
|
||||
actor,
|
||||
action: 'template.update',
|
||||
targetType: 'Template',
|
||||
targetId: updated.id,
|
||||
departmentId: updated.ownerDepartmentId,
|
||||
metadata: { scope: updated.scope, name: updated.name },
|
||||
});
|
||||
|
||||
return toFrontendTemplate(updated);
|
||||
}
|
||||
|
||||
async remove(actor: SafeUser, id: string) {
|
||||
const existing = await this.findTemplate(actor.tenantId, id);
|
||||
if (!canManageTemplate(this.actorToPolicy(actor), toTemplateResource(existing))) {
|
||||
throw new ForbiddenException('无权删除此模板');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.report.updateMany({
|
||||
where: { tenantId: actor.tenantId, templateId: id },
|
||||
data: { templateId: null },
|
||||
}),
|
||||
this.prisma.template.delete({ where: { id } }),
|
||||
]);
|
||||
|
||||
await this.audit?.record({
|
||||
actor,
|
||||
action: 'template.delete',
|
||||
targetType: 'Template',
|
||||
targetId: existing.id,
|
||||
departmentId: existing.ownerDepartmentId,
|
||||
metadata: { scope: existing.scope, name: existing.name },
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseListQuery(rawQuery: unknown): ListTemplatesQuery {
|
||||
const result = listTemplatesQuerySchema.safeParse(rawQuery);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(';'));
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private async findTemplate(tenantId: string, id: string) {
|
||||
const template = await this.prisma.template.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: templateInclude,
|
||||
});
|
||||
if (!template) {
|
||||
throw new NotFoundException('模板不存在');
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
private async resolveOwnerDepartmentId(actor: SafeUser, departmentName?: string) {
|
||||
if (actor.role === 'admin') {
|
||||
return actor.departmentId;
|
||||
}
|
||||
|
||||
if (!departmentName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const department = await this.prisma.department.findFirst({
|
||||
where: {
|
||||
tenantId: actor.tenantId,
|
||||
name: departmentName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!department) {
|
||||
throw new BadRequestException('部门不存在');
|
||||
}
|
||||
|
||||
return department.id;
|
||||
}
|
||||
|
||||
private toDbScope(scope: 'department' | 'personal'): TemplateScope {
|
||||
return scope === 'personal' ? 'PERSONAL' : 'DEPARTMENT';
|
||||
}
|
||||
|
||||
private actorToPolicy(actor: SafeUser) {
|
||||
return {
|
||||
id: actor.id,
|
||||
tenantId: actor.tenantId,
|
||||
departmentId: actor.departmentId,
|
||||
role: actor.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user