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 } // EM locator receiver antenna mode used when the point was captured enum LocateMode { PEAK NULL BROAD_PEAK SONDE } 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") } // A device is either a locator receiver (identified by serialNumber, usually // auto-registered from incoming data) or an MQTT publisher (app/gateway with // broker credentials, identified by mqttUsername) — or both, when a locator // connects to the broker directly. model Device { id String @id @default(cuid()) orgId String name String // Globally unique (manufacturer serials aren't org-scoped in the real world) — // this lets a bare devices//log MQTT topic identify the device without // any org context in the topic itself. serialNumber String? @unique 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) utilityType UtilityType @default(UNKNOWN) sequence Int? // GPS quality fixType GpsFixType @default(NONE) hAccuracy Decimal? @db.Decimal(7, 3) // meters vAccuracy Decimal? @db.Decimal(7, 3) // meters satellites Int? hdop Decimal? @db.Decimal(4, 2) // Locator receiver telemetry depth Decimal? @db.Decimal(6, 3) // meters below grade frequencyHz Int? // active/passive locate frequency currentMa Decimal? @db.Decimal(9, 3) // signal current on the line signalDb Decimal? @db.Decimal(6, 2) // signal strength gainDb Decimal? @db.Decimal(6, 2) // receiver gain locateMode LocateMode? phaseDeg Decimal? @db.Decimal(6, 2) compassDeg Decimal? @db.Decimal(5, 2) // line direction, 0-360 distortionPct Decimal? @db.Decimal(5, 2) 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") }