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:
299
server/prisma/migrations/20260501145311_init/migration.sql
Normal file
299
server/prisma/migrations/20260501145311_init/migration.sql
Normal 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;
|
||||
@@ -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 '{}';
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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
280
server/prisma/schema.prisma
Normal 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
153
server/prisma/seed.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user