forked from bai/curriculum-project-hub
feat(hub): Prisma schema + DB singleton,对齐 spec/System 契约
按 ADR-0001..0004/0017 重塑 schema,非照搬旧服务: - ADR-0001: ProjectGroupBinding project↔chat 1:1(chatId @unique 钉单射不变式) - ADR-0002: ProjectAgentLock projectId @id(at-most-one)+runId @unique;holder=run 非 session - ADR-0004: PermissionGrant(resource×principal×role)+PermissionSettings(六旋钮)新落; PermissionRole=read/edit/manage 与 PlatformRole=admin/teacher 分离(旧 schema 混淆了) - ADR-0017: AgentSession 无 claudeSessionId,改 provider+model(切 model 即新 session) - RunState 对齐 spec:加 WAITING_FOR_USER/TIMED_OUT(枚举完整性 OPEN) - Audit:runId 是 PINNED 关系,其余 OPEN 旧 schema 的 FeishuProjectBinding(user/chat 混表)拆为单一 ProjectGroupBinding。 prisma validate 通过;generate 通过;tsc --noEmit rc=0; 类型级 schema 约束检查全部通过(ADR-0002 主键/唯一、ADR-0017 无 claudeSessionId 等)。
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// Prisma schema for Curriculum Project Hub.
|
||||
//
|
||||
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
|
||||
// legacy teaching-material-host-service schema, each deliberate:
|
||||
//
|
||||
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model-
|
||||
// bound, not Claude-bound; `provider`+`model` replace it. Switching model
|
||||
// = new session, so the unique constraint is on the session id alone.
|
||||
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
|
||||
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
|
||||
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
|
||||
// user/chat targets into one binding table.
|
||||
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
|
||||
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
|
||||
// completeness OPEN — add states without a schema migration war).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// --- Platform identity ---------------------------------------------------
|
||||
|
||||
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
feishuOpenId String @unique
|
||||
displayName String
|
||||
avatarUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
platformRoles PlatformRoleAssignment[]
|
||||
createdProjects Project[] @relation("projectCreator")
|
||||
requestedRuns AgentRun[] @relation("runRequester")
|
||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||
auditEntries AuditEntry[] @relation("auditActor")
|
||||
}
|
||||
|
||||
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
|
||||
/// `admin` is the only override path for force-release (spec RequiresAdmin).
|
||||
model PlatformRoleAssignment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
role PlatformRole
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, revokedAt])
|
||||
@@index([role, revokedAt])
|
||||
}
|
||||
|
||||
enum PlatformRole {
|
||||
ADMIN
|
||||
TEACHER
|
||||
}
|
||||
|
||||
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
workspaceDir String
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
groupBinding ProjectGroupBinding?
|
||||
agentSessions AgentSession[]
|
||||
agentRuns AgentRun[]
|
||||
agentLock ProjectAgentLock?
|
||||
permissionGrants PermissionGrant[] @relation("projectGrants")
|
||||
permissionSettings PermissionSettings[] @relation("projectSettings")
|
||||
|
||||
@@index([archivedAt])
|
||||
}
|
||||
|
||||
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒
|
||||
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat).
|
||||
/// The "at most one binding per project" half is enforced by the 1:1 relation.
|
||||
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
|
||||
/// here; archival is a future policy, not a current column.
|
||||
model ProjectGroupBinding {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique
|
||||
chatId String @unique
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([chatId])
|
||||
}
|
||||
|
||||
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
|
||||
|
||||
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider`
|
||||
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs
|
||||
/// (ADR-0002); a switch ⇒ new session.
|
||||
model AgentSession {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
provider String
|
||||
model String
|
||||
title String?
|
||||
metadata Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
runs AgentRun[]
|
||||
|
||||
@@index([projectId, archivedAt])
|
||||
@@index([provider, model])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
/// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled.
|
||||
/// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a
|
||||
/// spec breach. DB mirrors the current enum.
|
||||
enum AgentRunStatus {
|
||||
ACTIVE
|
||||
WAITING_FOR_USER
|
||||
COMPLETED
|
||||
FAILED
|
||||
TIMED_OUT
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum AgentEntrypoint {
|
||||
FEISHU
|
||||
WEB
|
||||
CLI
|
||||
}
|
||||
|
||||
model AgentRun {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
sessionId String?
|
||||
requestedByUserId String?
|
||||
entrypoint AgentEntrypoint
|
||||
status AgentRunStatus @default(ACTIVE)
|
||||
prompt String
|
||||
model String
|
||||
provider String
|
||||
summary String?
|
||||
inputTokens Int?
|
||||
outputTokens Int?
|
||||
metadata Json
|
||||
error String?
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||
projectLock ProjectAgentLock?
|
||||
|
||||
@@index([projectId, status])
|
||||
@@index([sessionId])
|
||||
@@index([requestedByUserId])
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
/// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒
|
||||
/// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒
|
||||
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
||||
/// app-level invariant checked on read/write, not a DB constraint.
|
||||
model ProjectAgentLock {
|
||||
projectId String @id
|
||||
runId String @unique
|
||||
holderUserId String?
|
||||
acquiredAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
// --- Permission grants & settings (ADR-0004) ----------------------------
|
||||
|
||||
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
|
||||
/// Distinct from PlatformRole. Force-release is admin-only, outside this
|
||||
/// lattice (spec RequiresAdmin).
|
||||
enum PermissionRole {
|
||||
READ
|
||||
EDIT
|
||||
MANAGE
|
||||
}
|
||||
|
||||
/// ADR-0004 resource_type: project | artifact | project_group. The resource
|
||||
/// id's meaning is determined by its type (artifact id semantics align to the
|
||||
/// Courseware half; OPEN here).
|
||||
enum PermissionResourceType {
|
||||
PROJECT
|
||||
ARTIFACT
|
||||
PROJECT_GROUP
|
||||
}
|
||||
|
||||
/// ADR-0004 PermissionGrant: resource × principal × role.
|
||||
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
|
||||
/// The compound unique covers "one active grant per (resource, principal, role)"
|
||||
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
|
||||
/// row, not a conflict.
|
||||
model PermissionGrant {
|
||||
id String @id @default(cuid())
|
||||
resourceType PermissionResourceType
|
||||
resourceId String
|
||||
principal String
|
||||
role PermissionRole
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([resourceType, resourceId, principal, role, revokedAt])
|
||||
@@index([resourceType, resourceId, revokedAt])
|
||||
@@index([principal, revokedAt])
|
||||
}
|
||||
|
||||
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
||||
/// opaque string column each; the enum/value-domain is decided by admin policy,
|
||||
/// not by this schema. `resourceType`+`resourceId` identify the resource.
|
||||
/// Related to Project only when resourceType=PROJECT (null otherwise).
|
||||
model PermissionSettings {
|
||||
id String @id @default(cuid())
|
||||
resourceType PermissionResourceType
|
||||
resourceId String
|
||||
externalShare String
|
||||
comment String
|
||||
copyDownload String
|
||||
collaboratorMgmt String
|
||||
agentTrigger String
|
||||
agentCancel String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([resourceType, resourceId])
|
||||
@@index([resourceType, resourceId])
|
||||
}
|
||||
|
||||
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
||||
|
||||
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
|
||||
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
|
||||
/// PINNED relation; the rest is open-shaped columns.
|
||||
model AuditEntry {
|
||||
id String @id @default(cuid())
|
||||
runId String?
|
||||
actorUserId String?
|
||||
action String
|
||||
metadata Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([runId])
|
||||
@@index([actorUserId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
Reference in New Issue
Block a user