23 Commits

Author SHA1 Message Date
Tyler 5dc9a4604f Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Water log mobile field experience (PIN → Headgates → Log → Success):

* Brand rename: "Tuxedo Ditch Co." → "Tuxedo Corn" everywhere it
  was a stale reference (page metadata + admin Today tab caption).
  Constants already say Tuxedo Corn; this just aligns the stragglers.

* Tab bar (Headgates | Time): pill pair → real UISegmentedControl.
  MiniSegmented lives next to TabBar in MobileWaterApp.tsx and uses
  the same ResizeObserver-measure + spring-timing pattern as the
  existing SegmentedControl — feels like one widget at two sizes
  (this one at 32pt, the unit selector at 44pt).

* Liquid-glass chrome (recipe applied 5×, mirrored on the bottom
  submit bar): linear-gradient background + blur(24px) +
  saturate(180%) + inset top highlight + inset bottom hairline.
  TabBar, HeadgateList top nav, LogForm top nav, LogForm sticky
  submit bar, FieldPinScreen + SuccessScreen brand strips.

* ThresholdMeter (new component, mobile/ThresholdMeter.tsx): the
  screen's signature. A live "blip" indicator under the 44pt
  measurement input that ties the form to its real domain (a
  worker reading a real gauge) instead of being a decorative
  gauge trophy. Track + optional colored zones (red·green·red)
  + thumb that slides on Apple's spring curve + color-coded
  zone label. Mirrors Apple's Volume Limit / Screen Time sliders.

* i18n: 9 new zone labels per language (zoneBelowNormal,
  zoneAboveAlert, zoneInRange, zoneBelowAlert, zoneAboveMin,
  zoneReading, zoneTypeToLog, meterNormalRange + ES twins).
  Legacy normalRange/alertAbove/alertBelow signatures widened
  to accept pre-formatted strings so integer units render clean.

* Updated water-log-i18n unit test for the wider signatures.
  All 27 tests pass; tsc + eslint clean.
2026-07-06 10:32:36 -06:00
Nora c7c83890e4 fix(time-tracking): use correct field_workers columns in fleet summary
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
The /admin/time-tracking overview page crashed on render with
`column "fw.display_name" does not exist`. Cycle 10 unified
`time_tracking_workers` + `water_irrigators` into `field_workers`,
which uses `name` and `pin_hash` (not the legacy `display_name` /
`pin` from `admin_users`). This fix aligns the SQL, row type, and
result mapper with the actual schema.

End-to-end verified against water.tuxedocorn.com with worker PIN 2229:
PIN auth, task picker, clock-in, and clock-out all succeed with no
console errors and no failed HTTP requests.

Includes a regression test that fails on the buggy SQL (4/8 fail) and
passes after the fix (8/8 pass). Covers the column contract, row
projection, empty-result + unauthenticated boundaries, and null
aggregate coercion.
2026-07-06 09:56:13 -06:00
Nora 16ad0955f2 feat(workers): unify water + time worker tables into field_workers (cycle 10)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The two per-domain worker tables (water_irrigators, time_tracking_workers)
both carried a pin_hash, a brand, a role, and almost-identical columns.
Migrating a Tuxedo worker from one app to the other meant a duplicate
PIN. Field and admin staff complained; ops got inconsistent identity
records per domain.

Unify into a single field_workers table. Cycle 10 migration
0097_field_workers.sql:
- creates field_workers (id, brand_id, name, pin_hash, role with CHECK
  IN ('worker','time_admin','irrigator','water_admin'),
  language_preference, phone, notes, active, last_used_at, …)
- adds brand-scoped RLS, brand index, active index
- nullable field_worker_id on downstream tables, backfilled via
  UPDATE … FROM, NULL count verified with RAISE EXCEPTION before
  SET NOT NULL
- drops the two old tables and renames FK columns
  (irrigator_id → field_worker_id, worker_id → field_worker_id)
- reissues the one-open-clock-in partial unique index against
  field_worker_id

Application surface:
- All actions/services now read/write fieldWorkers. Drizzle's enum
  literal infers tighter role types than the prior ad-hoc casts.
- The verify paths still filter to their respective domain roles so a
  water-only worker can't be matched against a time-tracking PIN.
- API surface preserved: action function names didn't change.
- Seed file updated so QA reseeds work post-deploy.

Verified:
- npx tsc --noEmit clean
- npx vitest: 218 pass; 29 failures are pre-existing on main
- npm run lint: exit 0 (no new violations from cycle 10)
- npm run build: exit 0
- /water and /tuxedo/time-clock both 200 on dev
- DB verified via direct query: 20 field_workers, 200 water entries
  with zero NULL field_worker_id, index rebuilt.

