fc70344dab
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.
Database (0093_water_smartsheet_sync.sql):
- water_smartsheet_config: one row per brand; AES-256-GCM encrypted API
token (BYTEA), sheet id, column mapping JSONB, sync_frequency
(realtime | every_15_minutes | hourly), enable flag, last-sync
metadata. RLS via existing current_brand_id() pattern.
- water_smartsheet_sync_queue: one row per entry awaiting sync;
tracks status / attempts / exponential backoff. UNIQUE(brand_id,
entry_id) for idempotent enqueue.
- water_smartsheet_sync_log: append-only audit of every sync attempt
(success or failure); backs the Recent Activity panel in the UI.
Server:
- src/lib/crypto.ts: AES-256-GCM encrypt/decrypt + token mask helper.
Reads SMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key.
- src/lib/smartsheet.ts: typed REST wrapper (no SDK; the official
smartsheet npm has Node 22 compat issues). getSheetMeta, addRow,
findRowByColumn + typed SmartsheetApiError.
- src/services/smartsheet-sync.ts: syncEntryToSmartsheet (one entry),
drainSyncQueue (batch with backoff), runScheduledSync (cron entry).
Sequential processing stays well under Smartsheet's 300 req/min.
Max 5 attempts, then row stays 'failed' permanently.
- src/actions/water-log/smartsheet.ts: 6 server actions. Token never
returned in plaintext — UI gets maskedToken only. Permission check
matches the rest of the water-log module (can_manage_water_log).
- src/app/api/water-log/smartsheet-sync/route.ts: POST cron route,
bearer auth via SMARTSHEET_CRON_SECRET (falls back to CRON_SECRET).
Optional {brandId} body for manual retry.
- vercel.json: added */15 * * * * cron entry. Single entry covers
both 15-min and hourly frequencies (route filters per-brand).
UI:
- src/components/admin/water-log/SmartsheetIntegrationCard.tsx:
self-contained client component with useReducer. Sections: enable
toggle, sheet URL/ID + API token + Test Connection, sync frequency
radio group, column mapping dropdowns (populated after Test),
Recent Activity panel.
- src/app/admin/water-log/settings/page.tsx: mounted the card as new
'§ 05 — Integrations' section after the existing admin-PIN settings.
Card takes brandId as a prop (CLAUDE.md 'Brand ID Threading').
Hooks:
- src/actions/water-log/field.ts::submitWaterEntry: calls
triggerSyncForEntry(brandId, entryId) in a fire-and-forget void
after the entry insert. Sync failures NEVER bubble to the field
worker.
Env vars (.env.example):
- SMARTSHEET_TOKEN_ENC_KEY (REQUIRED, 32 random bytes base64)
- SMARTSHEET_CRON_SECRET (optional bearer)
- SMARTSHEET_SYNC_TIMEOUT_MS (optional, default 8000)
Deploy:
1. openssl rand -base64 32 → SMARTSHEET_TOKEN_ENC_KEY in .env.local
AND Vercel dashboard
2. npm run migrate:one 93 (pushes 0093_water_smartsheet_sync.sql)
3. Push triggers .gitea/workflows/deploy.yml
Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
/**
|
||
* 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<SmartsheetColumnMapping>()
|
||
.notNull(),
|
||
syncFrequency: text("sync_frequency")
|
||
.$type<SmartsheetFrequency>()
|
||
.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<SmartsheetQueueStatus>()
|
||
.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<SmartsheetLogAction>().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;
|