Lumen Docs

Schema

Prisma schema is the single source of truth. Located at apps/api/prisma/schema.prisma. Migrations live in apps/api/prisma/migrations/.

Core entities

User

model User {
  id            String         @id @default(uuid())
  email         String         @unique
  name          String
  passwordHash  String         @map("password_hash")
  platformRole  PlatformRole   @default(none) @map("platform_role")
  orgPosition   OrgPosition    @default(member) @map("org_position")
  departmentId  String?        @map("department_id")
  avatarUrl     String?        @map("avatar_url")
  avatarColor   String?        @map("avatar_color")
  status        UserStatus     @default(active)
  lastLoginAt   DateTime?      @map("last_login_at")
  createdAt     DateTime       @default(now()) @map("created_at")
  updatedAt     DateTime       @updatedAt @map("updated_at")

  department    Department?    @relation(fields: [departmentId], references: [id])
  groupMembers  GroupMember[]
  grants        ProjectGrant[] @relation("UserGrants")
  ownedProjects Project[]      @relation("ProjectOwner")
  // ... and more relations
}

Constraints:

  • platformRole = superadmin → partial unique index single_superadmin
  • orgPosition = ceo → partial unique index single_ceo
  • One user = one departmentId (single-dept membership)

Department

model Department {
  id          String         @id @default(uuid())
  name        String         @unique
  color       String         @default("#2a6fdb")
  description String?
  createdAt   DateTime       @default(now()) @map("created_at")

  members     User[]
  groups      Group[]
  grants      ProjectGrant[] @relation("DepartmentGrants")
}

Group

model Group {
  id           String         @id @default(uuid())
  name         String
  description  String?
  departmentId String         @map("department_id")
  createdAt    DateTime       @default(now()) @map("created_at")

  department   Department     @relation(fields: [departmentId], references: [id])
  members      GroupMember[]
  grants       ProjectGrant[] @relation("GroupGrants")

  @@unique([departmentId, name])
}

model GroupMember {
  groupId  String
  userId   String
  addedAt  DateTime @default(now())

  group    Group @relation(fields: [groupId], references: [id])
  user     User  @relation(fields: [userId], references: [id])

  @@id([groupId, userId])
}

Project

model Project {
  id           String   @id @default(uuid())
  name         String
  description  String?
  instructions String?
  color        String   @default("#2a6fdb")
  isPrivate    Boolean  @default(false) @map("is_private")
  settings     Json     @default("{}")
  ownerId      String?  @map("owner_id")
  createdBy    String?  @map("created_by")
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  owner         User?           @relation("ProjectOwner", fields: [ownerId], references: [id])
  members       ProjectMember[]
  documents     Document[]
  chunks        Chunk[]
  conversations Conversation[]
  memories      ProjectMemory[]
  grants        ProjectGrant[]
  shareRequests ShareRequest[]
  shareTokens   ShareToken[]
}

isPrivate is inverse of public. isPrivate = false means "every authenticated user gets use tier" via the resolver.

ProjectGrant

The core of the access model. One grant row = one target (user OR group OR department) with one tier on one project.

model ProjectGrant {
  id           String      @id @default(uuid())
  projectId    String      @map("project_id")
  userId       String?     @map("user_id")
  groupId      String?     @map("group_id")
  departmentId String?     @map("department_id")
  tier         AccessTier
  grantedById  String?     @map("granted_by_id")
  createdAt    DateTime    @default(now())
  updatedAt    DateTime    @updatedAt

  project      Project     @relation("ProjectGrants", fields: [projectId], references: [id])
  user         User?       @relation("UserGrants", fields: [userId], references: [id])
  group        Group?      @relation("GroupGrants", fields: [groupId], references: [id])
  department   Department? @relation("DepartmentGrants", fields: [departmentId], references: [id])

  @@unique([projectId, userId],       name: "projectId_userId")
  @@unique([projectId, groupId],      name: "projectId_groupId")
  @@unique([projectId, departmentId], name: "projectId_departmentId")
}

CHECK constraint grant_one_target enforces exactly one of (userId, groupId, departmentId) is set per row — applied via _constraints.sql post-migrate.

BootstrapState

Singleton row. Never deleted, only flipped.

model BootstrapState {
  id            String   @id @default("singleton")
  isInitialized Boolean  @default(false) @map("is_initialized")
  initializedAt DateTime? @map("initialized_at")
}

GET /bootstrap/status reads this; POST /bootstrap/init flips it.

Enums

enum PlatformRole {
  none
  admin
  engineer
  superadmin
}

enum OrgPosition {
  member
  manager
  ceo
}

enum AccessTier {
  use
  edit
  full
}

enum ShareStatus {
  pending
  approved
  rejected
  revoked
}

enum UserStatus {
  active
  inactive
  pending
}

enum DocumentStatus {
  pending
  processing
  ready
  error
}

Vector data

chunks table has a vector(384) column for pgvector. Schema doesn't model this as a Prisma field (no Prisma type for vector) — it's added via raw SQL in docker/init.sql.

ALTER TABLE chunks ADD COLUMN embedding vector(384);
CREATE INDEX chunks_embedding_idx ON chunks
  USING hnsw (embedding vector_cosine_ops);

Query uses inline literal (Prisma can't parameterize vector):

SELECT id, content, 1 - (embedding <=> '[0.1,0.2,...]'::vector) AS similarity
FROM chunks WHERE project_id = $1
ORDER BY embedding <=> '[0.1,0.2,...]'::vector
LIMIT 50;

RLS

  • Enabled on users, projects, documents, conversations, messages
  • Disabled on chunks — app-level access via resolver is used instead. RLS on chunks complicated hybrid search queries (join across policies breaks the vector index)
  • Policies live in docker/init.sql, auto-applied on fresh DB init