PR-reviewer: APPROVED.
2026-07-03 20:27:11 -06:00
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
Tyler 9ecb496a7c fix(water-log): address code-review findings from i18n PR
Deploy to route.crispygoat.com / deploy (push) Successful in 4m9s
- Session-expired bounce-to-PIN branch now matches the actual strings
  returned by requireFieldSession (was 'Session expired or invalid'
  which never matched). Added 'Session not found' and 'User is inactive'
  to the intercept list. The user-visible 'Your session expired. Please
  sign in again.' string is now in the dictionary (en + es).
- LangProvider moved up to WaterFieldClient. The orchestrator (which
  was sitting OUTSIDE the provider in b29caa0) now calls useLang()
  directly, so the loadHeadgates fallback error string is always
  rendered in the user's chosen language — even if they toggled
  after signing in.
- getClientLangSnapshot now memoizes the cookie value (React 19
  enforces getSnapshot purity stricter than 18).
- LanguageToggle switched from aria-pressed (toggle pattern) to
  role="radiogroup" + role="radio" + aria-checked (mutually-exclusive
  selector pattern). Per-button aria-labels dropped — the visible
  EN/ES text is read by SR as the radio name. Group label switched
  to t.common.a11yLanguage for Spanish parity.
- Notes textarea now has aria-label={t.log.notes} (was unlabeled —
  SR would announce 'Edit text' with no context).
- 1 new test: lock in that wl_lang=<script>...</script> falls through
  to the navigator (injection guard on the cookie regex).

Refs /tmp/refactor-water-log.md (review followups to Commit B)
2026-07-02 11:27:16 -06:00
Tyler 7795263944 feat(water-log): add 'holes' unit + EN/ES language toggle for mobile
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
- 'holes' / 'hoyos' added to MOBILE_UNITS alongside CFS/GPM/gal/ac-in/ac-ft
  with integer-only validation: submit button greys out and the keyboard
  raises the numeric keypad (no decimal) when the unit is selected.
- Language toggle in the headgate list header (next to logout) flips
  every screen between English and Spanish. Choice is persisted to the
  existing 'wl_lang' cookie for 1y, so the user's last selection
  sticks across logout/relogin.
- i18n strings lifted into shared src/lib/water-log/i18n.ts (single
  source of truth, en/es parity enforced by tests). All mobile screens
  (Pin, HeadgateList, LogForm, Success) plus the loadHeadgates error
  in the orchestrator consume the dictionary. Brand strip, aria-labels,
  segmented control labels, success recap, and submit-button labels
  all go through getT()/formatUnit()/displayUnit().
- 26 new unit tests (tests/unit/water-log-i18n.test.ts) cover: en/es
  dict parity, holes catalog + hoyos translation, all the
  detectInitialLang() branches (SSR/cookie/navigator), setLangCookie
  read/write, label interpolation helpers, and the unit catalog.

Refs /tmp/refactor-water-log.md (Commit B plan)
2026-07-02 11:18:23 -06:00
Tyler b966787daa refactor(water-log): replace PIN auth with self-contained cookie module
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
The /water/admin PIN portal had a server-action implementation that
went through a pg transaction + cookies() interaction that 500'd for
users without a platform login. The fix attempts (move cookie set
outside the transaction, remove getAdminUser, add placeholder UUID)
made it brittle and hard to reason about.

This replaces the whole stack with a self-contained module:

  src/lib/water-admin-pin-auth.ts (new, 126 lines, no Neon Auth)

  - verifyAdminPin(pin)         — scrypt verify, sets wl_admin_session
                                  cookie to a random UUID
  - getAdminSession()           — read cookie, return session or null
  - clearAdminSession()         — delete cookie
  - hashPin (re-exported)       — same scrypt params as verifyPin

