forked from EduCraft/curriculum-project-hub
afaf5bee09
RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。 ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。 trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。 新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联: 先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。 - models.ts: RoleEntry 加 systemPrompt + tools 字段 - tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图 - trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025 - runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes) - schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt) - permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked) - server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单 - 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
312 lines
11 KiB
Plaintext
312 lines
11 KiB
Plaintext
// 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")
|
||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||
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")
|
||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||
|
||
@@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])
|
||
}
|
||
|
||
|
||
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
|
||
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
|
||
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
|
||
/// /review vs /draft). Two gates in series — both must pass.
|
||
///
|
||
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
|
||
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal
|
||
/// (sub-typology OPEN). A project-scoped row grants the role on that project;
|
||
/// the compound unique covers "one active grant per (project, principal, role)".
|
||
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
|
||
model RoleTriggerGrant {
|
||
id String @id @default(cuid())
|
||
projectId String
|
||
roleId String
|
||
principal String
|
||
createdByUserId String?
|
||
createdAt DateTime @default(now())
|
||
revokedAt DateTime?
|
||
|
||
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
|
||
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||
|
||
@@unique([projectId, roleId, principal, revokedAt])
|
||
@@index([projectId, roleId, revokedAt])
|
||
@@index([principal, revokedAt])
|
||
}
|
||
|
||
// --- 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])
|
||
}
|