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;
+149 -1
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 {
pgTable,
@@ -10,6 +15,7 @@ import {
boolean,
timestamp,
index,
jsonb,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
@@ -159,3 +165,145 @@ export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
export type TimeTrackingNotificationLog =
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,
uniqueIndex,
integer,
customType,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
// Drizzle's stock `bytea` import is not exported in every version, so
// register a small custom type that maps to the existing `bytea` PG type.
const bytea = customType<{ data: Buffer; default: false }>({
dataType() {
return "bytea";
},
});
// Cycle 7: the `bytea` customType used to live here for the
// encrypted Smartsheet token columns. Those columns moved to
// `smartsheet_workspace` (see `db/schema/smartsheet-workspace.ts`),
// so the helper is no longer needed here.
export const waterHeadgates = pgTable(
"water_headgates",
@@ -323,9 +319,8 @@ export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
sheetId: text("sheet_id").notNull(),
encryptedApiToken: bytea("encrypted_api_token").notNull(),
tokenIv: bytea("token_iv").notNull(),
tokenAuthTag: bytea("token_auth_tag").notNull(),
// Cycle 7: encrypted token columns moved to smartsheet_workspace.
// See migration 0096_smartsheet_workbook_hub.sql.
columnMapping: jsonb("column_mapping")
.$type<SmartsheetColumnMapping>()
.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";
import { cookies } from "next/headers";
import { getSession } from "@/lib/auth";
// TODO(migration): the time-tracking feature was built on Supabase RPCs
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
// get_open_clock_in, get_time_tracking_tasks,
// get_worker_pay_period_hours, get_time_tracking_settings,
// get_water_user_by_id, get_water_admin_session, submit_water_entry,
// trigger_water_alert) plus tables that don't exist in the SaaS rebuild
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
// functions below are stubs that preserve the original signature so
// the field UI degrades gracefully (no PIN login, no entry submit) —
// 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.
import { eq, and, isNull, desc } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
timeTrackingWorkers,
timeTrackingTasks,
timeTrackingLogs,
timeTrackingSmartsheetSyncQueue,
} from "@/db/schema/time-tracking";
import { verifyPin } from "@/lib/water-log-pin";
import { getWorkerPeriodTotals } from "./index";
export type TimeTrackingSession = {
worker_id: string;
@@ -26,75 +43,292 @@ export type TimeTrackingSession = {
expires_at: string;
};
// ── Cookie helpers ─────────────────────────────────────────────────────────────
// ── Cookie helpers ─────────────────────────────────────────────────────────
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) {
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
function sessionCookieValue(session: TimeTrackingSession): string {
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("|");
if (parts.length !== 3) return null;
if (parts.length !== 7) return null;
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;
return {
worker_id,
name: "",
role: "worker",
lang: "en",
session_id,
brand_id: "",
expires_at,
};
return { worker_id, session_id, expires_at };
}
// ── Verify PIN ─────────────────────────────────────────────────────────────────
// ── Verify PIN ─────────────────────────────────────────────────────────────
export async function verifyTimeTrackingPin(
_brandId: string,
_pin: string
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
brandId: string,
pin: 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
// disable the PIN pad.
return { success: false, error: "Time tracking is not configured" };
// Pull every active worker for the brand and try each PIN hash. With
// a small worker pool this is fine; if it grows we could index a
// 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(
_brandId: string,
_taskId?: string,
_taskName = "General Labor"
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
await getSession();
brandId: string,
taskId?: string,
taskName = "General Labor",
): Promise<{
success: boolean;
log_id?: string;
clock_in?: string;
error?: string;
}> {
const session = await getTimeTrackingSession();
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(
_lunchMinutes = 0,
_notes?: string
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
await getSession();
lunchMinutes = 0,
notes?: string,
): Promise<{
success: boolean;
log_id?: string;
clock_out?: string;
total_minutes?: number;
error?: string;
}> {
const session = await getTimeTrackingSession();
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(
_brandId: string
): Promise<{
export async function getOpenClockIn(brandId: string): Promise<{
success: boolean;
open?: boolean;
log_id?: string;
@@ -103,34 +337,76 @@ export async function getOpenClockIn(
elapsed_minutes?: number;
error?: string;
}> {
await getSession();
const session = await getTimeTrackingSession();
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> {
await getSession();
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
// ── Get Current Session ────────────────────────────────────────────────────────
// ── Get Current Session ────────────────────────────────────────────────────
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
await getSession();
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE);
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 = {
id: string;
@@ -142,14 +418,28 @@ export type TimeTaskField = {
};
export async function getTimeTrackingTasksField(
_brandId: string,
_activeOnly = true
brandId: string,
activeOnly = true,
): Promise<TimeTaskField[]> {
await getSession(); return [];
return withBrand(brandId, async (db) => {
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 = {
success: boolean;
@@ -184,11 +474,34 @@ const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
});
export async function getWorkerPayPeriodHours(
_brandId: string
brandId: string,
): Promise<PayPeriodHours> {
await getSession();
const session = await getTimeTrackingSession();
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,
};
}
+627 -119
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";
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
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,
// 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 ─────────────────────────────────────────────────────────────────────
// ── Types ───────────────────────────────────────────────────────────────────
export type TimeWorker = {
id: string;
@@ -25,6 +37,7 @@ export type TimeWorker = {
name: string;
role: string;
lang: string;
// Hash, not plaintext. Use `verifyPin(rawPin, pin)` to check.
pin: string;
active: boolean;
last_used_at: string | null;
@@ -39,10 +52,13 @@ export type TimeTask = {
unit: string;
active: boolean;
sort_order: number;
brand_id: string;
created_at: string;
};
export type TimeLog = {
id: string;
brand_id: string;
worker_id: string;
worker_name: string;
task_id: string | null;
@@ -57,147 +73,465 @@ export type TimeLog = {
};
export type TimeSummary = {
by_worker: { 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 }[];
by_worker: {
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 };
};
// ── 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.
return [];
function rowToTask(r: typeof timeTrackingTasks.$inferSelect): TimeTask {
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(
_brandId: string,
_name: string,
_role = "worker",
_lang = "en"
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
brandId: string,
name: string,
role: string = "worker",
lang: string = "en",
): Promise<{
success: boolean;
worker?: TimeWorker;
pin?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
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 }> {
await getSession(); const adminUser = await getAdminUser();
export async function resetTimeWorkerPin(
workerId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
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(
_workerId: string,
_name: string,
_role: string,
_lang: string,
_active: boolean
workerId: string,
name: string,
role: string,
lang: string,
active: boolean,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
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 }> {
await getSession(); const adminUser = await getAdminUser();
export async function deleteTimeWorker(
workerId: string,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
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[]> {
await getSession(); return [];
export async function getTimeTrackingTasks(
brandId: string,
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(
_brandId: string,
_name: string,
_nameEs: string | null = null,
_unit = "hours",
_sortOrder = 0
brandId: string,
name: string,
nameEs: string | null = null,
unit: string = "hours",
sortOrder = 0,
): Promise<{ success: boolean; id?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
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(
_taskId: string,
_name: string,
_nameEs: string,
_unit: string,
_active: boolean,
_sortOrder: number
taskId: string,
name: string,
nameEs: string,
unit: string,
active: boolean,
sortOrder: number,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
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 }> {
await getSession(); const adminUser = await getAdminUser();
export async function deleteTimeTask(
taskId: string,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
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(
_brandId: string,
_options: {
workerId?: string;
taskId?: string;
start?: string;
end?: string;
limit?: number;
offset?: number;
} = {}
brandId: string,
options: LogListOptions = {},
): 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(
_logId: string,
_taskName: string,
_clockIn: string,
_clockOut: string | null,
_lunchMinutes: number,
_notes: string | null
): 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" };
}
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" };
function computeTotalMinutes(
clockIn: Date,
clockOut: Date | null,
lunchMinutes: number,
): number {
if (!clockOut) return 0;
const ms = clockOut.getTime() - clockIn.getTime();
return Math.max(0, Math.round(ms / 60000) - lunchMinutes);
}
export async function getTimeTrackingSummary(
_brandId: string,
_start: string,
_end: string
brandId: string,
start: string,
end: string,
): 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 = {
id: string;
@@ -218,21 +552,55 @@ export type TimeTrackingSettings = {
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.
return null;
export async function getTimeTrackingSettings(
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(
_brandId: string,
_settings: {
brandId: string,
settings: {
pay_period_start_day: number;
pay_period_length_days: number;
daily_overtime_threshold: number;
weekly_overtime_threshold: number;
overtime_multiplier: number;
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_sms_numbers?: string[];
enable_daily_alerts?: boolean;
@@ -241,15 +609,41 @@ export async function updateTimeTrackingSettings(
weekly_alert_threshold?: number;
send_end_of_period_summary?: boolean;
brand_name?: string;
}
},
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
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 = {
id: string;
@@ -267,9 +661,123 @@ export type NotificationLogEntry = {
};
export async function getTimeTrackingNotificationLog(
_brandId: string,
_limit = 100
brandId: string,
limit = 100,
): 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);
await getSession(); return [];
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,
};
});
}
+83 -21
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";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
// 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.
import { withBrand } from "@/db/client";
import { timeTrackingNotificationLog } from "@/db/schema/time-tracking";
import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index";
export type OvertimeCheckResult = {
sent: boolean;
trigger_type?: string;
trigger_type?: "daily" | "weekly" | "both";
message?: string;
notification_log_id?: string;
daily_hours?: number;
weekly_hours?: number;
};
export async function checkAndNotifyOvertime(
_brandId: string,
_workerId: string,
_workerName: string,
_dailyHours: number,
_weeklyHours: number
brandId: string,
workerId: string,
workerName: string,
// The two hour args are accepted for back-compat with the original
// 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> {
const adminUser = await getAdminUser();
if (!adminUser) return { sent: false, message: "Not authenticated" };
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { sent: false, message: "Not authenticated" };
const settings = await getTimeTrackingSettings(brandId);
if (!settings || !settings.overtime_notifications) {
return { sent: false, message: "Notifications disabled" };
}
return {
sent: false,
message: "Time tracking is not configured in the SaaS rebuild",
};
// Re-derive totals from the source of truth so the notification log
// 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.
*
* 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
* hook in `field.ts`. Permission model mirrors the rest of the water
* log module: only admins with `can_manage_water_log` (or
@@ -10,10 +15,9 @@
*
* 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.
* - `resolveWorkspaceToken` decrypts it locally for API calls.
* - The masked preview is fetched from the workspace (not the
* per-feature config).
*/
import { desc, eq, and, gte, inArray } from "drizzle-orm";
import { withBrand } from "@/db/client";
@@ -28,7 +32,8 @@ import {
type SmartsheetColumnMapping,
type SmartsheetFrequency,
} 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 { syncEntryToSmartsheet, drainSyncQueue } from "@/services/smartsheet-sync";
import { getAdminUser } from "@/lib/admin-permissions";
@@ -63,8 +68,9 @@ export type SmartsheetSyncLogEntry = {
export type SaveSmartsheetConfigInput = {
sheetId: string;
/** Plaintext token. Empty string = keep the saved token. */
token: string;
/** Cycle 7: token is owned by the workspace; this field is kept for
* backward compat with the existing UI but ignored on save. */
token?: string;
syncEnabled: boolean;
syncFrequency: SmartsheetFrequency;
columnMapping: SmartsheetColumnMapping;
@@ -106,6 +112,10 @@ export async function getSmartsheetConfig(
.where(eq(waterSmartsheetConfig.brandId, brandId))
.limit(1);
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) {
return {
configured: false,
@@ -113,39 +123,23 @@ export async function getSmartsheetConfig(
syncEnabled: false,
syncFrequency: "hourly",
columnMapping: null,
maskedToken: null,
maskedToken: workspace.maskedToken,
lastSyncAt: null,
lastSyncError: null,
hasToken: false,
hasToken: workspace.hasToken,
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 {
configured: true,
sheetId: config.sheetId,
syncEnabled: config.syncEnabled,
syncFrequency: config.syncFrequency,
columnMapping: config.columnMapping,
maskedToken: masked,
maskedToken: workspace.maskedToken,
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
lastSyncError: config.lastSyncError,
hasToken,
hasToken: workspace.hasToken,
updatedAt: config.updatedAt.toISOString(),
};
});
@@ -204,12 +198,23 @@ export async function saveSmartsheetConfig(
if (!mappingResult.ok) {
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.
const tokenTrimmed = input.token?.trim() ?? "";
// Cycle 7: the API token is owned by the workspace, not by this
// 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) => {
// ── Load existing to know if we have a token already ──
const existingRows = await db
.select()
.from(waterSmartsheetConfig)
@@ -217,20 +222,6 @@ export async function saveSmartsheetConfig(
.limit(1);
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> = {
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
@@ -238,34 +229,23 @@ export async function saveSmartsheetConfig(
columnMapping: input.columnMapping,
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.
const [upserted] = await db
.insert(waterSmartsheetConfig)
.values({
if (existing) {
await db
.update(waterSmartsheetConfig)
.set(updateValues)
.where(eq(waterSmartsheetConfig.brandId, brandId));
} else {
await db.insert(waterSmartsheetConfig).values({
brandId,
sheetId: normalizedSheetId,
encryptedApiToken: existing?.encryptedApiToken ?? Buffer.from([]),
tokenIv: existing?.tokenIv ?? Buffer.from([]),
tokenAuthTag: existing?.tokenAuthTag ?? Buffer.from([]),
columnMapping: input.columnMapping,
syncEnabled: input.syncEnabled,
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,
})
.onConflictDoUpdate({
target: waterSmartsheetConfig.brandId,
set: updateValues,
})
.returning();
if (!upserted) return { success: false, error: "Save failed" };
});
}
await logAuditEvent({
brandId,
@@ -278,11 +258,9 @@ export async function saveSmartsheetConfig(
sheetId: normalizedSheetId,
syncEnabled: input.syncEnabled,
syncFrequency: input.syncFrequency,
tokenRotated: tokenTrimmed.length > 0,
},
});
// Re-read for a clean response.
return {
success: true,
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
* for the "Test Connection" button on a fresh form).
* - If `token` is empty and a token is saved, decrypt + test that.
* - Returns the sheet name + columns on success.
* - If `token` is non-empty, it's used directly (test the hub card
* before saving).
* - If `token` is empty, the workspace token is decrypted and used.
*
* 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
* UI can show a helpful message.
@@ -353,29 +336,23 @@ export async function testSmartsheetConnection(
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() ?? "";
if (!token) {
const saved = await withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterSmartsheetConfig)
.where(eq(waterSmartsheetConfig.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." };
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 above." };
}
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 };
}
token = ws.token;
}
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 { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
import { redirect } from "next/navigation";
@@ -82,7 +82,7 @@ export default async function AdminWaterLogPage() {
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
<WaterLogAdminPanel
<WaterLogShell
initialUsers={users}
initialHeadgates={headgates}
initialEntries={entries}
+27 -6
View File
@@ -22,7 +22,9 @@ import {
regenerateAdminPin,
type AdminSettings,
} from "@/actions/water-log/settings";
import SmartsheetHub from "@/components/admin/water-log/SmartsheetHub";
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";
@@ -475,8 +477,10 @@ export default function WaterLogSettingsPage() {
</button>
</form>
{/* Smartsheet Integration — independent of the Admin Portal toggle above.
Self-contained card with its own save / test / activity surface. */}
{/* Smartsheet Workbook Hub (Cycle 7). One token, many sheets.
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">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
§ 05 Integrations
@@ -488,14 +492,31 @@ export default function WaterLogSettingsPage() {
Smartsheet
</h2>
<p className="mt-2 text-sm text-[#5a5d5a]">
Push every new water log entry to a Smartsheet sheet. The
brand admin (you) controls the connection, frequency, and
column mapping. The token is encrypted at rest.
Connect one Smartsheet workbook and pick which sheet each
feature writes to. The token is encrypted at rest; per-feature
cards below manage only their sheet mapping.
</p>
<div className="mt-6">
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
<SmartsheetHub brandId={TUXEDO_BRAND_ID} />
</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>
);
+1 -1
View File
@@ -99,7 +99,7 @@ export async function GET(req: NextRequest) {
]);
allWorkers.push(workers);
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) {
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 { getBrandSettingsPublic } from "@/actions/brand-settings";
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const BRAND_NAME = "Tuxedo Corn";
const BRAND_ACCENT = "green";
import {
TUXEDO_BRAND_ID,
TUXEDO_BRAND_NAME,
TUXEDO_BRAND_ACCENT,
} from "@/lib/water-log/brand";
export const metadata = {
title: "Worker Clock — Tuxedo Corn",
title: `Worker Clock — ${TUXEDO_BRAND_NAME}`,
};
export default async function TuxedoTimeClockPage() {
@@ -15,9 +16,9 @@ export default async function TuxedoTimeClockPage() {
return (
<TimeTrackingFieldClient
brandId={BRAND_ID}
brandName={BRAND_NAME}
brandAccent={BRAND_ACCENT}
brandId={TUXEDO_BRAND_ID}
brandName={TUXEDO_BRAND_NAME}
brandAccent={TUXEDO_BRAND_ACCENT}
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.
*
* Flow:
* pin ─verify─▶ headgates ─pick─▶ log ─submit─▶ success
* ▲ │
* └───────────────(logout)─────────────────┤
* └─(logout)─────(anywhere)──────────────────────────────┘
* │
* success ── Log Another ─▶ log (cleared) ─┘
* success ── Back to Headgates ─▶ headgates
* pin ─verify─▶ tabbed shell
* │
* ├──[Headgates]── headgates ─pick─▶ log ─submit─▶ success
* │ ▲ │
* │ │
* │ └───────── (logout) ────────────┤
*
* └──[Time]──── TimeTrackingFieldClient (clock-in / clock-out)
*
* Hydration:
* - 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
* 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:
* - `verifyWaterPin` → signs the irrigator in, sets cookie
* - `getWaterHeadgates` → loads the list of active gates
* - `submitWaterEntry` → posts a new reading
* - `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 {
@@ -34,13 +43,28 @@ import {
getWaterHeadgates,
logoutWater,
} 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 { MobileWaterHeadgateList } from "./HeadgateList";
import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen";
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";
// Server-side auth errors that should bounce the user to the PIN
@@ -56,6 +80,7 @@ const SESSION_ERRORS = [
export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin");
const [activeTab, setActiveTab] = useState<ActiveTab>("headgates");
// `useLang()` reads from the <LangProvider> in WaterFieldClient,
// so it's always up-to-date when the user toggles via the toggle
// in the headgate list header.
@@ -125,6 +150,11 @@ export function MobileWaterApp() {
setIrrigatorName(result.name ?? null);
setScreen("headgates");
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],
);
@@ -184,19 +214,47 @@ export function MobileWaterApp() {
// ── Logout (from anywhere) ────────────────────────────────────
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 {
await logoutWater();
} catch {
// Even if the server logout fails, clear local state so the
// user isn't stuck.
}
try {
await logoutTimeTracking();
} catch {
// Same — defensive cleanup.
}
setIrrigatorName(null);
setHeadgates([]);
setSelectedHeadgate(null);
setSubmittedLog(null);
setActiveTab("headgates");
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 ──────────────────────────────
// Compute the screen element first, then wrap it once in
// <LangProvider>. Wrapping per-case would remount the provider on
@@ -258,11 +316,113 @@ export function MobileWaterApp() {
/>
);
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
// WaterFieldClient — we just return the screen element here.
return screenElement;
// ── Post-PIN shell ────────────────────────────────────────────
// The pre-PIN screen is bare (no nav). After PIN we wrap the
// 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;
+10 -2
View File
@@ -49,8 +49,16 @@ export function isIntegerUnit(unit: string): boolean {
return unit === "holes";
}
/** Top-level screen identifiers — drives the state machine. */
export type Screen = "pin" | "headgates" | "log" | "success";
/** Top-level screen identifiers — drives the state machine.
*
* 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). */
export type SubmittedLog = {
+9
View File
@@ -12,3 +12,12 @@
* which is the standard pattern used throughout the rest of the app.
*/
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;
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> = {
@@ -231,6 +238,11 @@ export const LABELS: Record<Lang, Labels> = {
minutesAgo: (n) => `${n}m ago`,
hoursAgo: (n) => `${n}h ago`,
},
tab: {
headgates: "Headgates",
time: "Time",
logout: "Log out",
},
},
es: {
common: {
@@ -323,6 +335,11 @@ export const LABELS: Record<Lang, Labels> = {
minutesAgo: (n) => `hace ${n}m`,
hoursAgo: (n) => `hace ${n}h`,
},
tab: {
headgates: "Compuertas",
time: "Tiempo",
logout: "Salir",
},
},
};
+25 -11
View File
@@ -39,7 +39,7 @@ import {
waterSmartsheetSyncLog,
type SmartsheetColumnMapping,
} from "@/db/schema/water-log";
import { decryptToken } from "@/lib/crypto";
import { resolveWorkspaceToken } from "@/services/workspace-token";
import {
addRow,
findRowByColumn,
@@ -159,24 +159,38 @@ export async function syncEntryToSmartsheet(
);
}
// ── 3. Decrypt token + build cells ──
let token: string;
try {
token = decryptToken({
cipher: config.encryptedApiToken,
iv: config.tokenIv,
authTag: config.tokenAuthTag,
});
} catch (err) {
// ── 3. Resolve token from the workspace (Cycle 7) ──
const ws = await resolveWorkspaceToken(brandId);
if (!ws.ok) {
// "no_workspace" + "decryption_failed" are real failures — log
// them, bump attempts, and back off. "connection_disabled" is
// an intentional admin pause — skip cleanly, don't retry, don't
// pollute the activity log with "disabled" failures.
if (ws.reason === "connection_disabled") {
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(
db,
brandId,
entryId,
queue?.attempts ?? 0,
`decryption failed (key rotated or corrupt): ${sanitizeError(err)}`,
reason,
start,
);
}
const token = ws.token;
const cells = buildCells(
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",
"schedule": "*/15 * * * *"
},
{
"path": "/api/time-tracking/smartsheet-sync",
"schedule": "*/15 * * * *"
},
{
"path": "/api/email-automation/abandoned-cart",
"schedule": "0 */6 * * *"
+2
View File
@@ -9,6 +9,8 @@ export default defineConfig({
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
// 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/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/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
{ find: "@/db", replacement: path.resolve(__dirname, "db") },