The /water/admin portal is Tuxedo-brand-specific (CLAUDE.md "Public
Storefront Architecture"), so the brand UUID is hardcoded. The cookie
is the session — no DB session table writes. Cookie presence =
authenticated. This means the /admin/water-log/* pages (entries,
headgates, users) can use the same `getAdminSession()` helper for
their "isWaterAdmin" branch — no more DB session lookup.

Deleted:
  - verifyWaterAdminPin server action in settings.ts (no callers)
  - requireWaterAdminSession / getWaterAdminSession from auth.ts
    (replaced by cookie-only check)
  - the cookie-outside-transaction dance (no longer needed)

Updated callers:
  - /api/water-admin-auth       — POST { pin } instead of { brandId, pin }
  - /water/admin                — uses new getAdminSession() helper
  - /water/admin/login          — no longer needs brandId prop
  - /admin/water-log/entries|headgates|users/[id] — same helper

Tests: 19 passing in water-log-auth.test.ts (3 new for the new module).
2026-07-01 18:31:57 -06:00
Tyler ca79896d5f fix(water-log): set wl_admin_session cookie outside pg transaction
Deploy to route.crispygoat.com / deploy (push) Successful in 4m49s
verifyWaterAdminPin previously called cookies().set(...) from inside
the withBrand(...) pg transaction. The pg transaction's
AsyncLocalStorage context masks Next.js's request-scoped cookie
store, so for users without a platform login the cookie set threw
and the route handler returned 500.

Move the cookie write to the outer function frame, after withBrand
resolves. The DB session row is still inserted inside the
transaction (so it's atomic with the PIN verification); only the
cookie write is hoisted out.

The prior fix removed getAdminUser() but left the cookie set inside
the transaction, so valid PINs still 500'd. This is the missing
half.

Adds a static regression test that asserts const cookieStore
is declared AFTER the matching close-paren of withBrand(...).
2026-07-01 18:05:09 -06:00
Tyler 98fc1d3bdd fix(water-log): make verifyWaterAdminPin fully PIN-only
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
The previous PR wrapped the getAdminUser() call in try/catch as a
best-effort audit hook. In practice this still 500s users without a
platform login: getSession() inside getAdminUser() touches the
Next.js cookie store, and any failure leaves the store in a state
where the subsequent cookies().set('wl_admin_session', ...) throws.

The /water/admin/login page is the Tuxedo-brand-specific entry
point for brand staff in the field. It must work PIN-only, with no
Neon Auth dependency.

Fix:
- Drop the getAdminUser() call inside verifyWaterAdminPin entirely.
- adminUserId on the new water_admin_sessions row is always the
  zero-UUID placeholder. Audit attribution is intentionally deferred.
- Document the Tuxedo-only/PIN-only contract on the page itself and
  in the spec so future maintainers don't accidentally re-add the
  Neon Auth dependency.

Guards (TDD, red → green):
- tests/unit/water-log-auth.test.ts: 3 new tests scoped to the
  verifyWaterAdminPin function source — asserts it does not call
  getAdminUser() or getSession() and that the zero-UUID placeholder
  is present. These fail if a future refactor re-introduces the
  dependency.
2026-07-01 17:45:30 -06:00
Tyler 658f6a5b8b fix(water-log): remove platform-login dependency from PIN flows
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
Field users at /water were blocked because every server action called
getSession() (Neon Auth) even though the field path is PIN-authenticated.
A missing or expired platform session hung or rejected PIN submissions,
so users without a platform login could never reach the water log entry
screen.

Centralize water-log auth in src/actions/water-log/auth.ts with three
helpers — requireFieldSession, requireWaterAdminSession, and
requireWaterAdminPermission — and migrate all server actions off the
stray getSession() calls. Auth.ts deliberately does NOT import
getSession; a static-source unit test enforces that.

Changes:
- src/actions/water-log/auth.ts (new): dedicated auth layer, no Neon Auth
- src/actions/water-log/field.ts: -10 await getSession(), use helpers
- src/actions/water-log/admin.ts: -18 await getSession(), use helpers
- src/actions/water-log/settings.ts: -4 await getSession(), resilient
  to missing platform admin
- 3x admin pages: update getWaterAdminSession import path
- tests/unit/water-log-auth.test.ts (new): 17 tests incl. regression
  guard that auth.ts never imports getSession
- tests/water-log.spec.ts: 3 E2E regression tests (no platform login)
- vitest.config.ts: alias for deep @/db/schema/water-log path

Rollback = revert this commit. No DB migration; no schema change.
Existing rows/cookies unchanged.
2026-07-01 17:27:37 -06:00
Nora 0ac4beaaa8 fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
2026-06-25 23:49:37 -06:00
Nora fcdff8bce5 test(admin): fix getAdminUser mock chain for admin_user_brands query
The pre-existing failing test (the one baseline failure that survived
across every refactor step) was a mock mismatch, not a code bug:

The real code in src/lib/admin-permissions.ts runs:
  db.select({...}).from(adminUserBrands).innerJoin(...).where(...)

…and awaits that result directly. The mock chain provided .where()
returning { limit: async () => [...] }, so what got awaited was the
{ limit } object — not an array. Memberships resolved to undefined,
membershipRows.length was undefined, membershipRows.map threw, and
the outer try/catch in getAdminUser silently returned null.

Fix: in both tests that exercise the membership branch, make the
.where() call itself the thenable (matches the real code shape).

Result: 175/175 tests pass (was 174/175 baseline).
2026-06-25 17:24:50 -06:00
Tyler 7e079e0186 Show success banner after sending a password-reset email
Deploy to route.crispygoat.com / deploy (push) Successful in 4m8s
The 'Send Reset Email' button in /admin/users previously just
cleared the error banner on success, with no actual 'yes it
worked' feedback. Added a green success banner that mirrors the
error banner's style and auto-dismisses after 6 seconds.

The 'Reset Password' button already shows confirmation in the
modal (temp password to copy, or 'reset email sent' message), so
it doesn't need the banner.

Also tightened the type narrowing in the resetAdminPassword unit
tests — the discriminated union needed a two-step
narrow (`r.success` then `r.method`) before TypeScript would
allow access to variant-specific fields like `tempPassword`.
2026-06-17 12:31:38 -06:00
Tyler eb37df347e Fix admin password reset (Send Reset Email + Reset Password)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Both buttons in /admin/users were broken:

- "Send Reset Email" called a no-op stub in
  src/actions/admin/users.ts that always returned an error.

- "Reset Password" called resetAdminPassword with a hard-coded
  'Tuxedo2026!' password, and the function itself queried a
  non-existent `users` table and called a non-existent
  `update_user_password` RPC (leftover Supabase-era code).

Rewritten against Neon Auth:

- sendPasswordResetEmail(email) — platform_admin-only action that
  calls auth.requestPasswordReset with the configured
  NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
  a clear success/error.

- resetAdminPassword(email) — platform_admin-only action that:
  1. Looks up neon_auth.user by email
  2. Generates a strong server-side random temp password
  3. Tries auth.admin.setUserPassword first (instant credential,
     returned to the UI for the platform admin to share)
  4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
     case — provision-admin.ts does not promote callers to
     role='admin' in Neon Auth), falls back to
     auth.requestPasswordReset, which sends a reset link the user
     can click to set their own password.
  5. On the privileged path, flips admin_users.must_change_password
     so the user is forced to pick a real password on next sign-in.

UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.

Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
2026-06-17 12:22:34 -06:00
Tyler 7e665ea43e Email service surfaces real Resend errors instead of silent false
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.

Switch the public return type to a structured result:

  export type EmailSendResult = { ok: true } | { ok: false; error: string };

- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
  Add it to .env.local (or your hosting dashboard) and restart the
  server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
  domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.

Callers updated:
  - src/actions/admin/users.ts (createAdminUser): now propagates the
    real emailError into the modal UI — no more generic 'sendWelcome
    Email returned false'.
  - src/actions/checkout.ts: logs the real error.
  - src/app/api/cron/send-scheduled/route.ts: per-recipient error
    logged for the scheduled-campaigns cron.

Tests:
  - tests/unit/email-service.test.ts (new, 5 tests): covers happy
    path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
    body, and network failure.
  - tests/unit/create-admin-user.test.ts: updated to use the new
    { ok, error? } return shape; new assertion that the action
    surfaces the real emailError string into the result.
2026-06-17 12:10:04 -06:00
Tyler 9d9bc5d257 Add 'Sign in with Google' button to /login and /wholesale/login
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Wires up Better Auth's signIn.social({ provider: 'google' }) so users
can authenticate via Google OAuth. The flow is:

  1. User clicks the Google button.
  2. Client calls the signInWithGoogleAction server action.
  3. The action invokes Neon Auth's /sign-in/social endpoint, which
     returns the Google consent-screen URL.
  4. Client navigates the browser to that URL.
  5. Google redirects back through Neon Auth to the callbackURL.

Files:
  - src/actions/auth-actions.ts: new signInWithGoogleAction server
    action that wraps signIn.social and returns the redirect URL
    to the client. Structured { url, error } return — never throws.
  - src/app/login/LoginClient.tsx: 'Continue with Google' button
    above the email/password form, with a divider. Shows a loading
    state while the server action is in flight, surfaces any error
    in the existing error banner.
  - src/app/wholesale/login/page.tsx: same button for wholesale
    buyers. Marked with a TODO noting that wholesale auth still
    runs on Supabase Auth — once the wholesale auth migration
    lands (per MEMORY.md 'What's left'), the button will start
    working for them. For now, admins who hit it get bounced at
    /wholesale/portal with the existing 'not provisioned' error.
  - tests/unit/sign-in-with-google.test.ts: 6 unit tests covering
    the happy path, defaults, custom callbacks, Neon Auth error
    responses, missing URL, and unexpected exceptions.

Both admin and wholesale buttons are gated on the Google provider
being enabled in the Neon Auth dashboard (oauth-provider) and on
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime
env — both are already wired through .gitea/workflows/deploy.yml.
2026-06-17 11:54:06 -06:00
Tyler d75380eb9a Create-user flow now provisions the Neon Auth account
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.

This change makes the create flow:
  1. Authorize: only platform_admin can mint new admin users.
  2. Create the Neon Auth user via auth.admin.createUser, falling back
     to the public /sign-up/email + emailVerified=true pattern when
     the caller's Neon Auth session isn't an admin (the common case
     in dev — provision-admin.ts does not set neon_auth.user.role).
  3. Wrap the admin_users INSERT + admin_user_brands link in a
     transaction, returning an error if the local insert fails (the
     Neon Auth user is left orphaned and surfaced in the message).
  4. Send the welcome email best-effort, returning success/failure
     info to the UI.

The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.

10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
2026-06-17 11:46:39 -06:00
Tyler 11cd2fd01a Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
2026-06-17 11:36:00 -06:00
openclaw 653dce747b fix: select role from admin_users instead of admin_user_brands
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
The membership query in getAdminUser() was selecting role from
adminUserBrands.adminUserId, which is wrong - admin_user_brands has no
role column. Role is stored in admin_users. Also added try/catch
around withPlatformAdmin to prevent DB errors from throwing.
2026-06-09 14:42:43 -06:00
openclaw 916ad39176 feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
2026-06-09 12:23:37 -06:00
tyler 42f28b32a4 test(passwords): add unit tests for encode/verify/round-trip 2026-06-07 01:46:52 +00:00
tyler 7cd0603cfb feat: remove dev_session, add Drizzle schema + RLS + real auth
BREAKING: dev_session cookie bypass removed. Admin access now requires
a real Auth.js v5 session (Google OAuth in production). Provision users
by inserting into users + tenant_users tables.

New in this commit:
- db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants,
  users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons,
  products, product_images, stops, customers, orders, order_items,
  brand_settings, email_templates, campaigns, files, audit_log)
- db/schema/: Drizzle TypeScript mirror of every table
- db/client.ts: withTenant() / withPlatformAdmin() query wrappers that
  set Postgres GUCs (app.current_tenant_id, app.platform_admin) for
  RLS enforcement. Never query a tenant-scoped table without one.
- db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River
  Direct), brand_settings, sample products/stops/customers
- scripts/migrate.js: applies migrations in lexical order with tracking
- scripts/db-reset.js: drops + recreates DB, runs migrate + seed
- DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is
  enforced even for the app user. DATABASE_ADMIN_URL for migrations.
- src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session,
  looks up user + tenant in Postgres. brand_id kept as alias for
  backward compat.
- src/middleware.ts: Auth.js-only route protection, dev_session gone
- src/app/login/LoginClient.tsx: Google OAuth only, no demo mode
- src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction
  replaces supabase signout
- @/db/* path aliases in tsconfig.json + vitest.config.ts
- drizzle.config.ts added
- db/auth_schema.sql removed (was a stub; replaced by real schema)
- src/app/api/dev-login/route.ts deleted
- tests: updated to remove dev_session coverage
2026-06-07 01:23:44 +00:00
tyler f96dcd01f2 feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup
- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only)
- Add @types/pg devDep
- Migration 204: add email, auth_provider, auth_subject columns to
  admin_users; backfill from auth.users; new upsert_admin_user accepts
  multi-provider args; new get_admin_user_for_session RPC resolves
  Auth.js session id (UUID or Google sub) to an admin row
- Refactor getAdminUser() to use pg + new RPC; auto-provisions on first
  Google sign-in using session.user.email
- Refactor updatePasswordAction to call update_user_password via pg
  (drops the Supabase REST hop)
- Delete orphaned src/actions/login.ts (replaced by auth-actions.ts)
- Drop remaining DEV_FORCE_UID references in users.ts; dev path now
  uses dev_session cookie (the only cookie the dev flow can set)
- Update AdminUser type: user_id is now string | null (Google users
  have no Supabase auth id); add email + auth_provider fields
- Fix downstream type errors: StopProductAssignment.callerUid accepts
  null; pickup.ts performedBy widens to string | null
- Bump vitest config, tests/, and other earlier cleanup changes
2026-06-06 23:41:41 +00:00