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:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('SUPER', 'ADMIN', 'DOCTOR');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('DRAFT', 'COMPLETED');
-- CreateEnum
CREATE TYPE "TemplateScope" AS ENUM ('DEPARTMENT', 'PERSONAL');
-- CreateEnum
CREATE TYPE "FileKind" AS ENUM ('SIGNATURE', 'TEMPLATE_ASSET', 'VIDEO', 'FRAME', 'REPORT_EXPORT');
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Department" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL,
"name" TEXT NOT NULL,
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"phone" TEXT,
"email" TEXT,
"signatureFileId" TEXT,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"content" TEXT NOT NULL,
"fields" JSONB NOT NULL DEFAULT '[]',
"scope" "TemplateScope" NOT NULL,
"ownerDepartmentId" TEXT,
"ownerUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TemplateDepartmentPermission" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"canUse" BOOLEAN NOT NULL DEFAULT true,
"canManage" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TemplateDepartmentPermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"templateId" TEXT,
"title" TEXT NOT NULL,
"patientName" TEXT NOT NULL,
"hospitalId" TEXT NOT NULL,
"status" "ReportStatus" NOT NULL DEFAULT 'DRAFT',
"revision" INTEGER NOT NULL DEFAULT 1,
"content" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3),
"deletedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReportHistory" (
"id" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"action" TEXT NOT NULL,
"updatedById" TEXT NOT NULL,
"updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReportHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileResource" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"ownerId" TEXT,
"reportId" TEXT,
"kind" "FileKind" NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"checksum" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileResource_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemSetting" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"departmentId" TEXT,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"secretValue" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"actorUserId" TEXT,
"actorRole" TEXT,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"departmentId" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tenant_code_key" ON "Tenant"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Department_tenantId_code_key" ON "Department"("tenantId", "code");
-- CreateIndex
CREATE INDEX "User_tenantId_departmentId_role_idx" ON "User"("tenantId", "departmentId", "role");
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_username_key" ON "User"("tenantId", "username");
-- CreateIndex
CREATE UNIQUE INDEX "UserSession_tokenHash_key" ON "UserSession"("tokenHash");
-- CreateIndex
CREATE INDEX "Template_tenantId_scope_idx" ON "Template"("tenantId", "scope");
-- CreateIndex
CREATE INDEX "Template_tenantId_ownerDepartmentId_idx" ON "Template"("tenantId", "ownerDepartmentId");
-- CreateIndex
CREATE INDEX "Template_tenantId_ownerUserId_idx" ON "Template"("tenantId", "ownerUserId");
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDepartmentPermission_templateId_departmentId_key" ON "TemplateDepartmentPermission"("templateId", "departmentId");
-- CreateIndex
CREATE INDEX "Report_tenantId_departmentId_status_idx" ON "Report"("tenantId", "departmentId", "status");
-- CreateIndex
CREATE INDEX "Report_tenantId_authorId_idx" ON "Report"("tenantId", "authorId");
-- CreateIndex
CREATE INDEX "Report_tenantId_deletedAt_idx" ON "Report"("tenantId", "deletedAt");
-- CreateIndex
CREATE INDEX "ReportHistory_reportId_revision_idx" ON "ReportHistory"("reportId", "revision");
-- CreateIndex
CREATE INDEX "FileResource_tenantId_kind_idx" ON "FileResource"("tenantId", "kind");
-- CreateIndex
CREATE INDEX "FileResource_tenantId_reportId_idx" ON "FileResource"("tenantId", "reportId");
-- CreateIndex
CREATE UNIQUE INDEX "SystemSetting_tenantId_scope_departmentId_key_key" ON "SystemSetting"("tenantId", "scope", "departmentId", "key");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_action_idx" ON "AuditLog"("tenantId", "action");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_actorUserId_idx" ON "AuditLog"("tenantId", "actorUserId");
-- CreateIndex
CREATE INDEX "AuditLog_tenantId_targetType_targetId_idx" ON "AuditLog"("tenantId", "targetType", "targetId");
-- AddForeignKey
ALTER TABLE "Department" ADD CONSTRAINT "Department_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerDepartmentId_fkey" FOREIGN KEY ("ownerDepartmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateDepartmentPermission" ADD CONSTRAINT "TemplateDepartmentPermission_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TemplateDepartmentPermission" ADD CONSTRAINT "TemplateDepartmentPermission_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReportHistory" ADD CONSTRAINT "ReportHistory_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileResource" ADD CONSTRAINT "FileResource_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FileResource" ADD CONSTRAINT "FileResource_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SystemSetting" ADD CONSTRAINT "SystemSetting_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SystemSetting" ADD CONSTRAINT "SystemSetting_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- Add a flexible compatibility payload for the existing frontend report object.
ALTER TABLE "Report" ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,130 @@
-- Split report video/keyframe references out of Report.metadata.
CREATE TYPE "ReportMediaKind" AS ENUM ('VIDEO', 'FRAME');
CREATE TABLE "ReportMedia" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"fileId" TEXT,
"kind" "ReportMediaKind" NOT NULL,
"clientId" TEXT NOT NULL,
"name" TEXT,
"url" TEXT,
"time" DOUBLE PRECISION,
"videoIndex" INTEGER,
"videoName" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReportMedia_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ReportMedia_tenantId_reportId_kind_idx" ON "ReportMedia"("tenantId", "reportId", "kind");
CREATE INDEX "ReportMedia_tenantId_fileId_idx" ON "ReportMedia"("tenantId", "fileId");
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReportMedia" ADD CONSTRAINT "ReportMedia_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "FileResource"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "ReportMedia" (
"id",
"tenantId",
"reportId",
"fileId",
"kind",
"clientId",
"name",
"url",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
concat('rm_', r."id", '_video_', video."ordinality"),
r."tenantId",
r."id",
f."id",
'VIDEO'::"ReportMediaKind",
COALESCE(video."value"->>'id', concat('video-', video."ordinality")),
video."value"->>'name',
video."value"->>'url',
(video."ordinality" - 1)::integer,
jsonb_strip_nulls(jsonb_build_object(
'duration', video."value"->'duration',
'legacyFileId', video."value"->>'fileId'
)),
r."createdAt",
r."updatedAt"
FROM "Report" r
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(r."metadata"->'videos') = 'array' THEN r."metadata"->'videos'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS video("value", "ordinality")
LEFT JOIN "FileResource" f
ON f."id" = video."value"->>'fileId'
AND f."tenantId" = r."tenantId";
INSERT INTO "ReportMedia" (
"id",
"tenantId",
"reportId",
"fileId",
"kind",
"clientId",
"url",
"time",
"videoIndex",
"videoName",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
concat('rm_', r."id", '_frame_', frame."ordinality"),
r."tenantId",
r."id",
f."id",
'FRAME'::"ReportMediaKind",
COALESCE(frame."value"->>'id', concat('frame-', frame."ordinality")),
frame."value"->>'dataUrl',
CASE
WHEN jsonb_typeof(frame."value"->'time') = 'number' THEN (frame."value"->>'time')::double precision
ELSE NULL
END,
CASE
WHEN jsonb_typeof(frame."value"->'videoIndex') = 'number' THEN (frame."value"->>'videoIndex')::integer
ELSE NULL
END,
frame."value"->>'videoName',
(frame."ordinality" - 1)::integer,
jsonb_strip_nulls(jsonb_build_object(
'timeFormatted', frame."value"->>'timeFormatted',
'isManual', frame."value"->'isManual',
'manualOrder', frame."value"->'manualOrder',
'legacyFileId', frame."value"->>'fileId'
)),
r."createdAt",
r."updatedAt"
FROM "Report" r
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(r."metadata"->'capturedFrames') = 'array' THEN r."metadata"->'capturedFrames'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS frame("value", "ordinality")
LEFT JOIN "FileResource" f
ON f."id" = frame."value"->>'fileId'
AND f."tenantId" = r."tenantId";
UPDATE "FileResource" f
SET "reportId" = media."reportId"
FROM "ReportMedia" media
WHERE f."id" = media."fileId"
AND f."tenantId" = media."tenantId"
AND f."reportId" IS NULL;

View File

@@ -0,0 +1,12 @@
-- Persist Express sessions outside the Node.js process.
CREATE TABLE "AppSession" (
"id" TEXT NOT NULL,
"data" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

280
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,280 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
SUPER
ADMIN
DOCTOR
}
enum UserStatus {
ACTIVE
INACTIVE
}
enum ReportStatus {
DRAFT
COMPLETED
}
enum TemplateScope {
DEPARTMENT
PERSONAL
}
enum FileKind {
SIGNATURE
TEMPLATE_ASSET
VIDEO
FRAME
REPORT_EXPORT
}
enum ReportMediaKind {
VIDEO
FRAME
}
model Tenant {
id String @id @default(cuid())
name String
code String @unique
departments Department[]
users User[]
reports Report[]
reportMedia ReportMedia[]
templates Template[]
files FileResource[]
auditLogs AuditLog[]
settings SystemSetting[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Department {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
code String
users User[]
reports Report[]
templates Template[] @relation("TemplateOwnerDepartment")
permissions TemplateDepartmentPermission[]
settings SystemSetting[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, code])
}
model User {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id])
username String
passwordHash String
role UserRole
name String
status UserStatus @default(ACTIVE)
phone String?
email String?
signatureFileId String?
reports Report[] @relation("ReportAuthor")
sessions UserSession[]
personalTemplates Template[] @relation("PersonalTemplates")
auditLogs AuditLog[]
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, username])
@@index([tenantId, departmentId, role])
}
model UserSession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
model AppSession {
id String @id
data Json
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([expiresAt])
}
model Template {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
description String?
content String
fields Json @default("[]")
scope TemplateScope
ownerDepartmentId String?
ownerDepartment Department? @relation("TemplateOwnerDepartment", fields: [ownerDepartmentId], references: [id])
ownerUserId String?
ownerUser User? @relation("PersonalTemplates", fields: [ownerUserId], references: [id])
permissions TemplateDepartmentPermission[]
reports Report[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, scope])
@@index([tenantId, ownerDepartmentId])
@@index([tenantId, ownerUserId])
}
model TemplateDepartmentPermission {
id String @id @default(cuid())
templateId String
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
canUse Boolean @default(true)
canManage Boolean @default(false)
createdAt DateTime @default(now())
@@unique([templateId, departmentId])
}
model Report {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
departmentId String
department Department @relation(fields: [departmentId], references: [id])
authorId String
author User @relation("ReportAuthor", fields: [authorId], references: [id])
templateId String?
template Template? @relation(fields: [templateId], references: [id])
title String
patientName String
hospitalId String
status ReportStatus @default(DRAFT)
revision Int @default(1)
content String
metadata Json @default("{}")
histories ReportHistory[]
files FileResource[]
media ReportMedia[]
deletedAt DateTime?
deletedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, departmentId, status])
@@index([tenantId, authorId])
@@index([tenantId, deletedAt])
}
model ReportHistory {
id String @id @default(cuid())
reportId String
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
revision Int
content String
action String
updatedById String
updatedBy String
createdAt DateTime @default(now())
@@index([reportId, revision])
}
model FileResource {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
ownerId String?
reportId String?
report Report? @relation(fields: [reportId], references: [id], onDelete: Cascade)
reportMedia ReportMedia[]
kind FileKind
filename String
mimeType String
size Int
storageKey String
checksum String?
createdAt DateTime @default(now())
@@index([tenantId, kind])
@@index([tenantId, reportId])
}
model ReportMedia {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
reportId String
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
fileId String?
file FileResource? @relation(fields: [fileId], references: [id], onDelete: SetNull)
kind ReportMediaKind
clientId String
name String?
url String?
time Float?
videoIndex Int?
videoName String?
sortOrder Int @default(0)
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, reportId, kind])
@@index([tenantId, fileId])
}
model SystemSetting {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
scope String
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
key String
value Json
secretValue String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, scope, departmentId, key])
}
model AuditLog {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
actorUserId String?
actor User? @relation(fields: [actorUserId], references: [id])
actorRole String?
action String
targetType String
targetId String?
departmentId String?
ip String?
userAgent String?
metadata Json?
createdAt DateTime @default(now())
@@index([tenantId, action])
@@index([tenantId, actorUserId])
@@index([tenantId, targetType, targetId])
}

