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) // Set (and cleared on re-enable) via the devices admin page; a device // fetches this via GET /api/devices/:serial/status to show on its own screen. disabledReason String? lastSeenAt DateTime? @db.Timestamptz(6) // Most recent known position, from either a "status" ping (live-only, never // persisted as a LocatePoint) or a "log" point — whichever is newest. Lets // the devices page show current position without waiting for a log write. lastPosition Json? lastPositionAt 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[] certificate DeviceCertificate? @@map("devices") } // A client certificate issued to a device for mTLS auth on the broker's 8883 // listener; the cert's CN (= serialNumber) becomes the MQTT username. The CA // key/server key never touch the DB (see PkiService) — only device // certs/keys are stored here, mirroring how the reference implementation // (MQTT_DEVICE_AUTH.md) does it. One active cert per device; issuing a new // one requires deleting this row first. No revokedAt: deleting the row is // the only "revoke" action there is, and it isn't broker-enforced either way // (no CRL/OCSP), so a soft-delete flag would misleadingly imply otherwise. model DeviceCertificate { id String @id @default(cuid()) deviceId String @unique serialNumber String commonName String certificatePem String @db.Text privateKeyPem String @db.Text fingerprint String issuedAt DateTime @default(now()) @db.Timestamptz(6) expiresAt DateTime @db.Timestamptz(6) device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) @@map("device_certificates") } 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") }