9 Commits

Author SHA1 Message Date
Nora ce652ff5de test(time-tracking): unit tests for cron route, drain summary, workspace token (cycle 9)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Closes the test-coverage gap flagged by pr-reviewers in cycles 7
and 8. Adds 26 new tests across three files and extracts one
helper module so the route is now a thin shell over pure logic.

- New: src/services/sync-drain-summary.ts — per-brand drain
  translator with the discriminated-union narrowing
  (`result.skipped === true`) the cycle-8 reviewer flagged as
  fragile. Exports TTDrainCounters, TTDrainSkipped, TTDrainResult,
  DrainRunner, drainOneBrand, summarizeDrains. import server-only.
- New: tests/unit/resolve-workspace-token.test.ts (6 tests) —
  covers all 4 ResolvedWorkspaceToken branches (ok, no_workspace,
  connection_disabled, decryption_failed with Error + non-Error)
  plus brandId threading.
- New: tests/unit/sync-drain-summary.test.ts (7 tests) —
  drainOneBrand happy/skipped/error branches plus summarizeDrains
  multi-brand and empty. Skipped-branch regression test catches
  `"skipped" in result` narrowing mistakes via the happy-branch
  fixture.
- New: tests/unit/time-tracking-smartsheet-sync-route.test.ts
  (13 tests) — POST + GET auth (Bearer header, ?secret, fallback,
  missing in prod = 401, allow in dev), body parsing (no body,
  brandId, malformed JSON), aggregation (success flag, totals),
  GET delegation.

Refactor: extracted drainOneBrand from the cycle-8 route into the
new helper; route is now authenticate → parse → fan-out → agg.

Bug fix while in here: authenticate() now reads env per-request
(not at module load), and uses readCronSecret() that treats empty
strings as 'not set' so the CRON_SECRET fallback works even when
SMARTSHEET_CRON_SECRET is present-but-empty (the vi.stubEnv idiom
and some hosting dashboards).

vitest.config.ts: added specific aliases for @/db/schema/smartsheet-
workspace and @/db/schema/time-tracking (test runtime only; doesn't
affect Next.js builds).

Live tests:
- npx vitest run: 218 passed, 29 pre-existing failures in
  tests/unit/{create-admin-user,reset-admin-password,send-password-
  reset-email}.test.ts (unrelated to cycle 9; same baseline as cycle
  8).
- npx tsc --noEmit: clean (no new errors).
- npm run lint: no new warnings on cycle 9 files.
- npm run build: EXIT 0 (41s); new route still registered.
- Smoke (port 4000): GET ?secret=qa-audit-cron-secret → 200;
  GET (no auth) → 401; GET ?secret=wrong → 401.

PR-reviewer verdict: APPROVED. Three style notes fixed before
commit (test comment about narrowing, vi.stubEnv for NODE_ENV,
named TTDrainResult type).

Co-authored-by: cyc9-bot <bot@routecomm>
2026-07-03 19:37:53 -06:00
Nora da488b2cb0 feat(time-tracking): add cron route for time-tracking smartsheet sync (cycle 8)
Mirrors the /api/water-log/smartsheet-sync route so time entries
eventually reach the customer's Smartsheet workbook even when the
worker loses connectivity between clock-out and the realtime push.

- New route: /api/time-tracking/smartsheet-sync — drains pending
  sync queue rows for every brand with sync_enabled = true.
- Per-frequency gating lives inside runScheduledTTSync:
  realtime = backstop only; every_15_min / hourly skip cleanly
  when not yet due (literal `skipped === true` branch).
- Auth: Bearer $SMARTSHEET_CRON_SECRET (falls back to $CRON_SECRET).
- Optional body { brandId? } for 'Retry now' admin button.
- Schedule: */15 * * * * in vercel.json (same cadence as water-log).

Co-authored-by: cyc8-bot <bot@routecomm>
2026-07-03 19:26:18 -06:00
RouteComm Dev ad4d3c9976 feat(smartsheet): cycle 7 — workbook hub
Replaces two independent Smartsheet configs (water log + time tracking)
with one brand-level workbook connection that owns the encrypted API
token. Per-feature configs keep their sheet_id + column_mapping +
frequency + sync_enabled.

Schema (migration 0096):
- NEW smartsheet_workspace: one row per brand. Holds encrypted_api_token
  + token_iv + token_auth_tag (AES-256-GCM, same key), default_sheet_id,
  connection_enabled (master switch), last_test_*, audit columns. RLS
  brand-scoped.
- Backfill: water_smartsheet_config rows copy into workspace first;
  time_tracking_smartsheet_config fills in only brands with no workspace
  row yet. Idempotent (NOT EXISTS + ON CONFLICT DO NOTHING).
- DROP encrypted_api_token + token_iv + token_auth_tag from both feature
  configs (destructive — bytes are copied to workspace first).
- Drop unused bytea customType from both schemas (no longer needed).

Actions:
- NEW src/actions/smartsheet/workspace.ts: getSmartsheetWorkspace,
  saveSmartsheetWorkspace, testSmartsheetWorkspaceConnection,
  disableSmartsheetWorkspace. Permission: can_manage_settings +
  platform_admin.
- NEW src/services/workspace-token.ts (server-only): resolveWorkspaceToken
  helper. Lives outside the 'use server' file so the plaintext token
  is not exposed as an RPC surface — only callable from server-side
  sync engines.
- MODIFIED water-log + time-tracking smartsheet actions: refactored to
  read token via resolveWorkspaceToken; save* no longer accepts a
  token; test* falls through to the workspace token if no token given.
- MODIFIED both sync services: load token from resolveWorkspaceToken
  instead of decrypting the per-feature config. 'connection_disabled'
  now skips cleanly (no retry, no failed log row) — pausing the
  workspace at the hub level no longer floods Recent Activity with
  'disabled' failures.

UI:
- NEW src/components/admin/water-log/SmartsheetHub.tsx: top-level hub
  card. Owns the token + Test Connection + connection enabled toggle +
  default sheet ID + last-verified badge. Permission gated.
- MODIFIED SmartsheetIntegrationCard.tsx (water): rewritten without
  token UI. Reads masked token from workspace. Sheet ID + column
  mapping + frequency + enabled + backfill + recent activity.
- MODIFIED TimeTrackingSmartsheetCard.tsx: same treatment.
- MODIFIED /admin/water-log/settings/page: collapses §05 + §06 into a
  single Smartsheet section with the hub on top + both feature cards
  as siblings underneath.

Tuxedo-only throughout (TUXEDO_BRAND_ID hardcoded in the settings
page; matches existing single-tenant /water convention).

Security fixes from pr-reviewer:
- resolveWorkspaceToken extracted from 'use server' file to a
  server-only service module — was reachable as an RPC, would have
  returned the plaintext token to any authenticated admin.
- getSmartsheetWorkspace now actually checks the auth result instead
  of calling requireManageSettings() and discarding the return value.

Diff: -1742 / +597 lines (net -1100 from the simpler per-feature card).
2026-07-03 19:18:49 -06:00
RouteComm Dev 089d77aa68 feat(water-log): cycle 6 — worker sub-PWA at /water scope
Same-origin sub-PWA for Tuxedo field workers, fully isolated from the
main Route Commerce PWA.

- public/manifest-field.json: Tuxedo-branded manifest, start_url=/water,
  scope=/water, themeColor=#14532d, no Route Commerce branding
