Files
curriculum-project-hub/hub/prisma/schema.prisma
T

386 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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:
//
// - AgentSession is provider/model-bound. Provider runtime cursors such as
// Claude SDK `session_id` live in `metadata`, so switching model still means
// a new Hub session while same-session runs can resume provider context.
// - 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")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@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. `provider` + `model` capture
/// the binding; provider-specific runtime cursors live in `metadata`
/// (e.g. metadata.claudeSessionId). Same provider+model ⇒ reuse across runs
/// (ADR-0002); a switch ⇒ new Hub 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[]
messages AgentMessage[] @relation("sessionMessages")
@@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?
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
@@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())
heartbeatAt DateTime?
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
model AuditEntry {
id String @id @default(cuid())
runId String?
projectId String?
actorUserId String?
action String
metadata Json
createdAt DateTime @default(now())
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
@@index([runId])
@@index([projectId, createdAt])
@@index([actorUserId])
@@index([createdAt])
}
// --- Feishu event dedup --------------------------------------------------
/// Idempotency receipt for inbound Feishu ws events. The lark ws client may
/// redeliver an event (at-least-once); without dedup a duplicate would spawn a
/// second AgentRun — the lock would refuse it, but a FAILED run row + a busy
/// reply would still leak. `eventId @unique` makes the second insert a no-op
/// signal: the handler checks existence before processing.
model FeishuEventReceipt {
id String @id @default(cuid())
eventId String @unique
eventType String
messageId String?
receivedAt DateTime @default(now())
@@index([eventType])
@@index([messageId])
@@index([receivedAt])
}
// --- Structured run history (ADR-0003 Memory) ----------------------------
/// Per-message record of the agent loop. The transcript JSONL file remains the
/// agent's own read-back memory (runner loads it to seed the next run); these
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
/// session messages ≠ Feishu group chat history.
model AgentMessage {
id String @id @default(cuid())
sessionId String
runId String?
role String
content String
attachments Json
createdAt DateTime @default(now())
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
@@index([sessionId, createdAt])
@@index([runId])
}
/// File change captured during a run (writeFile/rename/delete in workspace).
/// before/after hash + diff for audit and admin review. Linked to the run
/// that produced it; the tool's execute closure records via a sink the runner
/// injects into ToolContext (A-3 file-change capture).
model AgentFileChange {
id String @id @default(cuid())
runId String
projectId String
path String
operation String
beforeHash String?
afterHash String?
diff String?
metadata Json
createdAt DateTime @default(now())
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
@@index([runId])
@@index([projectId])
@@index([path])
}