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 } // Which producer a point originated from (telemetry-schema.md `origin`). Tagged now // for SYN-6 path arbitration; no arbitration LOGIC runs yet (deferred, sprint-2 scope). enum PointOrigin { APP LOCATOR MAGLINK } // Which transport carried the point to the cloud (telemetry-schema.md `uploadPath`). // Same forward-compat tagging as PointOrigin. enum PointUploadPath { APP_MQTT DEVICE_MQTT REST_BATCH } 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? // Client-generated UUIDv7, the idempotency key across every upload path // (app MQTT, device-direct MQTT, REST batch — SRS §3.4.2 / telemetry-schema.md). // A replayed UUID is ingested exactly once (unique constraint below). Nullable: // the legacy device-direct path (LogIngest/PointsIngest) predates it and does not // supply one yet; the app path (SYN-2) always does. pointId String? @unique // Provenance tags (telemetry-schema.md). Set on the app path now; carried for // forward-compat with SYN-6 arbitration, which does not run yet. origin PointOrigin @default(LOCATOR) uploadPath PointUploadPath? originClientId String? // Event time asserted by the producer (record creation, telemetry-schema.md // `createdAt`). Ingest orders and dedups by this, NOT by arrival (`receivedAt`), // so replays and out-of-order delivery converge to the same stored ordering. createdAt DateTime? @db.Timestamptz(6) 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) // (jobId, recordedAt) serves GPS-time reads; (jobId, createdAt) serves the // event-time ordering the app path and portal read use (SRS-SYN-2). pointId is // already uniquely indexed above — that index also backs the dedup lookup. @@index([jobId, recordedAt]) @@index([jobId, createdAt]) @@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") }