/** * Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`. * * Six tables, all brand-scoped with RLS: * - water_headgates — physical gates a measurement is tied to * - water_irrigators — PIN-authenticated field workers * - water_sessions — short-lived PIN sessions for irrigators * - water_log_entries — the actual reading logs * - water_alert_log — high/low threshold alert history * - water_admin_settings — per-brand admin PIN + alert config * - water_admin_sessions — admin sign-in sessions (separate cookie) * - water_audit_log — who changed what, when */ import { pgTable, uuid, text, numeric, boolean, timestamp, date, jsonb, doublePrecision, index, uniqueIndex, integer, customType, } from "drizzle-orm/pg-core"; import { brands } from "./brands"; import { adminUsers } from "./brands"; // Drizzle's stock `bytea` import is not exported in every version, so // register a small custom type that maps to the existing `bytea` PG type. const bytea = customType<{ data: Buffer; default: false }>({ dataType() { return "bytea"; }, }); export const waterHeadgates = pgTable( "water_headgates", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), /** Per-headgate opaque token used in the QR code. */ headgateToken: text("headgate_token").notNull().unique(), /** Open / Closed / Maintenance. */ status: text("status").notNull().default("open"), /** Display unit: CFS, GPM, Inches, AF/Day, etc. */ unit: text("unit").notNull().default("CFS"), /** Optional max-flow marker in GPM. */ maxFlowGpm: numeric("max_flow_gpm"), /** High-water alert threshold (units match `unit`). */ highThreshold: numeric("high_threshold"), /** Low-water alert threshold (units match `unit`). */ lowThreshold: numeric("low_threshold"), notes: text("notes"), active: boolean("active").notNull().default(true), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandIdx: index("water_headgates_brand_idx").on(t.brandId), tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken), }), ); export const waterIrrigators = pgTable( "water_irrigators", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), pinHash: text("pin_hash").notNull(), languagePreference: text("language_preference") .notNull() .default("en"), /** "irrigator" submits entries only, "water_admin" can manage the brand. */ role: text("role").notNull().default("irrigator"), phone: text("phone"), notes: text("notes"), active: boolean("active").notNull().default(true), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandIdx: index("water_irrigators_brand_idx").on(t.brandId), }), ); export const waterSessions = pgTable( "water_sessions", { id: uuid("id").primaryKey().defaultRandom(), irrigatorId: uuid("irrigator_id") .notNull() .references(() => waterIrrigators.id, { onDelete: "cascade" }), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, ); export const waterLogEntries = pgTable( "water_log_entries", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), headgateId: uuid("headgate_id") .notNull() .references(() => waterHeadgates.id, { onDelete: "cascade" }), irrigatorId: uuid("irrigator_id") .notNull() .references(() => waterIrrigators.id, { onDelete: "cascade" }), /** Raw measurement value, in the entry's `unit`. */ measurement: numeric("measurement").notNull(), unit: text("unit").notNull(), /** "manual" | "meter" | "estimate" | "qr" */ method: text("method").notNull().default("manual"), /** Optional auto-computed total (in gallons) when CFS × duration is known. */ totalGallons: numeric("total_gallons"), notes: text("notes"), submittedVia: text("submitted_via").notNull().default("app"), photoUrl: text("photo_url"), latitude: doublePrecision("latitude"), longitude: doublePrecision("longitude"), /** Date-only mirror of loggedAt for fast grouping / dashboard queries. */ loggedDate: date("logged_date"), loggedAt: timestamp("logged_at", { withTimezone: true }) .notNull() .defaultNow(), loggedBy: uuid("logged_by").references(() => adminUsers.id), }, (t) => ({ brandIdx: index("water_log_entries_brand_idx").on(t.brandId), headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId), brandDateIdx: index("water_log_entries_brand_date_idx").on( t.brandId, t.loggedDate, ), irrigatorIdx: index("water_log_entries_irrigator_idx").on( t.irrigatorId, t.loggedAt, ), }), ); export const waterAlertLog = pgTable( "water_alert_log", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), alertType: text("alert_type").notNull(), headgateId: uuid("headgate_id").references(() => waterHeadgates.id, { onDelete: "set null", }), message: text("message").notNull(), sentTo: text("sent_to"), sentAt: timestamp("sent_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, ); export const waterAdminSettings = pgTable("water_admin_settings", { brandId: uuid("brand_id") .primaryKey() .references(() => brands.id, { onDelete: "cascade" }), /** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */ pinHash: text("pin_hash"), enabled: boolean("enabled").notNull().default(true), sessionDurationHours: integer("session_duration_hours") .notNull() .default(4), canEditEntries: boolean("can_edit_entries").notNull().default(true), canDeleteEntries: boolean("can_delete_entries").notNull().default(true), canExportCsv: boolean("can_export_csv").notNull().default(true), alertPhone: text("alert_phone"), alertsEnabled: boolean("alerts_enabled").notNull().default(false), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), updatedBy: uuid("updated_by").references(() => adminUsers.id), }); export const waterAdminSessions = pgTable( "water_admin_sessions", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), adminUserId: uuid("admin_user_id") .notNull() .references(() => adminUsers.id, { onDelete: "cascade" }), pinHashUsed: text("pin_hash_used").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ adminIdx: index("water_admin_sessions_admin_idx").on( t.adminUserId, t.expiresAt, ), }), ); export const waterAuditLog = pgTable( "water_audit_log", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), actorId: uuid("actor_id").references(() => adminUsers.id), actorLabel: text("actor_label").notNull(), action: text("action").notNull(), entityType: text("entity_type").notNull(), entityId: uuid("entity_id"), details: jsonb("details"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandRecentIdx: index("water_audit_log_brand_recent_idx").on( t.brandId, t.createdAt, ), }), ); // ── Inferred types (used by every action and client component) ──────────── export type WaterHeadgate = typeof waterHeadgates.$inferSelect; export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert; export type WaterIrrigator = typeof waterIrrigators.$inferSelect; export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert; export type WaterSession = typeof waterSessions.$inferSelect; export type WaterLogEntry = typeof waterLogEntries.$inferSelect; export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert; export type WaterAlertLog = typeof waterAlertLog.$inferSelect; export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect; export type WaterAdminSession = typeof waterAdminSessions.$inferSelect; export type WaterAuditLog = typeof waterAuditLog.$inferSelect; // ── Smartsheet integration (added in 0093_water_smartsheet_sync.sql) ──────── /** * Allowed sync frequencies. Stored as TEXT to match the convention * in CLAUDE.md ("Status enums stored as TEXT — no PostgreSQL ENUM type"). */ export const SMARTSHEET_FREQUENCIES = [ "realtime", "every_15_minutes", "hourly", ] as const; export type SmartsheetFrequency = (typeof SMARTSHEET_FREQUENCIES)[number]; /** * The Water-log fields a brand can map to their Smartsheet columns. * `entry_id` and `logged_at` are required (used for dedup). */ export type SmartsheetColumnKey = | "entry_id" | "logged_at" | "headgate" | "measurement" | "unit" | "irrigator" | "notes"; export const SMARTSHEET_COLUMN_KEYS: readonly SmartsheetColumnKey[] = [ "entry_id", "logged_at", "headgate", "measurement", "unit", "irrigator", "notes", ] as const; /** * Shape stored in `water_smartsheet_config.column_mapping`. The values * are Smartsheet column IDs (numeric strings, e.g. "8309876543210123"). * * Only `entry_id` and `logged_at` are required (used for dedup). All * other fields are nullable — `null` means "not mapped, don't write * this column". The UI uses an empty string in the dropdown to mean * the same thing and we coerce on the server. */ export type SmartsheetColumnMapping = { entry_id: string; logged_at: string; headgate: string | null; measurement: string | null; unit: string | null; irrigator: string | null; notes: string | null; }; export const waterSmartsheetConfig = pgTable("water_smartsheet_config", { brandId: uuid("brand_id") .primaryKey() .references(() => brands.id, { onDelete: "cascade" }), sheetId: text("sheet_id").notNull(), encryptedApiToken: bytea("encrypted_api_token").notNull(), tokenIv: bytea("token_iv").notNull(), tokenAuthTag: bytea("token_auth_tag").notNull(), columnMapping: jsonb("column_mapping") .$type() .notNull(), syncFrequency: text("sync_frequency") .$type() .notNull() .default("hourly"), syncEnabled: boolean("sync_enabled").notNull().default(false), lastSyncAt: timestamp("last_sync_at", { withTimezone: true }), lastSyncError: text("last_sync_error"), createdBy: text("created_by"), updatedBy: text("updated_by"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }); export type WaterSmartsheetConfig = typeof waterSmartsheetConfig.$inferSelect; export type WaterSmartsheetConfigInsert = typeof waterSmartsheetConfig.$inferInsert; /** * Sync queue status. Mirrors the SQL CHECK constraint. */ export const SMARTSHEET_QUEUE_STATUSES = [ "pending", "syncing", "synced", "failed", ] as const; export type SmartsheetQueueStatus = (typeof SMARTSHEET_QUEUE_STATUSES)[number]; export const waterSmartsheetSyncQueue = pgTable( "water_smartsheet_sync_queue", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), entryId: uuid("entry_id") .notNull() .references(() => waterLogEntries.id, { onDelete: "cascade" }), smartsheetRowId: text("smartsheet_row_id"), status: text("status") .$type() .notNull() .default("pending"), attempts: integer("attempts").notNull().default(0), lastError: text("last_error"), nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }) .notNull() .defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), syncedAt: timestamp("synced_at", { withTimezone: true }), }, (t) => ({ entryUnique: uniqueIndex("water_smartsheet_queue_entry_unique").on( t.brandId, t.entryId, ), brandStatusIdx: index("water_smartsheet_queue_brand_status_idx").on( t.brandId, t.status, t.nextAttemptAt, ), brandRecentIdx: index("water_smartsheet_queue_brand_created_idx").on( t.brandId, t.createdAt, ), }), ); export type WaterSmartsheetSyncQueue = typeof waterSmartsheetSyncQueue.$inferSelect; export type WaterSmartsheetSyncQueueInsert = typeof waterSmartsheetSyncQueue.$inferInsert; export const SMARTSHEET_LOG_ACTIONS = ["sync", "retry", "skip", "queue"] as const; export type SmartsheetLogAction = (typeof SMARTSHEET_LOG_ACTIONS)[number]; export const waterSmartsheetSyncLog = pgTable( "water_smartsheet_sync_log", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), entryId: uuid("entry_id").references(() => waterLogEntries.id, { onDelete: "set null", }), smartsheetRowId: text("smartsheet_row_id"), action: text("action").$type().notNull(), success: boolean("success").notNull(), error: text("error"), durationMs: integer("duration_ms"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandRecentIdx: index("water_smartsheet_log_brand_recent_idx").on( t.brandId, t.createdAt, ), }), ); export type WaterSmartsheetSyncLog = typeof waterSmartsheetSyncLog.$inferSelect; export type WaterSmartsheetSyncLogInsert = typeof waterSmartsheetSyncLog.$inferInsert;