// 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 ---------------------------------------------------

/// ADR-0020: SaaS tenant root. Every project and team belongs to exactly one
/// organization; platform operator access remains outside project roles.
model Organization {
  id        String             @id @default(cuid())
  slug      String             @unique
  name      String
  status    OrganizationStatus @default(ACTIVE)
  createdAt DateTime           @default(now())
  updatedAt DateTime           @updatedAt

  memberships                  OrganizationMembership[]
  projectSettings              OrganizationProjectSettings?
  capacityPolicy               OrganizationCapacityPolicy?
  folders                      Folder[]
  projects                     Project[]
  teams                        Team[]
  externalDirectoryConnections ExternalDirectoryConnection[]
  providerConnections          OrganizationProviderConnection[]
  feishuApplicationConnection  OrganizationFeishuApplicationConnection?
  agentSkills                  OrganizationAgentSkill[]
  agentRoles                   OrganizationAgentRole[]
  projectGroupBindings         ProjectGroupBinding[]
  auditEntries                 AuditEntry[]                             @relation("organizationAudit")
  projectSearchDocuments       ProjectSearchDocument[]

  @@index([status])
}

enum OrganizationStatus {
  ACTIVE
  SUSPENDED
  ARCHIVED
}

/// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership {
  id             String                 @id @default(cuid())
  organizationId String
  userId         String
  role           OrganizationMemberRole @default(MEMBER)
  createdAt      DateTime               @default(now())
  revokedAt      DateTime?

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  user         User         @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([organizationId, userId, revokedAt])
  @@index([organizationId, revokedAt])
  @@index([userId, revokedAt])
}

enum OrganizationMemberRole {
  OWNER
  ADMIN
  MEMBER
}

/// Organization-scoped, content-addressed Agent skill registration. The DB is
/// the runtime registry; `contentDigest` selects an immutable directory below
/// the platform-controlled skill store and is never interpreted as a path.
model OrganizationAgentSkill {
  id             String    @id @default(cuid())
  organizationId String
  name           String
  version        String
  description    String?
  contentDigest  String
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  disabledAt     DateTime?

  organization Organization                 @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  roleBindings OrganizationAgentRoleSkill[]

  @@unique([organizationId, name])
  @@unique([organizationId, id])
  @@index([organizationId, disabledAt])
  @@index([contentDigest])
}

/// ADR-0017 runtime role bundle. Roles are Organization-owned data rather than
/// a code enum: model, system prompt, tool allowlist and skill selection change
/// without a Hub release or process restart.
model OrganizationAgentRole {
  id             String    @id @default(cuid())
  organizationId String
  roleId         String
  label          String
  defaultModel   String?
  systemPrompt   String?
  tools          Json?
  sortOrder      Int       @default(0)
  isDefault      Boolean   @default(false)
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  disabledAt     DateTime?

  organization       Organization                 @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  skillBindings      OrganizationAgentRoleSkill[]
  selectedByBindings ProjectGroupBinding[]        @relation("selectedAgentRole")

  @@unique([organizationId, roleId])
  @@unique([organizationId, id])
  @@index([organizationId, disabledAt, sortOrder])
}

/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
/// gives stable skill listing and prompt discovery order for a role bundle.
model OrganizationAgentRoleSkill {
  organizationId String
  agentRoleId    String
  agentSkillId   String
  sortOrder      Int      @default(0)
  createdAt      DateTime @default(now())

  role  OrganizationAgentRole  @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade)
  skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade)

  @@id([organizationId, agentRoleId, agentSkillId])
  @@index([organizationId, agentRoleId, sortOrder])
  @@index([organizationId, agentSkillId])
}

/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
  organizationId           String   @id
  membersCanCreateProjects Boolean  @default(true)
  createdAt                DateTime @default(now())
  updatedAt                DateTime @updatedAt

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}

/// ADR-0022 layered capacity policy. Org admins may set per-dimension lower
/// limits; `limits` is a JSON map of CapacityDimension → number (only the set
/// ones). Each set value must be ≤ the platform ceiling for that dimension
/// (validated in service; spec `LayeredLimit.Valid`). Dimensions with no entry
/// fall back to the platform ceiling (`LayeredLimit.effective`).
model OrganizationCapacityPolicy {
  organizationId String   @id
  limits         Json
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}

/// A person known to the Hub. `feishuOpenId` remains a legacy compatibility
/// key; new customer-app identities live in FeishuUserIdentity and store a
/// connection-scoped opaque USER principal here instead of a raw open_id.
model User {
  id           String   @id @default(cuid())
  feishuOpenId String   @unique
  displayName  String
  avatarUrl    String?
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  organizationMemberships      OrganizationMembership[]
  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")
  providerCredentialVersions   ProviderCredentialVersion[]          @relation("providerCredentialVersionCreator")
  feishuCredentialVersions     FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
  feishuIdentities             FeishuUserIdentity[]
}

