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 indexsingle_superadminorgPosition = ceo→ partial unique indexsingle_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 onchunkscomplicated hybrid search queries (join across policies breaks the vector index) - Policies live in
docker/init.sql, auto-applied on fresh DB init