// 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]) }