/// ADR-0024: exactly one customer-owned Feishu application connection may be
/// configured for an Organization. External Feishu identifiers are scoped by
/// this stable connection id, never treated as process-global identities.
model OrganizationFeishuApplicationConnection {
  id                     String                       @id @default(cuid())
  organizationId         String                       @unique
  appIdentityFingerprint String                       @unique
  status                 OrganizationConnectionStatus @default(DRAFT)
  activeSecretVersionId  String?                      @unique
  activatedAt            DateTime?
  disabledAt             DateTime?
  createdAt              DateTime                     @default(now())
  updatedAt              DateTime                     @updatedAt

  organization        Organization                         @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  secretVersions      FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions")
  activeSecretVersion FeishuApplicationCredentialVersion?  @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
  userIdentities      FeishuUserIdentity[]

  @@index([status])
}

/// ADR-0024 / Issue 44: provider-local Feishu identities are never global.
/// The same open_id may identify different people under different customer
/// applications, so all lookup and uniqueness begins with connectionId.
model FeishuUserIdentity {
  id           String   @id @default(cuid())
  connectionId String
  userId       String
  openId       String
  unionId      String?
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  connection OrganizationFeishuApplicationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
  user       User                                    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([connectionId, openId])
  @@unique([connectionId, unionId])
  @@unique([connectionId, userId])
  @@index([userId])
}

/// ADR-0024: all Feishu app material, including provider-local app/bot ids, is
/// inside one immutable authenticated envelope version.
model FeishuApplicationCredentialVersion {
  id              String    @id @default(cuid())
  connectionId    String
  version         Int
  envelopeVersion Int       @default(1)
  keyId           String
  envelope        Json
  createdByUserId String?
  createdAt       DateTime  @default(now())
  retiredAt       DateTime?

  connection OrganizationFeishuApplicationConnection  @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
  activeFor  OrganizationFeishuApplicationConnection? @relation("activeFeishuCredentialVersion")
  createdBy  User?                                    @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)

  @@unique([connectionId, version])
  @@index([connectionId, retiredAt])
  @@index([keyId])
  @@index([createdByUserId])
}