153
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,153 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import argon2 from 'argon2';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required to seed the database');
}
const prisma = new PrismaClient({
adapter: new PrismaPg({
connectionString: process.env.DATABASE_URL,
}),
});
const defaultTemplateContent = `
<h1 style="text-align:center;">手术记录</h1>
<p>患者姓名:<span class="field-value" data-bind="patientName" contenteditable="true"></span></p>
<p>住院号:<span class="field-value" data-bind="hospitalId" contenteditable="true"></span></p>
<p>手术名称:<span class="field-value" data-bind="title" contenteditable="true"></span></p>
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤">
<div class="ai-content"><p>请在此处填写手术步骤。</p></div>
</div>
`;
const main = async () => {
const tenant = await prisma.tenant.upsert({
where: { code: 'default' },
update: {},
create: {
code: 'default',
name: '默认医院',
},
});
const adminDepartment = await prisma.department.upsert({
where: { tenantId_code: { tenantId: tenant.id, code: 'admin' } },
update: {},
create: {
tenantId: tenant.id,
code: 'admin',
name: '管理部门',
},
});
const surgeryDepartment = await prisma.department.upsert({
where: { tenantId_code: { tenantId: tenant.id, code: 'surgery' } },
update: {},
create: {
tenantId: tenant.id,
code: 'surgery',
name: '外科',
},
});
const passwordHash = await argon2.hash('123456');
const adminUser = await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: 'admin' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: adminDepartment.id,
username: 'admin',
passwordHash,
role: 'SUPER',
name: '超级管理员',
},
});
await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: 'manager' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: surgeryDepartment.id,
username: 'manager',
passwordHash,
role: 'ADMIN',
name: '科室管理员',
},
});
await prisma.user.upsert({
where: { tenantId_username: { tenantId: tenant.id, username: '0001' } },
update: {},
create: {
tenantId: tenant.id,
departmentId: surgeryDepartment.id,
username: '0001',
passwordHash,
role: 'DOCTOR',
name: '张医生',
},
});
const defaultTemplate = await prisma.template.upsert({
where: { id: 'tpl_default_surgery' },
update: {},
create: {
id: 'tpl_default_surgery',
tenantId: tenant.id,
name: '腹腔镜胆囊切除术报告',
description: '标准手术记录模板',
content: defaultTemplateContent,
fields: [],
scope: 'DEPARTMENT',
ownerDepartmentId: surgeryDepartment.id,
ownerUserId: null,
},
});
await prisma.templateDepartmentPermission.upsert({
where: {
templateId_departmentId: {
templateId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
},
},
update: {
canUse: true,
canManage: true,
},
create: {
templateId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
canUse: true,
canManage: true,
},
});
await prisma.auditLog.create({
data: {
tenantId: tenant.id,
actorUserId: adminUser.id,
actorRole: 'super',
action: 'seed.default_template',
targetType: 'template',
targetId: defaultTemplate.id,
departmentId: surgeryDepartment.id,
metadata: { name: defaultTemplate.name },
},
}).catch(() => {});
};
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});