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/<serial>/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")
}