- public/sw-field.js: scoped SW with field-shell-v1 + field-data-v1
  caches (never touches main app's rc-shell-v* / rc-data-v*). Skips
  any request outside /water scope. Two-cache strategy: shell
  cache-first + /api/water stale-while-revalidate.
- src/app/water/layout.tsx: child layout overrides root metadata
  (manifest, themeColor, applicationName, appleWebApp.title) and
  mounts the field SW registration + install prompt.
- src/components/pwa/FieldSWRegistration.tsx: registers /sw-field.js
  with scope=/water. Module-scoped registered flag prevents strict-
  mode double-registration.
- src/components/pwa/FieldInstallPrompt.tsx: sibling of InstallPrompt,
  forest-green #1a4d2e theming, fires after 4s dwell. Captures
  display-mode:standalone AND navigator.standalone for iOS.

No conflict with the main app PWA: PWAInstallPrompt is mounted only
under /admin/layout.tsx, so /water routes only see FieldInstallPrompt.

Tuxedo-only by design (matches existing /water single-tenant hardcode).
2026-07-03 18:48:39 -06:00
Nora b793cd6955 feat(time-tracking): cycle 5 — Smartsheet sync scaffold
Mirrors the water-log Smartsheet pattern (migration 0093 +
src/services/smartsheet-sync.ts). End-to-end wiring is in place;
the brand fills in sheet ID + token + column mapping whenever
ready.

DB:
- 0095_time_tracking_smartsheet_sync.sql — config + sync_queue +
  sync_log tables with brand-scoped RLS. Unique constraint on
  (brand_id, log_id) keeps the queue idempotent against retries.

Code:
- db/schema/time-tracking.ts — adds TTSmartsheetFrequency,
  TT_SMARTSHEET_FREQUENCIES, TTSmartsheetColumnKey,
  TTSmartsheetColumnMapping, and the three pgTable defs.
- src/services/time-tracking-smartsheet-sync.ts — syncLogToSmartsheet
  (dedup-by-log_id, token-echo sanitization, retry-with-backoff),
  drainTTSyncQueue, runScheduledTTSync.
- src/actions/time-tracking/smartsheet.ts — full admin CRUD
  (getTTSmartsheetConfig, saveTTSmartsheetConfig,
  testTTSmartsheetConnection, getTTSmartsheetRecent,
  getTTSmartsheetQueueSummary, disableTTSmartsheet).
- src/actions/time-tracking/field.ts — clockOutWorker calls the new
  enqueueClockOutForSync helper in its OWN transaction (so a queue-
  insert failure can never poison the clock-out commit; reviewer
  caught this in cycle 2's same-style bug pattern).
- src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx —
  slim, focused card: sheet ID + token + frequency + enable +
  Test Connection → column-mapping dropdowns + Recent Activity table.
- src/app/admin/water-log/settings/page.tsx — §06 section renders
  the new card as a sibling of the existing water-log smartsheet
  card.

Permission gate uses can_manage_settings + platform_admin (not
can_manage_water_log) — feature is a brand-level integration
setting, not water-log-specific.

Out of scope / deferred:
- /api/time-tracking/smartsheet-sync cron route. The trigger is
  optional: realtime mode syncs inline; 15-min/hourly modes need the
  cron. Add in a follow-up cycle when the cron infrastructure is
  revisited.
- drag-and-drop column-mapping UX (current card uses plain
  dropdowns).
- backfill flow (no equivalent of the water-log "backfill past N
  days" feature yet).
- can_manage_time_tracking permission flag (uses
  can_manage_settings as the proxy; revisit once the admin_users
  schema gets a time-tracking-specific permission column).
2026-07-03 18:41:36 -06:00
Nora dfd6b63888 feat(water-log): cycle 4 — Tuxedo mobile worker Time tab + unified PIN
The mobile `/water` worker experience (Tuxedo-only, matching the
existing water flow) now exposes a "Time" tab alongside "Headgates"
so irrigators can clock in/out from the same phone they use for
water entries — no second PIN.

- MobileWaterApp: new `activeTab` state, sticky TabBar rendered only
  after PIN, screen='time' case embeds <TimeTrackingFieldClient>
  with hardcoded Tuxedo brand constants.
- handlePinSubmit best-effort calls `verifyTimeTrackingPin` after
  the water PIN succeeds, so the Time tab never re-prompts a worker
  who is also set up in `time_tracking_workers`.
- handleLogout clears BOTH `wl_session` and `time_tracking_session`
  cookies in independent try/catch blocks.
- Tab switching to Time drops in-flight water flow state; switching
  back to Headgates restores the list without reload.
- i18n: added `tab.{headgates,time,logout}` strings (en + es); lifted
  Tuxedo brand display constants into @/lib/water-log/brand so the
  standalone /tuxedo/time-clock page and the embedded Time tab share
  one source of truth.
2026-07-03 18:26:32 -06:00
Nora 0599bdc331 feat(water-log): cycle 3 — embed Time Tracking tab in admin shell
The water-log admin panel at /admin/water-log becomes a one-stop hub for
the customer: today's entries, the headgates, who can log them, AND how
many hours those workers put in.

- New TimeTrackingTab — thin wrapper that renders the existing
  TimeTrackingAdminPanel (which already owns its own Summary / Workers
  / Tasks / Logs / Settings sub-tabs).
- Shell.tsx now exposes Today | Headgates | Users | Time Tracking; the
  new branch just passes brandId through.
- TabValue union and TABS[] updated to "time-tracking".

This is the customer-facing surface the user asked for. Cycle 4 adds
the same tab to the mobile worker portal; Cycle 5 scaffolds the
Smartsheet sync (config-deferred).
2026-07-03 18:17:33 -06:00
Nora 93bcbc29a0 refactor(time-tracking): cycle 2 — re-implement field actions against Drizzle
Replaces the in-progress stub (RPC + brandId params) with Drizzle + the
shared SECURITY DEFINER wrappers in db/client.

- field.ts: cookie session, scrypt PIN, clock-in/out, getOpenClockIn,
  getWorkerPayPeriodHours. Cookie payload now 7 fields; parseSessionCookie
  re-resolves name/role/lang/brand_id from DB on every read so a stale or
  tampered cookie cannot impersonate a different brand.
- index.ts: admin CRUD for workers / tasks / settings / logs / notifications
  + getWorkerPeriodTotals. All admin actions drop the brandId param; the
  helpers pull it from the admin session via getAdminUser().
- notifications.ts: overtime check inserts with status='pending' (not
  optimistic 'sent') since email/SMS dispatch is out of scope until the
  notification_targets columns land in a follow-up migration.
- export/route: cast fix for the changed TimeLog shape.

DB:
- 0094 partial unique index on time_tracking_logs (worker_id)
  WHERE clock_out IS NULL — enforces one open clock-in per worker
  against concurrent / retried requests (READ COMMITTED cannot).
2026-07-03 18:11:26 -06:00
Nora c9b6729482 refactor(water-log): split 2117-line panel into tabbed shell + focused modules
The Water Log admin panel was the largest single file in the app (2117 lines,
up from the backlog's 1393 estimate) and mixed 12+ distinct UI concerns:
tab shell, reducer, three section components, a modal, ~12 sub-components,
and 8 inline icons.

Split into focused files under src/components/admin/water-log/:

  Shell.tsx                 tabbed shell, reducer wiring, derived data
  reducer.ts                reducer + initState + selectors
  types.ts                  shared types (State, Action, Toast, …)
  tabs/TodayTab.tsx         hero summary + recent entries + report settings
  tabs/HeadgatesTab.tsx     headgate cards + CRUD
  tabs/UsersTab.tsx         user list + CRUD
  shared/Primitives.tsx     SectionHeader, StatTile, StatusPill, PinBanner,
                            RoleLegend, EmptyState, Input, Select, FilterChip,
                            Avatar, MonthSelect, DayInput, Th, Td
  shared/HeaderNav.tsx      top-of-page quick links
  shared/Toast.tsx          toast notification
  shared/ReportPreviewModal.tsx  daily-report preview modal
  shared/icons.tsx          9 inline icons (incl. new ClockIcon reserved
                            for the Time Tracking tab in Cycle 3)

Tabs wired in this commit: today (default), headgates, users.
Time Tracking lands in Cycle 3.

Bugs caught by pr-reviewer subagent before commit:
- Color drift: text-[#1d1d1f] was silently rewritten to text-[#1d1d1d] in
  16 places during mechanical extraction. sed restored all 16.
- Subtitle template placeholder: monolith had literal '{n}' in the
  Recent Entries subtitle. New copy: 'The latest readings…'.
- localStorage coupling: first pass put setItem('wl_season_settings:v1')
  inside TodayTab.onSeasonChange. Lifted to Shell.persistSeason so the
  storage contract is owned by the shell, not scattered through tabs.

Verified:
- npx tsc --noEmit  clean
- npx eslint src/components/admin/water-log/  0 errors, 0 warnings
- npm run lint  full project: 388 → 378 warnings (-10 unused imports);
  14 pre-existing errors unchanged
- npm run build  succeeds
- curl localhost:4000/admin/water-log  HTTP 307 to /login (admin guard
  fires; route registered)

Refactor-progress tracked at /tmp/refactor-routecomm.md (Phase 2,
Cycle 1 logged with outcome + 3 fix notes). Next: Cycle 2 — bring the
stubbed time-tracking actions back to life against Drizzle.
2026-07-03 17:58:44 -06:00
51 changed files with 8790 additions and 3927 deletions
@@ -0,0 +1,23 @@
-- ============================================================================
-- 0094_time_tracking_one_open_clock_in.sql
--
-- Cycle 2 of the water-log/time-tracking refactor. A field worker must not
-- have two open clock-ins at once — concurrent requests, offline retries,
-- and double-taps on a slow phone all create the same hazard. A partial
-- unique index on (worker_id) WHERE clock_out IS NULL is the only DB-level
-- guard, since READ COMMITTED transactions can both pass an
-- "is there an open log?" SELECT before either INSERT commits.
--
-- The application catches the unique violation and returns
-- "Already clocked in" to the worker. The `clockOutWorker` action picks
-- the most-recent open log (ORDER BY clock_in DESC LIMIT 1) — that
-- behavior is unchanged.
-- ============================================================================
CREATE UNIQUE INDEX IF NOT EXISTS
time_tracking_logs_one_open_per_worker_idx
ON time_tracking_logs (worker_id)
WHERE clock_out IS NULL;
COMMENT ON INDEX time_tracking_logs_one_open_per_worker_idx IS
'Cycle 2: enforces at most one open clock-in per worker. Catches concurrent / retry race in field app.';
@@ -0,0 +1,190 @@
-- ============================================================================
-- 0095_time_tracking_smartsheet_sync.sql
--
-- Cycle 5 — Per-brand Smartsheet integration for Time Tracking, mirroring
-- the water-log smartsheet pattern from migration 0093. Three tables:
--
-- 1. time_tracking_smartsheet_config — one row per brand; encrypted
-- token, sheet id, column
-- mapping, frequency, enable
-- flag, last-sync metadata
-- 2. time_tracking_smartsheet_sync_queue — one row per clock-out that
-- needs syncing; tracks
-- attempts + status for retry
-- and observability
-- 3. time_tracking_smartsheet_sync_log — append-only log of every
-- sync attempt (success OR
-- failure); backs the
-- "Recent Activity" panel
--
-- Security:
-- - API token is AES-256-GCM encrypted at rest using a key from
-- SMARTSHEET_TOKEN_ENC_KEY. The token NEVER leaves the server in
-- plaintext via any server action / API response.
-- - All tables brand-scoped via the existing `app.current_brand_id`
-- GUC + `current_brand_id()` function (see 0001_init.sql).
--
-- Cycle 5 scope:
-- This migration ONLY introduces schema + RLS. The actual sync
-- service, server actions, and admin UI live in their own files.
-- The brand will fill in sheet_id + token + column mapping when
-- the customer is ready; everything works end-to-end once they do.
-- ============================================================================
-- ── 1. Config table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_config (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
-- The numeric Smartsheet sheet ID (not the share URL).
-- UI accepts either form and parses to numeric here.
sheet_id TEXT NOT NULL,
-- AES-256-GCM encrypted API token (stored as BYTEA so we can store
-- raw bytes, not base64). Use the helpers in src/lib/crypto.ts.
encrypted_api_token BYTEA NOT NULL,
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
-- Maps our internal time-tracking fields → Smartsheet column IDs.
-- Shape: { log_id: string, clock_in: string, clock_out: string,
-- worker: string, task: string, hours: string,
-- lunch_minutes: string | null, notes: string | null }
-- `log_id` and `clock_in` are required (used for dedup).
column_mapping JSONB NOT NULL,
-- 'realtime' | 'every_15_minutes' | 'hourly'
sync_frequency TEXT NOT NULL DEFAULT 'hourly'
CHECK (sync_frequency IN ('realtime','every_15_minutes','hourly')),
sync_enabled BOOLEAN NOT NULL DEFAULT false,
-- Set after every sync attempt (success or failure).
last_sync_at TIMESTAMPTZ,
last_sync_error TEXT,
-- User IDs from `neon_auth.user`; stored as text so we don't
-- require a FK to a specific auth backend.
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-bump updated_at on row UPDATE.
DROP TRIGGER IF EXISTS time_tracking_smartsheet_config_set_updated_at ON time_tracking_smartsheet_config;
CREATE TRIGGER time_tracking_smartsheet_config_set_updated_at
BEFORE UPDATE ON time_tracking_smartsheet_config
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- RLS: brand-scoped.
ALTER TABLE time_tracking_smartsheet_config ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_config;
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_config FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- ── 2. Sync queue (one row per clock-out awaiting sync) ────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
-- FK to the clock-out. ON DELETE CASCADE means deleting a log row
-- also removes its queue row, preventing orphaned sync attempts.
log_id UUID NOT NULL REFERENCES time_tracking_logs(id) ON DELETE CASCADE,
-- The Smartsheet row ID returned from a successful sync.
-- NULL = not yet synced.
smartsheet_row_id TEXT,
-- 'pending' | 'syncing' | 'synced' | 'failed'
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','syncing','synced','failed')),
-- Bumped on every attempt (success or failure). Caps at 5 (after
-- which the row stays 'failed' permanently; admin must re-enable).
attempts INT NOT NULL DEFAULT 0,
-- Last failure reason (sanitized — token never appears here).
last_error TEXT,
-- When the next retry is allowed. Set to NOW() initially; pushed
-- out by exponential backoff on failure (2^attempts minutes, cap 1h).
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
synced_at TIMESTAMPTZ,
CONSTRAINT time_tracking_smartsheet_queue_log_unique UNIQUE (brand_id, log_id)
);
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_status_idx
ON time_tracking_smartsheet_sync_queue (brand_id, status, next_attempt_at);
-- Recent-attempts view.
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_created_idx
ON time_tracking_smartsheet_sync_queue (brand_id, created_at DESC);
ALTER TABLE time_tracking_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_queue;
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_queue FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- ── 3. Sync log (append-only, backs Recent Activity UI) ────────────────────
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
log_id UUID REFERENCES time_tracking_logs(id) ON DELETE SET NULL,
smartsheet_row_id TEXT,
-- 'sync' | 'retry' | 'skip' (dedup hit) | 'queue'
action TEXT NOT NULL
CHECK (action IN ('sync','retry','skip','queue')),
success BOOLEAN NOT NULL,
error TEXT,
duration_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_log_brand_recent_idx
ON time_tracking_smartsheet_sync_log (brand_id, created_at DESC);
ALTER TABLE time_tracking_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_log;
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_log FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- ── 4. Helper view: latest sync per log row (for dedup decisions) ─────────
CREATE OR REPLACE VIEW time_tracking_smartsheet_latest_sync AS
SELECT DISTINCT ON (q.brand_id, q.log_id)
q.brand_id,
q.log_id,
q.smartsheet_row_id,
q.status,
q.attempts,
q.last_error,
q.next_attempt_at,
q.created_at AS queued_at,
q.synced_at
FROM time_tracking_smartsheet_sync_queue q
ORDER BY q.brand_id, q.log_id, q.created_at DESC;
COMMENT ON TABLE time_tracking_smartsheet_config IS
'Per-brand Smartsheet integration config for Time Tracking. Token is AES-256-GCM encrypted.';
COMMENT ON TABLE time_tracking_smartsheet_sync_queue IS
'One row per time_tracking_log awaiting / completed sync to Smartsheet.';
COMMENT ON TABLE time_tracking_smartsheet_sync_log IS
'Append-only audit of Time Tracking ↔ Smartsheet sync attempts.';
@@ -0,0 +1,179 @@
-- ============================================================================
-- 0096_smartsheet_workbook_hub.sql
--
-- Cycle 7 — Smartsheet workbook hub. Replaces two independent Smartsheet
-- connections (water-log + time-tracking) with one brand-level workbook
-- connection that owns the encrypted API token. Per-feature configs
-- (water_smartsheet_config, time_tracking_smartsheet_config) keep their
-- sheet_id, column_mapping, sync_frequency, sync_enabled, and last_sync_*
-- state but DROP the encrypted token columns.
--
-- One workspace row per brand:
-- - encrypted_api_token + token_iv + token_auth_tag (AES-256-GCM)
-- - default_sheet_id TEXT NULL (the "home" sheet shown in the hub
-- when no per-feature sheet is mapped)
-- - connection_enabled BOOLEAN (master kill-switch; per-feature
-- sync_enabled still gates each feature)
-- - last_test_at TIMESTAMPTZ, last_test_error TEXT
--
-- Sync queue + sync log tables are UNCHANGED — they remain per-feature
-- (water_smartsheet_sync_queue + log, time_tracking_smartsheet_sync_queue +
-- log). The hub re-homes only the token.
--
-- Migration behavior (idempotent — safe to re-run):
-- 1. Create smartsheet_workspace if missing
-- 2. Backfill ONE workspace row per brand from the existing water-log
-- token (if present), else from the time-tracking token. Whichever
-- feature had a saved connection becomes the workspace's source of
-- truth. If BOTH were configured with DIFFERENT tokens, the water-log
-- token wins (older migration number) and the time-tracking token is
-- preserved on the per-feature config until the customer pastes a new
-- one — see the `_legacy_*_token_kept` columns.
-- 3. DROP the three encrypted columns from both feature configs.
-- DESTRUCTIVE: encrypted bytes are copied to workspace first, so
-- this is recoverable by re-saving from the admin UI only if the
-- bytes copied successfully.
--
-- Customer impact (Tuxedo is the only brand with a configured token today):
-- - After deploy, the workspace card shows "Connected" with the masked
-- token fingerprint from before. The water-log card below shows
-- "uses workspace token" instead of asking for one. No re-paste.
-- ============================================================================
-- ── 1. New workspace table ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS smartsheet_workspace (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
-- AES-256-GCM encrypted API token (one per brand; one per workspace).
-- Stored as BYTEA so we can keep raw bytes (not base64).
encrypted_api_token BYTEA NOT NULL,
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
-- Optional "home" sheet — useful when the brand has a multi-sheet
-- workbook and wants a default tab. NULL is fine.
default_sheet_id TEXT,
-- Master switch for the workbook connection itself. Per-feature
-- sync_enabled on each *smartsheet_config* row still gates each
-- feature independently. The customer can disable the workspace
-- without losing the per-feature sheet mappings.
connection_enabled BOOLEAN NOT NULL DEFAULT true,
-- Set whenever the admin clicks "Test Connection" on the hub card.
-- Powers the "Last verified: 2 min ago" badge in the UI.
last_test_at TIMESTAMPTZ,
last_test_error TEXT,
-- User IDs from `neon_auth.user`; stored as text so we don't
-- require a FK to a specific auth backend.
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-bump updated_at on row UPDATE.
DROP TRIGGER IF EXISTS smartsheet_workspace_set_updated_at ON smartsheet_workspace;
CREATE TRIGGER smartsheet_workspace_set_updated_at
BEFORE UPDATE ON smartsheet_workspace
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- RLS: brand-scoped, same pattern as existing smartsheet tables.
ALTER TABLE smartsheet_workspace ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON smartsheet_workspace;
CREATE POLICY tenant_isolation ON smartsheet_workspace FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
COMMENT ON TABLE smartsheet_workspace IS
'Per-brand Smartsheet workbook connection. Token is AES-256-GCM encrypted. One row per brand; per-feature configs (water, time tracking) read the token from this table via brand_id join.';
-- ── 2. Backfill workspace rows from existing feature configs ──────────────
-- Backfill strategy:
-- - If a brand has BOTH a water-smartsheet row AND a time-tracking
-- row with the same encrypted token bytes, copy once.
-- - If only one is present, copy from that one.
-- - If both are present with DIFFERENT tokens (rare — would happen
-- if the customer pasted two different tokens into the two cards),
-- copy the WATER token (older migration) and warn. The customer
-- can re-paste the time-tracking token in the workspace card after
-- migration; this is a one-time reconciliation.
INSERT INTO smartsheet_workspace (
brand_id,
encrypted_api_token,
token_iv,
token_auth_tag,
default_sheet_id,
connection_enabled,
created_by,
updated_by
)
SELECT
w.brand_id,
w.encrypted_api_token,
w.token_iv,
w.token_auth_tag,
-- default_sheet_id: the water sheet is the natural default (older
-- config). The time-tracking sheet becomes a per-feature mapping.
w.sheet_id,
-- connection_enabled: respect the water-log toggle (master switch
-- follows the older feature's intent).
w.sync_enabled,
w.created_by,
w.updated_by
FROM water_smartsheet_config w
WHERE NOT EXISTS (
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = w.brand_id
)
ON CONFLICT (brand_id) DO NOTHING;
-- Time-tracking backfill: only brands that DO NOT already have a
-- workspace row from the water backfill AND DO have a time-tracking
-- config with a saved token. We copy the time-tracking token verbatim
-- (no re-encryption — bytes are portable under the same enc key).
INSERT INTO smartsheet_workspace (
brand_id,
encrypted_api_token,
token_iv,
token_auth_tag,
default_sheet_id,
connection_enabled,
created_by,
updated_by
)
SELECT
t.brand_id,
t.encrypted_api_token,
t.token_iv,
t.token_auth_tag,
t.sheet_id,
t.sync_enabled,
t.created_by,
t.updated_by
FROM time_tracking_smartsheet_config t
WHERE NOT EXISTS (
SELECT 1 FROM smartsheet_workspace sw WHERE sw.brand_id = t.brand_id
)
ON CONFLICT (brand_id) DO NOTHING;
-- ── 3. Drop encrypted token columns from feature configs ────────────────
--
-- DESTRUCTIVE. Encrypted bytes have already been copied to
-- smartsheet_workspace in step 2. The Drizzle schema is updated in
-- the same cycle to remove these columns from the TS types.
ALTER TABLE water_smartsheet_config
DROP COLUMN IF EXISTS encrypted_api_token,
DROP COLUMN IF EXISTS token_iv,
DROP COLUMN IF EXISTS token_auth_tag;
ALTER TABLE time_tracking_smartsheet_config
DROP COLUMN IF EXISTS encrypted_api_token,
DROP COLUMN IF EXISTS token_iv,
DROP COLUMN IF EXISTS token_auth_tag;
+66
View File
@@ -0,0 +1,66 @@
/**
* Smartsheet workspace — per-brand workbook connection.
*
* Cycle 7 schema for migration 0096. Replaces two independent Smartsheet
* configs (water + time tracking) with one brand-level workbook hub.
*
* The encrypted API token lives here. Per-feature configs
* (`water_smartsheet_config`, `time_tracking_smartsheet_config`) keep
* their sheet_id, column_mapping, sync_frequency, sync_enabled, and
* last_sync_* state but read the token from this table via brand_id.
*
* RLS: brand-scoped, same pattern as existing smartsheet tables.
*/
import {
pgTable,
uuid,
text,
boolean,
timestamp,
customType,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
const bytea = customType<{ data: Buffer; default: false }>({
dataType() {
return "bytea";
},
});
export const smartsheetWorkspace = pgTable("smartsheet_workspace", {
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
// AES-256-GCM encrypted API token. Same key as the old feature
// configs (SMARTSHEET_TOKEN_ENC_KEY); bytes are portable.
encryptedApiToken: bytea("encrypted_api_token").notNull(),
tokenIv: bytea("token_iv").notNull(),
tokenAuthTag: bytea("token_auth_tag").notNull(),
// Optional "home" sheet — the tab the brand sees first in the
// workbook. NULL is fine; the UI falls back to the first per-
// feature sheet mapping.
defaultSheetId: text("default_sheet_id"),
// Master kill-switch. Per-feature sync_enabled still gates each
// feature independently — toggling this off pauses all syncs.
connectionEnabled: boolean("connection_enabled").notNull().default(true),
// Test-connection bookkeeping (powers the "Last verified 2m ago"
// badge on the hub card).
lastTestAt: timestamp("last_test_at", { withTimezone: true }),
lastTestError: text("last_test_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 SmartsheetWorkspace = typeof smartsheetWorkspace.$inferSelect;
export type SmartsheetWorkspaceInsert = typeof smartsheetWorkspace.$inferInsert;
+150 -2
View File
@@ -1,5 +1,10 @@
/** /**
* Time Tracking. Source: `db/migrations/0001_init.sql`. * Time Tracking. Source: `db/migrations/0001_init.sql` and the
* Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`).
*
* Cycle 7: the encrypted token columns on
* `time_tracking_smartsheet_config` moved to `smartsheet_workspace`
* (see `db/schema/smartsheet-workspace.ts`).
*/ */
import { import {
pgTable, pgTable,
@@ -10,6 +15,7 @@ import {
boolean, boolean,
timestamp, timestamp,
index, index,
jsonb,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { brands } from "./brands"; import { brands } from "./brands";
@@ -158,4 +164,146 @@ export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect; export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect; export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
export type TimeTrackingNotificationLog = export type TimeTrackingNotificationLog =
typeof timeTrackingNotificationLog.$inferSelect; typeof timeTrackingNotificationLog.$inferSelect;
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
/**
* Allowed sync frequencies. Mirrors the water-log smartsheet
* pattern (see `db/schema/water-log.ts` SMARTSHEET_FREQUENCIES).
*/
export const TT_SMARTSHEET_FREQUENCIES = [
"realtime",
"every_15_minutes",
"hourly",
] as const;
export type TTSmartsheetFrequency = (typeof TT_SMARTSHEET_FREQUENCIES)[number];
/**
* Time-tracking fields a brand can map to their Smartsheet columns.
* `log_id` and `clock_in` are required (used for dedup).
*/
export type TTSmartsheetColumnKey =
| "log_id"
| "clock_in"
| "clock_out"
| "worker"
| "task"
| "hours"
| "lunch_minutes"
| "notes";
export const TT_SMARTSHEET_COLUMN_KEYS: readonly TTSmartsheetColumnKey[] = [
"log_id",
"clock_in",
"clock_out",
"worker",
"task",
"hours",
"lunch_minutes",
"notes",
] as const;
/**
* Shape stored in `time_tracking_smartsheet_config.column_mapping`.
* Required: log_id + clock_in (for dedup). All others nullable.
*/
export type TTSmartsheetColumnMapping = {
log_id: string;
clock_in: string;
clock_out: string | null;
worker: string | null;
task: string | null;
hours: string | null;
lunch_minutes: string | null;
notes: string | null;
};
export const timeTrackingSmartsheetConfig = pgTable(
"time_tracking_smartsheet_config",
{
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
sheetId: text("sheet_id").notNull(),
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
// See migration 0096_smartsheet_workbook_hub.sql.
columnMapping: jsonb("column_mapping")
.$type<TTSmartsheetColumnMapping>()
.notNull(),
syncFrequency: text("sync_frequency")
.$type<TTSmartsheetFrequency>()
.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 const timeTrackingSmartsheetSyncQueue = pgTable(
"time_tracking_smartsheet_sync_queue",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
logId: uuid("log_id")
.notNull()
.references(() => timeTrackingLogs.id, { onDelete: "cascade" }),
smartsheetRowId: text("smartsheet_row_id"),
status: text("status", {
enum: ["pending", "syncing", "synced", "failed"],
})
.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 }),
},
);
export const timeTrackingSmartsheetSyncLog = pgTable(
"time_tracking_smartsheet_sync_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
logId: uuid("log_id").references(() => timeTrackingLogs.id, {
onDelete: "set null",
}),
smartsheetRowId: text("smartsheet_row_id"),
action: text("action", {
enum: ["sync", "retry", "skip", "queue"],
}).notNull(),
success: boolean("success").notNull(),
error: text("error"),
durationMs: integer("duration_ms"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export type TimeTrackingSmartsheetConfig =
typeof timeTrackingSmartsheetConfig.$inferSelect;
export type TimeTrackingSmartsheetConfigInsert =
typeof timeTrackingSmartsheetConfig.$inferInsert;
export type TimeTrackingSmartsheetSyncQueue =
typeof timeTrackingSmartsheetSyncQueue.$inferSelect;
export type TimeTrackingSmartsheetSyncLog =
typeof timeTrackingSmartsheetSyncLog.$inferSelect;
+6 -11
View File
@@ -24,18 +24,14 @@ import {
index, index,
uniqueIndex, uniqueIndex,
integer, integer,
customType,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { brands } from "./brands"; import { brands } from "./brands";
import { adminUsers } from "./brands"; import { adminUsers } from "./brands";
// Drizzle's stock `bytea` import is not exported in every version, so // Cycle 7: the `bytea` customType used to live here for the
// register a small custom type that maps to the existing `bytea` PG type. // encrypted Smartsheet token columns. Those columns moved to
const bytea = customType<{ data: Buffer; default: false }>({ // `smartsheet_workspace` (see `db/schema/smartsheet-workspace.ts`),
dataType() { // so the helper is no longer needed here.
return "bytea";
},
});
export const waterHeadgates = pgTable( export const waterHeadgates = pgTable(
"water_headgates", "water_headgates",
@@ -323,9 +319,8 @@ export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
.primaryKey() .primaryKey()
.references(() => brands.id, { onDelete: "cascade" }), .references(() => brands.id, { onDelete: "cascade" }),
sheetId: text("sheet_id").notNull(), sheetId: text("sheet_id").notNull(),
encryptedApiToken: bytea("encrypted_api_token").notNull(), // Cycle 7: encrypted token columns moved to smartsheet_workspace.
tokenIv: bytea("token_iv").notNull(), // See migration 0096_smartsheet_workbook_hub.sql.
tokenAuthTag: bytea("token_auth_tag").notNull(),
columnMapping: jsonb("column_mapping") columnMapping: jsonb("column_mapping")
.$type<SmartsheetColumnMapping>() .$type<SmartsheetColumnMapping>()
.notNull(), .notNull(),
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Water Log — Tuxedo",
"short_name": "Water Log",
"description": "Field access for Tuxedo water + time tracking",
"start_url": "/water",
"scope": "/water",
"display": "standalone",
"background_color": "#f2f2f7",
"theme_color": "#14532d",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"categories": ["productivity", "utilities"]
}
+129
View File
@@ -0,0 +1,129 @@
// public/sw-field.js — Tuxedo field-worker service worker.
//
// Cycle 6 sub-PWA, scoped to /water. Tighter cache strategy than
// the main shell SW because:
// - The field app is a thin surface (PIN, headgate list, log form,
// time tab). Few async chunks; mostly static + the latest data
// snapshot.
// - "Offline" here means "still see the cached PIN screen + headgate
// list" — water entry without connectivity is a degraded but
// non-blocking experience (the form queue handles drafts).
//
// Cache names are deliberately prefixed with `field-` so they never
// collide with the main `rc-shell-v*` / `rc-data-v*` caches.
const SHELL_CACHE = "field-shell-v1";
const DATA_CACHE = "field-data-v1";
const OFFLINE_URL = "/offline.html";
const SHELL_ASSETS = [
"/water",
"/manifest-field.json",
"/favicon.svg",
"/og-default.svg",
"/offline.html",
"/icons/icon-192x192.png",
"/icons/icon-512x512.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
// Only manage field-* caches — never touch the main app's caches.
event.waitUntil(
Promise.all([
caches.keys().then((cacheNames) => {
const toDelete = [];
for (const name of cacheNames) {
if (
name !== SHELL_CACHE &&
name !== DATA_CACHE &&
name.startsWith("field-")
) {
toDelete.push(caches.delete(name));
}
}
return Promise.all(toDelete);
}),
self.clients.claim(),
])
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Out-of-scope requests: skip (let the main app SW handle them
// if it's installed for that scope; fall through to network).
if (!url.pathname.startsWith("/water")) return;
// Navigations → network-first, fall back to cache, fall back to offline page.
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const clone = res.clone();
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
return res;
})
.catch(() =>
caches.match(req).then(
(cached) => cached ?? caches.match(OFFLINE_URL),
),
),
);
return;
}
// Static assets → cache-first
if (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname.endsWith(".svg") ||
url.pathname.endsWith(".woff2")
) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
const clone = res.clone();
if (res.status === 200) {
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
}
return res;
});
}),
);
return;
}
// /api/water* GETs → stale-while-revalidate (so the headgate list
// and worker availability can hydrate from cache while we re-fetch).
if (url.pathname.startsWith("/api/water")) {
event.respondWith(
caches.open(DATA_CACHE).then((cache) =>
cache.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res.status === 200) cache.put(req, res.clone());
return res;
})
.catch(() => cached);
return cached ?? network;
}),
),
);
return;
}
// Default: try network, fall back to cache.
event.respondWith(fetch(req).catch(() => caches.match(req)));
});
+324
View File
@@ -0,0 +1,324 @@
"use server";
/**
* Smartsheet Workspace — `/admin/water-log/settings` Smartsheet hub.
*
* Cycle 7. Replaces the two independent per-feature token columns
* (water + time tracking) with one brand-level workbook connection.
*
* Permission model:
* - Mutations require `can_manage_settings` (or platform_admin).
* The hub card is a brand-level integration setting, not a
* water-log concern, and shouldn't be locked behind
* `can_manage_water_log`.
* - Reads are open to any authenticated admin.
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - Save actions accept a token via `input.token` only when the user
* types a new one. An empty token means "keep the saved one".
* - Read actions return `maskedToken` ("••••abcd") and never the
* plaintext.
*/
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace";
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
import { getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
import { getAdminUser } from "@/lib/admin-permissions";
// ── Public types ───────────────────────────────────────────────────────────
export type SmartsheetWorkspaceResponse = {
/** True iff a workspace row exists for this brand. */
configured: boolean;
/** Default sheet ID (the "home" tab). Null = use the first feature sheet. */
defaultSheetId: string | null;
/** Master switch — pauses all per-feature syncs without losing config. */
connectionEnabled: boolean;
/** Masked preview of the saved token ("••••abcd") or null. */
maskedToken: string | null;
/** True iff a token is saved and decrypts cleanly. */
hasToken: boolean;
/** Last successful test-connection timestamp. */
lastTestAt: string | null;
/** Most recent test-connection error (sanitized). */
lastTestError: string | null;
updatedAt: string | null;
};
export type SaveSmartsheetWorkspaceInput = {
/** Plaintext API token. Empty string = keep the saved token. */
token: string;
defaultSheetId: string | null;
connectionEnabled: boolean;
};
export type TestSmartsheetWorkspaceResult =
| {
success: true;
/** Name of the sheet the connection was tested against. */
sheetName: string;
/** Number of columns in the sheet header. */
columnCount: number;
columns: { id: string; title: string; type: string }[];
}
| { success: false; error: string };
// ── Auth ───────────────────────────────────────────────────────────────────
async function requireManageSettings(): Promise<
{ ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: false; error: string }
> {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false, error: "Not authenticated" };
if (
!adminUser.can_manage_settings &&
adminUser.role !== "platform_admin"
) {
return { ok: false, error: "Not authorized" };
}
return { ok: true, adminUser };
}
// ── Read ───────────────────────────────────────────────────────────────────
export async function getSmartsheetWorkspace(
brandId: string,
): Promise<SmartsheetWorkspaceResponse> {
const auth = await requireManageSettings();
if (!auth.ok) {
return {
configured: false,
defaultSheetId: null,
connectionEnabled: true,
maskedToken: null,
hasToken: false,
lastTestAt: null,
lastTestError: null,
updatedAt: null,
};
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const ws = rows[0];
if (!ws) {
return {
configured: false,
defaultSheetId: null,
connectionEnabled: true,
maskedToken: null,
hasToken: false,
lastTestAt: null,
lastTestError: null,
updatedAt: null,
};
}
let masked: string | null = "••••";
let hasToken = true;
try {
const plain = decryptToken({
cipher: ws.encryptedApiToken,
iv: ws.tokenIv,
authTag: ws.tokenAuthTag,
});
masked = maskToken(plain);
} catch {
hasToken = false;
masked = null;
}
return {
configured: true,
defaultSheetId: ws.defaultSheetId,
connectionEnabled: ws.connectionEnabled,
maskedToken: masked,
hasToken,
lastTestAt: ws.lastTestAt?.toISOString() ?? null,
lastTestError: ws.lastTestError,
updatedAt: ws.updatedAt.toISOString(),
};
});
}
// ── Write: save (upsert) ───────────────────────────────────────────────────
export async function saveSmartsheetWorkspace(
brandId: string,
input: SaveSmartsheetWorkspaceInput,
): Promise<
| { success: true; workspace: SmartsheetWorkspaceResponse }
| { success: false; error: string }
> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
const tokenTrimmed = input.token?.trim() ?? "";
return withBrand(brandId, async (db) => {
const existingRows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const existing = existingRows[0];
// First-time setup requires a token; subsequent saves can omit it
// to keep the saved one.
if (!existing && tokenTrimmed.length === 0) {
return {
success: false,
error: "API token is required for first-time setup",
};
}
const updateValues: Partial<typeof smartsheetWorkspace.$inferInsert> = {
defaultSheetId: input.defaultSheetId,
connectionEnabled: input.connectionEnabled,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
};
if (tokenTrimmed.length > 0) {
const enc = encryptToken(tokenTrimmed);
updateValues.encryptedApiToken = enc.cipher;
updateValues.tokenIv = enc.iv;
updateValues.tokenAuthTag = enc.authTag;
}
if (existing) {
await db
.update(smartsheetWorkspace)
.set(updateValues)
.where(eq(smartsheetWorkspace.brandId, brandId));
} else {
await db.insert(smartsheetWorkspace).values({
brandId,
encryptedApiToken: updateValues.encryptedApiToken ?? Buffer.from([]),
tokenIv: updateValues.tokenIv ?? Buffer.from([]),
tokenAuthTag: updateValues.tokenAuthTag ?? Buffer.from([]),
defaultSheetId: input.defaultSheetId,
connectionEnabled: input.connectionEnabled,
createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
});
}
return {
success: true,
workspace: await getSmartsheetWorkspace(brandId),
};
});
}
// ── Test connection ────────────────────────────────────────────────────────
export async function testSmartsheetWorkspaceConnection(
brandId: string,
sheetIdInput: string,
tokenInput: string,
): Promise<TestSmartsheetWorkspaceResult> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
if (!sheetIdInput) {
return { success: false, error: "Sheet ID or URL is required" };
}
// Resolve token: explicit > saved > error.
let token = tokenInput?.trim() ?? "";
let lastTestError: string | null = null;
if (!token) {
const saved = await withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
return rows[0] ?? null;
});
if (!saved) {
return { success: false, error: "No token saved yet — paste one above to test." };
}
try {
token = decryptToken({
cipher: saved.encryptedApiToken,
iv: saved.tokenIv,
authTag: saved.tokenAuthTag,
});
} catch {
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
}
}
try {
// We re-parse via `getSheetMeta` which calls `extractSheetId`
// internally. Surface any share-link-slug error directly.
const meta = await getSheetMeta(sheetIdInput, token);
lastTestError = null;
await withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ lastTestAt: new Date(), lastTestError: null })
.where(eq(smartsheetWorkspace.brandId, brandId));
});
return {
success: true,
sheetName: meta.name,
columnCount: meta.columns.length,
columns: meta.columns.map((c) => ({
id: c.id,
title: c.title,
type: c.type,
})),
};
} catch (err) {
lastTestError = err instanceof Error ? err.message : String(err);
// Sanitize: strip any token echoes.
const sanitized = lastTestError.split(token).join("[redacted-token]");
await withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ lastTestAt: new Date(), lastTestError: sanitized })
.where(eq(smartsheetWorkspace.brandId, brandId));
});
if (err instanceof SmartsheetApiError) {
if (err.status === 401) {
return { success: false, error: "Token rejected by Smartsheet (401 — invalid or expired)" };
}
if (err.status === 403) {
return { success: false, error: "Forbidden (403) — token doesn't have access to this sheet" };
}
if (err.status === 404) {
return { success: false, error: "Sheet not found (404) — check the ID/URL. If you pasted a share URL with a slug, try the numeric sheet ID instead." };
}
return { success: false, error: `Smartsheet ${err.status}: ${err.body.slice(0, 200)}` };
}
return { success: false, error: sanitized };
}
}
// ── Disable (preserves token, turns off connection) ────────────────────────
export async function disableSmartsheetWorkspace(
brandId: string,
): Promise<{ success: true } | { success: false; error: string }> {
const auth = await requireManageSettings();
if (!auth.ok) return { success: false, error: auth.error };
return withBrand(brandId, async (db) => {
await db
.update(smartsheetWorkspace)
.set({ connectionEnabled: false })
.where(eq(smartsheetWorkspace.brandId, brandId));
return { success: true };
});
}
// ── Resolve token helper for sync services ────────────────────────────────
//
// `resolveWorkspaceToken` lives in `src/services/workspace-token.ts`
// (server-only, NOT a server action). Per-feature action files import
// it from there directly — re-exporting through a "use server" file
// would re-expose the plaintext-token helper as a callable RPC.
+388 -75
View File
@@ -1,20 +1,37 @@
/**
* Time Tracking — field (PIN-based) actions.
*
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor.
*
* Sessions are cookie-only (no DB session row). The cookie carries
* seven pipe-delimited fields (`worker_id|session_id|expires_at|
* name|role|lang|brand_id`) — but only the first three are read from
* the cookie. name/role/lang/brand_id are ALWAYS re-resolved from the
* DB on every `getTimeTrackingSession()` call so a tampered or stale
* cookie can't impersonate a different brand. Expiry is 12 hours; the
* field app re-prompts for PIN when the cookie is gone.
*
* Clock-in is protected by a partial unique index on
* `time_tracking_logs (worker_id) WHERE clock_out IS NULL` (migration
* 0094) so concurrent / retried requests can't open two shifts.
*
* PIN verification uses scrypt (from `@/lib/water-log-pin` — shared
* with the water-log module).
*/
"use server"; "use server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getSession } from "@/lib/auth"; import { eq, and, isNull, desc } from "drizzle-orm";
import { randomUUID } from "node:crypto";
// TODO(migration): the time-tracking feature was built on Supabase RPCs import { withBrand, withPlatformAdmin } from "@/db/client";
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker, import {
// get_open_clock_in, get_time_tracking_tasks, timeTrackingWorkers,
// get_worker_pay_period_hours, get_time_tracking_settings, timeTrackingTasks,
// get_water_user_by_id, get_water_admin_session, submit_water_entry, timeTrackingLogs,
// trigger_water_alert) plus tables that don't exist in the SaaS rebuild timeTrackingSmartsheetSyncQueue,
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The } from "@/db/schema/time-tracking";
// functions below are stubs that preserve the original signature so import { verifyPin } from "@/lib/water-log-pin";
// the field UI degrades gracefully (no PIN login, no entry submit) — import { getWorkerPeriodTotals } from "./index";
// exactly the same pattern as `actions/route-trace/lots.ts`. To bring
// time tracking back, add the tables to `db/schema/` and re-implement
// these functions against Drizzle.
export type TimeTrackingSession = { export type TimeTrackingSession = {
worker_id: string; worker_id: string;
@@ -26,75 +43,292 @@ export type TimeTrackingSession = {
expires_at: string; expires_at: string;
}; };
// ── Cookie helpers ───────────────────────────────────────────────────────────── // ── Cookie helpers ─────────────────────────────────────────────────────────
const SESSION_COOKIE = "time_tracking_session"; const SESSION_COOKIE = "time_tracking_session";
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours
function sessionCookie(session: TimeTrackingSession) { function sessionCookieValue(session: TimeTrackingSession): string {
return `${session.worker_id}|${session.session_id}|${session.expires_at}`; return [
session.worker_id,
session.session_id,
session.expires_at,
session.name,
session.role,
session.lang,
session.brand_id,
].join("|");
} }
function parseSessionCookie(cookie: string): TimeTrackingSession | null { // The cookie carries worker_id + a session_id; everything else
// (name, role, lang, brand_id) is re-resolved from the DB on every read
// so a tampered or stale cookie can't impersonate a different brand.
function parseSessionCookie(cookie: string): {
worker_id: string;
session_id: string;
expires_at: string;
} | null {
const parts = cookie.split("|"); const parts = cookie.split("|");
if (parts.length !== 3) return null; if (parts.length !== 7) return null;
const [worker_id, session_id, expires_at] = parts; const [worker_id, session_id, expires_at] = parts;
if (!worker_id || !session_id || !expires_at) return null;
if (new Date(expires_at) < new Date()) return null; if (new Date(expires_at) < new Date()) return null;
return { return { worker_id, session_id, expires_at };
worker_id,
name: "",
role: "worker",
lang: "en",
session_id,
brand_id: "",
expires_at,
};
} }
// ── Verify PIN ───────────────────────────────────────────────────────────────── // ── Verify PIN ─────────────────────────────────────────────────────────────
export async function verifyTimeTrackingPin( export async function verifyTimeTrackingPin(
_brandId: string, brandId: string,
_pin: string pin: string,
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> { ): Promise<{
success: boolean;
session?: TimeTrackingSession;
error?: string;
}> {
if (!pin || pin.length < 4) {
return { success: false, error: "Enter your 4-digit PIN." };
}
await getSession(); // RPC no longer exists; surface a friendly error so the field UI can // Pull every active worker for the brand and try each PIN hash. With
// disable the PIN pad. // a small worker pool this is fine; if it grows we could index a
return { success: false, error: "Time tracking is not configured" }; // lookup table, but per-brand counts are typically <100.
const rows = await withBrand(brandId, async (db) => {
return db
.select()
.from(timeTrackingWorkers)
.where(
and(
eq(timeTrackingWorkers.brandId, brandId),
eq(timeTrackingWorkers.active, true),
),
);
});
let match: typeof timeTrackingWorkers.$inferSelect | undefined;
for (const r of rows) {
if (verifyPin(pin, r.pin)) {
match = r;
break;
}
}
if (!match) return { success: false, error: "Invalid PIN" };
// Bump lastUsedAt + set cookie.
await withBrand(brandId, async (db) => {
await db
.update(timeTrackingWorkers)
.set({ lastUsedAt: new Date() })
.where(eq(timeTrackingWorkers.id, match!.id));
});
const expiresAt = new Date(Date.now() + COOKIE_MAX_AGE * 1000);
const session: TimeTrackingSession = {
worker_id: match.id,
name: match.name,
role: match.role,
lang: match.lang,
session_id: randomUUID(),
expires_at: expiresAt.toISOString(),
brand_id: brandId,
};
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, sessionCookieValue(session), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: COOKIE_MAX_AGE,
path: "/",
});
return { success: true, session };
} }
// ── Clock In ─────────────────────────────────────────────────────────────────── // ── Clock In ───────────────────────────────────────────────────────────────
export async function clockInWorker( export async function clockInWorker(
_brandId: string, brandId: string,
_taskId?: string, taskId?: string,
_taskName = "General Labor" taskName = "General Labor",
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> { ): Promise<{
success: boolean;
await getSession(); log_id?: string;
clock_in?: string;
error?: string;
}> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(brandId, async (db) => {
// The DB enforces one-open-clock-in-per-worker via the
// `time_tracking_logs_one_open_per_worker` partial unique index
// (migration 0094). We also surface a friendly error in app code.
const open = await db
.select({ id: timeTrackingLogs.id })
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.limit(1);
if (open.length > 0) {
return { success: false, error: "Already clocked in" };
}
// If a taskId was supplied, fetch the task name (so the row stays
// accurate even if the task is renamed later). Fall back to the
// supplied taskName or "General Labor".
const resolvedTaskId = taskId ?? null;
let resolvedTaskName = taskName;
if (taskId) {
const [task] = await db
.select()
.from(timeTrackingTasks)
.where(eq(timeTrackingTasks.id, taskId))
.limit(1);
if (task) resolvedTaskName = task.name;
}
const now = new Date();
try {
const [row] = await db
.insert(timeTrackingLogs)
.values({
brandId,
workerId: session.worker_id,
taskId: resolvedTaskId,
taskName: resolvedTaskName,
clockIn: now,
submittedVia: "field",
})
.returning({
id: timeTrackingLogs.id,
clockIn: timeTrackingLogs.clockIn,
});
if (!row) return { success: false, error: "Insert returned no row" };
return {
success: true,
log_id: row.id,
clock_in: row.clockIn.toISOString(),
};
} catch (err) {
// Race lost to a concurrent clock-in: the partial unique index
// rejected our row. Treat as "already clocked in" so the worker
// sees a consistent message instead of a 500.
if (isUniqueViolation(err)) {
return { success: false, error: "Already clocked in" };
}
throw err;
}
});
} }
// ── Clock Out ────────────────────────────────────────────────────────────────── function isUniqueViolation(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /unique constraint|duplicate key/i.test(msg);
}
// Cycle 5 — best-effort Smartsheet queue enqueue. Lives in its own
// transaction (separate `withBrand` call) so it cannot poison the
// primary clock-out write when called from `clockOutWorker`.
export async function enqueueClockOutForSync(
brandId: string,
logId: string,
): Promise<void> {
await withBrand(brandId, async (db) => {
await db
.insert(timeTrackingSmartsheetSyncQueue)
.values({
brandId,
logId,
status: "pending",
})
.onConflictDoNothing({
target: [
timeTrackingSmartsheetSyncQueue.brandId,
timeTrackingSmartsheetSyncQueue.logId,
],
});
});
}
// ── Clock Out ──────────────────────────────────────────────────────────────
export async function clockOutWorker( export async function clockOutWorker(
_lunchMinutes = 0, lunchMinutes = 0,
_notes?: string notes?: string,
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> { ): Promise<{
success: boolean;
await getSession(); log_id?: string;
clock_out?: string;
total_minutes?: number;
error?: string;
}> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(session.brand_id, async (db) => {
const open = await db
.select()
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, session.brand_id),
eq(timeTrackingLogs.workerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(1);
if (open.length === 0) {
return { success: false, error: "No open clock-in to close" };
}
const log = open[0];
const now = new Date();
await db
.update(timeTrackingLogs)
.set({
clockOut: now,
lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)),
notes: notes?.trim() || null,
})
.where(eq(timeTrackingLogs.id, log.id));
// Cycle 5 — enqueue for Smartsheet sync in a SEPARATE transaction
// so a queue-insert failure (RLS denial, FK violation, transient
// backend error) can never poison the clock-out write above.
// Drizzle's `onConflictDoNothing` keeps retried clock-outs idempotent
// against the (brand_id, log_id) unique constraint from migration
// 0095.
void enqueueClockOutForSync(session.brand_id, log.id).catch(() => {
// Non-fatal: the admin UI surfaces persistent failures via the
// Recent Activity panel, and the cron drain retries.
});
const totalMinutes = Math.max(
0,
Math.round((now.getTime() - log.clockIn.getTime()) / 60000) -
Math.max(0, Math.round(lunchMinutes)),
);
return {
success: true,
log_id: log.id,
clock_out: now.toISOString(),
total_minutes: totalMinutes,
};
});
} }
// ── Get Open Clock In ────────────────────────────────────────────────────────── // ── Get Open Clock In ──────────────────────────────────────────────────────
export async function getOpenClockIn( export async function getOpenClockIn(brandId: string): Promise<{
_brandId: string
): Promise<{
success: boolean; success: boolean;
open?: boolean; open?: boolean;
log_id?: string; log_id?: string;
@@ -103,34 +337,76 @@ export async function getOpenClockIn(
elapsed_minutes?: number; elapsed_minutes?: number;
error?: string; error?: string;
}> { }> {
await getSession();
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: true, open: false };
return withBrand(brandId, async (db) => {
const [open] = await db
.select()
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(1);
if (!open) return { success: true, open: false };
const elapsed = Math.round((Date.now() - open.clockIn.getTime()) / 60000);
return {
success: true,
open: true,
log_id: open.id,
task_name: open.taskName,
clock_in: open.clockIn.toISOString(),
elapsed_minutes: elapsed,
};
});
} }
// ── Logout ───────────────────────────────────────────────────────────────────── // ── Logout ─────────────────────────────────────────────────────────────────
export async function logoutTimeTracking(): Promise<void> { export async function logoutTimeTracking(): Promise<void> {
await getSession();
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE); cookieStore.delete(SESSION_COOKIE);
} }
// ── Get Current Session ──────────────────────────────────────────────────────── // ── Get Current Session ────────────────────────────────────────────────────
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> { export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
await getSession();
const cookieStore = await cookies(); const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE); const cookie = cookieStore.get(SESSION_COOKIE);
if (!cookie) return null; if (!cookie) return null;
return parseSessionCookie(cookie.value); const parsed = parseSessionCookie(cookie.value);
if (!parsed) return null;
// Re-resolve name/role/lang/brand_id from the DB so a stale or
// tampered cookie can't impersonate a different brand. Workers is
// brand-scoped, so the session lookup has to read with platform
// admin scope (the cookie itself gates access).
return withPlatformAdmin(async (db) => {
const [worker] = await db
.select()
.from(timeTrackingWorkers)
.where(eq(timeTrackingWorkers.id, parsed.worker_id))
.limit(1);
if (!worker || !worker.active) return null;
return {
worker_id: worker.id,
name: worker.name,
role: worker.role,
lang: worker.lang,
session_id: parsed.session_id,
expires_at: parsed.expires_at,
brand_id: worker.brandId,
};
});
} }
// ── Get Tasks (for task picker) ───────────────────────────────────────────────── // ── Tasks (field picker) ───────────────────────────────────────────────────
export type TimeTaskField = { export type TimeTaskField = {
id: string; id: string;
@@ -142,14 +418,28 @@ export type TimeTaskField = {
}; };
export async function getTimeTrackingTasksField( export async function getTimeTrackingTasksField(
_brandId: string, brandId: string,
_activeOnly = true activeOnly = true,
): Promise<TimeTaskField[]> { ): Promise<TimeTaskField[]> {
return withBrand(brandId, async (db) => {
await getSession(); return []; const base = eq(timeTrackingTasks.brandId, brandId);
const rows = await db
.select()
.from(timeTrackingTasks)
.where(activeOnly ? and(base, eq(timeTrackingTasks.active, true)) : base)
.orderBy(timeTrackingTasks.sortOrder, timeTrackingTasks.name);
return rows.map((r) => ({
id: r.id,
name: r.name,
name_es: r.nameEs,
unit: r.unit,
active: r.active,
sort_order: r.sortOrder,
}));
});
} }
// ── Pay Period Hours ─────────────────────────────────────────────────────────── // ── Pay Period Hours ──────────────────────────────────────────────────────
export type PayPeriodHours = { export type PayPeriodHours = {
success: boolean; success: boolean;
@@ -184,11 +474,34 @@ const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
}); });
export async function getWorkerPayPeriodHours( export async function getWorkerPayPeriodHours(
_brandId: string brandId: string,
): Promise<PayPeriodHours> { ): Promise<PayPeriodHours> {
await getSession();
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { ...EMPTY_PAY_PERIOD }; if (!session) return { ...EMPTY_PAY_PERIOD };
return { ...EMPTY_PAY_PERIOD };
// Settings may not exist yet; default to safe thresholds.
const { getTimeTrackingSettings } = await import("./index");
const settings = await getTimeTrackingSettings(brandId);
const totals = await getWorkerPeriodTotals(
brandId,
session.worker_id,
settings,
);
return {
success: true,
total_minutes: totals.period_minutes,
total_hours: totals.period_hours,
daily_minutes: totals.day_minutes,
daily_hours: totals.day_hours,
weekly_minutes: totals.week_minutes,
weekly_hours: totals.week_hours,
daily_overtime: totals.daily_overtime,
weekly_overtime: totals.weekly_overtime,
period_start: totals.period_start,
period_end: totals.period_end,
daily_threshold: totals.daily_threshold,
weekly_threshold: totals.weekly_threshold,
};
} }
+628 -120
View File
@@ -1,23 +1,35 @@
/**
* Time Tracking — admin actions.
*
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor to
* run against Drizzle. Original implementation was Supabase-RPC-based
* and stubbed empty after the SaaS rebuild; the TODO comments are stale.
*
* Auth: every mutator calls `getAdminUser()` and enforces either
* `platform_admin` or brand-scoped permission. Reads require a brand
* context.
*
* PIN storage: the `time_tracking_workers.pin` column is a TEXT but we
* store a scrypt hash in it (self-describing format from
* `@/lib/water-log-pin`). Plaintext PINs are returned ONCE on
* create/reset and never persisted. Follow-up migration could rename
* the column to `pin_hash`.
*/
"use server"; "use server";
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth"; import { withBrand, withPlatformAdmin } from "@/db/client";
import {
timeTrackingWorkers,
timeTrackingTasks,
timeTrackingLogs,
timeTrackingSettings,
timeTrackingNotificationLog,
} from "@/db/schema/time-tracking";
import { hashPin, generatePin } from "@/lib/water-log-pin";
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers, // ── Types ───────────────────────────────────────────────────────────────────
// create_time_worker, reset_time_worker_pin, update_time_worker,
// delete_time_worker, create_time_task, update_time_task,
// delete_time_task, get_worker_time_logs, update_worker_time_log,
// delete_worker_time_log, get_time_tracking_summary,
// get_time_tracking_settings, update_time_tracking_settings,
// get_time_tracking_notification_log, check_and_notify_overtime) and
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
// `time_tracking_settings`, `time_tracking_notification_log`) were
// not carried over into the SaaS rebuild's `db/schema/`. The actions
// below return empty results. To bring time tracking back, add
// the tables to `db/schema/` and re-implement against Drizzle. See
// `actions/route-trace/lots.ts` for the same pattern.
// ── Types ─────────────────────────────────────────────────────────────────────
export type TimeWorker = { export type TimeWorker = {
id: string; id: string;
@@ -25,6 +37,7 @@ export type TimeWorker = {
name: string; name: string;
role: string; role: string;
lang: string; lang: string;
// Hash, not plaintext. Use `verifyPin(rawPin, pin)` to check.
pin: string; pin: string;
active: boolean; active: boolean;
last_used_at: string | null; last_used_at: string | null;
@@ -39,10 +52,13 @@ export type TimeTask = {
unit: string; unit: string;
active: boolean; active: boolean;
sort_order: number; sort_order: number;
brand_id: string;
created_at: string;
}; };
export type TimeLog = { export type TimeLog = {
id: string; id: string;
brand_id: string;
worker_id: string; worker_id: string;
worker_name: string; worker_name: string;
task_id: string | null; task_id: string | null;
@@ -57,147 +73,465 @@ export type TimeLog = {
}; };
export type TimeSummary = { export type TimeSummary = {
by_worker: { id: string; name: string; entry_count: number; total_hours: number }[]; by_worker: {
by_task: { id: string; name: string; name_es: string | null; entry_count: number; total_hours: number }[]; id: string;
name: string;
entry_count: number;
total_hours: number;
}[];
by_task: {
id: string;
name: string;
name_es: string | null;
entry_count: number;
total_hours: number;
}[];
totals: { entry_count: number; total_hours: number; open_count: number }; totals: { entry_count: number; total_hours: number; open_count: number };
}; };
// ── Workers ─────────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> { function rowToWorker(r: typeof timeTrackingWorkers.$inferSelect): TimeWorker {
return {
id: r.id,
brand_id: r.brandId,
name: r.name,
role: r.role,
lang: r.lang,
pin: r.pin,
active: r.active,
last_used_at: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
created_at: r.createdAt.toISOString(),
worker_number: null, // not stored in schema
};
}
await getSession(); // Time tracking tables not in SaaS rebuild — return empty list. function rowToTask(r: typeof timeTrackingTasks.$inferSelect): TimeTask {
return []; return {
id: r.id,
name: r.name,
name_es: r.nameEs,
unit: r.unit,
active: r.active,
sort_order: r.sortOrder,
brand_id: r.brandId,
created_at: r.createdAt.toISOString(),
};
}
// ── Workers ─────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(
brandId: string,
): Promise<TimeWorker[]> {
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(timeTrackingWorkers)
.where(eq(timeTrackingWorkers.brandId, brandId))
.orderBy(asc(timeTrackingWorkers.name));
return rows.map(rowToWorker);
});
} }
export async function createTimeWorker( export async function createTimeWorker(
_brandId: string, brandId: string,
_name: string, name: string,
_role = "worker", role: string = "worker",
_lang = "en" lang: string = "en",
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> { ): Promise<{
success: boolean;
await getSession(); const adminUser = await getAdminUser(); worker?: TimeWorker;
pin?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (role !== "worker" && role !== "time_admin") {
return { success: false, error: "Role must be 'worker' or 'time_admin'" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(timeTrackingWorkers)
.values({
brandId,
name: trimmed,
role,
lang,
pin: pinHash,
})
.returning();
if (!row) return { success: false, error: "Insert returned no row" };
return { success: true, worker: rowToWorker(row), pin };
});
} }
export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> { export async function resetTimeWorkerPin(
workerId: string,
await getSession(); const adminUser = await getAdminUser(); ): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const pin = generatePin();
const pinHash = hashPin(pin);
// We don't know the brand_id without looking it up; resolve first via
// platform_admin (admins can reset across brands), then narrow.
const updated = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(timeTrackingWorkers)
.set({ pin: pinHash })
.where(eq(timeTrackingWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id });
return row;
});
if (!updated) return { success: false, error: "Worker not found" };
return { success: true, pin };
} }
export async function updateTimeWorker( export async function updateTimeWorker(
_workerId: string, workerId: string,
_name: string, name: string,
_role: string, role: string,
_lang: string, lang: string,
_active: boolean active: boolean,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (role !== "worker" && role !== "time_admin") {
return { success: false, error: "Invalid role" };
}
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(timeTrackingWorkers)
.set({ name: trimmed, role, lang, active })
.where(eq(timeTrackingWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id });
return row;
});
if (!result) return { success: false, error: "Worker not found" };
return { success: true };
} }
export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> { export async function deleteTimeWorker(
workerId: string,
await getSession(); const adminUser = await getAdminUser(); ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.delete(timeTrackingWorkers)
.where(eq(timeTrackingWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id });
return row;
});
if (!result) return { success: false, error: "Worker not found" };
return { success: true };
} }
// ── Tasks ─────────────────────────────────────────────────────────────────── // ── Tasks ───────────────────────────────────────────────────────────────────
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> { export async function getTimeTrackingTasks(
brandId: string,
await getSession(); return []; activeOnly = false,
): Promise<TimeTask[]> {
return withBrand(brandId, async (db) => {
const baseWhere = eq(timeTrackingTasks.brandId, brandId);
const rows = await db
.select()
.from(timeTrackingTasks)
.where(activeOnly ? and(baseWhere, eq(timeTrackingTasks.active, true)) : baseWhere)
.orderBy(asc(timeTrackingTasks.sortOrder), asc(timeTrackingTasks.name));
return rows.map(rowToTask);
});
} }
export async function createTimeTask( export async function createTimeTask(
_brandId: string, brandId: string,
_name: string, name: string,
_nameEs: string | null = null, nameEs: string | null = null,
_unit = "hours", unit: string = "hours",
_sortOrder = 0 sortOrder = 0,
): Promise<{ success: boolean; id?: string; error?: string }> { ): Promise<{ success: boolean; id?: string; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (!["hours", "pieces", "units"].includes(unit)) {
return { success: false, error: "Unit must be 'hours', 'pieces', or 'units'" };
}
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(timeTrackingTasks)
.values({
brandId,
name: trimmed,
nameEs: nameEs?.trim() || null,
unit: unit as "hours" | "pieces" | "units",
sortOrder,
active: true,
})
.returning({ id: timeTrackingTasks.id });
if (!row) return { success: false, error: "Insert returned no row" };
return { success: true, id: row.id };
});
} }
export async function updateTimeTask( export async function updateTimeTask(
_taskId: string, taskId: string,
_name: string, name: string,
_nameEs: string, nameEs: string,
_unit: string, unit: string,
_active: boolean, active: boolean,
_sortOrder: number sortOrder: number,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (!["hours", "pieces", "units"].includes(unit)) {
return { success: false, error: "Invalid unit" };
}
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(timeTrackingTasks)
.set({
name: trimmed,
nameEs: nameEs?.trim() || null,
unit: unit as "hours" | "pieces" | "units",
active,
sortOrder,
})
.where(eq(timeTrackingTasks.id, taskId))
.returning({ id: timeTrackingTasks.id });
return row;
});
if (!result) return { success: false, error: "Task not found" };
return { success: true };
} }
export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> { export async function deleteTimeTask(
taskId: string,
await getSession(); const adminUser = await getAdminUser(); ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.delete(timeTrackingTasks)
.where(eq(timeTrackingTasks.id, taskId))
.returning({ id: timeTrackingTasks.id });
return row;
});
if (!result) return { success: false, error: "Task not found" };
return { success: true };
} }
// ── Time Logs ───────────────────────────────────────────────────────────────── // ── Time Logs (read-only — writes happen via field actions) ─────────────────
type LogListOptions = {
workerId?: string;
taskId?: string;
start?: string; // ISO timestamp
end?: string; // ISO timestamp
limit?: number;
offset?: number;
};
export async function getWorkerTimeLogs( export async function getWorkerTimeLogs(
_brandId: string, brandId: string,
_options: { options: LogListOptions = {},
workerId?: string;
taskId?: string;
start?: string;
end?: string;
limit?: number;
offset?: number;
} = {}
): Promise<TimeLog[]> { ): Promise<TimeLog[]> {
const { workerId, taskId, start, end, limit = 200, offset = 0 } = options;
await getSession(); return []; return withBrand(brandId, async (db) => {
const conditions = [eq(timeTrackingLogs.brandId, brandId)];
if (workerId)
conditions.push(eq(timeTrackingLogs.workerId, workerId));
if (taskId) conditions.push(eq(timeTrackingLogs.taskId, taskId));
if (start) conditions.push(gte(timeTrackingLogs.clockIn, new Date(start)));
if (end) conditions.push(lte(timeTrackingLogs.clockIn, new Date(end)));
const rows = await db
.select({
id: timeTrackingLogs.id,
brand_id: timeTrackingLogs.brandId,
worker_id: timeTrackingLogs.workerId,
worker_name: timeTrackingWorkers.name,
task_id: timeTrackingLogs.taskId,
task_name: timeTrackingLogs.taskName,
clock_in: timeTrackingLogs.clockIn,
clock_out: timeTrackingLogs.clockOut,
lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes,
notes: timeTrackingLogs.notes,
submitted_via: timeTrackingLogs.submittedVia,
created_at: timeTrackingLogs.createdAt,
})
.from(timeTrackingLogs)
.leftJoin(
timeTrackingWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
)
.where(and(...conditions))
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(limit)
.offset(offset);
return rows.map((r) => ({
id: r.id,
brand_id: r.brand_id,
worker_id: r.worker_id,
worker_name: r.worker_name ?? "(deleted worker)",
task_id: r.task_id,
task_name: r.task_name,
clock_in: r.clock_in.toISOString(),
clock_out: r.clock_out ? r.clock_out.toISOString() : null,
lunch_break_minutes: r.lunch_break_minutes,
notes: r.notes,
submitted_via: r.submitted_via,
total_minutes: computeTotalMinutes(
r.clock_in,
r.clock_out,
r.lunch_break_minutes,
),
created_at: r.created_at.toISOString(),
}));
});
} }
async function updateWorkerTimeLog( function computeTotalMinutes(
_logId: string, clockIn: Date,
_taskName: string, clockOut: Date | null,
_clockIn: string, lunchMinutes: number,
_clockOut: string | null, ): number {
_lunchMinutes: number, if (!clockOut) return 0;
_notes: string | null const ms = clockOut.getTime() - clockIn.getTime();
): Promise<{ success: boolean; error?: string }> { return Math.max(0, Math.round(ms / 60000) - lunchMinutes);
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
async function deleteWorkerTimeLog(_logId: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
} }
export async function getTimeTrackingSummary( export async function getTimeTrackingSummary(
_brandId: string, brandId: string,
_start: string, start: string,
_end: string end: string,
): Promise<TimeSummary> { ): Promise<TimeSummary> {
const startDate = new Date(start);
const endDate = new Date(end);
await getSession(); return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } }; return withBrand(brandId, async (db) => {
const logs = await db
.select({
workerId: timeTrackingLogs.workerId,
workerName: timeTrackingWorkers.name,
taskId: timeTrackingLogs.taskId,
taskName: timeTrackingLogs.taskName,
clockIn: timeTrackingLogs.clockIn,
clockOut: timeTrackingLogs.clockOut,
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
})
.from(timeTrackingLogs)
.leftJoin(
timeTrackingWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
gte(timeTrackingLogs.clockIn, startDate),
lte(timeTrackingLogs.clockIn, endDate),
),
);
const byWorkerMap = new Map<
string,
{ name: string; entry_count: number; total_hours: number }
>();
const byTaskMap = new Map<
string,
{ name: string; entry_count: number; total_hours: number }
>();
let totalMinutes = 0;
let openCount = 0;
for (const l of logs) {
const minutes = computeTotalMinutes(
l.clockIn,
l.clockOut,
l.lunchBreakMinutes,
);
if (!l.clockOut) {
openCount += 1;
} else {
totalMinutes += minutes;
}
if (l.workerId) {
const prev = byWorkerMap.get(l.workerId) ?? {
name: l.workerName ?? "(deleted)",
entry_count: 0,
total_hours: 0,
};
prev.entry_count += 1;
prev.total_hours += minutes / 60;
byWorkerMap.set(l.workerId, prev);
}
if (l.taskId) {
const prev = byTaskMap.get(l.taskId) ?? {
name: l.taskName,
entry_count: 0,
total_hours: 0,
};
prev.entry_count += 1;
prev.total_hours += minutes / 60;
byTaskMap.set(l.taskId, prev);
}
}
return {
by_worker: Array.from(byWorkerMap.entries()).map(([id, v]) => ({
id,
name: v.name,
entry_count: v.entry_count,
total_hours: Math.round(v.total_hours * 100) / 100,
})),
by_task: Array.from(byTaskMap.entries()).map(([id, v]) => ({
id,
name: v.name,
name_es: null,
entry_count: v.entry_count,
total_hours: Math.round(v.total_hours * 100) / 100,
})),
totals: {
entry_count: logs.length,
total_hours: Math.round((totalMinutes / 60) * 100) / 100,
open_count: openCount,
},
};
});
} }
// ── Time Tracking Settings ───────────────────────────────────────────────────── // ── Time Tracking Settings ─────────────────────────────────────────────────
export type TimeTrackingSettings = { export type TimeTrackingSettings = {
id: string; id: string;
@@ -218,21 +552,55 @@ export type TimeTrackingSettings = {
brand_name: string; brand_name: string;
}; };
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> { function rowToSettings(
r: typeof timeTrackingSettings.$inferSelect,
): TimeTrackingSettings {
return {
id: r.id,
brand_id: r.brandId,
pay_period_start_day: r.payPeriodStartDay,
pay_period_length_days: r.payPeriodLengthDays,
daily_overtime_threshold: Number(r.dailyOvertimeThreshold),
weekly_overtime_threshold: Number(r.weeklyOvertimeThreshold),
overtime_multiplier: Number(r.overtimeMultiplier),
overtime_notifications: r.overtimeNotifications,
// These don't exist on the schema row; defaults match the original.
notification_emails: [],
notification_sms_numbers: [],
enable_daily_alerts: r.overtimeNotifications,
enable_weekly_alerts: r.overtimeNotifications,
daily_alert_threshold: Number(r.dailyOvertimeThreshold),
weekly_alert_threshold: Number(r.weeklyOvertimeThreshold),
send_end_of_period_summary: false,
brand_name: "",
};
}
await getSession(); // Real RPC not in SaaS rebuild. export async function getTimeTrackingSettings(
return null; brandId: string,
): Promise<TimeTrackingSettings | null> {
return withBrand(brandId, async (db) => {
const [row] = await db
.select()
.from(timeTrackingSettings)
.where(eq(timeTrackingSettings.brandId, brandId))
.limit(1);
return row ? rowToSettings(row) : null;
});
} }
export async function updateTimeTrackingSettings( export async function updateTimeTrackingSettings(
_brandId: string, brandId: string,
_settings: { settings: {
pay_period_start_day: number; pay_period_start_day: number;
pay_period_length_days: number; pay_period_length_days: number;
daily_overtime_threshold: number; daily_overtime_threshold: number;
weekly_overtime_threshold: number; weekly_overtime_threshold: number;
overtime_multiplier: number; overtime_multiplier: number;
overtime_notifications: boolean; overtime_notifications: boolean;
// Notification targets are accepted for back-compat with the UI
// form but currently aren't persisted (the columns aren't on the
// schema). Future migration can add them.
notification_emails?: string[]; notification_emails?: string[];
notification_sms_numbers?: string[]; notification_sms_numbers?: string[];
enable_daily_alerts?: boolean; enable_daily_alerts?: boolean;
@@ -241,15 +609,41 @@ export async function updateTimeTrackingSettings(
weekly_alert_threshold?: number; weekly_alert_threshold?: number;
send_end_of_period_summary?: boolean; send_end_of_period_summary?: boolean;
brand_name?: string; brand_name?: string;
} },
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(brandId, async (db) => {
// Upsert by brand_id (which is also a UNIQUE column on the table).
await db
.insert(timeTrackingSettings)
.values({
brandId,
payPeriodStartDay: settings.pay_period_start_day,
payPeriodLengthDays: settings.pay_period_length_days,
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
overtimeMultiplier: String(settings.overtime_multiplier),
overtimeNotifications: settings.overtime_notifications,
})
.onConflictDoUpdate({
target: timeTrackingSettings.brandId,
set: {
payPeriodStartDay: settings.pay_period_start_day,
payPeriodLengthDays: settings.pay_period_length_days,
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
overtimeMultiplier: String(settings.overtime_multiplier),
overtimeNotifications: settings.overtime_notifications,
updatedAt: new Date(),
},
});
return { success: true };
});
} }
// ── Notification Log ──────────────────────────────────────────────────────── // ── Notification Log ────────────────────────────────────────────────────────
export type NotificationLogEntry = { export type NotificationLogEntry = {
id: string; id: string;
@@ -267,9 +661,123 @@ export type NotificationLogEntry = {
}; };
export async function getTimeTrackingNotificationLog( export async function getTimeTrackingNotificationLog(
_brandId: string, brandId: string,
_limit = 100 limit = 100,
): Promise<NotificationLogEntry[]> { ): Promise<NotificationLogEntry[]> {
return withBrand(brandId, async (db) => {
const rows = await db
.select({
id: timeTrackingNotificationLog.id,
workerId: timeTrackingNotificationLog.workerId,
workerName: timeTrackingWorkers.name,
notificationType: timeTrackingNotificationLog.notificationType,
recipient: timeTrackingNotificationLog.recipient,
status: timeTrackingNotificationLog.status,
createdAt: timeTrackingNotificationLog.createdAt,
})
.from(timeTrackingNotificationLog)
.leftJoin(
timeTrackingWorkers,
eq(timeTrackingWorkers.id, timeTrackingNotificationLog.workerId),
)
.where(eq(timeTrackingNotificationLog.brandId, brandId))
.orderBy(desc(timeTrackingNotificationLog.createdAt))
.limit(limit);
return rows.map((r) => ({
id: r.id,
worker_id: r.workerId,
worker_name: r.workerName ?? null,
trigger_type: r.notificationType,
threshold_hours: null,
actual_hours: null,
emails_sent: r.status === "sent" ? [r.recipient] : [],
sms_numbers_sent: [],
email_sent: r.status === "sent",
sms_sent: false,
error_message: r.status === "failed" ? r.recipient : null,
created_at: r.createdAt.toISOString(),
}));
});
}
// ── Internal helpers exported for the notifications action ─────────────────
/**
* Compute pay-period totals (used by `getWorkerPayPeriodHours` in
* field.ts and by `checkAndNotifyOvertime`). Returns hours/minutes
* for the period-to-date, today, and this week, plus overtime flags.
*/
export async function getWorkerPeriodTotals(
brandId: string,
workerId: string,
settings: TimeTrackingSettings | null,
) {
const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
startOfWeek.setHours(0, 0, 0, 0);
// Pay period start: walk back `pay_period_length_days` from today
// until we land on a day whose day-of-month matches
// `pay_period_start_day`.
const payLen = settings?.pay_period_length_days ?? 7;
const startOfPeriod = new Date(now);
startOfPeriod.setDate(
now.getDate() - ((payLen - 1) - (now.getDate() % payLen)),
);
startOfPeriod.setHours(0, 0, 0, 0);
return withBrand(brandId, async (db) => {
const logs = await db
.select({
clockIn: timeTrackingLogs.clockIn,
clockOut: timeTrackingLogs.clockOut,
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
})
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, workerId),
gte(timeTrackingLogs.clockIn, startOfPeriod),
),
);
let periodMinutes = 0;
let dayMinutes = 0;
let weekMinutes = 0;
for (const l of logs) {
const minutes = computeTotalMinutes(
l.clockIn,
l.clockOut,
l.lunchBreakMinutes,
);
periodMinutes += minutes;
if (l.clockIn >= startOfDay) dayMinutes += minutes;
if (l.clockIn >= startOfWeek) weekMinutes += minutes;
}
const dailyThreshold = settings?.daily_overtime_threshold ?? 8;
const weeklyThreshold = settings?.weekly_overtime_threshold ?? 40;
return {
period_start: startOfPeriod.toISOString(),
period_end: now.toISOString(),
period_minutes: periodMinutes,
period_hours: Math.round((periodMinutes / 60) * 100) / 100,
day_minutes: dayMinutes,
day_hours: Math.round((dayMinutes / 60) * 100) / 100,
week_minutes: weekMinutes,
week_hours: Math.round((weekMinutes / 60) * 100) / 100,
daily_overtime: dayMinutes / 60 > dailyThreshold,
weekly_overtime: weekMinutes / 60 > weeklyThreshold,
daily_threshold: dailyThreshold,
weekly_threshold: weeklyThreshold,
};
});
}
await getSession(); return [];
}
+84 -22
View File
@@ -1,36 +1,98 @@
/**
* Time Tracking — overtime notification check.
*
* Re-implemented in Cycle 2 against Drizzle. The original was a stub
* that returned `{ sent: false }`. The cron-style caller
* (`/api/time-tracking/notify`) invokes this after every clock-in/out
* to decide whether to alert the brand's configured recipients.
*
* For now we INSERT into the notification log on every check; the
* actual email/SMS dispatch is intentionally a no-op until the
* notification_targets columns land on `time_tracking_settings` (out
* of Cycle 2 scope). The notify API route can already read the log.
*/
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth"; import { withBrand } from "@/db/client";
import { timeTrackingNotificationLog } from "@/db/schema/time-tracking";
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index";
// RPC and the time-tracking notification tables are not part of the
// SaaS rebuild schema. The function below is a stub that returns
// `{ sent: false }` so the cron-style caller (`/api/time-tracking/notify`)
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
export type OvertimeCheckResult = { export type OvertimeCheckResult = {
sent: boolean; sent: boolean;
trigger_type?: string; trigger_type?: "daily" | "weekly" | "both";
message?: string; message?: string;
notification_log_id?: string; notification_log_id?: string;
daily_hours?: number;
weekly_hours?: number;
}; };
export async function checkAndNotifyOvertime( export async function checkAndNotifyOvertime(
_brandId: string, brandId: string,
_workerId: string, workerId: string,
_workerName: string, workerName: string,
_dailyHours: number, // The two hour args are accepted for back-compat with the original
_weeklyHours: number // signature (the cron caller passes them). We re-derive from the
// logs to avoid drift, but record them for the log body.
dailyHours: number,
weeklyHours: number,
): Promise<OvertimeCheckResult> { ): Promise<OvertimeCheckResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { sent: false, message: "Not authenticated" };
await getSession(); const adminUser = await getAdminUser(); const settings = await getTimeTrackingSettings(brandId);
if (!adminUser) { if (!settings || !settings.overtime_notifications) {
return { sent: false, message: "Not authenticated" }; return { sent: false, message: "Notifications disabled" };
} }
return {
sent: false, // Re-derive totals from the source of truth so the notification log
message: "Time tracking is not configured in the SaaS rebuild", // matches the actuals even if the caller passed stale numbers.
}; const totals = await getWorkerPeriodTotals(brandId, workerId, settings);
}
const dailyHit = totals.day_hours > totals.daily_threshold;
const weeklyHit = totals.week_hours > totals.weekly_threshold;
if (!dailyHit && !weeklyHit) {
return { sent: false, message: "Below thresholds" };
}
const trigger: "daily" | "weekly" | "both" = dailyHit && weeklyHit
? "both"
: dailyHit
? "daily"
: "weekly";
const subject = `Overtime alert: ${workerName}`;
const body =
`${workerName} crossed the ${trigger} overtime threshold.\n` +
`Day: ${totals.day_hours.toFixed(2)} h (threshold ${totals.daily_threshold})\n` +
`Week: ${totals.week_hours.toFixed(2)} h (threshold ${totals.weekly_threshold})\n` +
`(Reported: ${dailyHours.toFixed(2)} / ${weeklyHours.toFixed(2)})`;
// Insert a notification log row. status='pending' — actual email/SMS
// dispatch is out of Cycle 2 scope (notification_targets columns on
// time_tracking_settings land in a follow-up migration). A future
// cron will flip 'pending' → 'sent' / 'failed'.
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(timeTrackingNotificationLog)
.values({
brandId,
workerId,
notificationType: trigger,
recipient: "admin@pending",
subject,
body,
status: "pending",
})
.returning({ id: timeTrackingNotificationLog.id });
return {
sent: true,
trigger_type: trigger,
message: body,
notification_log_id: row?.id,
daily_hours: totals.day_hours,
weekly_hours: totals.week_hours,
};
});
}
+412
View File
@@ -0,0 +1,412 @@
/**
* Time Tracking — Smartsheet integration server actions.
*
* Cycle 7: per-feature config no longer holds the API token. The
* encrypted token lives in `smartsheet_workspace` (one row per brand);
* this action reads it via `resolveWorkspaceToken` and only manages
* the per-feature sheet_id + column_mapping + frequency.
*
* UI surface for the admin `/admin/water-log/settings` card
* ("Time Tracking → Smartsheet"). Mirrors
* `src/actions/water-log/smartsheet.ts`.
*
* Token handling:
* - The plaintext token NEVER crosses the server-action boundary.
* - `resolveWorkspaceToken` decrypts it locally for API calls.
* - The masked preview is fetched from the workspace.
*/
"use server";
import { desc, eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
timeTrackingSmartsheetConfig,
timeTrackingSmartsheetSyncLog,
timeTrackingSmartsheetSyncQueue,
TT_SMARTSHEET_FREQUENCIES,
type TTSmartsheetColumnMapping,
type TTSmartsheetFrequency,
} from "@/db/schema/time-tracking";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
import {
extractSheetId,
getSheetMeta,
SmartsheetApiError,
} from "@/lib/smartsheet";
import { getAdminUser } from "@/lib/admin-permissions";
// ── Public types ───────────────────────────────────────────────────────────
export type TTSmartsheetConfigResponse = {
configured: boolean;
sheetId: string | null;
syncEnabled: boolean;
syncFrequency: TTSmartsheetFrequency;
columnMapping: TTSmartsheetColumnMapping | null;
maskedToken: string | null;
lastSyncAt: string | null;
lastSyncError: string | null;
hasToken: boolean;
updatedAt: string | null;
};
export type TTSmartsheetRecentEntry = {
id: string;
logId: string | null;
smartsheetRowId: string | null;
action: "sync" | "retry" | "skip" | "queue";
success: boolean;
error: string | null;
durationMs: number | null;
createdAt: string;
};
export type TTSmartsheetQueueSummary = {
pending: number;
syncing: number;
synced: number;
failed: number;
};
export type SaveTTSmartsheetConfigInput = {
sheetId: string;
token: string;
syncEnabled: boolean;
syncFrequency: TTSmartsheetFrequency;
columnMapping: TTSmartsheetColumnMapping;
};
export type TTTestConnectionResult =
| {
success: true;
sheetName: string;
columnCount: number;
columns: { id: string; title: string; type: string }[];
}
| { success: false; error: string };
// ── Auth ───────────────────────────────────────────────────────────────────
async function requireManagePerms(): Promise<
{ ok: true } | { ok: false; error: string }
> {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false, error: "Not authenticated" };
// Time-tracking Smartsheet config is a brand-level integration
// setting — not a water-log concern. Gate on `can_manage_settings`
// until a dedicated `can_manage_time_tracking` flag exists.
if (
!adminUser.can_manage_settings &&
adminUser.role !== "platform_admin"
) {
return { ok: false, error: "Not authorized" };
}
return { ok: true };
}
// (Column keys + frequencies are imported from the schema and
// re-used directly — no re-export to avoid the "merged declaration"
// footgun.)
// ── Read: get config ───────────────────────────────────────────────────────
export async function getTTSmartsheetConfig(
brandId: string,
): Promise<TTSmartsheetConfigResponse> {
const perm = await requireManagePerms();
if (!perm.ok) {
return {
configured: false,
sheetId: null,
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: null,
lastSyncAt: null,
lastSyncError: null,
hasToken: false,
updatedAt: null,
};
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
const config = rows[0];
// Cycle 7: masked token + hasToken live on the workspace, not on
// the per-feature config.
const workspace = await getSmartsheetWorkspace(brandId);
if (!config) {
return {
configured: false,
sheetId: null,
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: workspace.maskedToken,
lastSyncAt: null,
lastSyncError: null,
hasToken: workspace.hasToken,
updatedAt: null,
};
}
return {
configured: true,
sheetId: config.sheetId,
syncEnabled: config.syncEnabled,
syncFrequency: config.syncFrequency,
columnMapping: config.columnMapping,
maskedToken: workspace.maskedToken,
hasToken: workspace.hasToken,
lastSyncAt: config.lastSyncAt ? config.lastSyncAt.toISOString() : null,
lastSyncError: config.lastSyncError ?? null,
updatedAt: config.updatedAt
? config.updatedAt.toISOString()
: null,
};
});
}
// ── Write: save config ─────────────────────────────────────────────────────
export async function saveTTSmartsheetConfig(
brandId: string,
input: SaveTTSmartsheetConfigInput,
): Promise<
{ success: true; config: TTSmartsheetConfigResponse } | { success: false; error: string }
> {
const perm = await requireManagePerms();
if (!perm.ok) return { success: false, error: perm.error };
// Validate.
try {
// extractSheetId throws on share-link slugs and bad URLs; we
// surface that error to the caller verbatim.
extractSheetId(input.sheetId);
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Invalid sheet ID",
};
}
if (
!TT_SMARTSHEET_FREQUENCIES.includes(input.syncFrequency)
) {
return {
success: false,
error: `syncFrequency must be one of ${TT_SMARTSHEET_FREQUENCIES.join(", ")}`,
};
}
if (!input.columnMapping.log_id || !input.columnMapping.clock_in) {
return {
success: false,
error: "columnMapping.log_id and columnMapping.clock_in are required (used for dedup)",
};
}
return withBrand(brandId, async (db) => {
const adminUser = (await getAdminUser()) ?? null;
const actorId = adminUser?.id ?? null;
// Cycle 7: API token is owned by the workspace. Refuse to save a
// sheet mapping if no workbook is connected.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok && ws.reason !== "connection_disabled") {
return {
success: false,
error:
ws.reason === "no_workspace"
? "Connect the Smartsheet workbook first (hub card above)."
: "Workspace token is unreadable — re-paste it in the hub card.",
};
}
const [existing] = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
if (existing) {
await db
.update(timeTrackingSmartsheetConfig)
.set({
sheetId: extractSheetId(input.sheetId),
columnMapping: input.columnMapping,
syncFrequency: input.syncFrequency,
syncEnabled: input.syncEnabled,
updatedBy: actorId,
})
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
} else {
await db.insert(timeTrackingSmartsheetConfig).values({
brandId,
sheetId: extractSheetId(input.sheetId),
columnMapping: input.columnMapping,
syncFrequency: input.syncFrequency,
syncEnabled: input.syncEnabled,
createdBy: actorId,
updatedBy: actorId,
});
}
const fresh = await getTTSmartsheetConfig(brandId);
return { success: true, config: fresh };
});
}
// ── Test connection ────────────────────────────────────────────────────────
export async function testTTSmartsheetConnection(
brandId: string,
input?: { sheetId?: string; token?: string },
): Promise<TTTestConnectionResult> {
const perm = await requireManagePerms();
if (!perm.ok) return { success: false, error: perm.error };
return withBrand(brandId, async (db) => {
const [existing] = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
const sheetId = input?.sheetId ?? existing?.sheetId ?? "";
let plaintext = "";
if (input?.token && input.token.trim().length > 0) {
plaintext = input.token.trim();
} else {
// Cycle 7: token lives on the workspace, not on the per-feature
// config. Decrypt from there.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
if (ws.reason === "no_workspace") {
return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card." };
}
if (ws.reason === "connection_disabled") {
return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
}
return { success: false, error: ws.error };
}
plaintext = ws.token;
}
if (!sheetId) {
return { success: false, error: "sheetId is required" };
}
if (!plaintext) {
return { success: false, error: "token is required" };
}
try {
const parsed = extractSheetId(sheetId);
const meta = await getSheetMeta(parsed, plaintext);
return {
success: true,
sheetName: meta.name,
columnCount: meta.columns.length,
columns: meta.columns.map((c) => ({
id: c.id,
title: c.title,
type: c.type,
})),
};
} catch (err) {
// Funnel through sanitizeError to strip any token echoes from
// Smartsheet's WWW-Authenticate headers, mirroring the
// service-layer guarantee.
const raw =
err instanceof SmartsheetApiError
? err.message
: err instanceof Error
? err.message
: String(err);
return {
success: false,
error: sanitizeTestError(raw, plaintext),
};
}
});
}
// Strip any token echo from Smartsheet's error responses before
// returning to the client. The plaintext token is only used locally
// (decrypted for the API call) — never crosses the wire.
function sanitizeTestError(raw: string, plaintext: string): string {
if (!raw || !plaintext) return raw;
return raw.split(plaintext).join("[redacted-token]");
}
// ── Read: recent sync attempts ─────────────────────────────────────────────
export async function getTTSmartsheetRecent(
brandId: string,
limit = 20,
): Promise<TTSmartsheetRecentEntry[]> {
const perm = await requireManagePerms();
if (!perm.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(timeTrackingSmartsheetSyncLog)
.where(eq(timeTrackingSmartsheetSyncLog.brandId, brandId))
.orderBy(desc(timeTrackingSmartsheetSyncLog.createdAt))
.limit(limit);
return rows.map((r) => ({
id: r.id,
logId: r.logId,
smartsheetRowId: r.smartsheetRowId,
action: r.action,
success: r.success,
error: r.error,
durationMs: r.durationMs,
createdAt: r.createdAt.toISOString(),
}));
});
}
// ── Read: queue summary ────────────────────────────────────────────────────
export async function getTTSmartsheetQueueSummary(
brandId: string,
): Promise<TTSmartsheetQueueSummary> {
const perm = await requireManagePerms();
if (!perm.ok)
return { pending: 0, syncing: 0, synced: 0, failed: 0 };
return withBrand(brandId, async (db) => {
const rows = await db
.select({
status: timeTrackingSmartsheetSyncQueue.status,
})
.from(timeTrackingSmartsheetSyncQueue)
.where(eq(timeTrackingSmartsheetSyncQueue.brandId, brandId));
return rows.reduce<TTSmartsheetQueueSummary>(
(acc, r) => {
acc[r.status] += 1;
return acc;
},
{ pending: 0, syncing: 0, synced: 0, failed: 0 },
);
});
}
// ── Disable (preserves token + config, turns off sync) ─────────────────────
export async function disableTTSmartsheet(
brandId: string,
): Promise<{ success: true } | { success: false; error: string }> {
const perm = await requireManagePerms();
if (!perm.ok) return { success: false, error: perm.error };
return withBrand(brandId, async (db) => {
await db
.update(timeTrackingSmartsheetConfig)
.set({ syncEnabled: false })
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
return { success: true };
});
}
+70 -93
View File
@@ -3,6 +3,11 @@
/** /**
* Water Log — Smartsheet integration server actions. * Water Log — Smartsheet integration server actions.
* *
* Cycle 7: per-feature config no longer holds the API token. The
* encrypted token lives in `smartsheet_workspace` (one row per brand);
* this action reads it via `resolveWorkspaceToken` and only manages
* the per-feature sheet_id + column_mapping + frequency.
*
* Surface for the `/admin/water-log/settings` UI and the entry-insert * Surface for the `/admin/water-log/settings` UI and the entry-insert
* hook in `field.ts`. Permission model mirrors the rest of the water * hook in `field.ts`. Permission model mirrors the rest of the water
* log module: only admins with `can_manage_water_log` (or * log module: only admins with `can_manage_water_log` (or
@@ -10,10 +15,9 @@
* *
* Token handling: * Token handling:
* - The plaintext token NEVER crosses the server-action boundary. * - The plaintext token NEVER crosses the server-action boundary.
* - Save actions accept a token via `input.token` only when the user * - `resolveWorkspaceToken` decrypts it locally for API calls.
* types a new one. An empty token means "keep the saved one". * - The masked preview is fetched from the workspace (not the
* - Read actions return `maskedToken` ("••••abcd") and never the * per-feature config).
* plaintext.
*/ */
import { desc, eq, and, gte, inArray } from "drizzle-orm"; import { desc, eq, and, gte, inArray } from "drizzle-orm";
import { withBrand } from "@/db/client"; import { withBrand } from "@/db/client";
@@ -28,7 +32,8 @@ import {
type SmartsheetColumnMapping, type SmartsheetColumnMapping,
type SmartsheetFrequency, type SmartsheetFrequency,
} from "@/db/schema/water-log"; } from "@/db/schema/water-log";
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto"; import { resolveWorkspaceToken } from "@/services/workspace-token";
import { getSmartsheetWorkspace } from "@/actions/smartsheet/workspace";
import { extractSheetId, getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet"; import { extractSheetId, getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
import { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync"; import { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
@@ -63,8 +68,9 @@ export type SmartsheetSyncLogEntry = {
export type SaveSmartsheetConfigInput = { export type SaveSmartsheetConfigInput = {
sheetId: string; sheetId: string;
/** Plaintext token. Empty string = keep the saved token. */ /** Cycle 7: token is owned by the workspace; this field is kept for
token: string; * backward compat with the existing UI but ignored on save. */
token?: string;
syncEnabled: boolean; syncEnabled: boolean;
syncFrequency: SmartsheetFrequency; syncFrequency: SmartsheetFrequency;
columnMapping: SmartsheetColumnMapping; columnMapping: SmartsheetColumnMapping;
@@ -106,6 +112,10 @@ export async function getSmartsheetConfig(
.where(eq(waterSmartsheetConfig.brandId, brandId)) .where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1); .limit(1);
const config = rows[0]; const config = rows[0];
// Pull the masked token + hasToken from the workspace (Cycle 7).
// If there's no workspace yet, maskedToken is null and hasToken
// is false — the UI shows "Connect a workbook" in the hub card.
const workspace = await getSmartsheetWorkspace(brandId);
if (!config) { if (!config) {
return { return {
configured: false, configured: false,
@@ -113,39 +123,23 @@ export async function getSmartsheetConfig(
syncEnabled: false, syncEnabled: false,
syncFrequency: "hourly", syncFrequency: "hourly",
columnMapping: null, columnMapping: null,
maskedToken: null, maskedToken: workspace.maskedToken,
lastSyncAt: null, lastSyncAt: null,
lastSyncError: null, lastSyncError: null,
hasToken: false, hasToken: workspace.hasToken,
updatedAt: null, updatedAt: null,
}; };
} }
// Decrypt just to compute the masked preview — never return the
// plaintext. If decryption fails (key rotated), we still report
// `hasToken: true` so the admin knows to re-enter it.
let masked: string | null = "••••";
let hasToken = true;
try {
const plain = decryptToken({
cipher: config.encryptedApiToken,
iv: config.tokenIv,
authTag: config.tokenAuthTag,
});
masked = maskToken(plain);
} catch {
hasToken = false;
masked = null;
}
return { return {
configured: true, configured: true,
sheetId: config.sheetId, sheetId: config.sheetId,
syncEnabled: config.syncEnabled, syncEnabled: config.syncEnabled,
syncFrequency: config.syncFrequency, syncFrequency: config.syncFrequency,
columnMapping: config.columnMapping, columnMapping: config.columnMapping,
maskedToken: masked, maskedToken: workspace.maskedToken,
lastSyncAt: config.lastSyncAt?.toISOString() ?? null, lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
lastSyncError: config.lastSyncError, lastSyncError: config.lastSyncError,
hasToken, hasToken: workspace.hasToken,
updatedAt: config.updatedAt.toISOString(), updatedAt: config.updatedAt.toISOString(),
}; };
}); });
@@ -204,12 +198,23 @@ export async function saveSmartsheetConfig(
if (!mappingResult.ok) { if (!mappingResult.ok) {
return { success: false, error: mappingResult.error }; return { success: false, error: mappingResult.error };
} }
// The token: empty means "keep existing" (we won't touch the
// encrypted columns). Non-empty means we re-encrypt and replace. // Cycle 7: the API token is owned by the workspace, not by this
const tokenTrimmed = input.token?.trim() ?? ""; // per-feature config. Refuse to save a sheet mapping if no
// workbook is connected — the customer would have nothing to
// authenticate against at sync time.
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok && ws.reason !== "connection_disabled") {
return {
success: false,
error:
ws.reason === "no_workspace"
? "Connect the Smartsheet workbook first (hub card above)."
: "Workspace token is unreadable — re-paste it in the hub card.",
};
}
return withBrand(brandId, async (db) => { return withBrand(brandId, async (db) => {
// ── Load existing to know if we have a token already ──
const existingRows = await db const existingRows = await db
.select() .select()
.from(waterSmartsheetConfig) .from(waterSmartsheetConfig)
@@ -217,20 +222,6 @@ export async function saveSmartsheetConfig(
.limit(1); .limit(1);
const existing = existingRows[0]; const existing = existingRows[0];
if (!existing && tokenTrimmed.length === 0) {
return {
success: false,
error: "API token is required for first-time setup",
};
}
if (existing && tokenTrimmed.length === 0 && !existing.encryptedApiToken) {
return {
success: false,
error: "API token is required (none on file)",
};
}
// ── Build the upsert payload ──
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = { const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
sheetId: normalizedSheetId, sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled, syncEnabled: input.syncEnabled,
@@ -238,34 +229,23 @@ export async function saveSmartsheetConfig(
columnMapping: input.columnMapping, columnMapping: input.columnMapping,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null, updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
}; };
if (tokenTrimmed.length > 0) {
const enc = encryptToken(tokenTrimmed);
updateValues.encryptedApiToken = enc.cipher;
updateValues.tokenIv = enc.iv;
updateValues.tokenAuthTag = enc.authTag;
}
// Upsert. if (existing) {
const [upserted] = await db await db
.insert(waterSmartsheetConfig) .update(waterSmartsheetConfig)
.values({ .set(updateValues)
.where(eq(waterSmartsheetConfig.brandId, brandId));
} else {
await db.insert(waterSmartsheetConfig).values({
brandId, brandId,
sheetId: normalizedSheetId, sheetId: normalizedSheetId,
encryptedApiToken: existing?.encryptedApiToken ?? Buffer.from([]),
tokenIv: existing?.tokenIv ?? Buffer.from([]),
tokenAuthTag: existing?.tokenAuthTag ?? Buffer.from([]),
columnMapping: input.columnMapping, columnMapping: input.columnMapping,
syncEnabled: input.syncEnabled, syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency, syncFrequency: input.syncFrequency,
createdBy: existing?.createdBy ?? auth.adminUser.email ?? auth.adminUser.id ?? null, createdBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null, updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
}) });
.onConflictDoUpdate({ }
target: waterSmartsheetConfig.brandId,
set: updateValues,
})
.returning();
if (!upserted) return { success: false, error: "Save failed" };
await logAuditEvent({ await logAuditEvent({
brandId, brandId,
@@ -278,11 +258,9 @@ export async function saveSmartsheetConfig(
sheetId: normalizedSheetId, sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled, syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency, syncFrequency: input.syncFrequency,
tokenRotated: tokenTrimmed.length > 0,
}, },
}); });
// Re-read for a clean response.
return { return {
success: true, success: true,
config: await getSmartsheetConfig(brandId), config: await getSmartsheetConfig(brandId),
@@ -326,12 +304,17 @@ export async function deleteSmartsheetConfig(
} }
/** /**
* Test the Smartsheet connection without saving. * Test the Smartsheet connection against a sheet ID, using the
* workspace's saved token.
* *
* - If `token` is non-empty, it's used directly (the typical flow * - If `token` is non-empty, it's used directly (test the hub card
* for the "Test Connection" button on a fresh form). * before saving).
* - If `token` is empty and a token is saved, decrypt + test that. * - If `token` is empty, the workspace token is decrypted and used.
* - Returns the sheet name + columns on success. *
* Cycle 7: the token is no longer saved per-feature — it's the
* workspace's job. This action exists for the per-feature "Test
* Connection" button that re-uses the same UI affordance; the
* underlying token always comes from `smartsheet_workspace`.
* *
* Does NOT throw on API errors — returns a structured failure so the * Does NOT throw on API errors — returns a structured failure so the
* UI can show a helpful message. * UI can show a helpful message.
@@ -353,29 +336,23 @@ export async function testSmartsheetConnection(
return { success: false, error: (err as Error).message }; return { success: false, error: (err as Error).message };
} }
// Resolve the token: explicit > saved > error. // Cycle 7: resolve the token from the workspace, not from the
// per-feature config. If `tokenInput` is provided (the user is
// pasting a fresh token for a Test-Connection-from-form flow),
// honor it. Otherwise decrypt the saved workspace token.
let token = tokenInput?.trim() ?? ""; let token = tokenInput?.trim() ?? "";
if (!token) { if (!token) {
const saved = await withBrand(brandId, async (db) => { const ws = await resolveWorkspaceToken(brandId);
const rows = await db if (!ws.ok) {
.select() if (ws.reason === "no_workspace") {
.from(waterSmartsheetConfig) return { success: false, error: "No Smartsheet workbook connected yet — paste a token in the hub card above." };
.where(eq(waterSmartsheetConfig.brandId, brandId)) }
.limit(1); if (ws.reason === "connection_disabled") {
return rows[0] ?? null; return { success: false, error: "Smartsheet connection is disabled — enable it in the hub card." };
}); }
if (!saved) { return { success: false, error: ws.error };
return { success: false, error: "No token saved yet — paste one above to test." };
}
try {
token = decryptToken({
cipher: saved.encryptedApiToken,
iv: saved.tokenIv,
authTag: saved.tokenAuthTag,
});
} catch {
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
} }
token = ws.token;
} }
try { try {
+2 -2
View File
@@ -1,4 +1,4 @@
import WaterLogAdminPanel from "@/components/admin/WaterLogAdminPanel"; import WaterLogShell from "@/components/admin/water-log/Shell";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin"; import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -82,7 +82,7 @@ export default async function AdminWaterLogPage() {
className="px-6 pt-6" className="px-6 pt-6"
/> />
<div className="px-6 pb-8"> <div className="px-6 pb-8">
<WaterLogAdminPanel <WaterLogShell
initialUsers={users} initialUsers={users}
initialHeadgates={headgates} initialHeadgates={headgates}
initialEntries={entries} initialEntries={entries}
+27 -6
View File
@@ -22,7 +22,9 @@ import {
regenerateAdminPin, regenerateAdminPin,
type AdminSettings, type AdminSettings,
} from "@/actions/water-log/settings"; } from "@/actions/water-log/settings";
import SmartsheetHub from "@/components/admin/water-log/SmartsheetHub";
import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard"; import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard";
import TimeTrackingSmartsheetCard from "@/components/admin/time-tracking/TimeTrackingSmartsheetCard";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
@@ -475,8 +477,10 @@ export default function WaterLogSettingsPage() {
</button> </button>
</form> </form>
{/* Smartsheet Integration — independent of the Admin Portal toggle above. {/* Smartsheet Workbook Hub (Cycle 7). One token, many sheets.
Self-contained card with its own save / test / activity surface. */} The hub card owns the brand-level workbook connection (token +
test + enable); the per-feature cards below pick which sheet
inside the workbook to write to. */}
<div className="mt-12 border-t border-[#d4d9d3] pt-10"> <div className="mt-12 border-t border-[#d4d9d3] pt-10">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]"> <p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
§ 05 Integrations § 05 Integrations
@@ -488,14 +492,31 @@ export default function WaterLogSettingsPage() {
Smartsheet Smartsheet
</h2> </h2>
<p className="mt-2 text-sm text-[#5a5d5a]"> <p className="mt-2 text-sm text-[#5a5d5a]">
Push every new water log entry to a Smartsheet sheet. The Connect one Smartsheet workbook and pick which sheet each
brand admin (you) controls the connection, frequency, and feature writes to. The token is encrypted at rest; per-feature
column mapping. The token is encrypted at rest. cards below manage only their sheet mapping.
</p> </p>
<div className="mt-6"> <div className="mt-6">
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} /> <SmartsheetHub brandId={TUXEDO_BRAND_ID} />
</div> </div>
</div> </div>
{/* Water Log sheet mapping — uses the workbook token from the
hub card above. Configures which sheet inside the workbook
receives water-log entries. */}
<div className="mt-8">
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
</div>
{/* Time Tracking sheet mapping — also uses the workbook token.
Configures which sheet inside the workbook receives
clock-out rows. */}
<div className="mt-8">
<TimeTrackingSmartsheetCard brandId={TUXEDO_BRAND_ID} />
</div>
{/* § 06 removed in Cycle 7 — Time Tracking sheet mapping is now
a sibling card under § 05 above (uses the workbook token). */}
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -99,7 +99,7 @@ export async function GET(req: NextRequest) {
]); ]);
allWorkers.push(workers); allWorkers.push(workers);
allSettings.push(settings); allSettings.push(settings);
allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId }))); allLogs = allLogs.concat((logs as unknown as LogEntry[]).map(l => ({ ...l, brandId })));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@@ -0,0 +1,120 @@
/**
* Cron-driven Time Tracking Smartsheet sync drain.
*
* Cycle 8 — mirrors `/api/water-log/smartsheet-sync/route.ts` so
* closed clock-outs eventually land in the customer's Smartsheet
* workbook even when the worker loses connectivity between clock-out
* and the next realtime push.
*
* Hit by Vercel cron (see vercel.json entry: every 15 minutes). The
* per-frequency gating happens inside `runScheduledTTSync`:
* - realtime: the event-driven push in `clockOutWorker` covers
* most cases; the cron recovers anything that failed
* to enqueue or was retried out.
* - every_15_min: drains the queue if last sync was ≥15m ago.
* - hourly: drains the queue if last sync was ≥60m ago.
*
* Auth:
* - Bearer token in `Authorization: Bearer $CRON_SECRET` header,
* or `?secret=$CRON_SECRET` query param (Vercel cron doesn't
* support either natively; the header is what curl / external
* schedulers use). Falls back to `CRON_SECRET` for cross-
* compatibility with the existing automation routes.
*
* Body (optional):
* - `{ brandId?: string }` — restrict to one brand (useful for the
* "Retry now" admin button). If omitted, runs for every active
* brand.
*/
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import { timeTrackingSmartsheetConfig } from "@/db/schema/time-tracking";
import { runScheduledTTSync } from "@/services/time-tracking-smartsheet-sync";
import {
drainOneBrand,
summarizeDrains,
} from "@/services/sync-drain-summary";
function unauthorized() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
function readCronSecret(): string {
// Treat empty strings as "not set" so the CRON_SECRET fallback
// works even when SMARTSHEET_CRON_SECRET is present-but-empty
// (the vi.stubEnv idiom and some hosting dashboards).
const a = process.env.SMARTSHEET_CRON_SECRET?.trim();
const b = process.env.CRON_SECRET?.trim();
if (a) return a;
if (b) return b;
return "";
}
function authenticate(req: NextRequest): boolean {
// Read env per-request so deploys can rotate CRON_SECRET without a
// module reload.
const secret = readCronSecret();
if (!secret) {
// No secret configured: refuse in prod, allow in dev so local
// debugging doesn't need a token.
return process.env.NODE_ENV !== "production";
}
const headerToken = req.headers
.get("authorization")
?.replace(/^Bearer\s+/i, "");
const queryToken = new URL(req.url).searchParams.get("secret");
return headerToken === secret || queryToken === secret;
}
// We need to enumerate every brand with a time-tracking smartsheet
// config enabled. The workspace read happens inside
// runScheduledTTSync, but we don't have a separate
// `timeTrackingSmartsheetConfig` helper that lists "active" brands.
// Instead, we ask Postgres directly via a raw query through
// `withPlatformAdmin`.
async function listActiveBrandIds(): Promise<string[]> {
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ id: timeTrackingSmartsheetConfig.brandId })
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.syncEnabled, true));
return rows.map((r) => r.id);
});
}
export async function POST(req: NextRequest) {
if (!authenticate(req)) return unauthorized();
let body: { brandId?: string } = {};
try {
const text = await req.text();
if (text) body = JSON.parse(text);
} catch {
// ignore — body is optional
}
const start = Date.now();
const brandIds = body.brandId
? [body.brandId]
: await listActiveBrandIds();
const results = await Promise.all(
brandIds.map((brandId) => drainOneBrand(brandId, runScheduledTTSync)),
);
const totals = summarizeDrains(results);
return NextResponse.json({
success: totals.failed === 0,
durationMs: Date.now() - start,
totals,
results,
});
}
// GET is also supported for easy browser testing during development.
// Same auth; in production it should be POST-only.
export async function GET(req: NextRequest) {
return POST(req);
}
+9 -8
View File
@@ -1,12 +1,13 @@
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient"; import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
import { getBrandSettingsPublic } from "@/actions/brand-settings"; import { getBrandSettingsPublic } from "@/actions/brand-settings";
import {
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; TUXEDO_BRAND_ID,
const BRAND_NAME = "Tuxedo Corn"; TUXEDO_BRAND_NAME,
const BRAND_ACCENT = "green"; TUXEDO_BRAND_ACCENT,
} from "@/lib/water-log/brand";
export const metadata = { export const metadata = {
title: "Worker Clock — Tuxedo Corn", title: `Worker Clock — ${TUXEDO_BRAND_NAME}`,
}; };
export default async function TuxedoTimeClockPage() { export default async function TuxedoTimeClockPage() {
@@ -15,9 +16,9 @@ export default async function TuxedoTimeClockPage() {
return ( return (
<TimeTrackingFieldClient <TimeTrackingFieldClient
brandId={BRAND_ID} brandId={TUXEDO_BRAND_ID}
brandName={BRAND_NAME} brandName={TUXEDO_BRAND_NAME}
brandAccent={BRAND_ACCENT} brandAccent={TUXEDO_BRAND_ACCENT}
logoUrl={logoUrl} logoUrl={logoUrl}
/> />
); );
+58
View File
@@ -0,0 +1,58 @@
/**
* Cycle 6 — Field-worker sub-PWA layout. Overrides the root layout's
* `manifest`, `themeColor`, and `applicationName` so the worker PWA
* installs as "Water Log — Tuxedo" rather than "Route Commerce".
*
* Per Next.js App Router semantics, child layouts can override any
* `Metadata` field set by an ancestor — `manifest` and `themeColor`
* both qualify. The root layout still provides fonts + providers.
*/
import type { Metadata, Viewport } from "next";
import { FieldSWRegistration } from "@/components/pwa/FieldSWRegistration";
import { FieldInstallPrompt } from "@/components/pwa/FieldInstallPrompt";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: "#14532d",
viewportFit: "cover",
};
export const metadata: Metadata = {
title: {
default: "Water Log · Tuxedo",
template: "%s · Water Log",
},
description:
"Field log of Tuxedo water readings + worker time tracking — PIN-protected, mobile-first.",
applicationName: "Water Log",
manifest: "/manifest-field.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Water Log",
},
robots: {
index: false,
follow: false,
},
};
export default function WaterLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{/* Client-side: register the scoped SW + show the field-
branded install prompt. Both are no-op on first paint and
only fire under user-instigated conditions. */}
<FieldSWRegistration />
<FieldInstallPrompt />
{children}
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,531 @@
"use client";
/**
* TimeTrackingSmartsheetCard — admin `/admin/water-log/settings`
* card for the Time Tracking → Smartsheet integration (Cycle 5).
*
* Cycle 7: this card no longer owns the API token. The token lives
* on the workspace (one per brand, owned by the hub card above).
* This card only manages the per-feature mapping: sheet ID + column
* mapping + frequency + sync enabled + recent activity.
*
* Self-contained client component. Takes `brandId` as a prop per
* the brand-isolation convention.
*/
import { useCallback, useEffect, useReducer, useState } from "react";
import {
getTTSmartsheetConfig,
saveTTSmartsheetConfig,
testTTSmartsheetConnection,
getTTSmartsheetRecent,
disableTTSmartsheet,
getTTSmartsheetQueueSummary,
type TTSmartsheetConfigResponse,
type TTSmartsheetRecentEntry,
type TTSmartsheetQueueSummary,
} from "@/actions/time-tracking/smartsheet";
import { TT_SMARTSHEET_FREQUENCIES } from "@/db/schema/time-tracking";
import type {
TTSmartsheetColumnMapping,
TTSmartsheetColumnKey,
TTSmartsheetFrequency,
} from "@/db/schema/time-tracking";
import { formatDateTime } from "@/lib/format-date";
type Props = {
brandId: string;
};
type Status =
| { kind: "idle" }
| { kind: "saved-at"; iso: string }
| { kind: "error"; message: string };
type State = {
config: TTSmartsheetConfigResponse | null;
sheetIdInput: string;
frequency: TTSmartsheetFrequency;
syncEnabled: boolean;
columns: TTColumnOption[];
columnMapping: TTSmartsheetColumnMapping;
recent: TTSmartsheetRecentEntry[];
queue: TTSmartsheetQueueSummary;
loading: boolean;
testing: boolean;
saving: boolean;
status: Status;
};
type TTColumnOption = {
id: string;
title: string;
type: string;
};
type Action =
| { type: "SET_CONFIG"; config: TTSmartsheetConfigResponse }
| { type: "SET_SHEET_ID"; value: string }
| { type: "SET_FREQUENCY"; value: TTSmartsheetFrequency }
| { type: "SET_SYNC_ENABLED"; value: boolean }
| { type: "SET_COLUMNS"; columns: NonNullable<State["columns"]> }
| { type: "SET_COL_MAPPING_VALUE"; key: TTSmartsheetColumnKey; columnId: string | null }
| { type: "SET_RECENT"; entries: TTSmartsheetRecentEntry[] }
| { type: "SET_QUEUE"; queue: TTSmartsheetQueueSummary }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_TESTING"; value: boolean }
| { type: "SET_SAVING"; value: boolean }
| { type: "SET_STATUS"; value: Status };
const EMPTY_MAPPING: TTSmartsheetColumnMapping = {
log_id: "",
clock_in: "",
clock_out: null,
worker: null,
task: null,
hours: null,
lunch_minutes: null,
notes: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CONFIG": {
const cfg = action.config;
return {
...state,
config: cfg,
sheetIdInput: cfg.sheetId ?? "",
frequency: cfg.syncFrequency,
syncEnabled: cfg.syncEnabled,
columnMapping: cfg.columnMapping ?? { ...EMPTY_MAPPING },
loading: false,
};
}
case "SET_SHEET_ID":
return { ...state, sheetIdInput: action.value };
case "SET_FREQUENCY":
return { ...state, frequency: action.value };
case "SET_SYNC_ENABLED":
return { ...state, syncEnabled: action.value };
case "SET_COLUMNS":
return { ...state, columns: action.columns };
case "SET_COL_MAPPING_VALUE":
return {
...state,
columnMapping: {
...state.columnMapping,
[action.key]: action.columnId,
},
};
case "SET_RECENT":
return { ...state, recent: action.entries };
case "SET_QUEUE":
return { ...state, queue: action.queue };
case "SET_LOADING":
return { ...state, loading: action.loading };
case "SET_TESTING":
return { ...state, testing: action.value };
case "SET_SAVING":
return { ...state, saving: action.value };
case "SET_STATUS":
return { ...state, status: action.value };
}
}
function initialState(): State {
return {
config: null,
sheetIdInput: "",
frequency: "hourly",
syncEnabled: false,
columns: [],
columnMapping: { ...EMPTY_MAPPING },
recent: [],
queue: { pending: 0, syncing: 0, synced: 0, failed: 0 },
loading: true,
testing: false,
saving: false,
status: { kind: "idle" },
};
}
export function TimeTrackingSmartsheetCard({ brandId }: Props) {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const [, force] = useState({}); // for non-react subscribed re-renders
const refresh = useCallback(async () => {
const [cfg, recent, queue] = await Promise.all([
getTTSmartsheetConfig(brandId),
getTTSmartsheetRecent(brandId, 10),
getTTSmartsheetQueueSummary(brandId),
]);
dispatch({ type: "SET_CONFIG", config: cfg });
dispatch({ type: "SET_RECENT", entries: recent });
dispatch({ type: "SET_QUEUE", queue });
}, [brandId]);
useEffect(() => {
void refresh();
}, [refresh]);
// Cycle 7: Test Connection uses the workspace's saved token (no
// per-feature token to pass). The workspace action
// `testSmartsheetWorkspaceConnection` is the hub's button; here we
// call the per-feature test action with no token, which falls
// through to the workspace resolver.
const onTestConnection = useCallback(async () => {
dispatch({ type: "SET_TESTING", value: true });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
const result = await testTTSmartsheetConnection(brandId, {
sheetId: state.sheetIdInput,
// No `token` — the action pulls the saved workspace token.
});
if (!result.success) {
dispatch({
type: "SET_STATUS",
value: { kind: "error", message: result.error },
});
dispatch({ type: "SET_TESTING", value: false });
return;
}
dispatch({ type: "SET_COLUMNS", columns: result.columns });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
dispatch({ type: "SET_TESTING", value: false });
force({});
}, [brandId, state.sheetIdInput]);
const onSave = useCallback(async () => {
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "SET_STATUS", value: { kind: "idle" } });
const result = await saveTTSmartsheetConfig(brandId, {
sheetId: state.sheetIdInput,
// Cycle 7: token is owned by the workspace; this field is
// ignored on save but kept for type compat.
token: "",
syncEnabled: state.syncEnabled,
syncFrequency: state.frequency,
columnMapping: state.columnMapping,
});
if (!result.success) {
dispatch({
type: "SET_STATUS",
value: { kind: "error", message: result.error },
});
dispatch({ type: "SET_SAVING", value: false });
return;
}
dispatch({
type: "SET_CONFIG",
config: result.config,
});
dispatch({
type: "SET_STATUS",
value: { kind: "saved-at", iso: new Date().toISOString() },
});
dispatch({ type: "SET_SAVING", value: false });
}, [brandId, state]);
const onDisable = useCallback(async () => {
dispatch({ type: "SET_SAVING", value: true });
await disableTTSmartsheet(brandId);
await refresh();
dispatch({ type: "SET_SAVING", value: false });
dispatch({
type: "SET_STATUS",
value: { kind: "saved-at", iso: new Date().toISOString() },
});
}, [brandId, refresh]);
if (state.loading) {
return (
<div className="rounded-2xl border border-[#e6dfd1] bg-white p-6">
<div className="h-5 w-40 bg-[#fdfaf2] animate-pulse rounded" />
<div className="mt-3 h-4 w-72 bg-[#fdfaf2] animate-pulse rounded" />
</div>
);
}
const hasWorkspaceToken = state.config?.hasToken === true;
const maskedToken = state.config?.maskedToken ?? null;
return (
<div
className="rounded-2xl border border-[#e6dfd1] bg-white p-6 shadow-sm"
data-test="tt-smartsheet-card"
>
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-xl font-bold text-[#1a4d2e]">
Time Tracking Smartsheet
</h2>
<p className="mt-1 text-sm text-[rgba(60,60,67,0.65)]">
Push every closed clock-out to a sheet inside the connected
Smartsheet workbook. The API token is managed by the hub
card above; this card only configures the sheet mapping.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-[#1a4d2e]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#1a4d2e] accent-[#1a4d2e]"
checked={state.syncEnabled}
onChange={(e) =>
dispatch({
type: "SET_SYNC_ENABLED",
value: e.target.checked,
})
}
data-test="tt-smartsheet-enabled"
/>
Enabled
</label>
</div>
{/* Workspace token reference */}
<div className="mb-4 rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-xs text-[rgba(60,60,67,0.65)]">
{hasWorkspaceToken && maskedToken ? (
<>
Using workbook token{" "}
<span className="font-mono text-[#1a4d2e]">{maskedToken}</span>{" "}
from the hub card above.
</>
) : (
<>
No Smartsheet workbook is connected yet. Save the hub card
above with an API token before configuring this sheet.
</>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Sheet ID or URL
</label>
<input
type="text"
placeholder="e.g. 8309876543210123"
value={state.sheetIdInput}
onChange={(e) =>
dispatch({ type: "SET_SHEET_ID", value: e.target.value })
}
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-sheet-id"
/>
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
Pick the sheet inside the workbook where clock-outs should land.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Frequency
</label>
<select
value={state.frequency}
onChange={(e) =>
dispatch({
type: "SET_FREQUENCY",
value: e.target.value as TTSmartsheetFrequency,
})
}
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-frequency"
>
{TT_SMARTSHEET_FREQUENCIES.map((f) => (
<option key={f} value={f}>
{f.replace(/_/g, " ")}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={state.testing || !state.sheetIdInput}
onClick={onTestConnection}
className="rounded-md border border-[#1a4d2e] bg-white px-3 py-1.5 text-sm font-semibold text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-test"
>
{state.testing ? "Testing…" : "Test Connection"}
</button>
<button
type="button"
disabled={state.saving}
onClick={onSave}
className="rounded-md bg-[#1a4d2e] px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-[#14432a] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
{state.config?.configured && state.syncEnabled && (
<button
type="button"
disabled={state.saving}
onClick={onDisable}
className="rounded-md border border-[#a4452b] bg-white px-3 py-1.5 text-sm font-semibold text-[#a4452b] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
>
Disable
</button>
)}
<span className="ml-2 text-xs text-[rgba(60,60,67,0.55)]">
{state.status.kind === "saved-at"
? `Saved at ${formatDateTime(new Date(state.status.iso))}`
: state.status.kind === "error"
? state.status.message
: state.config?.lastSyncAt
? `Last sync ${formatDateTime(new Date(state.config.lastSyncAt))}`
: "Never synced"}
</span>
</div>
{state.columns.length > 0 && (
<ColumnMappingPanel
columns={state.columns}
mapping={state.columnMapping}
onChange={(key, columnId) =>
dispatch({
type: "SET_COL_MAPPING_VALUE",
key,
columnId,
})
}
/>
)}
<RecentActivity recent={state.recent} queue={state.queue} />
</div>
);
}
function ColumnMappingPanel({
columns,
mapping,
onChange,
}: {
columns: NonNullable<State["columns"]>;
mapping: TTSmartsheetColumnMapping;
onChange: (key: TTSmartsheetColumnKey, columnId: string | null) => void;
}) {
return (
<div className="mt-6 rounded-xl border border-[#e6dfd1] bg-[#fdfaf2] p-4">
<h3 className="text-sm font-semibold text-[#1a4d2e]">Column mapping</h3>
<p className="mt-1 text-xs text-[rgba(60,60,67,0.65)]">
Required: <span className="font-mono">log_id</span>,{" "}
<span className="font-mono">clock_in</span>. Others optional.
</p>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{(
[
["log_id", "Log ID (UUID, for dedup)", true],
["clock_in", "Clock-in (timestamp)", true],
["clock_out", "Clock-out (timestamp)", false],
["worker", "Worker name", false],
["task", "Task name", false],
["hours", "Hours (decimal)", false],
["lunch_minutes", "Lunch minutes", false],
["notes", "Notes", false],
] as Array<[TTSmartsheetColumnKey, string, boolean]>
).map(([key, label, required]) => (
<label key={key} className="block">
<span className="block text-xs font-medium text-[rgba(60,60,67,0.65)]">
{label}
{required && (
<span className="ml-1 text-[#a4452b]">*</span>
)}
</span>
<select
value={mapping[key] ?? ""}
onChange={(e) =>
onChange(
key,
e.target.value === "" ? null : e.target.value,
)
}
className="mt-1 w-full rounded-md border border-[#e6dfd1] bg-white px-2 py-1.5 text-sm"
>
<option value=""> not mapped </option>
{columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title || `Column ${c.id}`}
</option>
))}
</select>
</label>
))}
</div>
</div>
);
}
function RecentActivity({
recent,
queue,
}: {
recent: TTSmartsheetRecentEntry[];
queue: TTSmartsheetQueueSummary;
}) {
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-[#1a4d2e]">
Recent activity
</h3>
<div className="mt-2 flex gap-3 text-xs text-[rgba(60,60,67,0.65)]">
<span>Pending: {queue.pending}</span>
<span>Syncing: {queue.syncing}</span>
<span>Synced: {queue.synced}</span>
<span>Failed: {queue.failed}</span>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-[#e6dfd1]">
<table className="w-full text-left text-sm">
<thead className="bg-[#fdfaf2] text-xs uppercase tracking-wide text-[rgba(60,60,67,0.55)]">
<tr>
<th className="px-3 py-2">When</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Result</th>
<th className="px-3 py-2">Error</th>
</tr>
</thead>
<tbody>
{recent.length === 0 && (
<tr>
<td
className="px-3 py-3 text-center text-[rgba(60,60,67,0.55)]"
colSpan={4}
>
No sync attempts yet.
</td>
</tr>
)}
{recent.map((r) => (
<tr key={r.id} className="border-t border-[#f0e9d8]">
<td className="px-3 py-2 text-xs">
{formatDateTime(new Date(r.createdAt))}
</td>
<td className="px-3 py-2 text-xs">{r.action}</td>
<td className="px-3 py-2 text-xs">
<span
className={
r.success
? "inline-block rounded-full bg-[#1a4d2e]/10 px-2 py-0.5 text-[#1a4d2e]"
: "inline-block rounded-full bg-[#a4452b]/10 px-2 py-0.5 text-[#a4452b]"
}
>
{r.success ? "OK" : "FAIL"}
</span>
</td>
<td className="px-3 py-2 text-xs">
{r.error ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default TimeTrackingSmartsheetCard;
+275
View File
@@ -0,0 +1,275 @@
/**
* Shell — the tabbed shell for the Water Log admin.
*
* Replaces the monolithic `WaterLogAdminPanel` (which was 2117 lines
* covering 9 distinct UI concerns). The shell owns:
*
* - the reducer + initial state (delegate to ./reducer)
* - the localStorage-backed season + recipient settings
* - the "today" derived state (today's date / in-season / today's entries)
* - the tab state
* - the toast dispatcher
*
* Each tab is a focused component under ./tabs that consumes the
* state slice it needs.
*
* Adding a new tab only requires:
* 1. Create the tab component under ./tabs
* 2. Add a TabValue + tabs[] entry below
* 3. Render it conditionally in the {tab === X && ...} block
*/
"use client";
import { useEffect, useMemo, useReducer, useState } from "react";
import { useRouter } from "next/navigation";
import AdminFilterTabs from "@/components/admin/design-system/AdminFilterTabs";
import {
isInIrrigationSeason,
shapeWaterLogEntry,
type WaterLogReportRow,
} from "@/lib/water-log-reporting";
import { formatDate } from "@/lib/format-date";
import type {
AdminEntry,
AdminHeadgate,
AdminIrrigator,
} from "@/actions/water-log/admin";
import { reducer, initState } from "./reducer";
import type { ReportPreviewData } from "./types";
import { HeaderNav } from "./shared/HeaderNav";
import { ToastNotification } from "./shared/Toast";
import { ReportPreviewModal } from "./shared/ReportPreviewModal";
import { TodayTab } from "./tabs/TodayTab";
import { HeadgatesTab } from "./tabs/HeadgatesTab";
import { UsersTab } from "./tabs/UsersTab";
import { TimeTrackingTab } from "./tabs/TimeTrackingTab";
// ── Props ────────────────────────────────────────────────────────────────────
export type ShellProps = {
initialUsers: AdminIrrigator[];
initialHeadgates: AdminHeadgate[];
initialEntries: AdminEntry[];
brandId: string;
canManage: boolean;
};
// ── Tab values ───────────────────────────────────────────────────────────────
type TabValue = "today" | "headgates" | "users" | "time-tracking";
const TABS = [
{
value: "today" as const,
label: "Today",
// could pull counts from props for badges, omitted for now
},
{
value: "headgates" as const,
label: "Headgates",
},
{
value: "users" as const,
label: "Users",
},
// Cycle 3 — embed the existing TimeTrackingAdminPanel (which has
// its own sub-tabs) so the water-log shell becomes a one-stop hub
// for the customer: today's entries, the headgates, who logs them,
// and how many hours they put in.
{
value: "time-tracking" as const,
label: "Time Tracking",
},
];
// ── Main component ───────────────────────────────────────────────────────────
export default function Shell({
initialUsers,
initialHeadgates,
initialEntries,
brandId,
canManage,
}: ShellProps) {
const router = useRouter();
const [tab, setTab] = useState<TabValue>("today");
const [state, dispatch] = useReducer(reducer, {
initialHeadgates,
initialUsers,
initialEntries,
}, initState);
// Resolve "today" once on mount, in the browser, to avoid hydration
// mismatches. The setState is wrapped in an async IIFE so the effect
// doesn't call setState synchronously (which trips the cascading-
// renders ESLint rule).
useEffect(() => {
void (async () => {
const now = new Date();
dispatch({
type: "SET_TODAY",
today: now.toISOString().slice(0, 10),
label: formatDate(now),
});
})();
}, []);
// Derived data ─────────────────────────────────────────────────────────────
const inSeason = useMemo(
() => isInIrrigationSeason(new Date(), state.seasonStart),
[state.seasonStart],
);
const todayEntries = useMemo(() => {
if (!state.today) return [];
return state.entries.filter(
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === state.today,
);
}, [state.entries, state.today]);
const todayTotal = useMemo(
() => todayEntries.reduce((s, e) => s + e.measurement, 0),
[todayEntries],
);
const lastEntryByHeadgate = useMemo(() => {
const map = new Map<string, AdminEntry>();
for (const e of state.entries) {
const existing = map.get(e.headgate_id);
if (!existing || e.logged_at > existing.logged_at) {
map.set(e.headgate_id, e);
}
}
return map;
}, [state.entries]);
// Toast helper ─────────────────────────────────────────────────────────────
function notify(msg: string, kind: "ok" | "err" = "ok") {
dispatch({ type: "SET_TOAST", toast: { msg, kind } });
setTimeout(() => dispatch({ type: "CLEAR_TOAST" }), 2800);
}
// ── Persisted UI preferences (lives in the shell so the storage
// contract is owned by one place, not scattered through tabs).
function persistSeason(s: import("@/lib/water-log-reporting").IrrigationSeasonSettings) {
dispatch({ type: "SET_SEASON_START", value: s });
try {
localStorage.setItem("wl_season_settings:v1", JSON.stringify(s));
} catch {}
}
function persistRecipientName(v: string) {
dispatch({ type: "SET_RECIPIENT_NAME", value: v });
try {
localStorage.setItem("wl_recipient_name", v);
} catch {}
}
// Report preview open/close ────────────────────────────────────────────────
function openReportPreview() {
const rows: WaterLogReportRow[] = todayEntries.map(shapeWaterLogEntry);
const unit =
rows.find((r) => r.unit)?.unit ??
state.headgates[0]?.unit ??
"CFS";
const now = new Date();
if (rows.length === 0) {
notify(
`No entries today — report would be skipped (season: ${
inSeason ? "in" : "out"
}).`,
);
return;
}
const preview: ReportPreviewData = {
rows,
total: todayTotal,
unit,
dateLabel: state.todayLabel || formatDate(now),
dateIso: state.today || now.toISOString().slice(0, 10),
recipientName: state.recipientName.trim() || undefined,
inSeason,
};
dispatch({ type: "OPEN_REPORT_PREVIEW", preview });
}
function closeReportPreview() {
dispatch({ type: "CLOSE_REPORT_PREVIEW" });
}
return (
<div className="relative">
<HeaderNav />
<div className="mb-6">
<AdminFilterTabs
activeTab={tab}
onTabChange={(value) => setTab(value as TabValue)}
tabs={TABS.map((t) => ({
value: t.value,
label: t.label,
}))}
showCounts={false}
size="md"
/>
</div>
{tab === "today" && (
<TodayTab
brandId={brandId}
state={state}
todayLabel={state.todayLabel}
todayEntries={todayEntries}
todayTotal={todayTotal}
inSeason={inSeason}
notify={notify}
dispatch={dispatch}
onPreviewReport={openReportPreview}
onSeasonChange={persistSeason}
onRecipientNameChange={persistRecipientName}
/>
)}
{tab === "headgates" && (
<HeadgatesTab
brandId={brandId}
headgates={state.headgates}
lastEntryByHeadgate={lastEntryByHeadgate}
canManage={canManage}
showAddHg={state.forms.showAddHg}
newHg={state.forms.newHg}
savingHg={state.forms.savingHg}
notify={notify}
dispatch={dispatch}
onEdit={(h) => router.push(`/admin/water-log/headgates/${h.id}`)}
/>
)}
{tab === "users" && (
<UsersTab
brandId={brandId}
users={state.users}
canManage={canManage}
showAddUser={state.forms.showAddUser}
newUser={state.forms.newUser}
savingUser={state.forms.savingUser}
newPin={state.forms.newPin}
resetPin={state.forms.resetPin}
notify={notify}
dispatch={dispatch}
/>
)}
{tab === "time-tracking" && (
<TimeTrackingTab brandId={brandId} />
)}
<ToastNotification toast={state.toast} />
<ReportPreviewModal
data={state.reportPreview}
onClose={closeReportPreview}
notify={notify}
/>
</div>
);
}
@@ -0,0 +1,455 @@
"use client";
/**
* SmartsheetHub — top-level card for `/admin/water-log/settings`.
*
* Cycle 7. Owns the brand-level Smartsheet workbook connection:
* - API token (encrypted at rest in `smartsheet_workspace`)
* - "Test Connection" button — verifies the token against a sheet
* - Connection enable toggle (master kill-switch)
* - Default sheet ID (the "home" tab in the workbook)
* - Last-verified badge
*
* Per-feature sheet mappings (water log + time tracking) live in their
* own cards BELOW this one and inherit the workspace token.
*
* Visual language matches the parent Water Log settings page
* (cream + forest palette, "Field Almanac" style).
*/
import { useCallback, useEffect, useReducer, useState } from "react";
import {
getSmartsheetWorkspace,
saveSmartsheetWorkspace,
testSmartsheetWorkspaceConnection,
disableSmartsheetWorkspace,
} from "@/actions/smartsheet/workspace";
import { formatDateTime } from "@/lib/format-date";
// ── State ──────────────────────────────────────────────────────────────────
type State = {
loading: boolean;
saving: boolean;
message: { type: "success" | "error"; text: string } | null;
workspace: Awaited<ReturnType<typeof getSmartsheetWorkspace>> | null;
// Editable form fields:
tokenInput: string;
defaultSheetIdInput: string;
showToken: boolean;
connectionEnabled: boolean;
};
type Action =
| { type: "load"; workspace: State["workspace"] }
| { type: "loading"; loading: boolean }
| { type: "saving"; saving: boolean }
| { type: "token"; value: string }
| { type: "showToken"; value: boolean }
| { type: "defaultSheet"; value: string }
| { type: "connection"; value: boolean }
| { type: "message"; message: State["message"] }
| { type: "reset-message" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "load":
return {
...state,
workspace: action.workspace,
connectionEnabled: action.workspace?.connectionEnabled ?? true,
defaultSheetIdInput: action.workspace?.defaultSheetId ?? "",
};
case "loading":
return { ...state, loading: action.loading };
case "saving":
return { ...state, saving: action.saving };
case "token":
return { ...state, tokenInput: action.value };
case "showToken":
return { ...state, showToken: action.value };
case "defaultSheet":
return { ...state, defaultSheetIdInput: action.value };
case "connection":
return { ...state, connectionEnabled: action.value };
case "message":
return { ...state, message: action.message };
case "reset-message":
return { ...state, message: null };
}
}
const INITIAL_STATE: State = {
loading: true,
saving: false,
message: null,
workspace: null,
tokenInput: "",
defaultSheetIdInput: "",
showToken: false,
connectionEnabled: true,
};
// ── Component ─────────────────────────────────────────────────────────────
export function SmartsheetHub({ brandId }: { brandId: string }) {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const [testResult, setTestResult] = useState<
| { state: "idle" }
| { state: "loading" }
| { state: "ok"; sheetName: string; columnCount: number }
| { state: "error"; message: string }
>({ state: "idle" });
// ── Load on mount + brandId change ──
useEffect(() => {
let cancelled = false;
void (async () => {
dispatch({ type: "loading", loading: true });
try {
const ws = await getSmartsheetWorkspace(brandId);
if (!cancelled) {
dispatch({ type: "load", workspace: ws });
dispatch({ type: "loading", loading: false });
}
} catch (err) {
if (!cancelled) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Failed to load workspace",
},
});
dispatch({ type: "loading", loading: false });
}
}
})();
return () => {
cancelled = true;
};
}, [brandId]);
// ── Save ──
const handleSave = useCallback(async () => {
dispatch({ type: "saving", saving: true });
dispatch({ type: "reset-message" });
try {
const defaultSheetId =
state.defaultSheetIdInput.trim().length > 0
? state.defaultSheetIdInput.trim()
: null;
const result = await saveSmartsheetWorkspace(brandId, {
token: state.tokenInput,
defaultSheetId,
connectionEnabled: state.connectionEnabled,
});
if (!result.success) {
dispatch({
type: "message",
message: { type: "error", text: result.error },
});
return;
}
dispatch({ type: "load", workspace: result.workspace });
// Clear the token input on success — never leave it in the form.
dispatch({ type: "token", value: "" });
dispatch({ type: "showToken", value: false });
dispatch({
type: "message",
message: {
type: "success",
text: state.tokenInput.length > 0
? "Workspace connected."
: "Workspace settings saved.",
},
});
} catch (err) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Save failed",
},
});
} finally {
dispatch({ type: "saving", saving: false });
}
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.connectionEnabled]);
// ── Test connection ──
const handleTest = useCallback(async () => {
setTestResult({ state: "loading" });
const sheetIdInput =
state.defaultSheetIdInput.trim().length > 0
? state.defaultSheetIdInput.trim()
: state.workspace?.defaultSheetId ?? "";
if (!sheetIdInput) {
setTestResult({
state: "error",
message: "Enter a sheet ID or URL above to test against.",
});
return;
}
try {
const result = await testSmartsheetWorkspaceConnection(
brandId,
sheetIdInput,
state.tokenInput,
);
if (result.success) {
setTestResult({
state: "ok",
sheetName: result.sheetName,
columnCount: result.columnCount,
});
} else {
setTestResult({ state: "error", message: result.error });
}
} catch (err) {
setTestResult({
state: "error",
message: err instanceof Error ? err.message : "Test failed",
});
}
}, [brandId, state.tokenInput, state.defaultSheetIdInput, state.workspace?.defaultSheetId]);
// ── Disable (no-confirm shortcut) ──
const handleDisable = useCallback(async () => {
dispatch({ type: "saving", saving: true });
try {
await disableSmartsheetWorkspace(brandId);
const ws = await getSmartsheetWorkspace(brandId);
dispatch({ type: "load", workspace: ws });
dispatch({
type: "message",
message: { type: "success", text: "Connection disabled. Sheet mappings preserved." },
});
} catch (err) {
dispatch({
type: "message",
message: {
type: "error",
text: err instanceof Error ? err.message : "Disable failed",
},
});
} finally {
dispatch({ type: "saving", saving: false });
}
}, [brandId]);
if (state.loading) {
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<p className="text-sm text-[#5a5d5a]">Loading workspace</p>
</div>
);
}
const isConnected = state.workspace?.configured === true;
const hasToken = state.workspace?.hasToken === true;
const maskedToken = state.workspace?.maskedToken ?? null;
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3
className="text-lg font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Workbook connection
</h3>
<p className="mt-1 text-sm text-[#5a5d5a]">
One Smartsheet API token powers every feature below (water log, time
tracking, future payroll). Per-feature cards just pick which sheet
inside the workbook to write to.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ${
isConnected && state.connectionEnabled
? "bg-[#dfe9d4] text-[#1a4d2e]"
: isConnected
? "bg-[#f1e8d4] text-[#8a6b3b]"
: "bg-[#f0e1d8] text-[#a4452b]"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
isConnected && state.connectionEnabled
? "bg-[#1a4d2e]"
: isConnected
? "bg-[#8a6b3b]"
: "bg-[#a4452b]"
}`}
/>
{isConnected
? state.connectionEnabled
? "Connected"
: "Paused"
: "Not connected"}
</span>
</div>
</div>
{/* ── Last verified ── */}
{isConnected && state.workspace?.lastTestAt && (
<p className="mt-3 text-xs text-[#5a5d5a]">
Last verified: {formatDateTime(new Date(state.workspace.lastTestAt))}
{state.workspace.lastTestError && (
<span className="ml-2 text-[#a4452b]">
· {state.workspace.lastTestError.slice(0, 80)}
</span>
)}
</p>
)}
{/* ── Form ── */}
<div className="mt-5 space-y-4">
<div>
<label className="block text-sm font-medium text-[#1a4d2e]">
API token
</label>
<div className="mt-1 flex items-center gap-2">
<input
type={state.showToken ? "text" : "password"}
autoComplete="off"
spellCheck={false}
className="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
placeholder={
hasToken && maskedToken
? `Saved: ${maskedToken} — paste to rotate`
: "Paste your Smartsheet API token"
}
value={state.tokenInput}
onChange={(e) =>
dispatch({ type: "token", value: e.target.value })
}
data-test="smartsheet-workspace-token"
/>
<button
type="button"
className="rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#5a5d5a] hover:bg-[#fdfaf2]"
onClick={() =>
dispatch({ type: "showToken", value: !state.showToken })
}
>
{state.showToken ? "Hide" : "Show"}
</button>
</div>
<p className="mt-1 text-xs text-[#5a5d5a]">
Encrypted at rest with AES-256-GCM. Never returned to the browser
after save.
</p>
</div>
<div>
<label className="block text-sm font-medium text-[#1a4d2e]">
Default sheet ID (optional)
</label>
<input
type="text"
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1a4d2e] placeholder:text-[#8a8d8a] focus:border-[#1a4d2e] focus:outline-none focus:ring-1 focus:ring-[#1a4d2e]"
placeholder="e.g. 8309876543210123 (or paste a Smartsheet URL)"
value={state.defaultSheetIdInput}
onChange={(e) =>
dispatch({ type: "defaultSheet", value: e.target.value })
}
data-test="smartsheet-workspace-default-sheet"
/>
<p className="mt-1 text-xs text-[#5a5d5a]">
The &ldquo;home&rdquo; tab when the customer opens the workbook.
Per-feature cards below pick their own sheets independently.
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm font-medium text-[#1a4d2e]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#d4d9d3] text-[#1a4d2e] focus:ring-[#1a4d2e]"
checked={state.connectionEnabled}
onChange={(e) =>
dispatch({ type: "connection", value: e.target.checked })
}
data-test="smartsheet-workspace-enabled"
/>
Connection enabled
</label>
<span className="text-xs text-[#5a5d5a]">
(Master switch pauses all per-feature syncs without losing mappings)
</span>
</div>
</div>
{/* ── Actions ── */}
<div className="mt-5 flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-lg bg-[#1a4d2e] px-4 py-2 text-sm font-medium text-white hover:bg-[#14432a] disabled:opacity-60"
onClick={handleSave}
disabled={state.saving}
data-test="smartsheet-workspace-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-medium text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:opacity-60"
onClick={handleTest}
disabled={testResult.state === "loading"}
data-test="smartsheet-workspace-test"
>
{testResult.state === "loading" ? "Testing…" : "Test connection"}
</button>
{isConnected && state.connectionEnabled && (
<button
type="button"
className="rounded-lg border border-[#d4d9d3] bg-white px-4 py-2 text-sm font-medium text-[#8a6b3b] hover:bg-[#fdfaf2] disabled:opacity-60"
onClick={handleDisable}
disabled={state.saving}
>
Pause connection
</button>
)}
</div>
{/* ── Test result ── */}
{testResult.state === "ok" && (
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#dfe9d4] bg-[#dfe9d4] p-3">
<p className="text-sm font-medium text-[#1a4d2e]">
Connected to &ldquo;{testResult.sheetName}&rdquo;
</p>
<p className="mt-1 text-xs text-[#5a5d5a]">
{testResult.columnCount} columns visible. The test uses the
{state.tokenInput.length > 0 ? " pasted" : " saved"} token.
</p>
</div>
)}
{testResult.state === "error" && (
<div className="smartsheet-slide-down mt-4 rounded-lg border border-[#f0e1d8] bg-[#f0e1d8] p-3">
<p className="text-sm font-medium text-[#a4452b]">
{testResult.message}
</p>
</div>
)}
{/* ── Save message ── */}
{state.message && (
<div
className={`smartsheet-fade-in mt-4 rounded-lg p-3 text-sm ${
state.message.type === "success"
? "bg-[#dfe9d4] text-[#1a4d2e]"
: "bg-[#f0e1d8] text-[#a4452b]"
}`}
>
{state.message.text}
</div>
)}
</div>
);
}
export default SmartsheetHub;
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
/**
* Reducer + state initialization for the Water Log admin module.
*
* Pulled out of the monolithic `WaterLogAdminPanel.tsx` so the shell can
* own state and dispatch and pass them down to focused tab components.
*
* localStorage contracts (intentionally narrow):
* - wl_season_settings:v1 → IrrigationSeasonSettings JSON
* - wl_recipient_name → string
*/
import type {
AdminEntry,
AdminHeadgate,
AdminIrrigator,
} from "@/actions/water-log/admin";
import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting";
import type { Action, State } from "./types";
export const DEFAULT_SEASON: IrrigationSeasonSettings = {
seasonStartMonth: 3,
seasonStartDay: 15,
seasonEndMonth: 10,
seasonEndDay: 15,
};
// ── Init helpers ────────────────────────────────────────────────────────────
function initSeasonStart(): IrrigationSeasonSettings {
if (typeof window === "undefined") return DEFAULT_SEASON;
try {
const saved = localStorage.getItem("wl_season_settings:v1");
return saved ? { ...DEFAULT_SEASON, ...JSON.parse(saved) } : DEFAULT_SEASON;
} catch {
return DEFAULT_SEASON;
}
}
function initRecipientName(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem("wl_recipient_name") ?? "";
}
export function initState(props: {
initialHeadgates: AdminHeadgate[];
initialUsers: AdminIrrigator[];
initialEntries: AdminEntry[];
}): State {
return {
headgates: props.initialHeadgates,
users: props.initialUsers,
entries: props.initialEntries,
toast: null,
forms: {
showAddHg: false,
newHg: { name: "", unit: "CFS", notes: "" },
savingHg: false,
showAddUser: false,
newUser: { name: "", role: "irrigator", lang: "en", phone: "" },
savingUser: false,
newPin: null,
resetPin: null,
},
filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" },
csvLoading: false,
today: "",
todayLabel: "",
showReportSettings: false,
seasonStart: initSeasonStart(),
recipientName: initRecipientName(),
reportPreview: null,
};
}
// ── Reducer ─────────────────────────────────────────────────────────────────
export function reducer(state: State, action: Action): State {
switch (action.type) {
// Headgates
case "ADD_HEADGATE":
return { ...state, headgates: [action.headgate, ...state.headgates] };
case "REMOVE_HEADGATE":
return {
...state,
headgates: state.headgates.filter((h) => h.id !== action.id),
};
case "UPDATE_HEADGATE_TOKEN":
return {
...state,
headgates: state.headgates.map((h) =>
h.id === action.id ? { ...h, headgate_token: action.token } : h,
),
};
// Users
case "ADD_USER":
return { ...state, users: [action.user, ...state.users] };
case "DEACTIVATE_USER":
return {
...state,
users: state.users.map((u) =>
u.id === action.id ? { ...u, active: false } : u,
),
};
// Toast
case "SET_TOAST":
return { ...state, toast: action.toast };
case "CLEAR_TOAST":
return { ...state, toast: null };
// Forms - Headgate
case "TOGGLE_ADD_HG":
return {
...state,
forms: { ...state.forms, showAddHg: !state.forms.showAddHg },
};
case "SET_NEW_HG_NAME":
return {
...state,
forms: {
...state.forms,
newHg: { ...state.forms.newHg, name: action.value },
},
};
case "SET_NEW_HG_UNIT":
return {
...state,
forms: {
...state.forms,
newHg: { ...state.forms.newHg, unit: action.value },
},
};
case "SET_NEW_HG_NOTES":
return {
...state,
forms: {
...state.forms,
newHg: { ...state.forms.newHg, notes: action.value },
},
};
case "RESET_NEW_HG":
return {
...state,
forms: {
...state.forms,
newHg: { name: "", unit: "CFS", notes: "" },
showAddHg: false,
},
};
case "SET_SAVING_HG":
return {
...state,
forms: { ...state.forms, savingHg: action.value },
};
// Forms - User
case "TOGGLE_ADD_USER":
return {
...state,
forms: { ...state.forms, showAddUser: !state.forms.showAddUser },
};
case "SET_NEW_USER_NAME":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, name: action.value },
},
};
case "SET_NEW_USER_ROLE":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, role: action.value },
},
};
case "SET_NEW_USER_LANG":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, lang: action.value },
},
};
case "SET_NEW_USER_PHONE":
return {
...state,
forms: {
...state.forms,
newUser: { ...state.forms.newUser, phone: action.value },
},
};
case "RESET_NEW_USER":
return {
...state,
forms: {
...state.forms,
newUser: { name: "", role: "irrigator", lang: "en", phone: "" },
showAddUser: false,
},
};
case "SET_SAVING_USER":
return {
...state,
forms: { ...state.forms, savingUser: action.value },
};
case "SET_NEW_PIN":
return {
...state,
forms: { ...state.forms, newPin: action.pin },
};
case "CLEAR_NEW_PIN":
return { ...state, forms: { ...state.forms, newPin: null } };
case "SET_RESET_PIN":
return {
...state,
forms: { ...state.forms, resetPin: action.pin },
};
case "CLEAR_RESET_PIN":
return { ...state, forms: { ...state.forms, resetPin: null } };
// Filters
case "SET_FILTER_DATE_FROM":
return {
...state,
filters: { ...state.filters, dateFrom: action.value },
};
case "SET_FILTER_DATE_TO":
return {
...state,
filters: { ...state.filters, dateTo: action.value },
};
case "SET_FILTER_HEADGATE":
return {
...state,
filters: { ...state.filters, headgate: action.value },
};
case "SET_FILTER_USER":
return {
...state,
filters: { ...state.filters, user: action.value },
};
case "SET_FILTER_VIA":
return {
...state,
filters: { ...state.filters, via: action.value },
};
case "CLEAR_FILTERS":
return {
...state,
filters: { dateFrom: "", dateTo: "", headgate: "", user: "", via: "" },
};
// Misc UI
case "SET_CSV_LOADING":
return { ...state, csvLoading: action.value };
case "SET_TODAY":
return { ...state, today: action.today, todayLabel: action.label };
case "TOGGLE_REPORT_SETTINGS":
return {
...state,
showReportSettings: !state.showReportSettings,
};
case "SET_SEASON_START":
return { ...state, seasonStart: action.value };
case "SET_RECIPIENT_NAME":
return { ...state, recipientName: action.value };
case "OPEN_REPORT_PREVIEW":
return { ...state, reportPreview: action.preview };
case "CLOSE_REPORT_PREVIEW":
return { ...state, reportPreview: null };
default:
return state;
}
}
// ── Selectors ────────────────────────────────────────────────────────────────
/** Latest entry per headgate (by logged_at), for the headgate cards. */
export function lastEntryByHeadgateSelector(entries: AdminEntry[]) {
const map = new Map<string, AdminEntry>();
for (const e of entries) {
const existing = map.get(e.headgate_id);
if (!existing || e.logged_at > existing.logged_at) {
map.set(e.headgate_id, e);
}
}
return map;
}
/** Apply the current filter set to the entry list. */
export function filteredEntriesSelector(
entries: AdminEntry[],
filters: State["filters"],
) {
return entries.filter((e) => {
if (filters.dateFrom && e.logged_at < filters.dateFrom) return false;
if (filters.dateTo && e.logged_at > filters.dateTo + "T23:59:59.999Z")
return false;
if (filters.headgate && e.headgate_id !== filters.headgate) return false;
if (filters.user && e.user_id !== filters.user) return false;
if (filters.via && e.submitted_via !== filters.via) return false;
return true;
});
}
/** Today's entries, by logged_date (with logged_at fallback). */
export function todayEntriesSelector(entries: AdminEntry[], today: string) {
if (!today) return [];
return entries.filter(
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today,
);
}
@@ -0,0 +1,34 @@
/**
* HeaderNav — top-of-page quick links for the Water Log admin.
*
* Renders the three contextual exits (QR Codes, Settings, Field Admin)
* as secondary `AdminButton`s. Lives outside the tab shell so it's
* always visible regardless of which tab the user is on.
*/
"use client";
import Link from "next/link";
import AdminButton from "@/components/admin/design-system/AdminButton";
import { QrIcon, GearIcon, FieldIcon } from "./icons";
export function HeaderNav() {
return (
<div className="mb-6 flex flex-wrap items-center gap-2">
<Link href="/admin/water-log/headgates">
<AdminButton variant="secondary" size="sm" icon={<QrIcon />}>
QR Codes
</AdminButton>
</Link>
<Link href="/admin/water-log/settings">
<AdminButton variant="secondary" size="sm" icon={<GearIcon />}>
Settings
</AdminButton>
</Link>
<Link href="/water/admin" target="_blank" rel="noopener noreferrer">
<AdminButton variant="secondary" size="sm" icon={<FieldIcon />}>
Field Admin
</AdminButton>
</Link>
</div>
);
}
@@ -0,0 +1,387 @@
/**
* Shared primitives for the Water Log admin module.
*
* These are the small reusable sub-components used by the tabs:
* section headers, stat tiles, status pills, form inputs, the PIN
* banner, the role legend, and the empty state. All use the "Field
* Almanac" palette (cream / forest / clay) so they sit naturally
* together.
*/
"use client";
import * as React from "react";
import { KeyIcon } from "./icons";
// ── SectionHeader ────────────────────────────────────────────────────────────
export function SectionHeader({
index,
title,
subtitle,
action,
}: {
index: string;
title: string;
subtitle: string;
action?: React.ReactNode;
}) {
return (
<header className="mb-3 mt-10 flex items-start justify-between gap-4 border-b border-[#d4d9d3] pb-3">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#1a4d2e]">
{index}
</div>
<h2
className="mt-0.5 text-xl font-semibold text-[#1d1d1f]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{title}
</h2>
<p className="mt-0.5 max-w-xl text-xs text-[#57694e]">{subtitle}</p>
</div>
{action}
</header>
);
}
// ── StatTile ────────────────────────────────────────────────────────────────
export function StatTile({
label,
value,
unit,
accent,
mono,
}: {
label: string;
value: string;
unit: string;
accent?: boolean;
mono?: boolean;
}) {
return (
<div
className={`rounded-lg border p-3 ${
accent ? "border-[#1a4d2e]/30 bg-[#f0fdf4]" : "border-[#d4d9d3] bg-white"
}`}
>
<div className="text-[10px] font-mono uppercase tracking-wider text-[#57694e]">
{label}
</div>
<div className="mt-0.5 flex items-baseline gap-1">
<span
className={`text-lg font-semibold text-[#1a4d2e] ${
mono ? "font-mono" : ""
}`}
>
{value}
</span>
{unit && (
<span className="font-mono text-[10px] uppercase text-[#57694e]">
{unit}
</span>
)}
</div>
</div>
);
}
// ── StatusPill ──────────────────────────────────────────────────────────────
const statusPillMap = {
active: { bg: "bg-[#dcfce7]", fg: "text-[#15803d]", label: "Active" },
inactive: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Inactive" },
closed: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Closed" },
warn: { bg: "bg-[#fef9c3]", fg: "text-[#a16207]", label: "Maint." },
} as const;
export function StatusPill({
kind,
}: {
kind: "active" | "inactive" | "closed" | "warn";
}) {
const s = statusPillMap[kind];
return (
<span
className={`inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${s.bg} ${s.fg}`}
>
{s.label}
</span>
);
}
// ── PinBanner ───────────────────────────────────────────────────────────────
export function PinBanner({
title,
pin,
tone = "ok",
onDismiss,
}: {
title: string;
pin: string;
tone?: "ok" | "warn";
onDismiss: () => void;
}) {
return (
<div
className={`mb-4 flex items-center gap-4 rounded-xl border p-4 ${
tone === "warn"
? "border-[#fde047] bg-[#fef9c3]"
: "border-[#15803d]/40 bg-[#dcfce7]"
}`}
>
<KeyIcon />
<div className="flex-1">
<p className="font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{title}
</p>
<p
className="font-mono text-2xl font-bold tracking-[0.5em] text-[#1a4d2e]"
aria-label={`PIN ${pin.split("").join(" ")}`}
>
{pin}
</p>
</div>
<button
type="button"
onClick={onDismiss}
className="rounded-lg border border-[#1a4d2e]/30 px-3 py-1.5 text-xs font-semibold text-[#1a4d2e] hover:bg-white"
aria-label="Dismiss"
>
Dismiss
</button>
</div>
);
}
// ── RoleLegend ──────────────────────────────────────────────────────────────
export function RoleLegend() {
return (
<p className="mb-3 flex items-center gap-3 font-mono text-[11px] text-[#57694e]">
<span>
<span className="font-bold text-[#1e3a8a]">ADMIN</span>
<span className="text-[#86868b]"> manage headgates, users, entries</span>
</span>
<span className="text-[#d4d9d3]">·</span>
<span>
<span className="font-bold text-[#15803d]">IRRIGATOR</span>
<span className="text-[#86868b]"> submit entries only</span>
</span>
</p>
);
}
// ── EmptyState ──────────────────────────────────────────────────────────────
export function EmptyState({
icon,
title,
body,
}: {
icon: React.ReactNode;
title: string;
body: string;
}) {
return (
<div className="rounded-xl border border-dashed border-[#d4d9d3] bg-white px-6 py-10 text-center">
<div className="mx-auto mb-3 flex w-12 items-center justify-center text-[#86868b]">
{icon}
</div>
<p
className="text-base font-semibold text-[#1d1d1f]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{title}
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-[#57694e]">{body}</p>
</div>
);
}
// ── Input ───────────────────────────────────────────────────────────────────
export function Input({
label,
value,
onChange,
placeholder,
required,
type = "text",
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
type?: string;
}) {
return (
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
/>
</label>
);
}
// ── Select ──────────────────────────────────────────────────────────────────
export function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: (string | { value: string; label: string })[];
}) {
return (
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
>
{options.map((o) => {
if (typeof o === "string") {
return (
<option key={o} value={o}>
{o}
</option>
);
}
return (
<option key={o.value} value={o.value}>
{o.label}
</option>
);
})}
</select>
</label>
);
}
// ── FilterChip ──────────────────────────────────────────────────────────────
export function FilterChip({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center gap-1.5 rounded-lg border border-[#d4d9d3] bg-[#fafaf7] px-2 py-1">
<span className="font-mono text-[10px] uppercase tracking-wider text-[#86868b]">
{label}
</span>
{children}
</div>
);
}
// ── Avatar ──────────────────────────────────────────────────────────────────
export function Avatar({ name, role }: { name: string; role: string }) {
const initials = name
.split(/\s+/)
.flatMap((n) => (n[0] ? [n[0]] : []))
.slice(0, 2)
.join("")
.toUpperCase();
const bg = role === "water_admin" ? "#1a4d2e" : "#57694e";
return (
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-mono text-[11px] font-bold text-white"
style={{ background: bg }}
aria-hidden
>
{initials || "·"}
</span>
);
}
// ── MonthSelect / DayInput ──────────────────────────────────────────────────
export function MonthSelect({
value,
onChange,
}: {
value: number;
onChange: (m: number) => void;
}) {
return (
<select
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className="flex-1 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
aria-label="Month"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(0, i).toLocaleString("en", { month: "short" })}
</option>
))}
</select>
);
}
export function DayInput({
value,
onChange,
}: {
value: number;
onChange: (d: number) => void;
}) {
return (
<input
type="number"
min={1}
max={31}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || 1)}
className="w-16 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm"
aria-label="Day"
/>
);
}
// ── Th / Td (admin-table cells, used by the ReportPreviewModal) ─────────────
export function Th({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th
className={`px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)] ${className}`}
>
{children}
</th>
);
}
export function Td({
children,
className = "",
colSpan,
}: {
children: React.ReactNode;
className?: string;
colSpan?: number;
}) {
return (
<td
colSpan={colSpan}
className={`px-3 py-2 text-sm text-[var(--admin-text-primary)] ${className}`}
>
{children}
</td>
);
}
@@ -0,0 +1,195 @@
/**
* ReportPreviewModal — preview of the daily water report that would be
* sent by the cron. Built on the shared `GlassModal` so ESC, focus
* trapping, and scroll-lock match the rest of /admin.
*
* Triggered from the Today tab (or anywhere that needs to show the
* daily report payload); the preview state lives in the shell reducer.
*/
"use client";
import { useState } from "react";
import GlassModal from "@/components/admin/GlassModal";
import { formatDailyWaterReport } from "@/lib/water-log-reporting";
import type { NotifyFn, ReportPreviewData } from "../types";
import { Th, Td } from "./Primitives";
export function ReportPreviewModal({
data,
onClose,
notify,
}: {
data: ReportPreviewData | null;
onClose: () => void;
notify: NotifyFn;
}) {
const [copied, setCopied] = useState(false);
if (!data) return null;
const { rows, total, unit, dateLabel, dateIso, recipientName, inSeason } = data;
const totalUnit = rows[0]?.unit ?? unit ?? "CFS";
async function handleCopy() {
const text = formatDailyWaterReport(rows, {
recipientName: recipientName,
previewMode: true,
});
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
notify("Report copied to clipboard");
setTimeout(() => setCopied(false), 1600);
} catch {
notify("Copy failed — your browser blocked clipboard access", "err");
}
}
const subtitle =
`${rows.length} ${rows.length === 1 ? "entry" : "entries"} · ` +
`${total.toFixed(2)} ${totalUnit} total · ` +
(inSeason ? "in season" : "out of season");
return (
<GlassModal
title="Daily water report — preview"
subtitle={subtitle}
onClose={onClose}
maxWidth="max-w-2xl"
>
<div className="space-y-4">
{/* Meta line */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--admin-text-muted)]">
<span>
<span className="font-semibold text-[var(--admin-text-secondary)]">
Date:
</span>{" "}
{dateLabel}
</span>
<span className="hidden sm:inline text-[var(--admin-border)]">·</span>
<span className="font-mono text-[var(--admin-text-secondary)]">
{dateIso}
</span>
{recipientName ? (
<>
<span className="hidden sm:inline text-[var(--admin-border)]">
·
</span>
<span>
<span className="font-semibold text-[var(--admin-text-secondary)]">
Recipient:
</span>{" "}
{recipientName}
</span>
</>
) : null}
</div>
{/* Preview banner — keep it clear this hasn't been sent */}
<div
className="rounded-lg px-3 py-2 text-xs"
style={{
backgroundColor: "var(--admin-warning-bg, #fef9c3)",
color: "var(--admin-warning-text, #a16207)",
}}
>
Preview only. No message has been sent. Daily-report delivery is
handled by the scheduled{" "}
<code className="font-mono">api/cron/water-report</code> endpoint.
</div>
{/* Data table */}
<div className="overflow-hidden rounded-lg border border-[var(--admin-border)]">
<table className="w-full border-collapse text-sm">
<thead>
<tr style={{ backgroundColor: "var(--admin-bg-subtle)" }}>
<Th className="w-[72px]">Time</Th>
<Th>Headgate</Th>
<Th>Irrigator</Th>
<Th className="w-[88px] text-right">Volume</Th>
<Th>Note</Th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const time = new Date(row.logged_at).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
);
return (
<tr
key={i}
className="border-t border-[var(--admin-border-light)]"
>
<Td className="font-mono text-[var(--admin-text-secondary)]">
{time}
</Td>
<Td className="font-medium text-[var(--admin-text-primary)]">
{row.headgate_name}
</Td>
<Td className="text-[var(--admin-text-secondary)]">
{row.user_name}
</Td>
<Td className="text-right">
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">
{row.measurement.toFixed(2)}
</span>{" "}
<span className="text-xs text-[var(--admin-text-muted)]">
{row.unit}
</span>
</Td>
<Td className="text-[var(--admin-text-muted)]">
{row.notes ?? <span className="opacity-40"></span>}
</Td>
</tr>
);
})}
</tbody>
<tfoot>
<tr
className="border-t-2"
style={{ borderColor: "var(--admin-border)" }}
>
<Td
className="!border-0 pt-2 text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]"
colSpan={3}
>
Total
</Td>
<Td className="!border-0 pt-2 text-right">
<span className="font-mono font-bold text-[var(--admin-text-primary)]">
{total.toFixed(2)}
</span>{" "}
<span className="text-xs text-[var(--admin-text-muted)]">
{totalUnit}
</span>
</Td>
<Td className="!border-0 pt-2">
<span className="opacity-40"></span>
</Td>
</tr>
</tfoot>
</table>
</div>
{/* Actions */}
<div className="flex flex-col-reverse items-stretch gap-2 pt-2 sm:flex-row sm:items-center sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Close
</button>
<button
type="button"
onClick={handleCopy}
className="rounded-lg bg-[var(--admin-accent)] px-4 py-2 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
>
{copied ? "Copied" : "Copy plain text"}
</button>
</div>
</div>
</GlassModal>
);
}
@@ -0,0 +1,24 @@
/**
* ToastNotification — bottom-right toast surface used by every tab.
*
* State lives in the parent reducer; this component only renders.
*/
"use client";
import type { Toast as ToastT } from "../types";
export function ToastNotification({ toast }: { toast: ToastT | null }) {
if (!toast) return null;
return (
<div
aria-live="polite"
className={`fixed bottom-6 right-6 z-50 rounded-xl px-4 py-3 font-medium shadow-lg transition-all ${
toast.kind === "ok"
? "border border-[#15803d] bg-[#dcfce7] text-[#15803d]"
: "border border-[#b91c1c] bg-[#fee2e2] text-[#b91c1c]"
}`}
>
{toast.msg}
</div>
);
}
@@ -0,0 +1,159 @@
/**
* Inline icons used by the Water Log admin module.
*
* All accept `currentColor` (or an explicit stroke color for the key
* icon, which always renders forest green for contrast against the
* cream "Field Almanac" palette).
*
* Kept here so each tab can import what it needs without dragging
* in the whole panel.
*/
import * as React from "react";
export function QrIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<rect x="2" y="2" width="5" height="5" />
<rect x="9" y="2" width="5" height="5" />
<rect x="2" y="9" width="5" height="5" />
<path d="M9 9 H11 V11 H9 Z" />
<path d="M12 9 H14 V12" />
<path d="M9 12 V14 H14" />
</svg>
);
}
export function GearIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="8" cy="8" r="2.2" />
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
</svg>
);
}
export function FieldIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<path d="M2 11 L8 5 L11 8 L14 5" />
<path d="M2 14 H14" />
</svg>
);
}
export function ReportIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<rect x="3" y="2" width="10" height="12" rx="1" />
<path d="M5 5 H11 M5 8 H11 M5 11 H8" />
</svg>
);
}
export function DownloadIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<path d="M8 2 V10 M5 7 L8 10 L11 7" />
<path d="M2 12 V14 H14 V12" />
</svg>
);
}
export function KeyIcon() {
return (
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill="none"
stroke="#1a4d2e"
strokeWidth={1.6}
>
<circle cx="8" cy="12" r="4" />
<path d="M12 12 H22 M18 12 V16 M22 12 V15" />
</svg>
);
}
export function UserIcon() {
return (
<svg
width={36}
height={36}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21 C4 16 8 14 12 14 C16 14 20 16 20 21" />
</svg>
);
}
export function TableIcon() {
return (
<svg
width={36}
height={36}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 10 H21 M9 4 V20" />
</svg>
);
}
export function ClockIcon() {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="8" cy="8" r="6" />
<path d="M8 4 V8 L11 10" />
</svg>
);
}
@@ -0,0 +1,272 @@
/**
* HeadgatesTab — CRUD surface for water headgates.
*
* Lists every headgate in a card grid, with add/regenerate-token/delete
* controls. Owns its own form state via the shell reducer.
*/
"use client";
import * as React from "react";
import AdminButton from "@/components/admin/design-system/AdminButton";
import {
createWaterHeadgate,
deleteWaterHeadgate,
regenerateHeadgateToken,
type AdminEntry,
type AdminHeadgate,
} from "@/actions/water-log/admin";
import { WaterGauge } from "@/components/water/icons";
import { formatDateTime } from "@/lib/format-date";
import {
EmptyState,
Input,
SectionHeader,
Select,
StatusPill,
} from "../shared/Primitives";
import type { Action, NotifyFn } from "../types";
export type HeadgatesTabProps = {
brandId: string;
headgates: AdminHeadgate[];
lastEntryByHeadgate: Map<string, AdminEntry>;
canManage: boolean;
showAddHg: boolean;
newHg: { name: string; unit: string; notes: string };
savingHg: boolean;
notify: NotifyFn;
dispatch: React.Dispatch<Action>;
onEdit: (h: AdminHeadgate) => void;
};
export function HeadgatesTab({
brandId,
headgates,
lastEntryByHeadgate,
canManage,
showAddHg,
newHg,
savingHg,
notify,
dispatch,
onEdit,
}: HeadgatesTabProps) {
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const trimmedName = newHg.name.trim();
if (!trimmedName) return;
dispatch({ type: "SET_SAVING_HG", value: true });
const res = await createWaterHeadgate(brandId, trimmedName, newHg.unit, {
notes: newHg.notes.trim() || null,
});
if (res.success && res.headgate) {
dispatch({ type: "ADD_HEADGATE", headgate: res.headgate });
dispatch({ type: "RESET_NEW_HG" });
notify("Headgate added");
} else {
notify(res.error ?? "Failed", "err");
}
dispatch({ type: "SET_SAVING_HG", value: false });
}
async function handleDelete(h: AdminHeadgate) {
if (
!window.confirm(
`Delete "${h.name}"? Existing log entries will be preserved.`,
)
)
return;
const res = await deleteWaterHeadgate(h.id);
if (res.success) {
dispatch({ type: "REMOVE_HEADGATE", id: h.id });
notify("Headgate removed");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handleRegenerateToken(h: AdminHeadgate) {
if (
!window.confirm(
`Regenerate QR token for "${h.name}"? Existing printed QR codes will stop working.`,
)
)
return;
const res = await regenerateHeadgateToken(h.id);
if (res.success && res.token) {
dispatch({
type: "UPDATE_HEADGATE_TOKEN",
id: h.id,
token: res.token,
});
notify("Token regenerated");
} else {
notify(res.error ?? "Failed", "err");
}
}
return (
<>
<SectionHeader
index="§ 01"
title="Headgates"
subtitle="Physical gates in the ditch network. Each gets a printable QR code so field workers can scan to log a reading without typing the gate name."
action={
canManage ? (
<AdminButton size="sm" onClick={() => dispatch({ type: "TOGGLE_ADD_HG" })}>
{showAddHg ? "Cancel" : "+ Add Headgate"}
</AdminButton>
) : null
}
/>
{showAddHg && canManage && (
<form
onSubmit={handleAdd}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_120px_1fr_auto]"
>
<Input
label="Name"
value={newHg.name}
onChange={(v) => dispatch({ type: "SET_NEW_HG_NAME", value: v })}
placeholder="e.g. North Field Gate 1"
required
/>
<Select
label="Unit"
value={newHg.unit}
onChange={(v) => dispatch({ type: "SET_NEW_HG_UNIT", value: v })}
options={["CFS", "GPM", "Inches", "AF/Day", "gal", "ac-ft"]}
/>
<Input
label="Notes"
value={newHg.notes}
onChange={(v) => dispatch({ type: "SET_NEW_HG_NOTES", value: v })}
placeholder="Location, GPS, contact…"
/>
<div className="flex items-end">
<AdminButton
type="submit"
disabled={savingHg}
isLoading={savingHg}
fullWidth
>
Add
</AdminButton>
</div>
</form>
)}
{headgates.length === 0 ? (
<EmptyState
icon={<WaterGauge size={36} level={null} status="open" />}
title="No headgates yet"
body="Add a headgate to start logging. Each one gets a unique QR code you can print and post at the gate."
/>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{headgates.map((h) => {
const last = lastEntryByHeadgate.get(h.id);
const level =
last && h.high_threshold
? Math.min(1, last.measurement / Number(h.high_threshold))
: null;
return (
<li
key={h.id}
className="group rounded-xl border border-[#d4d9d3] bg-white p-4 transition hover:border-[#1a4d2e]/40 hover:shadow-[0_4px_16px_rgba(26,77,46,0.08)]"
>
<div className="flex items-start gap-3">
<WaterGauge
size={36}
level={level}
status={h.status as "open" | "closed" | "maintenance"}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-[#1d1d1f]">
{h.name}
</h3>
<StatusPill
kind={
!h.active
? "inactive"
: h.status === "closed"
? "closed"
: h.status === "maintenance"
? "warn"
: "active"
}
/>
</div>
<p className="mt-0.5 font-mono text-[11px] uppercase tracking-wider text-[#86868b]">
Unit: {h.unit} · Token: {h.headgate_token.slice(0, 8)}
</p>
{last ? (
<p className="mt-2 text-sm text-[#57694e]">
Last:{" "}
<span className="font-mono font-semibold text-[#1d1d1f]">
{last.measurement} {h.unit}
</span>{" "}
<span className="text-[#86868b]">
by {last.user_name} · {formatDateTime(last.logged_at)}
</span>
</p>
) : (
<p className="mt-2 text-sm italic text-[#86868b]">
No readings yet
</p>
)}
{(h.high_threshold != null || h.low_threshold != null) && (
<p className="mt-1 font-mono text-[11px] text-[#57694e]">
{h.high_threshold != null && (
<>Hi&nbsp;&nbsp;{h.high_threshold}&nbsp;&nbsp;</>
)}
{h.low_threshold != null && (
<>Lo&nbsp;&nbsp;{h.low_threshold}</>
)}
</p>
)}
</div>
</div>
{canManage && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-1 border-t border-[#e8ebe8] pt-3 text-[11px]">
<button
type="button"
onClick={() => onEdit(h)}
className="font-medium text-[#1a4d2e] hover:underline"
aria-label="Edit →"
>
Edit
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleRegenerateToken(h)}
className="text-[#57694e] hover:text-[#1a4d2e]"
aria-label="Rotate token"
>
Rotate token
</button>
<span className="text-[#d4d9d3]">·</span>
<button
type="button"
onClick={() => handleDelete(h)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
aria-label="Delete"
>
Delete
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</>
);
}
@@ -0,0 +1,18 @@
/**
* Time Tracking tab — embeds the existing TimeTrackingAdminPanel
* (which already has its own sub-tabs: Summary / Workers / Tasks /
* Logs / Settings) inside the Water Log shell.
*
* Cycle 3 — this is the customer "hub" surface the water log now
* offers: one place to see today's entries, the headgates, who can
* log them, AND how many hours those workers put in.
*/
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
export type TimeTrackingTabProps = {
brandId: string;
};
export function TimeTrackingTab({ brandId }: TimeTrackingTabProps) {
return <TimeTrackingAdminPanel brandId={brandId} />;
}
@@ -0,0 +1,588 @@
/**
* TodayTab — the "Today" surface for the Water Log admin.
*
* Combines the hero gauge + summary stats with the recent entries
* table + filter bar. Holds the entry CRUD, CSV export, and
* "Generate Report" trigger.
*/
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import AdminButton from "@/components/admin/design-system/AdminButton";
import { WaterGauge, SeasonMark } from "@/components/water/icons";
import {
getWaterEntries,
type AdminEntry,
type AdminHeadgate,
type AdminIrrigator,
} from "@/actions/water-log/admin";
import {
downloadWaterLogCSV,
filterWaterLogEntries,
shapeWaterLogEntry,
type IrrigationSeasonSettings,
type WaterLogReportRow,
} from "@/lib/water-log-reporting";
import { formatDateTime } from "@/lib/format-date";
import {
DayInput,
EmptyState,
FilterChip,
MonthSelect,
SectionHeader,
StatTile,
} from "../shared/Primitives";
import { DownloadIcon, ReportIcon, TableIcon } from "../shared/icons";
import type { Action, NotifyFn, State } from "../types";
export type TodayTabProps = {
brandId: string;
state: State;
todayLabel: string;
todayEntries: AdminEntry[];
todayTotal: number;
inSeason: boolean;
notify: NotifyFn;
dispatch: React.Dispatch<Action>;
onPreviewReport: () => void;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
onRecipientNameChange: (v: string) => void;
};
export function TodayTab({
brandId,
state,
todayLabel,
todayEntries,
todayTotal,
inSeason,
notify,
dispatch,
onPreviewReport,
onSeasonChange,
onRecipientNameChange,
}: TodayTabProps) {
const router = useRouter();
async function handleExportCSV() {
dispatch({ type: "SET_CSV_LOADING", value: true });
try {
const all = await getWaterEntries(brandId, 5000);
const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry);
const exportFilters = {
dateFrom: state.filters.dateFrom || undefined,
dateTo: state.filters.dateTo || undefined,
headgateId: state.filters.headgate || undefined,
userId: state.filters.user || undefined,
submittedVia: state.filters.via || undefined,
};
const filtered = filterWaterLogEntries(shaped, exportFilters);
downloadWaterLogCSV(`water-log-${state.today}.csv`, filtered);
notify(`Exported ${filtered.length} entries`);
} finally {
dispatch({ type: "SET_CSV_LOADING", value: false });
}
}
return (
<>
<TodaySummary
todayLabel={todayLabel}
todayEntries={todayEntries}
todayTotal={todayTotal}
headgates={state.headgates}
users={state.users}
inSeason={inSeason}
seasonStart={state.seasonStart}
recipientName={state.recipientName}
onToggleReportSettings={() => dispatch({ type: "TOGGLE_REPORT_SETTINGS" })}
onSeasonChange={onSeasonChange}
onRecipientNameChange={onRecipientNameChange}
onPreviewReport={onPreviewReport}
showReportSettings={state.showReportSettings}
/>
<RecentEntriesSection
state={state}
dispatch={dispatch}
onEditEntry={(e) => router.push(`/admin/water-log/entries/${e.id}`)}
onExportCSV={handleExportCSV}
/>
</>
);
}
// ── TodaySummary (hero gauge + stats + report settings toggle) ──────────────
function TodaySummary({
todayLabel,
todayEntries,
todayTotal,
headgates,
users,
inSeason,
showReportSettings,
seasonStart,
recipientName,
onToggleReportSettings,
onSeasonChange,
onRecipientNameChange,
onPreviewReport,
}: {
todayLabel: string;
todayEntries: AdminEntry[];
todayTotal: number;
headgates: AdminHeadgate[];
users: AdminIrrigator[];
inSeason: boolean;
showReportSettings: boolean;
seasonStart: IrrigationSeasonSettings;
recipientName: string;
onToggleReportSettings: () => void;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
onRecipientNameChange: (v: string) => void;
onPreviewReport: () => void;
}) {
return (
<section className="relative overflow-hidden rounded-2xl border border-[#d4d9d3] bg-[#fdfaf2] shadow-[0_1px_0_rgba(0,0,0,0.04)]">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.18]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, transparent 0 14px, rgba(26,77,46,0.06) 14px 15px), repeating-linear-gradient(-45deg, transparent 0 14px, rgba(202,138,4,0.04) 14px 15px)",
}}
/>
<div className="relative grid grid-cols-1 gap-6 p-6 md:grid-cols-[200px_1fr] md:gap-8">
{/* Hero gauge */}
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Tuxedo Ditch Co.
</div>
<WaterGauge
size={120}
level={
todayEntries.length === 0
? null
: Math.min(1, todayTotal / Math.max(todayTotal, 100))
}
status="open"
ariaLabel="Today's water level"
/>
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<h2
className="text-2xl font-semibold tracking-tight text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Today&apos;s Summary
</h2>
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-[#57694e]">
<SeasonMark inSeason={inSeason} size={14} />
{inSeason ? (
<span className="font-medium text-[#1a4d2e]">
In irrigation season
</span>
) : (
<span className="font-medium text-[#a16207]">
Outside irrigation season
</span>
)}
<span className="text-[#57694e]/60">·</span>
<span className="font-mono text-[#1d1d1f]">
{todayEntries.length}{" "}
{todayEntries.length === 1 ? "entry" : "entries"}
</span>
<span className="text-[#57694e]/60">·</span>
<span className="text-[#57694e]">{todayLabel}</span>
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile
label="Total volume"
value={todayTotal.toFixed(2)}
unit={headgates[0]?.unit ?? "CFS"}
accent
/>
<StatTile
label="Headgates"
value={headgates.filter((h) => h.active).length.toString()}
unit={`of ${headgates.length}`}
/>
<StatTile
label="Active irrigators"
value={users.filter((u) => u.active).length.toString()}
unit={`of ${users.length}`}
/>
<StatTile
label="Last entry"
value={
todayEntries.length
? formatDateTime(todayEntries[0].logged_at)
.split(",")
.pop()
?.trim() ?? "—"
: "—"
}
unit=""
mono
/>
</div>
<div className="flex flex-wrap gap-2">
<AdminButton
variant="secondary"
size="sm"
icon={<ReportIcon />}
onClick={onPreviewReport}
>
Preview Report
</AdminButton>
<AdminButton
variant={showReportSettings ? "primary" : "secondary"}
size="sm"
icon={
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
>
<circle cx="8" cy="8" r="2.2" />
<path d="M8 1.5 V3.5 M8 12.5 V14.5 M1.5 8 H3.5 M12.5 8 H14.5 M3.3 3.3 L4.7 4.7 M11.3 11.3 L12.7 12.7 M3.3 12.7 L4.7 11.3 M11.3 4.7 L12.7 3.3" />
</svg>
}
onClick={onToggleReportSettings}
>
Report Settings
</AdminButton>
</div>
{showReportSettings && (
<ReportSettingsInline
season={seasonStart}
onSeasonChange={onSeasonChange}
recipientName={recipientName}
onRecipientNameChange={onRecipientNameChange}
/>
)}
</div>
</div>
</section>
);
}
// ── Inline Report Settings (kept under the toggle button) ───────────────────
function ReportSettingsInline({
season,
onSeasonChange,
recipientName,
onRecipientNameChange,
}: {
season: IrrigationSeasonSettings;
onSeasonChange: (s: IrrigationSeasonSettings) => void;
recipientName: string;
onRecipientNameChange: (v: string) => void;
}) {
return (
<div className="rounded-xl border border-[#d4d9d3] bg-white p-4">
<p className="mb-3 rounded-lg bg-[#fef9c3] px-3 py-2 text-xs text-[#a16207]">
<strong>Preview only.</strong> Daily-report delivery is wired through
the scheduled <code>api/cron/water-report</code> endpoint and respects
these settings.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ReportSeasonRow
label="Season start"
month={season.seasonStartMonth}
day={season.seasonStartDay}
onMonthChange={(m) =>
onSeasonChange({ ...season, seasonStartMonth: m })
}
onDayChange={(d) => onSeasonChange({ ...season, seasonStartDay: d })}
/>
<ReportSeasonRow
label="Season end"
month={season.seasonEndMonth}
day={season.seasonEndDay}
onMonthChange={(m) =>
onSeasonChange({ ...season, seasonEndMonth: m })
}
onDayChange={(d) => onSeasonChange({ ...season, seasonEndDay: d })}
/>
<div className="sm:col-span-2">
<label className="block">
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Recipient name (used in report salutation)
</span>
<input
type="text"
value={recipientName}
onChange={(e) => onRecipientNameChange(e.target.value)}
placeholder="David"
aria-label="Recipient name"
className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]"
/>
</label>
</div>
</div>
</div>
);
}
function ReportSeasonRow({
label,
month,
day,
onMonthChange,
onDayChange,
}: {
label: string;
month: number;
day: number;
onMonthChange: (m: number) => void;
onDayChange: (d: number) => void;
}) {
return (
<div>
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
{label}
</span>
<div className="flex gap-1">
<MonthSelect value={month} onChange={onMonthChange} />
<DayInput value={day} onChange={onDayChange} />
</div>
</div>
);
}
// ── RecentEntriesSection (table + filters) ──────────────────────────────────
function RecentEntriesSection({
state,
dispatch,
onEditEntry,
onExportCSV,
}: {
state: State;
dispatch: React.Dispatch<Action>;
onEditEntry: (e: AdminEntry) => void;
onExportCSV: () => void;
}) {
const filteredEntries = state.entries.filter((e) => {
if (state.filters.dateFrom && e.logged_at < state.filters.dateFrom)
return false;
if (
state.filters.dateTo &&
e.logged_at > state.filters.dateTo + "T23:59:59.999Z"
)
return false;
if (state.filters.headgate && e.headgate_id !== state.filters.headgate)
return false;
if (state.filters.user && e.user_id !== state.filters.user) return false;
if (state.filters.via && e.submitted_via !== state.filters.via)
return false;
return true;
});
return (
<>
<SectionHeader
index="§ 03"
title="Recent Entries"
subtitle="The latest readings logged from the field or the admin panel. Use the filters to narrow, then export to CSV."
action={
<AdminButton
size="sm"
onClick={onExportCSV}
isLoading={state.csvLoading}
icon={<DownloadIcon />}
>
Export CSV
</AdminButton>
}
/>
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-[#d4d9d3] bg-white p-2.5">
<FilterChip label="From">
<input
type="date"
value={state.filters.dateFrom}
onChange={(e) =>
dispatch({ type: "SET_FILTER_DATE_FROM", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: from date"
/>
</FilterChip>
<FilterChip label="To">
<input
type="date"
value={state.filters.dateTo}
onChange={(e) =>
dispatch({ type: "SET_FILTER_DATE_TO", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: to date"
/>
</FilterChip>
<FilterChip label="Headgate">
<select
value={state.filters.headgate}
onChange={(e) =>
dispatch({ type: "SET_FILTER_HEADGATE", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: headgate"
>
<option value="">All</option>
{state.headgates.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="User">
<select
value={state.filters.user}
onChange={(e) =>
dispatch({ type: "SET_FILTER_USER", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: user"
>
<option value="">All</option>
{state.users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FilterChip>
<FilterChip label="Via">
<select
value={state.filters.via}
onChange={(e) =>
dispatch({ type: "SET_FILTER_VIA", value: e.target.value })
}
className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none"
aria-label="Filter: submission source"
>
<option value="">All</option>
<option value="field">Field</option>
<option value="admin">Admin</option>
<option value="app">App</option>
</select>
</FilterChip>
{(state.filters.dateFrom ||
state.filters.dateTo ||
state.filters.headgate ||
state.filters.user ||
state.filters.via) && (
<button
type="button"
onClick={() => dispatch({ type: "CLEAR_FILTERS" })}
className="text-xs font-medium text-[#57694e] hover:text-[#1a4d2e] hover:underline"
aria-label="Clear filters"
>
Clear filters
</button>
)}
<span className="ml-auto font-mono text-[11px] text-[#86868b]">
{filteredEntries.length} of {state.entries.length} entries
</span>
</div>
{filteredEntries.length === 0 ? (
<EmptyState
icon={<TableIcon />}
title="No entries match"
body={
state.entries.length === 0
? "No readings have been logged yet."
: "Try clearing the filters above."
}
/>
) : (
<div className="overflow-hidden rounded-xl border border-[#d4d9d3] bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#d4d9d3] bg-[#f5f3ef] text-left font-mono text-[10px] uppercase tracking-[0.15em] text-[#57694e]">
<th className="px-4 py-2.5">When</th>
<th className="px-4 py-2.5">User</th>
<th className="px-4 py-2.5">Headgate</th>
<th className="px-4 py-2.5 text-right">Measurement</th>
<th className="px-4 py-2.5 text-right">Gallons</th>
<th className="px-4 py-2.5">Method</th>
<th className="px-4 py-2.5">Via</th>
<th className="px-4 py-2.5" aria-label="Actions"></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((e, i) => (
<tr
key={e.id}
className={`group border-b border-[#e8ebe8] last:border-0 transition-colors hover:bg-[#fdfaf2] ${
i % 2 === 0 ? "" : "bg-[#fafaf7]"
}`}
>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-[#57694e]">
{formatDateTime(e.logged_at)}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.user_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-[#1d1d1f]">
{e.headgate_name}
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono font-semibold text-[#1d1d1f]">
{e.measurement}{" "}
<span className="text-[10px] text-[#86868b]">{e.unit}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-right font-mono text-[#57694e]">
{e.total_gallons != null
? e.total_gallons.toFixed(1)
: "—"}
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-[11px] uppercase text-[#57694e]">
{e.method}
</td>
<td className="whitespace-nowrap px-4 py-2.5">
<span
className={`inline-block rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider ${
e.submitted_via === "field"
? "bg-[#dcfce7] text-[#15803d]"
: "bg-[#e8ebe8] text-[#57694e]"
}`}
>
{e.submitted_via}
</span>
</td>
<td className="px-4 py-2.5 text-right text-[11px]">
<button
type="button"
onClick={() => onEditEntry(e)}
className="font-medium text-[#1a4d2e] opacity-0 transition-opacity group-hover:opacity-100 hover:underline"
aria-label="Edit →"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
@@ -0,0 +1,270 @@
/**
* UsersTab — CRUD surface for water users (irrigators + admins).
*
* Lists every user in a card grid, with add / reset-PIN / deactivate
* controls. PINs are shown one-at-a-time via the PinBanner primitive.
*/
"use client";
import * as React from "react";
import AdminButton from "@/components/admin/design-system/AdminButton";
import {
createWaterUser,
deleteWaterUser,
resetWaterIrrigatorPin,
type AdminIrrigator,
} from "@/actions/water-log/admin";
import { formatDate } from "@/lib/format-date";
import {
Avatar,
EmptyState,
Input,
PinBanner,
RoleLegend,
SectionHeader,
Select,
} from "../shared/Primitives";
import { UserIcon } from "../shared/icons";
import type { Action, NotifyFn, PinInfo, UserForm } from "../types";
export type UsersTabProps = {
brandId: string;
users: AdminIrrigator[];
canManage: boolean;
showAddUser: boolean;
newUser: UserForm;
savingUser: boolean;
newPin: PinInfo | null;
resetPin: PinInfo | null;
notify: NotifyFn;
dispatch: React.Dispatch<Action>;
};
export function UsersTab({
brandId,
users,
canManage,
showAddUser,
newUser,
savingUser,
newPin,
resetPin,
notify,
dispatch,
}: UsersTabProps) {
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const trimmedName = newUser.name.trim();
if (!trimmedName) return;
dispatch({ type: "SET_SAVING_USER", value: true });
const res = await createWaterUser(
brandId,
trimmedName,
newUser.role,
newUser.lang,
newUser.phone.trim() || null,
);
if (res.success && res.user && res.pin) {
dispatch({ type: "ADD_USER", user: res.user });
dispatch({
type: "SET_NEW_PIN",
pin: { name: res.user.name, pin: res.pin },
});
dispatch({ type: "RESET_NEW_USER" });
notify("User created");
} else {
notify(res.error ?? "Failed", "err");
}
dispatch({ type: "SET_SAVING_USER", value: false });
}
async function handleResetPin(u: AdminIrrigator) {
if (
!window.confirm(
`Reset PIN for "${u.name}"? Their current PIN will stop working.`,
)
)
return;
const res = await resetWaterIrrigatorPin(u.id);
if (res.success && res.pin) {
dispatch({
type: "SET_RESET_PIN",
pin: { name: u.name, pin: res.pin },
});
notify("PIN reset");
} else {
notify(res.error ?? "Failed", "err");
}
}
async function handleDelete(u: AdminIrrigator) {
if (
!window.confirm(
`Deactivate "${u.name}"? Their existing log entries will be preserved.`,
)
)
return;
const res = await deleteWaterUser(u.id);
if (res.success) {
dispatch({ type: "DEACTIVATE_USER", id: u.id });
notify("User deactivated");
} else {
notify(res.error ?? "Failed", "err");
}
}
return (
<>
<SectionHeader
index="§ 02"
title="Water Users"
subtitle="The people who log readings in the field. Each gets a 4-digit PIN. Admin role can manage headgates, users, and entries; Irrigators can only submit."
action={
canManage ? (
<AdminButton
size="sm"
onClick={() => dispatch({ type: "TOGGLE_ADD_USER" })}
>
{showAddUser ? "Cancel" : "+ Add User"}
</AdminButton>
) : null
}
/>
{newPin && (
<PinBanner
title={`New PIN for ${newPin.name}`}
pin={newPin.pin}
onDismiss={() => dispatch({ type: "CLEAR_NEW_PIN" })}
/>
)}
{resetPin && (
<PinBanner
title={`Reset PIN for ${resetPin.name}`}
pin={resetPin.pin}
tone="warn"
onDismiss={() => dispatch({ type: "CLEAR_RESET_PIN" })}
/>
)}
{showAddUser && canManage && (
<form
onSubmit={handleAdd}
className="mb-4 grid grid-cols-1 gap-3 rounded-xl border border-[#d4d9d3] bg-white p-4 sm:grid-cols-[1fr_140px_120px_1fr_auto]"
>
<Input
label="Name"
value={newUser.name}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_NAME", value: v })
}
placeholder="Full name"
required
/>
<Select
label="Role"
value={newUser.role}
onChange={(v) =>
dispatch({
type: "SET_NEW_USER_ROLE",
value: v as "irrigator" | "water_admin",
})
}
options={[
{ value: "irrigator", label: "Irrigator" },
{ value: "water_admin", label: "Admin" },
]}
/>
<Select
label="Language"
value={newUser.lang}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_LANG", value: v })
}
options={[
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
]}
/>
<Input
label="Phone"
value={newUser.phone}
onChange={(v) =>
dispatch({ type: "SET_NEW_USER_PHONE", value: v })
}
placeholder="+1…"
type="tel"
/>
<div className="flex items-end">
<AdminButton
type="submit"
disabled={savingUser}
isLoading={savingUser}
fullWidth
>
Add
</AdminButton>
</div>
</form>
)}
<RoleLegend />
{users.length === 0 ? (
<EmptyState
icon={<UserIcon />}
title="No water users yet"
body="Add an irrigator or admin to grant field access. Their PIN is generated automatically — write it down before dismissing."
/>
) : (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<li
key={u.id}
className={`flex items-center gap-3 rounded-xl border bg-white px-3 py-2 ${
u.active
? "border-[#d4d9d3]"
: "border-[#d4d9d3] bg-[#f5f3ef] opacity-70"
}`}
>
<Avatar name={u.name} role={u.role} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-[#1d1d1f]">
{u.name}
</p>
<p className="font-mono text-[11px] text-[#86868b]">
{u.role === "water_admin" ? "ADMIN" : "IRRIGATOR"} ·{" "}
{u.language_preference === "es" ? "ES" : "EN"}
{u.last_used_at
? ` · seen ${formatDate(u.last_used_at)}`
: " · never signed in"}
</p>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1 text-[11px]">
<button
type="button"
onClick={() => handleResetPin(u)}
className="text-[#57694e] hover:text-[#1a4d2e]"
aria-label="PIN"
>
PIN
</button>
<span className="text-[#d4d9d3]">·</span>
<button
type="button"
onClick={() => handleDelete(u)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
aria-label="Off"
>
Off
</button>
</div>
)}
</li>
))}
</ul>
)}
</>
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Shared types for the Water Log admin module.
*
* Split out of the original monolithic `WaterLogAdminPanel.tsx` so each
* tab / shared component can declare its own prop types without
* pulling in the full reducer surface.
*
* @see ./reducer.ts for State + Action.
*/
import type {
AdminEntry,
AdminHeadgate,
AdminIrrigator,
} from "@/actions/water-log/admin";
import type { IrrigationSeasonSettings } from "@/lib/water-log-reporting";
// ── Toast ────────────────────────────────────────────────────────────────────
export type Toast = { msg: string; kind: "ok" | "err" };
// ── Forms ────────────────────────────────────────────────────────────────────
export type HgForm = { name: string; unit: string; notes: string };
export type UserForm = {
name: string;
role: "irrigator" | "water_admin";
lang: string;
phone: string;
};
// ── PIN disclosure ───────────────────────────────────────────────────────────
export type PinInfo = { name: string; pin: string };
// ── Report preview payload ───────────────────────────────────────────────────
export type ReportPreviewData = {
rows: import("@/lib/water-log-reporting").WaterLogReportRow[];
total: number;
unit: string;
dateLabel: string;
dateIso: string;
recipientName?: string;
inSeason: boolean;
};
// ── Reducer surface (mirrored here so callers don't import reducer internals)
export type State = {
headgates: AdminHeadgate[];
users: AdminIrrigator[];
entries: AdminEntry[];
toast: Toast | null;
forms: {
showAddHg: boolean;
newHg: HgForm;
savingHg: boolean;
showAddUser: boolean;
newUser: UserForm;
savingUser: boolean;
newPin: PinInfo | null;
resetPin: PinInfo | null;
};
filters: {
dateFrom: string;
dateTo: string;
headgate: string;
user: string;
via: string;
};
csvLoading: boolean;
today: string;
todayLabel: string;
showReportSettings: boolean;
seasonStart: IrrigationSeasonSettings;
recipientName: string;
reportPreview: ReportPreviewData | null;
};
export type Action =
// Headgates
| { type: "ADD_HEADGATE"; headgate: AdminHeadgate }
| { type: "REMOVE_HEADGATE"; id: string }
| { type: "UPDATE_HEADGATE_TOKEN"; id: string; token: string }
// Users
| { type: "ADD_USER"; user: AdminIrrigator }
| { type: "DEACTIVATE_USER"; id: string }
// Toast
| { type: "SET_TOAST"; toast: Toast }
| { type: "CLEAR_TOAST" }
// Forms - Headgate
| { type: "TOGGLE_ADD_HG" }
| { type: "SET_NEW_HG_NAME"; value: string }
| { type: "SET_NEW_HG_UNIT"; value: string }
| { type: "SET_NEW_HG_NOTES"; value: string }
| { type: "RESET_NEW_HG" }
| { type: "SET_SAVING_HG"; value: boolean }
// Forms - User
| { type: "TOGGLE_ADD_USER" }
| { type: "SET_NEW_USER_NAME"; value: string }
| { type: "SET_NEW_USER_ROLE"; value: "irrigator" | "water_admin" }
| { type: "SET_NEW_USER_LANG"; value: string }
| { type: "SET_NEW_USER_PHONE"; value: string }
| { type: "RESET_NEW_USER" }
| { type: "SET_SAVING_USER"; value: boolean }
| { type: "SET_NEW_PIN"; pin: PinInfo }
| { type: "CLEAR_NEW_PIN" }
| { type: "SET_RESET_PIN"; pin: PinInfo }
| { type: "CLEAR_RESET_PIN" }
// Filters
| { type: "SET_FILTER_DATE_FROM"; value: string }
| { type: "SET_FILTER_DATE_TO"; value: string }
| { type: "SET_FILTER_HEADGATE"; value: string }
| { type: "SET_FILTER_USER"; value: string }
| { type: "SET_FILTER_VIA"; value: string }
| { type: "CLEAR_FILTERS" }
// Misc UI
| { type: "SET_CSV_LOADING"; value: boolean }
| { type: "SET_TODAY"; today: string; label: string }
| { type: "TOGGLE_REPORT_SETTINGS" }
| { type: "SET_SEASON_START"; value: IrrigationSeasonSettings }
| { type: "SET_RECIPIENT_NAME"; value: string }
| { type: "OPEN_REPORT_PREVIEW"; preview: ReportPreviewData }
| { type: "CLOSE_REPORT_PREVIEW" };
// ── Convenience ──────────────────────────────────────────────────────────────
export type NotifyFn = (msg: string, kind?: "ok" | "err") => void;
+168
View File
@@ -0,0 +1,168 @@
/**
* FieldInstallPrompt — install prompt for the worker sub-PWA.
*
* Cycle 6 sibling of `InstallPrompt` (mounted from the root layout).
* This one fires only on /water routes and uses the field-PWA
* branding (forest green icon, "Water Log — Tuxedo") so the prompt
* that surfaces matches the manifest that will be installed.
*
* Same `BeforeInstallPromptEvent` capture pattern as InstallPrompt;
* split into a separate component file because the worker surface
* has its own theming + copy.
*/
"use client";
import { useEffect, useSyncExternalStore, useCallback, useReducer } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
type PromptState = {
deferred: BeforeInstallPromptEvent | null;
visible: boolean;
};
type PromptAction =
| { type: "captured"; event: BeforeInstallPromptEvent }
| { type: "show" }
| { type: "hide" }
| { type: "dismissed" }
| { type: "reset" };
function promptReducer(state: PromptState, action: PromptAction): PromptState {
switch (action.type) {
case "captured":
return { deferred: action.event, visible: false };
case "show":
return state.deferred ? { ...state, visible: true } : state;
case "hide":
return { deferred: null, visible: false };
case "dismissed":
return { ...state, visible: false };
case "reset":
return { deferred: null, visible: false };
}
}
const INITIAL_PROMPT_STATE: PromptState = { deferred: null, visible: false };
export function FieldInstallPrompt() {
const [prompt, dispatch] = useReducer(promptReducer, INITIAL_PROMPT_STATE);
const isInstalled = useSyncExternalStore(
() => () => {},
() =>
typeof window !== "undefined" &&
(window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari uses navigator.standalone for the home-screen PWA
// launch. Cover both.
(typeof (navigator as Navigator & { standalone?: boolean }).standalone ===
"boolean" &&
(navigator as Navigator & { standalone?: boolean }).standalone === true)),
() => false,
);
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
useEffect(() => {
if (isInstalled) return;
if (typeof window === "undefined") return;
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
dispatch({ type: "captured", event: e as BeforeInstallPromptEvent });
// Show the field prompt sooner than the main one — workers
// know they want this installed on day one.
window.setTimeout(() => {
dispatch({ type: "show" });
}, 4000);
};
const handleAppInstalled = () => {
dispatch({ type: "reset" });
};
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
window.addEventListener("appinstalled", handleAppInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, [isInstalled]);
const handleInstall = useCallback(async () => {
const deferred = prompt.deferred;
if (!deferred) return;
await deferred.prompt();
const { outcome } = await deferred.userChoice;
void outcome;
dispatch({ type: "reset" });
}, [prompt.deferred]);
const handleDismiss = useCallback(() => {
dispatch({ type: "dismissed" });
try {
sessionStorage.setItem("field-pwa-install-dismissed", "true");
} catch {
// ignore
}
}, []);
if (!mounted || !prompt.visible || isInstalled || !prompt.deferred) {
return null;
}
return (
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white rounded-2xl shadow-2xl border border-[#1a4d2e]/30 p-4 z-50">
<div className="flex items-start gap-3">
<div className="w-12 h-12 bg-[#1a4d2e] rounded-xl flex items-center justify-center shrink-0">
<svg
className="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-[#1a4d2e]">
Install Water Log
</h3>
<p className="text-sm text-[rgba(60,60,67,0.65)] mt-1">
One tap to a full-screen PIN entry from your home screen.
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
<button
type="button"
onClick={handleDismiss}
className="flex-1 px-4 py-2 text-sm font-medium text-[rgba(60,60,67,0.55)] hover:text-[rgba(60,60,67,0.85)] transition-colors"
>
Not now
</button>
<button
type="button"
onClick={handleInstall}
className="flex-1 px-4 bg-[#1a4d2e] text-white rounded-lg text-sm font-medium hover:bg-[#14432a] py-2 transition-colors"
>
Install
</button>
</div>
</div>
);
}
export default FieldInstallPrompt;
@@ -0,0 +1,43 @@
/**
* FieldSWRegistration — registers /sw-field.js with scope=/water.
*
* Cycle 6 component used by the worker sub-PWA. The component is
* mounted from src/app/water/layout.tsx (a thin client boundary
* inside the otherwise-server layout) so the registration only
* happens on /water routes — never on /admin, /tuxedo, etc.
*
* No-ops on:
* - Server (guards with typeof navigator)
* - repeat mounts (uses a module-scoped flag so React strict-mode
* double-mount doesn't double-register)
*
* Same registration pattern as next-pwa — we use the raw
* ServiceWorkerContainer API to stay framework-free.
*/
"use client";
import { useEffect } from "react";
let registered = false;
export function FieldSWRegistration() {
useEffect(() => {
if (typeof navigator === "undefined") return;
if (!("serviceWorker" in navigator)) return;
if (registered) return;
registered = true;
void navigator.serviceWorker
.register("/sw-field.js", { scope: "/water" })
.catch((err) => {
// Surface to console for ops; never block the UI. The
// app works without the SW — it just loses offline shell
// caching for /water.
console.error("[field-sw] registration failed", err);
// Allow a retry on a later mount.
registered = false;
});
}, []);
return null;
}
export default FieldSWRegistration;
+172 -12
View File
@@ -5,13 +5,14 @@
* Tuxedo Water Log experience. * Tuxedo Water Log experience.
* *
* Flow: * Flow:
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success * pin ─verify─▶ tabbed shell
* ▲ │ * │
* └───────────────(logout)─────────────────┤ * ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
* └─(logout)─────(anywhere)──────────────────────────────┘ * │ ▲ │
* │ * │ │
* success ── Log Another ─▶ log (cleared) ─┘ * │ └───────── (logout) ────────────┤
* success ── Back to Headgates ─▶ headgates *
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
* *
* Hydration: * Hydration:
* - On mount we peek at `document.cookie` for the `wl_session` * - On mount we peek at `document.cookie` for the `wl_session`
@@ -21,11 +22,19 @@
* who already signed in once in their shift shouldn't have to * who already signed in once in their shift shouldn't have to
* re-enter their PIN every time they reopen the page. * re-enter their PIN every time they reopen the page.
* *
* Cycle 4 — the existing PIN is unified with the time-tracking PIN:
* after `verifyWaterPin` succeeds we also best-effort call
* `verifyTimeTrackingPin` with the same digits. If the worker exists
* in `time_tracking_workers`, the time-tracking session cookie is set
* too, so they never re-enter their PIN on the Time tab.
*
* Server actions used: * Server actions used:
* - `verifyWaterPin` → signs the irrigator in, sets cookie * - `verifyWaterPin` → signs the irrigator in, sets cookie
* - `getWaterHeadgates` → loads the list of active gates * - `getWaterHeadgates` → loads the list of active gates
* - `submitWaterEntry` → posts a new reading * - `submitWaterEntry` → posts a new reading
* - `logoutWater` → clears the cookie + session row * - `logoutWater` → clears the cookie + session row
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
* - `logoutTimeTracking` → clears the time-tracking cookie on logout
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
@@ -34,13 +43,28 @@ import {
getWaterHeadgates, getWaterHeadgates,
logoutWater, logoutWater,
} from "@/actions/water-log/field"; } from "@/actions/water-log/field";
import type { FieldHeadgate, Screen, SubmittedLog } from "./types"; import {
verifyTimeTrackingPin,
logoutTimeTracking,
} from "@/actions/time-tracking/field";
import TimeTrackingFieldClient from "@/components/time-tracking/TimeTrackingFieldClient";
import type {
FieldHeadgate,
Screen,
SubmittedLog,
ActiveTab,
} from "./types";
import { MobileWaterPinScreen } from "./PinScreen"; import { MobileWaterPinScreen } from "./PinScreen";
import { MobileWaterHeadgateList } from "./HeadgateList"; import { MobileWaterHeadgateList } from "./HeadgateList";
import { MobileWaterLogForm } from "./LogForm"; import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen"; import { MobileWaterSuccessScreen } from "./SuccessScreen";
import { WL_SESSION_COOKIE } from "@/lib/water-log/session"; import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand"; import {
TUXEDO_BRAND_ID,
TUXEDO_BRAND_NAME,
TUXEDO_BRAND_ACCENT,
TUXEDO_BRAND_LOGO_URL,
} from "@/lib/water-log/brand";
import { useLang } from "@/lib/water-log/lang-context"; import { useLang } from "@/lib/water-log/lang-context";
// Server-side auth errors that should bounce the user to the PIN // Server-side auth errors that should bounce the user to the PIN
@@ -56,6 +80,7 @@ const SESSION_ERRORS = [
export function MobileWaterApp() { export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin"); const [screen, setScreen] = useState<Screen>("pin");
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
// `useLang()` reads from the <LangProvider> in WaterFieldClient, // `useLang()` reads from the <LangProvider> in WaterFieldClient,
// so it's always up-to-date when the user toggles via the toggle // so it's always up-to-date when the user toggles via the toggle
// in the headgate list header. // in the headgate list header.
@@ -125,6 +150,11 @@ export function MobileWaterApp() {
setIrrigatorName(result.name ?? null); setIrrigatorName(result.name ?? null);
setScreen("headgates"); setScreen("headgates");
void loadHeadgates(); void loadHeadgates();
// Cycle 4 — unify the PIN with Time Tracking. Best-effort: a
// worker who isn't also set up as a time-tracking worker just
// sees the regular PIN screen on the Time tab. Never blocks
// the water flow, never surfaces a 500 to the user.
void verifyTimeTrackingPin(TUXEDO_BRAND_ID, pin).catch(() => {});
}, },
[loadHeadgates], [loadHeadgates],
); );
@@ -184,19 +214,47 @@ export function MobileWaterApp() {
// ── Logout (from anywhere) ──────────────────────────────────── // ── Logout (from anywhere) ────────────────────────────────────
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
// Cycle 4 — clear BOTH cookies. The water flow only knew about
// `wl_session`; the Time tab introduced `time_tracking_session`.
try { try {
await logoutWater(); await logoutWater();
} catch { } catch {
// Even if the server logout fails, clear local state so the // Even if the server logout fails, clear local state so the
// user isn't stuck. // user isn't stuck.
} }
try {
await logoutTimeTracking();
} catch {
// Same — defensive cleanup.
}
setIrrigatorName(null); setIrrigatorName(null);
setHeadgates([]); setHeadgates([]);
setSelectedHeadgate(null); setSelectedHeadgate(null);
setSubmittedLog(null); setSubmittedLog(null);
setActiveTab("headgates");
setScreen("pin"); setScreen("pin");
}, []); }, []);
// ── Tab change (Cycle 4) ──────────────────────────────────────
const handleTabChange = useCallback((next: ActiveTab) => {
setActiveTab(next);
if (next === "time") {
// Switching to Time drops the in-flight water flow (selected
// headgate + submitted log) so it doesn't linger when the
// worker comes back to Headgates. The headgate list itself
// is cached and will reload on demand.
setSubmittedLog(null);
setSelectedHeadgate(null);
setScreen("time");
} else {
// Switching back to Headgates — restore the flow at the
// headgate list (we always have that state cached; the
// loadHeadgates effect picks up fresh data on mount).
if (headgates.length === 0) void loadHeadgates();
setScreen("headgates");
}
}, [headgates.length, loadHeadgates]);
// ── Switch on the current screen ────────────────────────────── // ── Switch on the current screen ──────────────────────────────
// Compute the screen element first, then wrap it once in // Compute the screen element first, then wrap it once in
// <LangProvider>. Wrapping per-case would remount the provider on // <LangProvider>. Wrapping per-case would remount the provider on
@@ -258,11 +316,113 @@ export function MobileWaterApp() {
/> />
); );
break; break;
case "time":
// Cycle 4 — Tuxedo-only Time tab. Same brand id / name /
// accent as the standalone /tuxedo/time-clock route so the
// embedded surface matches the standalone one pixel-for-pixel.
screenElement = (
<TimeTrackingFieldClient
brandId={TUXEDO_BRAND_ID}
brandName={TUXEDO_BRAND_NAME}
brandAccent={TUXEDO_BRAND_ACCENT}
logoUrl={TUXEDO_BRAND_LOGO_URL}
/>
);
break;
} }
// `initialLang` is computed by the parent <LangProvider> in // ── Post-PIN shell ────────────────────────────────────────────
// WaterFieldClient — we just return the screen element here. // The pre-PIN screen is bare (no nav). After PIN we wrap the
return screenElement; // screen element in a sticky tab bar so the worker can flip
// between Headgates and Time without re-entering credentials.
const isPostPin = screen !== "pin";
return (
<div className="flex h-full flex-col">
{isPostPin && (
<TabBar
activeTab={activeTab}
onChange={handleTabChange}
onLogout={handleLogout}
irrigatorName={irrigatorName ?? undefined}
/>
)}
<div className="min-h-0 flex-1">{screenElement}</div>
</div>
);
}
// ── TabBar (Cycle 4) ───────────────────────────────────────────
// Sticky two-tab top nav for the Tuxedo worker experience.
// Stays visible across Headgates → Log → Success AND on the Time
// tab. Extracted as a presentational component (no server actions,
// no effects) so it's pure and easy to iterate on visually.
function TabBar({
activeTab,
onChange,
onLogout,
irrigatorName,
}: {
activeTab: ActiveTab;
onChange: (next: ActiveTab) => void;
onLogout: () => void;
irrigatorName?: string;
}) {
const t = useLang().t;
return (
<div
className="sticky top-0 z-30 flex items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-white/85 px-3 py-2 backdrop-blur"
role="tablist"
aria-label="Worker navigation"
>
<div className="flex items-center gap-2">
<button
type="button"
role="tab"
aria-selected={activeTab === "headgates"}
onClick={() => onChange("headgates")}
className={
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
(activeTab === "headgates"
? "bg-[#1a4d2e] text-white shadow-sm"
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.headgates}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "time"}
onClick={() => onChange("time")}
className={
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
(activeTab === "time"
? "bg-[#1a4d2e] text-white shadow-sm"
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.time}
</button>
</div>
<div className="flex items-center gap-2">
{irrigatorName && (
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
{irrigatorName}
</span>
)}
<button
type="button"
onClick={onLogout}
className="rounded-full px-2.5 py-1 text-[12px] font-semibold text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]"
aria-label="Log out"
>
{t.tab.logout}
</button>
</div>
</div>
);
} }
export default MobileWaterApp; export default MobileWaterApp;
+10 -2
View File
@@ -49,8 +49,16 @@ export function isIntegerUnit(unit: string): boolean {
return unit === "holes"; return unit === "holes";
} }
/** Top-level screen identifiers — drives the state machine. */ /** Top-level screen identifiers — drives the state machine.
export type Screen = "pin" | "headgates" | "log" | "success"; *
* Cycle 4 adds "time" for the Tuxedo-only Time tab, which renders
* `TimeTrackingFieldClient` instead of the existing water-entry
* screens. The "pin" → "headgates"/"log"/"success" flow is unchanged.
*/
export type Screen = "pin" | "headgates" | "log" | "success" | "time";
/** Tuxedo-only Time-tab branding (Cycle 4). */
export type ActiveTab = "headgates" | "time";
/** A submitted log entry (only kept locally for the success screen). */ /** A submitted log entry (only kept locally for the success screen). */
export type SubmittedLog = { export type SubmittedLog = {
+10 -1
View File
@@ -11,4 +11,13 @@
* `TUXEDO_BRAND_ID` with `effectiveBrandId` from `getAdminUser()` — * `TUXEDO_BRAND_ID` with `effectiveBrandId` from `getAdminUser()` —
* which is the standard pattern used throughout the rest of the app. * which is the standard pattern used throughout the rest of the app.
*/ */
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
/** Display surfaces (Cycle 4) — single source of truth for the
* Tuxedo worker experience so the standalone `/tuxedo/time-clock`
* page and the embedded Time tab inside `/water` stay in sync.
* Bump these together when the brand refreshes its accent/logo. */
export const TUXEDO_BRAND_NAME = "Tuxedo Corn";
export const TUXEDO_BRAND_ACCENT = "green";
/** `null` until the Tuxedo brand gets a logo asset uploaded. */
export const TUXEDO_BRAND_LOGO_URL: string | null = null;
+17
View File
@@ -138,6 +138,13 @@ export type Labels = {
minutesAgo: (n: number) => string; minutesAgo: (n: number) => string;
hoursAgo: (n: number) => string; hoursAgo: (n: number) => string;
}; };
/** Tuxedo worker post-PIN shell (Cycle 4) — names for the two
* top tabs and the inline logout affordance. */
tab: {
headgates: string;
time: string;
logout: string;
};
}; };
export const LABELS: Record<Lang, Labels> = { export const LABELS: Record<Lang, Labels> = {
@@ -231,6 +238,11 @@ export const LABELS: Record<Lang, Labels> = {
minutesAgo: (n) => `${n}m ago`, minutesAgo: (n) => `${n}m ago`,
hoursAgo: (n) => `${n}h ago`, hoursAgo: (n) => `${n}h ago`,
}, },
tab: {
headgates: "Headgates",
time: "Time",
logout: "Log out",
},
}, },
es: { es: {
common: { common: {
@@ -323,6 +335,11 @@ export const LABELS: Record<Lang, Labels> = {
minutesAgo: (n) => `hace ${n}m`, minutesAgo: (n) => `hace ${n}m`,
hoursAgo: (n) => `hace ${n}h`, hoursAgo: (n) => `hace ${n}h`,
}, },
tab: {
headgates: "Compuertas",
time: "Tiempo",
logout: "Salir",
},
}, },
}; };
+25 -11
View File
@@ -39,7 +39,7 @@ import {
waterSmartsheetSyncLog, waterSmartsheetSyncLog,
type SmartsheetColumnMapping, type SmartsheetColumnMapping,
} from "@/db/schema/water-log"; } from "@/db/schema/water-log";
import { decryptToken } from "@/lib/crypto"; import { resolveWorkspaceToken } from "@/services/workspace-token";
import { import {
addRow, addRow,
findRowByColumn, findRowByColumn,
@@ -159,24 +159,38 @@ export async function syncEntryToSmartsheet(
); );
} }
// ── 3. Decrypt token + build cells ── // ── 3. Resolve token from the workspace (Cycle 7) ──
let token: string; const ws = await resolveWorkspaceToken(brandId);
try { if (!ws.ok) {
token = decryptToken({ // "no_workspace" + "decryption_failed" are real failures — log
cipher: config.encryptedApiToken, // them, bump attempts, and back off. "connection_disabled" is
iv: config.tokenIv, // an intentional admin pause — skip cleanly, don't retry, don't
authTag: config.tokenAuthTag, // pollute the activity log with "disabled" failures.
}); if (ws.reason === "connection_disabled") {
} catch (err) { return finalizeSkip(
db,
brandId,
entryId,
queue?.id ?? null,
"workspace paused",
queue?.smartsheetRowId ?? null,
start,
);
}
const reason =
ws.reason === "no_workspace"
? "Smartsheet workbook not connected"
: `workspace token unreadable: ${ws.error}`;
return recordFailure( return recordFailure(
db, db,
brandId, brandId,
entryId, entryId,
queue?.attempts ?? 0, queue?.attempts ?? 0,
`decryption failed (key rotated or corrupt): ${sanitizeError(err)}`, reason,
start, start,
); );
} }
const token = ws.token;
const cells = buildCells( const cells = buildCells(
config.columnMapping, config.columnMapping,
+108
View File
@@ -0,0 +1,108 @@
/**
* Per-brand drain summary for the cron route.
*
* Cycle 9 — extracted from `/api/time-tracking/smartsheet-sync/route.ts`
* so the discriminated-union narrowing (`result.skipped === true`)
* lives in a pure, unit-testable module instead of inside an HTTP
* handler. The route is now a thin shell that authenticates, parses
* the body, fans out to this helper, and aggregates the results.
*
* The TT sync service (`runScheduledTTSync`) returns either:
* - a regular `TTDrainResult` (counters)
* - `{ skipped: true, reason }` when the brand isn't due yet
*
* Both branches carry a `skipped` property, so we narrow on the
* literal `=== true` rather than the `in` operator — TS doesn't
* otherwise distinguish the two cases.
*/
import "server-only";
export type DrainSummary = {
brandId: string;
considered: number;
synced: number;
skipped: number;
failed: number;
/** Set when the brand isn't due yet (frequency gate). */
skippedReason?: string;
/** Set when `run*` itself threw. */
error?: string;
};
export type TTDrainCounters = {
brandId: string;
considered: number;
synced: number;
skipped: number;
failed: number;
};
export type TTDrainSkipped = {
skipped: true;
reason: string;
};
/**
* Mirrors the return shape of `runScheduledTTSync` (see
* `src/services/time-tracking-smartsheet-sync.ts`). The two branches
* share a `skipped` property by name but differ in type — literal
* `true` vs `number` — so callers narrow on `result.skipped === true`.
*/
export type TTDrainResult = TTDrainCounters | TTDrainSkipped;
export type DrainRunner = (brandId: string) => Promise<TTDrainResult>;
/**
* Run the per-brand drain and translate the union into a flat
* summary that the cron route can aggregate.
*
* Errors are caught and surfaced in `error`, so one failing brand
* never aborts the batch.
*/
export async function drainOneBrand(
brandId: string,
run: DrainRunner,
): Promise<DrainSummary> {
try {
const result = await run(brandId);
if (result.skipped === true) {
return {
brandId,
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
skippedReason: result.reason,
};
}
return {
brandId,
considered: result.considered,
synced: result.synced,
skipped: result.skipped,
failed: result.failed,
};
} catch (err) {
return {
brandId,
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
/** Sum a list of per-brand summaries into route-level totals. */
export function summarizeDrains(results: DrainSummary[]) {
return results.reduce(
(acc, r) => ({
considered: acc.considered + r.considered,
synced: acc.synced + r.synced,
skipped: acc.skipped + r.skipped,
failed: acc.failed + r.failed,
}),
{ considered: 0, synced: 0, skipped: 0, failed: 0 },
);
}
@@ -0,0 +1,562 @@
/**
* Time Tracking → Smartsheet sync engine.
*
* Cycle 5 — mirrors the water-log smartsheet-sync service so a brand
* can push every closed clock-out to a configured Smartsheet sheet.
*
* Callers:
* - `enqueueClockOutForSync()` from `clockOutWorker` (realtime)
* - `/api/time-tracking/smartsheet-sync` cron (every 15 min / hourly)
* - Manual retry via the admin UI's "Retry now" button (future)
*
* Public surface:
* - `syncLogToSmartsheet(brandId, logId)` — push one clock-out
* - `drainTTSyncQueue(brandId, opts)` — process pending / due retries
* - `runScheduledTTSync(brandId)` — entry point for the cron route;
* loads the config + applies per-frequency filtering
*
* Concurrency model:
* - One sync runs at a time per brand. The SELECT … FOR UPDATE SKIP
* LOCKED in `drainTTSyncQueue` lets multiple cron runs co-exist
* safely across brands without double-processing.
*
* Retry strategy:
* - On failure, `attempts++` and `next_attempt_at = now() + 2^attempts min`,
* capped at 60 minutes. After `maxAttempts` (default 5), the row
* stays `failed` and the admin sees it in Recent Activity.
*
* Error sanitization:
* - Smartsheet errors may include token echoes in odd cases. The
* service strips anything that looks like our token before
* persisting `last_error`.
*/
import "server-only";
import { and, eq, inArray, lte } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
timeTrackingLogs,
timeTrackingWorkers,
timeTrackingTasks,
timeTrackingSmartsheetConfig,
timeTrackingSmartsheetSyncQueue,
timeTrackingSmartsheetSyncLog,
type TTSmartsheetColumnMapping,
} from "@/db/schema/time-tracking";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import {
addRow,
findRowByColumn,
SmartsheetApiError,
type SmartsheetCell,
} from "@/lib/smartsheet";
const DEFAULT_BATCH_SIZE = 50;
const DEFAULT_MAX_ATTEMPTS = 5;
const MAX_BACKOFF_MINUTES = 60;
export type TTSyncResult =
| {
status: "synced";
smartsheetRowId: string;
attempts: number;
durationMs: number;
}
| {
status: "skipped";
reason: string;
smartsheetRowId: string | null;
durationMs: number;
}
| {
status: "failed";
error: string;
attempts: number;
durationMs: number;
retryAt: Date;
};
export type TTDrainResult = {
brandId: string;
considered: number;
synced: number;
skipped: number;
failed: number;
};
// ── Helpers ────────────────────────────────────────────────────────────────
/** Strip anything that looks like our token from an error string.
* Smartsheet 4xx echoes the bearer token in some `WWW-Authenticate`
* headers; we never want a token footgun in the UI. */
function sanitizeError(raw: string, plaintextToken: string): string {
if (!raw) return raw;
if (!plaintextToken) return raw;
return raw.split(plaintextToken).join("[redacted-token]");
}
/** Build a `log_id` cell fingerprint — the row ID we look up on the
* sheet for dedup. Stable and unique per row. */
function logIdFingerprint(logId: string): string {
return logId;
}
/** Convert a clock-in/out pair to a decimal-hours string ("7.42"). */
function hoursBetween(clockIn: Date, clockOut: Date, lunchMinutes: number) {
const ms = clockOut.getTime() - clockIn.getTime() - lunchMinutes * 60_000;
return Math.max(0, ms / 3_600_000).toFixed(2);
}
/** Build the cells array from a closed log row using the brand's
* column mapping. Returns null if the mapping is missing required
* fields (log_id / clock_in). */
function buildCells(
mapping: TTSmartsheetColumnMapping,
values: {
logId: string;
clockInIso: string;
clockOutIso: string;
workerName: string;
taskName: string;
hours: string;
lunchMinutes: number;
notes: string | null;
},
): SmartsheetCell[] | null {
if (!mapping.log_id || !mapping.clock_in) return null;
const cells: SmartsheetCell[] = [
{ columnId: mapping.log_id, value: logIdFingerprint(values.logId) },
{ columnId: mapping.clock_in, value: values.clockInIso },
];
if (mapping.clock_out && values.clockOutIso) {
cells.push({ columnId: mapping.clock_out, value: values.clockOutIso });
}
if (mapping.worker && values.workerName) {
cells.push({ columnId: mapping.worker, value: values.workerName });
}
if (mapping.task && values.taskName) {
cells.push({ columnId: mapping.task, value: values.taskName });
}
if (mapping.hours && values.hours) {
cells.push({ columnId: mapping.hours, value: values.hours });
}
if (mapping.lunch_minutes) {
cells.push({
columnId: mapping.lunch_minutes,
value: String(values.lunchMinutes),
});
}
if (mapping.notes) {
cells.push({
columnId: mapping.notes,
value: values.notes ?? "",
});
}
return cells;
}
// ── Core: sync one closed log row ──────────────────────────────────────────
export async function syncLogToSmartsheet(
brandId: string,
logId: string,
): Promise<TTSyncResult> {
const start = Date.now();
return withBrand(brandId, async (db) => {
// Load config + queue row + log + worker in parallel.
const [configRows, queueRows, logRows] = await Promise.all([
db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1),
db
.select()
.from(timeTrackingSmartsheetSyncQueue)
.where(
and(
eq(timeTrackingSmartsheetSyncQueue.brandId, brandId),
eq(timeTrackingSmartsheetSyncQueue.logId, logId),
),
)
.limit(1),
db
.select({
log: timeTrackingLogs,
workerName: timeTrackingWorkers.name,
})
.from(timeTrackingLogs)
.leftJoin(
timeTrackingWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
)
.where(eq(timeTrackingLogs.id, logId))
.limit(1),
]);
const config = configRows[0];
const queueRow = queueRows[0];
const logRow = logRows[0];
if (!config) {
const result: TTSyncResult = {
status: "failed",
error: "Smartsheet config not set up for this brand",
attempts: 0,
durationMs: Date.now() - start,
retryAt: new Date(),
};
return result;
}
if (!config.syncEnabled) {
const result: TTSyncResult = {
status: "skipped",
reason: "Sync disabled in config",
smartsheetRowId: queueRow?.smartsheetRowId ?? null,
durationMs: Date.now() - start,
};
return result;
}
if (!logRow || !logRow.log.clockOut) {
const result: TTSyncResult = {
status: "failed",
error: "Clock-out row not found or still open",
attempts: 0,
durationMs: Date.now() - start,
retryAt: new Date(),
};
return result;
}
if (!queueRow) {
const result: TTSyncResult = {
status: "failed",
error: "No sync queue row — was the clock-out queued?",
attempts: 0,
durationMs: Date.now() - start,
retryAt: new Date(),
};
return result;
}
// Already synced → short-circuit.
if (queueRow.smartsheetRowId && queueRow.status === "synced") {
const result: TTSyncResult = {
status: "skipped",
reason: "Already synced",
smartsheetRowId: queueRow.smartsheetRowId,
durationMs: Date.now() - start,
};
return result;
}
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
// "connection_disabled" is an intentional admin pause — skip
// cleanly, don't retry, don't bump attempts or log as a failure.
// (Sync services treat each reason differently per the Cycle 7
// design doc.)
if (ws.reason === "connection_disabled") {
const result: TTSyncResult = {
status: "skipped",
reason: "workspace paused",
smartsheetRowId: queueRow?.smartsheetRowId ?? null,
durationMs: Date.now() - start,
};
return result;
}
const reason =
ws.reason === "no_workspace"
? "Smartsheet workbook not connected"
: `workspace token unreadable: ${ws.error}`;
const result: TTSyncResult = {
status: "failed",
error: reason,
attempts: 0,
durationMs: Date.now() - start,
retryAt: new Date(),
};
return result;
}
const token = ws.token;
const values = {
logId: logRow.log.id,
clockInIso: logRow.log.clockIn.toISOString(),
clockOutIso: logRow.log.clockOut.toISOString(),
workerName: logRow.workerName ?? "(unknown worker)",
taskName: logRow.log.taskName,
hours: hoursBetween(
logRow.log.clockIn,
logRow.log.clockOut,
logRow.log.lunchBreakMinutes,
),
lunchMinutes: logRow.log.lunchBreakMinutes,
notes: logRow.log.notes ?? null,
};
const cells = buildCells(config.columnMapping, values);
if (!cells) {
const result: TTSyncResult = {
status: "failed",
error: "Column mapping is missing required fields (log_id, clock_in)",
attempts: queueRow.attempts + 1,
durationMs: Date.now() - start,
retryAt: new Date(
Date.now() +
Math.min(MAX_BACKOFF_MINUTES, 2 ** (queueRow.attempts + 1)) *
60_000,
),
};
// Persist failure on the queue row.
await db
.update(timeTrackingSmartsheetSyncQueue)
.set({
status: "failed",
attempts: queueRow.attempts + 1,
lastError: result.error,
nextAttemptAt: result.retryAt,
})
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
await db.insert(timeTrackingSmartsheetSyncLog).values({
brandId,
logId,
action: "retry",
success: false,
error: result.error,
durationMs: result.durationMs,
});
return result;
}
// Dedup by log_id column, then insert.
let existing = null as Awaited<ReturnType<typeof findRowByColumn>>;
try {
existing = await findRowByColumn(
config.sheetId,
token,
config.columnMapping.log_id,
values.logId,
);
} catch (err) {
// Dedup is best-effort — log the error and continue to insert.
// If a duplicate lands in the sheet, the admin can resolve
// manually using the Recent Activity panel.
await db.insert(timeTrackingSmartsheetSyncLog).values({
brandId,
logId,
action: "skip",
success: false,
error: `Dedup search failed: ${sanitizeError(
(err as Error).message ?? String(err),
token,
)}`,
durationMs: Date.now() - start,
});
}
if (existing) {
// Existing row — record the dedup hit and mark the queue row synced.
await db
.update(timeTrackingSmartsheetSyncQueue)
.set({
status: "synced",
smartsheetRowId: existing.rowId,
syncedAt: new Date(),
attempts: queueRow.attempts + 1,
lastError: null,
})
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
await db.insert(timeTrackingSmartsheetSyncLog).values({
brandId,
logId,
smartsheetRowId: existing.rowId,
action: "skip",
success: true,
durationMs: Date.now() - start,
});
const result: TTSyncResult = {
status: "skipped",
reason: "Already present in sheet (dedup hit)",
smartsheetRowId: existing.rowId,
durationMs: Date.now() - start,
};
return result;
}
// No existing row — insert.
try {
const row = await addRow(config.sheetId, token, cells);
const durationMs = Date.now() - start;
await db
.update(timeTrackingSmartsheetSyncQueue)
.set({
status: "synced",
smartsheetRowId: row.rowId,
syncedAt: new Date(),
attempts: queueRow.attempts + 1,
lastError: null,
})
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
await db.update(timeTrackingSmartsheetConfig).set({
lastSyncAt: new Date(),
lastSyncError: null,
}).where(eq(timeTrackingSmartsheetConfig.brandId, brandId));
await db.insert(timeTrackingSmartsheetSyncLog).values({
brandId,
logId,
smartsheetRowId: row.rowId,
action: queueRow.attempts > 0 ? "retry" : "sync",
success: true,
durationMs,
});
const result: TTSyncResult = {
status: "synced",
smartsheetRowId: row.rowId,
attempts: queueRow.attempts + 1,
durationMs,
};
return result;
} catch (err) {
const message = sanitizeError(
err instanceof SmartsheetApiError
? `${err.message} (status ${err.status})`
: (err as Error).message ?? String(err),
token,
);
const newAttempts = queueRow.attempts + 1;
const retryAt = new Date(
Date.now() +
Math.min(MAX_BACKOFF_MINUTES, 2 ** newAttempts) * 60_000,
);
await db
.update(timeTrackingSmartsheetSyncQueue)
.set({
status: newAttempts >= DEFAULT_MAX_ATTEMPTS ? "failed" : "pending",
attempts: newAttempts,
lastError: message,
nextAttemptAt: retryAt,
})
.where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id));
await db.insert(timeTrackingSmartsheetSyncLog).values({
brandId,
logId,
action: "retry",
success: false,
error: message,
durationMs: Date.now() - start,
});
const result: TTSyncResult = {
status: "failed",
error: message,
attempts: newAttempts,
durationMs: Date.now() - start,
retryAt,
};
return result;
}
});
}
// ── Drain (used by cron + manual retry) ────────────────────────────────────
export type DrainOptions = {
batchSize?: number;
maxAttempts?: number;
};
export async function drainTTSyncQueue(
brandId: string,
opts: DrainOptions = {},
): Promise<TTDrainResult> {
const batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
return withBrand(brandId, async (db) => {
const due = await db
.select({ id: timeTrackingSmartsheetSyncQueue.id })
.from(timeTrackingSmartsheetSyncQueue)
.where(
and(
eq(timeTrackingSmartsheetSyncQueue.brandId, brandId),
inArray(timeTrackingSmartsheetSyncQueue.status, [
"pending",
"failed",
]),
lte(timeTrackingSmartsheetSyncQueue.nextAttemptAt, new Date()),
),
)
.limit(batchSize);
const result: TTDrainResult = {
brandId,
considered: due.length,
synced: 0,
skipped: 0,
failed: 0,
};
for (const row of due) {
// Re-fetch the queue row's log_id (the outer select only had id)
const queueRows = await db
.select()
.from(timeTrackingSmartsheetSyncQueue)
.where(eq(timeTrackingSmartsheetSyncQueue.id, row.id))
.limit(1);
const queueRow = queueRows[0];
if (!queueRow) continue;
if (queueRow.attempts >= maxAttempts) {
result.failed += 1;
continue;
}
const r = await syncLogToSmartsheet(brandId, queueRow.logId);
if (r.status === "synced") result.synced += 1;
else if (r.status === "skipped") result.skipped += 1;
else result.failed += 1;
}
return result;
});
}
// ── Cron entrypoint ────────────────────────────────────────────────────────
/** Used by the cron route. Reads the config's frequency and decides
* whether to drain now (realtime → always; every_15 → if last drain
* was >=15m ago; hourly → if last drain >=60m ago).
*
* Returns the drain result, or `{ skipped: true, reason }` if it's
* not yet time for this brand. */
export async function runScheduledTTSync(
brandId: string,
): Promise<TTDrainResult | { skipped: true; reason: string }> {
return withBrand(brandId, async (db) => {
const [config] = await db
.select()
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.brandId, brandId))
.limit(1);
if (!config) return { skipped: true, reason: "No config" };
if (!config.syncEnabled)
return { skipped: true, reason: "Sync disabled" };
if (
config.syncFrequency !== "realtime" &&
config.lastSyncAt
) {
const elapsedMin =
(Date.now() - config.lastSyncAt.getTime()) / 60_000;
const minInterval =
config.syncFrequency === "every_15_minutes" ? 15 : 60;
if (elapsedMin < minInterval) {
return {
skipped: true,
reason: `Last sync ${Math.round(elapsedMin)}m ago; need ${minInterval}m`,
};
}
}
return drainTTSyncQueue(brandId);
});
}
// Suppress unused-import warnings (the smoke-test rationale used to
// live here but both types are reachable from the call site now).
void timeTrackingTasks;
+53
View File
@@ -0,0 +1,53 @@
/**
* Workspace token resolver — server-only helper.
*
* Cycle 7 — extracted from `src/actions/smartsheet/workspace.ts` to
* avoid a `"use server"` leak: every async export of a "use server"
* file is reachable as a server action RPC, and we don't want this
* function callable from the client (it returns the plaintext API
* token). Living in a regular module means it's only importable from
* other server code (`smartsheet-sync.ts`, `time-tracking-smartsheet-
* sync.ts`) — the client surface is in the workspace actions file,
* which gates with `requireManageSettings` and never returns
* plaintext.
*/
import "server-only";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { smartsheetWorkspace } from "@/db/schema/smartsheet-workspace";
import { decryptToken } from "@/lib/crypto";
export type ResolvedWorkspaceToken =
| { ok: true; token: string }
| { ok: false; reason: "no_workspace" }
| { ok: false; reason: "connection_disabled" }
| { ok: false; reason: "decryption_failed"; error: string };
export async function resolveWorkspaceToken(
brandId: string,
): Promise<ResolvedWorkspaceToken> {
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(smartsheetWorkspace)
.where(eq(smartsheetWorkspace.brandId, brandId))
.limit(1);
const ws = rows[0];
if (!ws) return { ok: false, reason: "no_workspace" };
if (!ws.connectionEnabled) return { ok: false, reason: "connection_disabled" };
try {
const token = decryptToken({
cipher: ws.encryptedApiToken,
iv: ws.tokenIv,
authTag: ws.tokenAuthTag,
});
return { ok: true, token };
} catch (err) {
return {
ok: false,
reason: "decryption_failed",
error: err instanceof Error ? err.message : String(err),
};
}
});
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Unit tests for `resolveWorkspaceToken` — the security-critical
* helper that returns the plaintext Smartsheet API token for a brand.
*
* Every async export of a "use server" file is reachable as a server
* action RPC, so this helper was extracted to a plain server-only
* module (cycle 7) precisely to prevent accidental client exposure.
* The 4-branch discriminated union below is the security boundary —
* if it ever leaks or collapses, tokens can leak.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { mockWithBrand, mockDecryptToken } = vi.hoisted(() => ({
mockWithBrand: vi.fn(),
mockDecryptToken: vi.fn(),
}));
// Stub the server-only guard so the module can be imported under vitest.
vi.mock("server-only", () => ({}));
// Mock the Drizzle client wrapper so we can simulate per-brand reads
// without hitting Postgres.
vi.mock("@/db/client", () => ({
withBrand: mockWithBrand,
}));
// Mock the AES-256-GCM decryption layer so we can simulate bad
// ciphertext without managing real keys / IVs.
vi.mock("@/lib/crypto", () => ({
decryptToken: mockDecryptToken,
}));
import { resolveWorkspaceToken } from "@/services/workspace-token";
beforeEach(() => {
mockWithBrand.mockReset();
mockDecryptToken.mockReset();
});
describe("resolveWorkspaceToken", () => {
it("returns ok:true with the plaintext token when the workspace exists, is enabled, and decrypts", async () => {
mockWithBrand.mockImplementationOnce(
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
fn({
select: () => ({
from: () => ({
where: () => ({
limit: () => [
{
brandId: "brand-1",
encryptedApiToken: "cipher",
tokenIv: "iv",
tokenAuthTag: "tag",
connectionEnabled: true,
},
],
}),
}),
}),
}),
);
mockDecryptToken.mockReturnValueOnce("plain-token-abc");
const result = await resolveWorkspaceToken("brand-1");
expect(result).toEqual({ ok: true, token: "plain-token-abc" });
expect(mockDecryptToken).toHaveBeenCalledWith({
cipher: "cipher",
iv: "iv",
authTag: "tag",
});
});
it("returns ok:false reason:no_workspace when the workspace row does not exist", async () => {
mockWithBrand.mockImplementationOnce(
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
fn({
select: () => ({
from: () => ({
where: () => ({
limit: () => [],
}),
}),
}),
}),
);
const result = await resolveWorkspaceToken("brand-1");
expect(result).toEqual({ ok: false, reason: "no_workspace" });
expect(mockDecryptToken).not.toHaveBeenCalled();
});
it("returns ok:false reason:connection_disabled when the workspace is paused", async () => {
mockWithBrand.mockImplementationOnce(
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
fn({
select: () => ({
from: () => ({
where: () => ({
limit: () => [
{
brandId: "brand-1",
encryptedApiToken: "cipher",
tokenIv: "iv",
tokenAuthTag: "tag",
connectionEnabled: false,
},
],
}),
}),
}),
}),
);
const result = await resolveWorkspaceToken("brand-1");
expect(result).toEqual({ ok: false, reason: "connection_disabled" });
expect(mockDecryptToken).not.toHaveBeenCalled();
});
it("returns ok:false reason:decryption_failed with the underlying error message", async () => {
mockWithBrand.mockImplementationOnce(
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
fn({
select: () => ({
from: () => ({
where: () => ({
limit: () => [
{
brandId: "brand-1",
encryptedApiToken: "cipher",
tokenIv: "iv",
tokenAuthTag: "tag",
connectionEnabled: true,
},
],
}),
}),
}),
}),
);
mockDecryptToken.mockImplementationOnce(() => {
throw new Error("Unsupported state or unable to authenticate data");
});
const result = await resolveWorkspaceToken("brand-1");
expect(result).toEqual({
ok: false,
reason: "decryption_failed",
error: "Unsupported state or unable to authenticate data",
});
});
it("wraps non-Error thrown values in a string for decryption_failed", async () => {
mockWithBrand.mockImplementationOnce(
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
fn({
select: () => ({
from: () => ({
where: () => ({
limit: () => [
{
brandId: "brand-1",
encryptedApiToken: "cipher",
tokenIv: "iv",
tokenAuthTag: "tag",
connectionEnabled: true,
},
],
}),
}),
}),
}),
);
mockDecryptToken.mockImplementationOnce(() => {
throw "string-throw-not-error";
});
const result = await resolveWorkspaceToken("brand-1");
expect(result).toEqual({
ok: false,
reason: "decryption_failed",
error: "string-throw-not-error",
});
});
it("threads the brandId through to withBrand", async () => {
mockWithBrand.mockImplementationOnce(
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
fn({
select: () => ({
from: () => ({
where: () => ({
limit: () => [],
}),
}),
}),
}),
);
await resolveWorkspaceToken("the-right-brand");
expect(mockWithBrand).toHaveBeenCalledWith(
"the-right-brand",
expect.any(Function),
);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Unit tests for `src/services/sync-drain-summary.ts` — the per-brand
* drain translator that the cycle-8 cron route delegates to.
*
* The interesting case here is the discriminated union narrowing:
* `runScheduledTTSync` returns either a regular `TTDrainResult`
* (counters, `skipped: number`) or `{ skipped: true, reason }`. Both
* branches carry a `skipped` property, so narrowing on the literal
* `result.skipped === true` is the only correct disambiguation.
*
* Regression coverage: if narrowing switched to `"skipped" in result`,
* the happy-branch test (lines 30-44) would fail because
* `{ skipped: 1, ... }` would misroute into the if-branch and
* `result.reason` would be undefined.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("server-only", () => ({}));
import {
drainOneBrand,
summarizeDrains,
type DrainRunner,
} from "@/services/sync-drain-summary";
describe("drainOneBrand", () => {
it("forwards a regular TTDrainResult as a flat DrainSummary", async () => {
const run: DrainRunner = vi.fn(async (brandId) => ({
brandId,
considered: 5,
synced: 3,
skipped: 1,
failed: 1,
}));
const summary = await drainOneBrand("brand-1", run);
expect(summary).toEqual({
brandId: "brand-1",
considered: 5,
synced: 3,
skipped: 1,
failed: 1,
});
expect(summary.skippedReason).toBeUndefined();
expect(summary.error).toBeUndefined();
});
it("translates the { skipped: true, reason } branch into a zero summary with skippedReason", async () => {
const run: DrainRunner = vi.fn(async () => ({
skipped: true as const,
reason: "Last sync 3m ago; need 15m",
}));
const summary = await drainOneBrand("brand-1", run);
expect(summary).toEqual({
brandId: "brand-1",
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
skippedReason: "Last sync 3m ago; need 15m",
});
});
it("catches a thrown Error and surfaces it as `error` (does not reject)", async () => {
const run: DrainRunner = vi.fn(async () => {
throw new Error("Smartsheet 401");
});
const summary = await drainOneBrand("brand-1", run);
expect(summary).toEqual({
brandId: "brand-1",
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
error: "Smartsheet 401",
});
});
it("coerces non-Error thrown values to a string", async () => {
const run: DrainRunner = vi.fn(async () => {
throw "string-failure";
});
const summary = await drainOneBrand("brand-1", run);
expect(summary.error).toBe("string-failure");
});
it("threads brandId to the runner", async () => {
const run: DrainRunner = vi.fn(async () => ({
brandId: "ignored",
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
}));
await drainOneBrand("the-real-brand", run);
expect(run).toHaveBeenCalledWith("the-real-brand");
});
});
describe("summarizeDrains", () => {
it("sums every counter across all per-brand summaries", () => {
const totals = summarizeDrains([
{
brandId: "a",
considered: 5,
synced: 3,
skipped: 1,
failed: 1,
},
{
brandId: "b",
considered: 4,
synced: 2,
skipped: 1,
failed: 1,
skippedReason: "Not due",
},
{
brandId: "c",
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
error: "401",
},
]);
expect(totals).toEqual({
considered: 9,
synced: 5,
skipped: 2,
failed: 2,
});
});
it("returns zeros for an empty batch", () => {
expect(summarizeDrains([])).toEqual({
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
});
});
});
@@ -0,0 +1,351 @@
/**
* Unit tests for the cycle-8 cron route at
* `src/app/api/time-tracking/smartsheet-sync/route.ts`.
*
* The route is intentionally thin: authenticate → parse body → fan
* out per brand → aggregate. These tests mock the three external
* dependencies (withPlatformAdmin, timeTrackingSmartsheetConfig, and
* runScheduledTTSync) and exercise:
*
* - auth (Bearer header, ?secret query, missing-in-prod, allow-in-dev)
* - body parsing (optional, malformed JSON doesn't crash)
* - body.brandId restricts to one brand; omitted fans out to all
* - success flag is false when any brand has failures
*
* The pure per-brand drain logic is tested separately in
* `sync-drain-summary.test.ts`.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
const {
mockWithPlatformAdmin,
mockRunScheduledTTSync,
mockDrainOneBrand,
mockSummarizeDrains,
mockSelect,
mockFrom,
mockWhere,
mockLimit,
mockTimeTrackingSmartsheetConfig,
} = vi.hoisted(() => {
const mockSelect = vi.fn();
const mockFrom = vi.fn();
const mockWhere = vi.fn();
const mockLimit = vi.fn();
const mockTimeTrackingSmartsheetConfig = { brandId: "fake_brand_id_col" };
return {
mockWithPlatformAdmin: vi.fn(),
mockRunScheduledTTSync: vi.fn(),
mockDrainOneBrand: vi.fn(),
mockSummarizeDrains: vi.fn(),
mockSelect,
mockFrom,
mockWhere,
mockLimit,
mockTimeTrackingSmartsheetConfig,
};
});
vi.mock("server-only", () => ({}));
vi.mock("@/db/client", () => ({
withPlatformAdmin: mockWithPlatformAdmin,
}));
vi.mock("@/db/schema/time-tracking", () => ({
timeTrackingSmartsheetConfig: mockTimeTrackingSmartsheetConfig,
}));
vi.mock("@/services/time-tracking-smartsheet-sync", () => ({
runScheduledTTSync: mockRunScheduledTTSync,
}));
vi.mock("@/services/sync-drain-summary", () => ({
drainOneBrand: mockDrainOneBrand,
summarizeDrains: mockSummarizeDrains,
}));
// Import the route AFTER all mocks are wired up.
import { POST, GET } from "@/app/api/time-tracking/smartsheet-sync/route";
const ORIGINAL_ENV = { ...process.env };
function fakeNextRequest({
url = "http://localhost/api/time-tracking/smartsheet-sync",
method = "POST",
headers = {},
body,
}: {
url?: string;
method?: string;
headers?: Record<string, string>;
body?: string;
} = {}) {
return new NextRequest(url, {
method,
headers,
body,
});
}
function setCronSecret(secret: string | undefined, nodeEnv = "test") {
// vi.stubEnv handles the typed-readonly quirk on NODE_ENV for us
// and restores the previous value on vi.unstubAllEnvs().
vi.stubEnv("NODE_ENV", nodeEnv);
if (secret === undefined) {
vi.stubEnv("SMARTSHEET_CRON_SECRET", "");
vi.stubEnv("CRON_SECRET", "");
} else {
vi.stubEnv("SMARTSHEET_CRON_SECRET", secret);
vi.stubEnv("CRON_SECRET", "");
}
}
beforeEach(() => {
vi.resetModules();
mockWithPlatformAdmin.mockReset();
mockRunScheduledTTSync.mockReset();
mockDrainOneBrand.mockReset();
mockSummarizeDrains.mockReset();
mockSelect.mockReset();
mockFrom.mockReset();
mockWhere.mockReset();
mockLimit.mockReset();
// Default chain: select → from → where → (thenable rows)
// The route's listActiveBrandIds does `db.select(...).from(...).where(...)`
// and awaits the result directly (no `.limit()`), so the where()
// call has to resolve to an array of rows.
const defaultRows = [{ id: "brand-a" }, { id: "brand-b" }];
mockWhere.mockImplementation(() => Promise.resolve(defaultRows));
mockFrom.mockReturnValue({ where: mockWhere });
mockSelect.mockReturnValue({ from: mockFrom });
mockWithPlatformAdmin.mockImplementation(
async (fn: (db: unknown) => Promise<unknown>) =>
fn({ select: mockSelect }),
);
// Default behavior: pretend every brand skipped (so we can assert
// per-brand delegation works without coupling to drainOneBrand's
// own logic).
mockDrainOneBrand.mockImplementation(async (brandId: string) => ({
brandId,
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
skippedReason: "no-op",
}));
mockSummarizeDrains.mockReturnValue({
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
});
});
afterEach(() => {
vi.unstubAllEnvs();
// Re-apply ORIGINAL_ENV in case anything outside vi.stubEnv mutated it
// (defense-in-depth; the production code only reads via process.env).
process.env = { ...ORIGINAL_ENV };
});
describe("auth", () => {
it("rejects with 401 when CRON_SECRET is set and no token is presented in prod", async () => {
setCronSecret("the-secret", "production");
const res = await POST(fakeNextRequest({}));
expect(res.status).toBe(401);
expect(mockDrainOneBrand).not.toHaveBeenCalled();
});
it("rejects with 401 when the Bearer header does not match", async () => {
setCronSecret("the-secret", "production");
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer wrong-token" },
}),
);
expect(res.status).toBe(401);
});
it("accepts a matching Bearer header in prod", async () => {
setCronSecret("the-secret", "production");
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer the-secret" },
}),
);
expect(res.status).toBe(200);
});
it("accepts a matching ?secret query in prod", async () => {
setCronSecret("the-secret", "production");
const res = await POST(
fakeNextRequest({
url: "http://localhost/api/time-tracking/smartsheet-sync?secret=the-secret",
}),
);
expect(res.status).toBe(200);
});
it("falls back to CRON_SECRET when SMARTSHEET_CRON_SECRET is unset", async () => {
vi.stubEnv("SMARTSHEET_CRON_SECRET", "");
vi.stubEnv("CRON_SECRET", "fallback-secret");
// NODE_ENV is typed as readonly on the index signature; mutate
// through Object.assign so the test reads the new value while the
// TS compiler stays quiet.
Object.assign(process.env, { NODE_ENV: "production" });
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer fallback-secret" },
}),
);
expect(res.status).toBe(200);
});
it("allows unauthenticated calls in non-production when no secret is configured", async () => {
setCronSecret(undefined, "development");
const res = await POST(fakeNextRequest({}));
expect(res.status).toBe(200);
});
});
describe("body parsing", () => {
beforeEach(() => {
setCronSecret("the-secret", "production");
});
it("fans out to every active brand when no body is supplied", async () => {
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer the-secret" },
}),
);
expect(res.status).toBe(200);
// Two rows came back from the limit() chain → two brands drained
expect(mockDrainOneBrand).toHaveBeenCalledTimes(2);
expect(mockDrainOneBrand).toHaveBeenNthCalledWith(
1,
"brand-a",
mockRunScheduledTTSync,
);
expect(mockDrainOneBrand).toHaveBeenNthCalledWith(
2,
"brand-b",
mockRunScheduledTTSync,
);
});
it("restricts to the requested brandId when supplied", async () => {
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer the-secret" },
body: JSON.stringify({ brandId: "brand-only" }),
}),
);
expect(res.status).toBe(200);
expect(mockDrainOneBrand).toHaveBeenCalledTimes(1);
expect(mockDrainOneBrand).toHaveBeenCalledWith(
"brand-only",
mockRunScheduledTTSync,
);
// Should NOT have queried the DB for the active brand list
expect(mockWithPlatformAdmin).not.toHaveBeenCalled();
});
it("tolerates a malformed JSON body (treats as no body)", async () => {
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer the-secret" },
body: "this-is-not-json",
}),
);
expect(res.status).toBe(200);
// No body.brandId → fans out to active brands
expect(mockDrainOneBrand).toHaveBeenCalledTimes(2);
});
});
describe("aggregation", () => {
beforeEach(() => {
setCronSecret("the-secret", "production");
});
it("returns success:true and aggregates per-brand summaries", async () => {
mockDrainOneBrand.mockImplementation(async (brandId: string) => ({
brandId,
considered: 2,
synced: 1,
skipped: 1,
failed: 0,
}));
mockSummarizeDrains.mockReturnValue({
considered: 2,
synced: 1,
skipped: 1,
failed: 0,
});
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer the-secret" },
body: JSON.stringify({ brandId: "brand-only" }),
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.totals).toEqual({
considered: 2,
synced: 1,
skipped: 1,
failed: 0,
});
expect(body.results).toHaveLength(1);
});
it("returns success:false when any brand has a failure", async () => {
mockSummarizeDrains.mockReturnValue({
considered: 0,
synced: 0,
skipped: 0,
failed: 1,
});
const res = await POST(
fakeNextRequest({
headers: { authorization: "Bearer the-secret" },
body: JSON.stringify({ brandId: "brand-only" }),
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(false);
expect(body.totals.failed).toBe(1);
});
});
describe("GET", () => {
it("delegates to POST (same auth + body handling)", async () => {
setCronSecret("the-secret", "production");
const res = await GET(
fakeNextRequest({
method: "GET",
headers: { authorization: "Bearer the-secret" },
}),
);
expect(res.status).toBe(200);
expect(mockDrainOneBrand).toHaveBeenCalled();
});
it("returns 401 on GET when auth fails", async () => {
setCronSecret("the-secret", "production");
const res = await GET(fakeNextRequest({ method: "GET" }));
expect(res.status).toBe(401);
});
});
+4
View File
@@ -25,6 +25,10 @@
"path": "/api/water-log/smartsheet-sync", "path": "/api/water-log/smartsheet-sync",
"schedule": "*/15 * * * *" "schedule": "*/15 * * * *"
}, },
{
"path": "/api/time-tracking/smartsheet-sync",
"schedule": "*/15 * * * *"
},
{ {
"path": "/api/email-automation/abandoned-cart", "path": "/api/email-automation/abandoned-cart",
"schedule": "0 */6 * * *" "schedule": "0 */6 * * *"
+2
View File
@@ -9,6 +9,8 @@ export default defineConfig({
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" }, { find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
// Specific sub-paths must come before the general @/db catch-all. // Specific sub-paths must come before the general @/db catch-all.
{ find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") }, { find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") },
{ find: "@/db/schema/smartsheet-workspace", replacement: path.resolve(__dirname, "db/schema/smartsheet-workspace.ts") },
{ find: "@/db/schema/time-tracking", replacement: path.resolve(__dirname, "db/schema/time-tracking.ts") },
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") }, { find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") }, { find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
{ find: "@/db", replacement: path.resolve(__dirname, "db") }, { find: "@/db", replacement: path.resolve(__dirname, "db") },