/// ADR-0024: model-provider connection identity is stable and belongs to one
/// Organization. Both BYOK and platform-managed credentials are tenant-local;
/// only ACTIVE connections with an active immutable secret version resolve.
model OrganizationProviderConnection {
  id                    String                       @id @default(cuid())
  organizationId        String
  providerId            String
  mode                  ProviderCredentialMode
  status                OrganizationConnectionStatus @default(DRAFT)
  activeSecretVersionId String?                      @unique
  activatedAt           DateTime?
  disabledAt            DateTime?
  createdAt             DateTime                     @default(now())
  updatedAt             DateTime                     @updatedAt

  organization        Organization                @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  secretVersions      ProviderCredentialVersion[] @relation("providerCredentialVersions")
  activeSecretVersion ProviderCredentialVersion?  @relation("activeProviderCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)

  @@unique([organizationId, providerId])
  @@index([organizationId, status])
  @@index([providerId, status])
}

enum ProviderCredentialMode {
  BYOK
  PLATFORM_MANAGED
}

enum OrganizationConnectionStatus {
  DRAFT
  ACTIVE
  DISABLED
}

/// ADR-0024: write-only provider secret material is one authenticated envelope
/// per immutable version. keyId/envelopeVersion are redacted rotation metadata;
/// all provider URL/token/API-key fields remain inside envelope ciphertext.
model ProviderCredentialVersion {
  id              String    @id @default(cuid())
  connectionId    String
  version         Int
  envelopeVersion Int       @default(1)
  keyId           String
  envelope        Json
  createdByUserId String?
  createdAt       DateTime  @default(now())
  retiredAt       DateTime?

  connection OrganizationProviderConnection  @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
  activeFor  OrganizationProviderConnection? @relation("activeProviderCredentialVersion")
  createdBy  User?                           @relation("providerCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)

  @@unique([connectionId, version])
  @@index([connectionId, retiredAt])
  @@index([keyId])
  @@index([createdByUserId])
}

/// ADR-0019: typed principals for permission grants and actor resolution.
/// USER and Feishu external principals use connection-scoped opaque ids, never
/// raw provider-local ids; TEAM uses Hub Team.id.
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())
  organizationId String
  slug           String
  name           String
  description    String?
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  archivedAt     DateTime?

  organization     Organization          @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  memberships      TeamMembership[]
  externalBindings TeamExternalBinding[]

  @@index([organizationId, archivedAt])
  @@index([organizationId, slug, 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 ExternalDirectoryConnection {
  id               String    @id @default(cuid())
  organizationId   String
  provider         String
  providerTenantId String?
  source           String
  status           String    @default("ACTIVE")
  createdAt        DateTime  @default(now())
  updatedAt        DateTime  @updatedAt
  revokedAt        DateTime?

  organization Organization                  @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  memberships  ExternalPrincipalMembership[]

  @@unique([organizationId, provider, source])
  @@index([organizationId, revokedAt])
  @@index([provider, providerTenantId])
}

/// Locally synchronized Feishu external principal membership, scoped through
/// an organization-owned ExternalDirectoryConnection.
model ExternalPrincipalMembership {
  id            String        @id @default(cuid())
  userId        String
  connectionId  String
  principalType PrincipalType
  principalId   String
  syncedAt      DateTime      @default(now())
  revokedAt     DateTime?

  user       User                        @relation(fields: [userId], references: [id], onDelete: Cascade)
  connection ExternalDirectoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)

  @@unique([userId, principalType, principalId, connectionId, revokedAt])
  @@index([userId, revokedAt])
  @@index([connectionId, revokedAt])
  @@index([principalType, principalId, revokedAt])
}

// --- Project & Feishu binding (ADR-0001) ---------------------------------

/// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources.
enum FolderKind {
  REGULAR
  SYSTEM_INBOX
}

model Folder {
  id             String     @id @default(cuid())
  organizationId String
  parentId       String?
  name           String
  kind           FolderKind @default(REGULAR)
  sortKey        String     @default("")
  createdAt      DateTime   @default(now())
  updatedAt      DateTime   @updatedAt
  archivedAt     DateTime?

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  parent       Folder?      @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict)
  children     Folder[]     @relation("folderTree")
  projects     Project[]

  @@index([organizationId, parentId, archivedAt])
  @@index([organizationId, parentId, sortKey])
}

model Project {
  id              String    @id @default(cuid())
  organizationId  String
  folderId        String?
  code            String?
  name            String
  workspaceDir    String
  createdByUserId String?
  createdAt       DateTime  @default(now())
  updatedAt       DateTime  @updatedAt
  archivedAt      DateTime?

  organization      Organization           @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  folder            Folder?                @relation(fields: [folderId], references: [id], onDelete: SetNull)
  createdBy         User?                  @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
  groupBindings     ProjectGroupBinding[]
  agentSessions     AgentSession[]
  agentRuns         AgentRun[]
  agentLock         ProjectAgentLock?
  roleTriggerGrants RoleTriggerGrant[]     @relation("projectRoleGrants")
  auditEntries      AuditEntry[]           @relation("projectAudit")
  fileChanges       AgentFileChange[]      @relation("projectFileChanges")
  searchDocument    ProjectSearchDocument?

  @@unique([organizationId, id])
  @@index([organizationId, archivedAt])
  @@index([folderId, archivedAt])
  @@index([archivedAt])
}

/// Derived, rebuildable search projection for project discovery. PostgreSQL
/// triggers keep it synchronized with Project and Folder mutations; Project
/// remains the source of truth and authorization remains outside this table.
model ProjectSearchDocument {
  projectId            String   @id
  organizationId       String
  name                 String
  code                 String?
  normalizedCode       String
  normalizedName       String
  breadcrumb           String
  normalizedBreadcrumb String
  normalizedSearchText String
  updatedAt            DateTime @updatedAt

  project      Project      @relation(fields: [projectId], references: [id], onDelete: Cascade)
  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@index([organizationId])
}

/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat.
model ProjectGroupBinding {
  id                  String    @id @default(cuid())
  organizationId      String
  projectId           String
  chatId              String
  createdByUserId     String?
  createdAt           DateTime  @default(now())
  updatedAt           DateTime  @updatedAt
  archivedAt          DateTime?
  selectedAgentRoleId String

  organization Organization          @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  project      Project               @relation(fields: [organizationId, projectId], references: [organizationId, id], onDelete: Cascade)
  createdBy    User?                 @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
  selectedRole OrganizationAgentRole @relation("selectedAgentRole", fields: [organizationId, selectedAgentRoleId], references: [organizationId, id], onDelete: Restrict)

  @@index([organizationId])
  @@index([projectId, archivedAt])
  @@index([chatId, archivedAt])
  @@index([selectedAgentRoleId])
}

// --- 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?
  /// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
  costUsd           Decimal?        @db.Decimal(18, 8)
  /// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
  costSource        String?
  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([projectId, finishedAt])
  @@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).
/// Force-release is platform-admin-only, outside this lattice (spec
/// RequiresAdmin); platform admin is a separate control plane (ADR-0023),
/// not modeled in alpha (ADR-0025).
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?
  organizationId 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)
  organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull)

  @@index([runId])
  @@index([projectId, createdAt])
  @@index([organizationId, 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])
}
