generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum OrgRole { ORG_ADMIN MEMBER VIEWER } enum JobStatus { OPEN IN_PROGRESS COMPLETED CANCELLED } enum JobSource { WEB DEVICE } enum UtilityType { ELECTRIC GAS WATER SEWER TELECOM CATV FIBER STEAM UNKNOWN } enum GpsFixType { NONE AUTONOMOUS DGPS FLOAT_RTK FIXED_RTK } model Organization { id String @id @default(cuid()) name String slug String @unique createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) memberships OrgMembership[] jobs Job[] devices Device[] apiKeys ApiKey[] @@map("organizations") } model User { id String @id @default(cuid()) email String @unique passwordHash String name String createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) memberships OrgMembership[] assignedJobs Job[] @relation("JobAssignee") createdJobs Job[] @relation("JobCreator") apiKeys ApiKey[] @@map("users") } model OrgMembership { id String @id @default(cuid()) orgId String userId String role OrgRole @default(MEMBER) createdAt DateTime @default(now()) @db.Timestamptz(6) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([orgId, userId]) @@map("org_memberships") } model Job { id String @id @default(cuid()) orgId String ticketNumber String title String description String? address String? status JobStatus @default(OPEN) source JobSource @default(WEB) assignedToId String? createdById String? createdByDeviceId String? dueAt DateTime? @db.Timestamptz(6) startedAt DateTime? @db.Timestamptz(6) completedAt DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) assignedTo User? @relation("JobAssignee", fields: [assignedToId], references: [id]) createdBy User? @relation("JobCreator", fields: [createdById], references: [id]) points LocatePoint[] @@unique([orgId, ticketNumber]) @@index([orgId, status]) @@map("jobs") } model Device { id String @id @default(cuid()) orgId String name String serialNumber String? mqttUsername String @unique isActive Boolean @default(true) lastSeenAt DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) points LocatePoint[] @@map("devices") } model LocatePoint { id BigInt @id @default(autoincrement()) jobId String deviceId String? lat Decimal @db.Decimal(10, 8) lng Decimal @db.Decimal(11, 8) altitude Decimal? @db.Decimal(8, 3) fixType GpsFixType @default(NONE) hAccuracy Decimal? @db.Decimal(7, 3) depth Decimal? @db.Decimal(6, 3) utilityType UtilityType @default(UNKNOWN) sequence Int? recordedAt DateTime @db.Timestamptz(6) receivedAt DateTime @default(now()) @db.Timestamptz(6) raw Json? geom Unsupported("geometry(Point, 4326)")? job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull) @@index([jobId, recordedAt]) @@map("locate_points") } model ApiKey { id String @id @default(cuid()) orgId String name String keyPrefix String keyHash String @unique scopes String[] createdById String? expiresAt DateTime? @db.Timestamptz(6) lastUsedAt DateTime? @db.Timestamptz(6) revokedAt DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) createdBy User? @relation(fields: [createdById], references: [id]) @@index([keyPrefix]) @@map("api_keys") } // Raw MQTT ingest log (every message on devices/#, matched or not) model DeviceEvent { id Int @id @default(autoincrement()) topic String payload String receivedAt DateTime @default(now()) @map("received_at") @db.Timestamptz(6) @@map("device_events") }