forked from EduCraft/curriculum-project-hub
463 lines
16 KiB
Plaintext
463 lines
16 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:
|
||
//
|
||
// - 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")
|
||
teamMemberships TeamMembership[]
|
||
externalPrincipalMemberships ExternalPrincipalMembership[]
|
||
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
|
||
}
|
||
|
||
/// ADR-0019: typed principals for permission grants and actor resolution.
|
||
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
|
||
/// principals by their Feishu ids.
|
||
enum PrincipalType {
|
||
USER
|
||
TEAM
|
||
FEISHU_CHAT
|
||
FEISHU_DEPARTMENT
|
||
FEISHU_USER_GROUP
|
||
APP
|
||
}
|
||
|
||
/// Hub-managed teacher team. A team is a first-class permission principal.
|
||
model Team {
|
||
id String @id @default(cuid())
|
||
slug String @unique
|
||
name String
|
||
description String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
archivedAt DateTime?
|
||
|
||
memberships TeamMembership[]
|
||
externalBindings TeamExternalBinding[]
|
||
|
||
@@index([archivedAt])
|
||
}
|
||
|
||
/// Direct Hub team membership. External Feishu groups can also map to teams via
|
||
/// TeamExternalBinding; both sources are resolved at authorization time.
|
||
model TeamMembership {
|
||
id String @id @default(cuid())
|
||
teamId String
|
||
userId String
|
||
createdAt DateTime @default(now())
|
||
revokedAt DateTime?
|
||
|
||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([teamId, userId, revokedAt])
|
||
@@index([teamId, revokedAt])
|
||
@@index([userId, revokedAt])
|
||
}
|
||
|
||
/// Bind a Hub team to an external Feishu principal. When an actor resolves to
|
||
/// the external principal, the actor also resolves to this team.
|
||
model TeamExternalBinding {
|
||
id String @id @default(cuid())
|
||
teamId String
|
||
principalType PrincipalType
|
||
principalId String
|
||
createdAt DateTime @default(now())
|
||
revokedAt DateTime?
|
||
|
||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([teamId, principalType, principalId, revokedAt])
|
||
@@index([principalType, principalId, revokedAt])
|
||
@@index([teamId, revokedAt])
|
||
}
|
||
|
||
/// Locally synchronized Feishu external principal membership.
|
||
model ExternalPrincipalMembership {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
principalType PrincipalType
|
||
principalId String
|
||
source String
|
||
syncedAt DateTime @default(now())
|
||
revokedAt DateTime?
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, principalType, principalId, source, revokedAt])
|
||
@@index([userId, revokedAt])
|
||
@@index([principalType, principalId, revokedAt])
|
||
@@index([source, syncedAt])
|
||
}
|
||
|
||
// --- 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?
|
||
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/role/model-bound. `provider` + `roleId` +
|
||
/// `model` capture the binding; provider-specific runtime cursors live in
|
||
/// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒
|
||
/// reuse across runs (ADR-0002); a switch ⇒ new Hub session.
|
||
model AgentSession {
|
||
id String @id @default(cuid())
|
||
projectId String
|
||
provider String
|
||
roleId 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, roleId, model])
|
||
@@index([projectId, provider, roleId, model, archivedAt])
|
||
@@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.
|
||
/// ADR-0019 replaces the old opaque `principal` with typed
|
||
/// `principalType/principalId` so user/team/Feishu principals compose through
|
||
/// the same authorization path.
|
||
model PermissionGrant {
|
||
id String @id @default(cuid())
|
||
resourceType PermissionResourceType
|
||
resourceId String
|
||
principalType PrincipalType
|
||
principalId String
|
||
role PermissionRole
|
||
createdByUserId String?
|
||
createdAt DateTime @default(now())
|
||
revokedAt DateTime?
|
||
|
||
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([resourceType, resourceId, revokedAt])
|
||
@@index([principalType, principalId, revokedAt])
|
||
}
|
||
|
||
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
||
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
|
||
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
|
||
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
|
||
|
||
@@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). ADR-0019 makes the principal typed. 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
|
||
principalType PrincipalType
|
||
principalId 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)
|
||
|
||
@@index([projectId, roleId, revokedAt])
|
||
@@index([principalType, principalId, 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])
|
||
}
|