Skip to content

Claude/hostile code audit rbz3f8 - #590

Merged
smj1860 merged 11 commits into
mainfrom
claude/hostile-code-audit-rbz3f8
Aug 7, 2026
Merged

Claude/hostile code audit rbz3f8#590
smj1860 merged 11 commits into
mainfrom
claude/hostile-code-audit-rbz3f8

Conversation

@smj1860

@smj1860 smj1860 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • Added check-in-day arrival reminder SMS messages, including optional check-in times.
    • Guidebook openings now track repeat opens while preventing duplicate daily booking redemptions.
    • Guidebook and sponsor experiences now use each property’s timezone for accurate stay phases and reminders.
  • Bug Fixes

    • Prevented duplicate sponsor checkout sessions and improved handling of failed payments.
    • Improved SMS retry behavior after failed or skipped sends.
    • Invalid media-kit, sponsor, and booking links now receive clearer validation responses or 404 pages.
    • Database and delivery errors are surfaced more reliably instead of being silently ignored.

claude added 8 commits August 7, 2026 14:22
…astern

Both guest-facing guidebook pages worked out "what time is it for this guest"
in a hardcoded America/New_York, for every property in the country.
properties.timezone was not even in the select.

Two guest-visible consequences, both worst in the evening:

  • computeStay(). Once Eastern rolls past midnight, `today >= checkoutDate`
    fires and the guidebook shows CHECKOUT instructions to a guest who is
    still mid-stay. One hour early in Central, two in Mountain, three in
    Pacific, five in Hawaii.

  • hourOfDay(). It feeds getActiveSlotTypes(), which decides which SPONSOR
    SLOTS render. Sponsors pay for that placement, so the wrong hour shows the
    wrong paying businesses — and the entire 5pm–8pm evening dining window on
    the west coast reads as 8pm–11pm. It also drives the sky state and the
    breakfast/evening recommendation bands, and on the slug page (which has no
    booking) it is the sole input to the arrival-vs-mid phase.

Live, not theoretical: production is already 4 of 27 properties in
America/Chicago, and the error only grows as the product moves west.

FALLBACK_TIMEZONE stays, but only as a fallback for a property somehow
lacking one, with a comment saying it is not the default and why that
distinction cost something.

The test pins the boundary directly — 2026-08-11T04:30:00Z is 11:30pm Aug 10
in Chicago and 12:30am Aug 11 in New York — and deliberately keeps an
assertion that the SAME instant still flips under America/New_York. Without
it, a fixture that failed to straddle a date boundary would pass for the wrong
reason and prove nothing. Hawaii covers the widest US offset, so the case does
not read as a one-hour rounding curiosity.

Verified: next build exits 0 (BUILD_ID confirmed from that run), tsc clean,
3048 unit + 112 component tests pass, lint 192 at the ceiling, check:ui-classes
clean, semgrep chokepoints exit 0, ratchet vs. the real PR base exit 0, no
ratchet count increased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
…ot move

Two findings in optInGuestSms. The consent LOGIC around them is careful — STOP
honoured globally by phone across every org and booking, re-consent restricted
to the handset, revocation records kept forever, degraded consent reads failing
closed — which is what made these two stand out.

1. The 15-minute number-correction window was measured from `opted_in_at`,
   which the upsert REFRESHES on every submission. So it was never "15 minutes
   from the opt-in" as documented; it was 15 minutes from the LAST submission,
   and resubmitting just inside it restarted the clock. The window could be
   walked forward without limit — precisely the days-later repoint the comment
   above it says a leaked guidebook link enables.

   It now anchors on `created_at`, which the upsert never names and which
   therefore keeps the original row's timestamp through every conflict-update.
   No migration needed; the immutable anchor was already there.

   Both existing window tests had `opted_in_at` fixtures. Worth noting that
   the refusal test did not simply need updating — with `created_at` absent it
   fell back to epoch 0 and PASSED for entirely the wrong reason, which is the
   shape a fixture change can hide behind. Both now set `created_at`, and a
   third case covers the walk-forward directly: original opt-in 24h ago,
   resubmitted a minute ago, repoint refused.

2. The booking lookup discarded its error. The two consent reads that follow
   it both fail closed with an explicit note about why; this one did not, so a
   transient failure was indistinguishable from a bad token and a guest holding
   a perfectly valid link was told the link was invalid — with nothing logged
   and nothing reported.

   supabase-error-handling baseline for this file 3 -> 2.

Also logs FUTURE_REMEDIATION item 24: the opt-in stores no EVIDENCE of the
consent it relies on — no disclosure text or version, no IP or user agent, no
record of which token was used. Under TCPA the burden of proving prior express
written consent sits with the sender, and the strongest artefact available
today is a row saying a number opted in at a timestamp. Not fixed here because
it needs a migration and, more to the point, storing request metadata against a
phone number is a PII expansion that needs its own retention rule — a decision
for whoever owns the compliance posture, not a default to pick mid-audit.

Two canaries, each reverted with the edit verified before the suite ran.

Verified: next build exits 0 (BUILD_ID confirmed from that run), tsc clean,
3050 unit + 112 component tests pass, lint 192 at the ceiling, check:ui-classes
clean, semgrep chokepoints exit 0, ratchet vs. the real PR base exit 0, no
ratchet count increased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
…n it skips

Every guest-SMS path in the guidebook used the same one-shot claim to prevent
double-sending, and every one of them released it on only half the failures.

`sendSMS` returns `{sent:false}` ONLY for a deliberate skip — SMS_ENABLED off,
the daily nudge budget, demo-org suppression — because dispatchToTelnyx throws
on a timeout or any non-2xx. So the branch every site had written handled the
case that isn't a failure, and a real failure walked out past the release with
the claim still held. The Inngest retry then matched zero rows on
`.is('<column>', null)`, returned `already_sent`, and reported success for a
message nobody ever received.

For guidebook-guest-opted-in that is a guest who never gets their door code.
The comment above its claim already describes this exact failure for a
different case; it was still open one layer down.

The same file was inverted in the other direction too: it THREW on a
deliberate skip, so with SMS_ENABLED=false (production's current state) every
guest opt-in produced a failing, retried run reading "SMS send failed" — untrue,
and noise that would mask real failures once SMS is switched on. A retry cannot
change an env var.

- guidebook-guest-opted-in: release + rethrow on a throw; release + return
  `skipped` on a deliberate skip.
- guidebook-stay-extension-handler: same for both the guest and PM-SMS claims,
  via a shared releaseSendClaim() that also binds the release's own result — a
  failed release left the claim held forever with nothing logged.
- lib/sms/optin-claim: new sendClaimedDailySms() owns claim -> send ->
  release-on-either-exit, so the three nudge call sites (two morning, one
  evening) cannot drift apart again. Rendering moves inside the claimed
  section: renderSmsBody can throw too, and releasing only around sendSMS
  burned the day's slot on a template failure just the same.

Also closes the silent-read family across the same feature (9 sites, both
files now at 0 in the supabase-error-handling ratchet):

- stay-extension-handler's context read discarded both errors, so `booking`
  came back null, `portalUrl` was null, and the ENTIRE guest-SMS block was
  skipped while the PM email went out reading "checks out on undefined" — the
  step reported success either way, so Inngest never retried.
- stay-extension-cron: a failed bookings read looked like "this org has no
  checkouts", a failed existence check looked like "not yet handled", and a
  failed next-booking read looked like "open calendar" — each ending in a
  successful `dispatched: 0`. The insert now distinguishes 23505 (another run
  won the UNIQUE(booking_id) race — benign) from every other error, which
  previously took the same silent `continue`.
- Both claim UPDATEs and the PM-email stamp gain `.eq('org_id', ...)`.

Tests: the opt-in test that fed a `{sent:false}` and asserted a throw encoded
the old inverted behaviour; split into the two cases the fix distinguishes.
New unit/sms/optin-claim.test.ts covers the real helper directly, since the
cron tests stub it. Every fix canary-verified by reverting it individually.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
…criptions

`guidebook_sponsors.checkout_session_id` was written on every checkout and
then never read back from anywhere in the codebase. The field exists for
exactly one purpose — the work-order invoice route has used it that way all
along ("Store the session ID for potential reuse on duplicate clicks",
app/api/invoices/[invoiceId]/checkout/route.ts) — but the sponsor path only
ever wrote it.

So every click, reload, or retry minted a fresh Stripe Checkout Session, each
payable for 24 hours. Two of them paid means two subscriptions, two
checkout.session.completed webhooks with DISTINCT event ids (so
stripe_processed_events does not collapse them), and an activation handler
that overwrites stripe_subscription_id with whichever lands last — leaving the
other subscription billing the business monthly with nothing in FieldStay able
to reach it.

- Reuse an already-open session instead of creating a second one.
- Compare-and-swap the new session id against the value just read, rather than
  blind-overwriting. The reuse check covers the repeat-click case; the CAS
  covers the concurrent one. The loser expires its own session and returns the
  winner's, so exactly one payable session exists per sponsor. A plain
  check-then-write is the TOCTOU the audit checklist calls out — the
  precondition belongs in the WHERE clause.
- Refuse checkout from `payment_failed`, not just `active`. payment_failed is
  set from invoice.payment_failed, which does NOT end the subscription: Stripe
  keeps dunning and guidebook-sponsor-payment-recovered flips the row straight
  back to active. A sponsor who saw the failure notice could buy a second
  subscription while the first was still being retried. `cancelled` stays
  allowed — that subscription is genuinely gone.
- Bind the CAS result. With reuse in place this write is load-bearing: a
  silent failure degrades straight back to "new session every time".

Also in the activation handler:

- Report when a second, DIFFERENT subscription id replaces a live one. Our own
  flow should no longer produce this, but a subscription created outside it
  still can, and orphaning one must not pass unnoticed. The write proceeds.
- Throw on the guidebook-unlock upsert, matching the identical upsert in
  guidebook-sponsor-payment-recovered. Discarded, a failed unlock left the
  guidebook locked while the step returned `wasUnlocked: true` and the audit
  row recorded an unlock that never happened.

And the media-kit-token lookup now unwraps rather than discarding its error —
a sponsor holding a valid link was told it was invalid whenever the query
itself failed. Byte-for-byte the defect already fixed in optInGuestSms, one
function over in the same file.

Verified against production first: 2 sponsors, both `pending`, zero checkouts
ever completed — so no live billing was affected.

Every fix canary-verified by reverting it individually. One existing assertion
(the tenant-scope check on guidebook_sponsors) was a positional list that a new
read would have silently weakened; restated as "every access is scoped by both
id and org_id".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
…D_RE

`/api/guidebook/redeem` is public and unauthenticated, and both ids it takes
land in `uuid` columns — `guidebook_sponsors.id` and
`bookings.guidebook_token`. Postgres does not coerce: a non-UUID string is
error 22P02, not an empty result. The route only checked `typeof === 'string'`.

- A malformed `sponsorId` reached `.eq('id', …)`, 22P02'd, threw out of
  unwrap(), and the catch turned it into `{ok:true}` PLUS a reportError. On an
  unauthenticated endpoint that is a free way for anyone to burn the Sentry
  quota and bury this route's genuine database failures in the noise — and the
  limiter bounds it only while Redis is up, since this one deliberately fails
  open. A malformed id is a bad request, so it now 400s before touching the DB.

- A malformed `bookingToken` was worse than unattributed. The 22P02 escaped the
  try block entirely and hit the outer catch, so the redemption INSERT never
  ran — the redemption was discarded, not merely anonymous, while the guest
  still saw `{ok:true}`. The route's own stated fallback three lines down is an
  anonymous redemption; an unusable token now skips the booking lookup and
  takes exactly that path.

The identical UUID regex had been open-coded in four files (two crew routes,
crew work-order complete, lib/storage/object-path.ts) and was simply missing
from the fifth place that needed it, so it moves to lib/validation/uuid.ts and
those four now import it. Five copies is how the sixth gets forgotten.

Test-fixture note, and the reason the second fix nearly shipped unverified:
this suite used ids like 'sponsor_1' and 'tok_abc' that could never exist in
production — against the live schema they are 22P02, not a miss. So the mock
was accepting input the database rejects, and the first version of the
malformed-token test PASSED with the fix reverted. The double now answers
22P02 for a non-UUID filtered against a uuid column, which is what Postgres
does, and both canaries bite.

guidebook_offer_redemptions has zero rows in production and nothing reads it
yet, so no collected data was affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
Confirmed the interaction: tapping a sponsor offer opens the redemption pass —
a full-screen "Guest Perk · Verified Live" card with a live clock, captioned
"Show this screen to staff — clock proves it's live". It is a coupon presented
at the counter, and its row count is the number a paying sponsor will judge
their slot by.

Opening that pass more than once is the normal case, not an edge case: a guest
looks at the offer from the couch, closes it, walks to the business, and opens
it again to show staff. Every reopen was its own row, so COUNT(*) overstated
real redemptions by however many times the guest looked at their own coupon.

- New partial unique index on (sponsor_id, booking_id, UTC date of opened_at),
  applied to production AND the E2E project with matching ledger rows.
- The route treats 23505 as the success path — already counted today. Caught
  rather than expressed as upsert({onConflict}) because the index is an
  EXPRESSION index and PostgREST's on_conflict only takes plain column names.

Per DAY, not per stay: a daily perk is legitimately redeemable on each day of a
booking, so collapsing a whole stay would under-count. The UTC day boundary
splits a single evening's repeat opens only when they straddle UTC midnight
(6-8pm US local) — that errs toward one extra, never toward discarding a
genuinely distinct day, which is the safe direction here.

Anonymous redemptions (booking_id NULL, from the property-level /g/[slug]
guidebook, which has no booking token) are deliberately outside the predicate:
with no guest identity there is nothing to dedupe on, and collapsing them by
(sponsor, day) would merge different guests into one.

Guardrail: the constraint and the handler branch live in different files with
nothing tying them together, and both drift directions are silent — drop the
index and the 23505 branch becomes dead code while counts quietly inflate;
drop the branch and every reopen becomes a reported error, moving the noise
from the sponsor's number into Sentry. unit/guardrails/redemption-dedup-pairing
asserts they stay together. Both directions canary-verified.

Verified zero conflicting rows in production before applying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
…ints

guidebook_sponsors.media_kit_token is a `uuid`, and all three surfaces that
take one only checked `typeof === 'string'`. Postgres does not coerce, so a
well-formed-but-not-UUID token is error 22P02 — and each surface turned that
into the wrong answer, in opposite directions:

- /g/kit/[media_kit_token] and its /print sibling already carried the note
  that a failed READ must not read as an invalid token, and throw to the error
  boundary rather than notFound() for exactly that reason. The inverse held
  just as strongly and was unhandled: a plainly invalid URL 22P02'd, unwrap()
  threw, and the sponsor got "something went wrong on our side" for a bad link.
  Both failures now get their own honest surface — malformed 404s before any
  database round trip, a real query failure still reaches the boundary.

- /api/guidebook/sponsor-checkout passed the token straight through to
  createSponsorCheckoutSession, where it 22P02'd out of unwrap() into the
  action's catch — reporting to Sentry and telling the sponsor "Unable to start
  checkout. Please try again." for a link that will never work however many
  times they try. It now returns the same "Invalid media kit link." a
  nonexistent token gets, without touching the DB or Sentry.

All three are public and unauthenticated, so the Sentry-report-per-malformed-
request path was also a free way to burn the quota and bury these surfaces'
genuine failures in noise.

Same fixture problem as the redeem route, and worth repeating because it is a
pattern: this suite used 'kit-token-abc-123', which could not have existed in
production — against the live schema it is 22P02, not a miss. The new page
tests use a double that answers 22P02 for a non-UUID compared to a uuid column,
so they fail when the guard is removed rather than passing regardless. All
three guards canary-verified individually.

The media-kit pages had no test file at all before this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
Two things you asked for after the dedup landed.

── Engagement counter ───────────────────────────────────────────────────────
The per-day dedup made the redemption count honest but discarded a real second
signal: how many times the pass was actually opened. "12 redemptions, opened 31
times" says something "12 redemptions" alone does not.

guidebook_offer_redemptions.open_count, incremented on the existing row rather
than added as a second table — the dedup key IS the natural grain, so the
counter stays bounded by the same constraint instead of growing per tap, and
both numbers come out of one query: COUNT(*) redemptions, SUM(open_count)
opens.

The write moves to record_guidebook_offer_open(). It has to be a function: the
arbiter is a partial EXPRESSION index and PostgREST's on_conflict takes only
plain column names, so the upsert is not expressible through the JS client at
all — and read-then-write in the route would be a TOCTOU where two taps racing
both read 1 and both write 2. p_booking_id is DEFAULT NULL so the anonymous
case can omit it; without that the generated Args type is a required
non-nullable string and the only ways to pass null are a cast or a second
write path.

Postgres grants EXECUTE to PUBLIC by default, which on Supabase means anon
could call it over /rest/v1/rpc/ with the publishable key and write rows to a
tenant table with no session. Revoked and granted to service_role only —
verified against the live DB, not just asserted in the file. The guardrail now
covers that, the ON CONFLICT arbiter matching the index, and the route not
regressing to a bare insert (which would leave open_count pinned at 1 and
flatline engagement silently). All three canary-verified.

── Arrival reminder ─────────────────────────────────────────────────────────
The morning cron's filter is `checkin_date <= today AND checkout_date >=
today`, so a guest whose stay STARTS today is included — but it fires 7-11 AM
and check-in is typically mid-afternoon. They were getting the full nudge
("it's 72°F at your rental, here's a coffee spot 0.4 mi away") hours before
they had keys.

New arrival_reminder template, sent instead of the nudge on check-in day, via
the same claim slot so a guest still gets exactly one morning message. Placed
before the lat/lng and weather guards — an arrival reminder needs neither, and
a property with no coordinates should still be able to send one. The check-in
sentence is omitted entirely rather than left blank when checkin_time is null
(27 of 27 production properties have one, but OwnerRez-synced properties write
null explicitly).

The outgoing guest on a same-day flip is deliberately unchanged, per your
call: `checkout_date >= today` keeps them on checkout morning, when they ARE
still in the house.

formatTime12h moves out of the guest guidebook Client Component to
lib/utils/time-of-day.ts so the Inngest step can use it — these are wall-clock
values with no timezone, so they must not be run through Intl with a timeZone.

Migration applied to production and E2E. Note: MCP apply_migration assigned its
own version, and a DIFFERENT one per project (…170639 vs …170648), matching
neither the committed filename — exactly the ledger drift CLAUDE.md documents.
Both ledgers normalized to the file's version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fieldstay Ready Ready Preview Aug 7, 2026 6:24pm

Request Review

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smj1860 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@smj1860, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b49491b-3a6e-4da3-b7a5-244fa59bf849

📥 Commits

Reviewing files that changed from the base of the PR and between 45d5a11 and f151ed3.

📒 Files selected for processing (11)
  • .semgrep/baseline-counts.json
  • lib/guidebook/offer.ts
  • lib/inngest/functions/guidebook-sms-evening-cron.ts
  • lib/inngest/functions/guidebook-sms-morning-cron.ts
  • lib/inngest/functions/guidebook-stay-extension-cron.ts
  • lib/sms/pick-nearest-sponsor.ts
  • unit/guardrails/n-plus-one-loops.test.ts
  • unit/guardrails/supabase-error-handling.test.ts
  • unit/guardrails/unbounded-select.test.ts
  • unit/inngest/guidebook-sponsor-activated.test.ts
  • unit/lib/guidebook-offer.test.ts
📝 Walkthrough

Walkthrough

The PR adds shared UUID and time utilities, timezone-aware guidebook calculations, daily redemption aggregation, Stripe checkout concurrency protection, explicit Supabase error propagation, and centralized SMS claim handling with check-in-day arrival reminders.

Changes

Guidebook validation and timezone handling

Layer / File(s) Summary
Shared validation and timezone handling
lib/validation/uuid.ts, lib/utils/time-of-day.ts, app/api/..., app/g/..., lib/storage/object-path.ts, components/guidebook/guest-guidebook-view.tsx, unit/guidebook/guest-stay-timezone.test.ts
Routes and pages use shared UUID validation. Guidebook calculations use property timezones with a New York fallback. Time formatting uses a shared wall-clock utility.
Daily redemption tracking
app/api/guidebook/redeem/route.ts, supabase/migrations/..., types/database.ts, unit/route-handlers/guidebook-redeem.test.ts, unit/guardrails/redemption-dedup-pairing.test.ts
Redemptions use a service-role RPC with booking-aware daily deduplication and open_count increments. Anonymous redemptions remain separate.
Checkout concurrency and opt-in correction
app/actions/guidebook.ts, FUTURE_REMEDIATION.md, unit/guidebook/guidebook-actions.test.ts
Checkout sessions are reused or claimed with compare-and-swap logic. Guest opt-in correction windows use immutable created_at, and booking lookup failures are reported explicitly.
Background failure propagation
lib/inngest/functions/..., unit/inngest/..., unit/guardrails/..., .semgrep/baseline-counts.json
Background functions unwrap database responses, scope queries, preserve retryable failures, report subscription conflicts, and distinguish duplicate-key races from other write failures.
Claimed SMS delivery and arrival reminders
lib/sms/..., lib/inngest/functions/guidebook-...cron.ts, lib/inngest/functions/guidebook-guest-opted-in.ts, unit/sms/..., unit/inngest/...
SMS claim cleanup is centralized. Morning SMS now supports check-in-day arrival reminders with optional check-in times. Template and failure-path tests were added.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related PRs

Suggested reviewers: claude

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title identifies the pull request as a hostile code audit, which matches the security, validation, error-handling, and concurrency fixes in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/hostile-code-audit-rbz3f8

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/inngest/functions/guidebook-sponsor-activated.ts (1)

17-57: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the sponsor subscription assignment atomic.

activate-sponsor-row reads stripe_subscription_id before it updates. Two concurrent guidebook/sponsor.checkout.completed events can both see null, then both condition-pass and overwrite the row. The last update wins and only one duplicate subscription is reported.

Store a pending activation token in guidebook_sponsors or use a conditional update/RPC that accepts only null or the expected subscription ID. When no claim is made, report the conflict and stop further processing for manual reconciliation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/inngest/functions/guidebook-sponsor-activated.ts` around lines 17 - 57,
Make the subscription assignment in the activate-sponsor-row flow atomic instead
of relying on the preceding read in existingRes. Use a conditional update or RPC
that only accepts a null stripe_subscription_id or the expected subscriptionId;
when no row is claimed, report the duplicate-subscription conflict and stop
further processing, while preserving normal activation for successful claims.
🧹 Nitpick comments (5)
app/actions/guidebook.ts (2)

499-510: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider tryUnwrap for consistency with the sponsor read.

The booking read binds bookingRes.error manually, while createSponsorCheckoutSession in the same file now uses unwrap. Both fail closed, so behaviour is correct. A tryUnwrap* helper would give the same explicit error branch, keep the friendly message, and match the repository convention for Supabase reads. This is optional and applies equally to the two consent reads below.

As per coding guidelines: "Use unwrap/unwrapList/unwrapCount or tryUnwrap* for Supabase reads so query errors are not mistaken for empty results".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions/guidebook.ts` around lines 499 - 510, Update the Supabase reads
in optInGuestSms, including the booking lookup and the two consent reads, to use
the repository’s tryUnwrap helper pattern instead of manually inspecting
bookingRes.error. Preserve the existing fail-closed behavior, friendly error
response, logging, and reportError calls.

Source: Coding guidelines


53-62: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Distinguish a missing session from a transient Stripe failure.

openSessionUrl maps every retrieve rejection to null. A genuinely expired or deleted session and a transient Stripe outage or rate-limit then produce the same result. On a transient failure the caller mints a second session, and the stored one is still payable because the compare-and-swap succeeds and nothing expires it. That is the double-subscription case this helper exists to prevent.

Stripe returns status: 'expired' for an expired session rather than throwing, so the catch mostly covers transport and permission faults. Report those instead of treating them as "no session".

♻️ Proposed change to separate resource_missing from transient errors
 async function openSessionUrl(sessionId: string | null): Promise<string | null> {
   if (!sessionId) return null
   try {
     const existing = await stripe.checkout.sessions.retrieve(sessionId)
     return existing.status === 'open' ? existing.url : null
-  } catch {
-    // Expired or not found — the caller mints a new one.
+  } catch (err) {
+    // Not found — the caller mints a new one. Anything else is a transient
+    // Stripe fault: minting a second session while the stored one may still
+    // be payable is the double-subscription risk this helper closes.
+    const code = (err as { code?: string }).code
+    if (code !== 'resource_missing') {
+      reportError(err, { site: 'serverAction.guidebook.openSessionUrl' })
+    }
     return null
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions/guidebook.ts` around lines 53 - 62, Update openSessionUrl so
Stripe retrieve failures are no longer converted to null: return null only when
the session is absent or has a non-open status, while propagating transient or
permission errors from stripe.checkout.sessions.retrieve to the caller.
unit/route-handlers/guidebook-sponsor-checkout.test.ts (1)

106-118: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider covering an absent mediaKitToken.

The route types the field as mediaKitToken?: string, so undefined is a reachable input. The new test covers a malformed string only. A case posting {} would pin the same 400 response for a missing field and guard isUuid against a future non-string regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unit/route-handlers/guidebook-sponsor-checkout.test.ts` around lines 106 -
118, Add a test alongside the malformed-token case that posts an empty payload
without mediaKitToken, then assert a 400 response with { error: 'Invalid media
kit link.' } and verify createSponsorCheckoutSession is not called. This should
cover the optional field’s undefined path and preserve validation before
reaching the action.
unit/guidebook/guidebook-actions.test.ts (1)

186-210: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the compare-and-swap precondition, not only its outcome.

makeSupabase returns the queued response regardless of which filters the chain received. This test therefore passes even if the update reverts to a blind overwrite with no checkout_session_id predicate, because the second queued entry is { data: null } either way. The defect under test is precisely that missing predicate.

makeSupabase already records every filter in calls. Assert on it. The same applies to the cs_expired case at lines 166-184, which should assert .eq('checkout_session_id', 'cs_expired').

💚 Proposed assertion using the recorded calls
       const result = await createSponsorCheckoutSession('kit_token_abc')
 
       // A plain `if (already active)` before the write is the exact TOCTOU the
       // audit checklist calls out — the precondition has to be in the WHERE
       // clause, and the loser has to clean up after itself.
+      // The precondition must be IN the query, so assert it was sent.
+      expect(supabase.calls).toContainEqual({
+        table:  'guidebook_sponsors',
+        method: 'is',
+        args:   ['checkout_session_id', null],
+      })
       expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith('cs_loser')
       expect(result).toEqual({ url: 'https://checkout.stripe.com/cs_winner' })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unit/guidebook/guidebook-actions.test.ts` around lines 186 - 210, Update the
CAS tests for createSponsorCheckoutSession, including the cs_expired case and
the concurrent-loss case, to inspect makeSupabase’s recorded calls and assert
the update chain includes an eq filter for checkout_session_id with the
previously active session ID. Keep the existing outcome assertions, but verify
the precondition is applied to the write rather than only relying on queued
responses.
unit/inngest/guidebook-sms-evening-cron.test.ts (1)

35-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated sendClaimedDailySms mock factory casts local mocks to any. Both test files copy the same factory. In each copy, claimDailySmsSlot and releaseDailySmsSlot are locally declared vi.fn values, so the as any casts and the eslint-disable comments are unnecessary. The guidelines forbid as any.

  • unit/inngest/guidebook-sms-evening-cron.test.ts#L35-L47: call the local mocks directly, or give them explicit parameter signatures, and delete the three eslint-disable comments.
  • unit/inngest/guidebook-sms-morning-cron.test.ts#L35-L47: apply the same change; consider extracting the shared factory into unit/inngest/test-helpers.ts so the two copies cannot drift.

As per coding guidelines: "Use concrete TypeScript types, never any or as any".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unit/inngest/guidebook-sms-evening-cron.test.ts` around lines 35 - 47, Remove
the unnecessary any casts and eslint-disable comments in the duplicated
sendClaimedDailySms mock factories: call the locally declared claimDailySmsSlot
and releaseDailySmsSlot mocks directly, or provide explicit parameter
signatures. Apply this in unit/inngest/guidebook-sms-evening-cron.test.ts lines
35-47 and unit/inngest/guidebook-sms-morning-cron.test.ts lines 35-47;
optionally extract the shared factory into unit/inngest/test-helpers.ts to keep
both implementations consistent.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/api/guidebook/sponsor-checkout/route.ts`:
- Around line 35-45: Update the request parsing before the action in the sponsor
checkout route to parse the JSON body as unknown, catch malformed JSON, and
return the invalid-link response with HTTP 400. Narrow nullable or non-object
bodies with a type guard, extract an optional mediaKitToken safely, and pass the
narrowed token to isUuid and the action without dereferencing null.

In `@app/g/`[slug]/page.tsx:
- Around line 118-120: Introduce or reuse a shared helper that validates an IANA
timezone and falls back to FALLBACK_TIMEZONE when invalid, then pass its result
to every affected Intl.DateTimeFormat call: app/g/[slug]/page.tsx lines 118-120,
app/g/b/[token]/page.tsx lines 36-42, and app/g/b/[token]/page.tsx lines
154-157. Update each site to use the helper rather than passing
properties.timezone directly.

In `@lib/inngest/functions/guidebook-sponsor-activated.ts`:
- Line 6: Add `import 'server-only'` as the first import in
`lib/inngest/functions/guidebook-sponsor-activated.ts` (6-6),
`lib/inngest/functions/guidebook-stay-extension-cron.ts` (4-4), and
`lib/inngest/functions/guidebook-stay-extension-handler.ts` (3-8), before their
existing imports, to mark all three Inngest modules as server-only.

In `@lib/inngest/functions/guidebook-stay-extension-handler.ts`:
- Around line 307-313: Update the pm_notified_at stamp handling in the guidebook
stay extension handler so a stampError is thrown after logging and reporting it,
allowing step.run() to retry the failed step. Only return { notified: true }
after the stamp succeeds; do not convert a failed write into a successful
notification result.
- Around line 33-60: Update releaseSendClaim so a failed claim-release update is
propagated instead of returning successfully, ensuring callers can retry the
non-idempotent SMS or notification delivery; preserve the existing logging and
reporting. Trace the callers of releaseSendClaim and handle the propagated
failure consistently, then add coverage for an update failure that verifies the
claim is recoverable and delivery is retried rather than reported as already
sent or already notified.

In `@lib/utils/time-of-day.ts`:
- Around line 19-27: Update formatTime12h to validate the complete time string
before formatting, including hours, minutes, and optional seconds, and return
null for malformed or out-of-range values such as 25:99:00, 12:30:garbage, and
-1:00:00. Support 00:00:00–23:59:59, and explicitly handle 24:00:00 according to
the intended behavior rather than allowing it through implicitly.

In `@lib/validation/uuid.ts`:
- Around line 38-40: Update the JSDoc for isUuid to describe that it recognizes
only canonical 8-4-4-4-12 UUID strings matching UUID_RE, rather than all UUID
representations accepted by PostgreSQL; leave the implementation unchanged.

In
`@supabase/migrations/20260807150000_guidebook_offer_redemptions_daily_dedup.sql`:
- Around line 35-41: Update the migration before creating
uniq_guidebook_offer_redemptions_sponsor_booking_day: add the open_count column,
identify duplicate rows by sponsor_id, booking_id, and the UTC date derived from
opened_at, aggregate their open counts into one retained row, and delete
redundant rows. Then create the unique index so historical duplicates cannot
cause index creation to fail.

In `@unit/route-handlers/guidebook-redeem.test.ts`:
- Around line 54-60: Replace the any-typed chain in the from mock with a
concrete local query-chain type declaring select, eq, maybeSingle, and insert,
and type the chain implementation accordingly. Preserve the existing fluent
return behavior and recorded eq arguments without using any or as any.

---

Outside diff comments:
In `@lib/inngest/functions/guidebook-sponsor-activated.ts`:
- Around line 17-57: Make the subscription assignment in the
activate-sponsor-row flow atomic instead of relying on the preceding read in
existingRes. Use a conditional update or RPC that only accepts a null
stripe_subscription_id or the expected subscriptionId; when no row is claimed,
report the duplicate-subscription conflict and stop further processing, while
preserving normal activation for successful claims.

---

Nitpick comments:
In `@app/actions/guidebook.ts`:
- Around line 499-510: Update the Supabase reads in optInGuestSms, including the
booking lookup and the two consent reads, to use the repository’s tryUnwrap
helper pattern instead of manually inspecting bookingRes.error. Preserve the
existing fail-closed behavior, friendly error response, logging, and reportError
calls.
- Around line 53-62: Update openSessionUrl so Stripe retrieve failures are no
longer converted to null: return null only when the session is absent or has a
non-open status, while propagating transient or permission errors from
stripe.checkout.sessions.retrieve to the caller.

In `@unit/guidebook/guidebook-actions.test.ts`:
- Around line 186-210: Update the CAS tests for createSponsorCheckoutSession,
including the cs_expired case and the concurrent-loss case, to inspect
makeSupabase’s recorded calls and assert the update chain includes an eq filter
for checkout_session_id with the previously active session ID. Keep the existing
outcome assertions, but verify the precondition is applied to the write rather
than only relying on queued responses.

In `@unit/inngest/guidebook-sms-evening-cron.test.ts`:
- Around line 35-47: Remove the unnecessary any casts and eslint-disable
comments in the duplicated sendClaimedDailySms mock factories: call the locally
declared claimDailySmsSlot and releaseDailySmsSlot mocks directly, or provide
explicit parameter signatures. Apply this in
unit/inngest/guidebook-sms-evening-cron.test.ts lines 35-47 and
unit/inngest/guidebook-sms-morning-cron.test.ts lines 35-47; optionally extract
the shared factory into unit/inngest/test-helpers.ts to keep both
implementations consistent.

In `@unit/route-handlers/guidebook-sponsor-checkout.test.ts`:
- Around line 106-118: Add a test alongside the malformed-token case that posts
an empty payload without mediaKitToken, then assert a 400 response with { error:
'Invalid media kit link.' } and verify createSponsorCheckoutSession is not
called. This should cover the optional field’s undefined path and preserve
validation before reaching the action.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ba995830-4baa-4061-891d-08ca03641f20

📥 Commits

Reviewing files that changed from the base of the PR and between c84a0c5 and 45d5a11.

⛔ Files ignored due to path filters (1)
  • types/database.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (43)
  • .semgrep/baseline-counts.json
  • FUTURE_REMEDIATION.md
  • app/actions/guidebook.ts
  • app/api/crew/inventory-count/route.ts
  • app/api/crew/messages/route.ts
  • app/api/crew/work-orders/[id]/complete/route.ts
  • app/api/guidebook/redeem/route.ts
  • app/api/guidebook/sponsor-checkout/route.ts
  • app/g/[slug]/page.tsx
  • app/g/b/[token]/page.tsx
  • app/g/kit/[media_kit_token]/page.tsx
  • app/g/kit/[media_kit_token]/print/page.tsx
  • components/guidebook/guest-guidebook-view.tsx
  • lib/inngest/functions/guidebook-guest-opted-in.ts
  • lib/inngest/functions/guidebook-sms-evening-cron.ts
  • lib/inngest/functions/guidebook-sms-morning-cron.ts
  • lib/inngest/functions/guidebook-sponsor-activated.ts
  • lib/inngest/functions/guidebook-stay-extension-cron.ts
  • lib/inngest/functions/guidebook-stay-extension-handler.ts
  • lib/sms/optin-claim.ts
  • lib/sms/template-registry.ts
  • lib/sms/templates.ts
  • lib/storage/object-path.ts
  • lib/utils/time-of-day.ts
  • lib/validation/uuid.ts
  • supabase/migrations/20260807150000_guidebook_offer_redemptions_daily_dedup.sql
  • supabase/migrations/20260807170000_guidebook_offer_open_count.sql
  • types/database.ts
  • unit/guardrails/n-plus-one-loops.test.ts
  • unit/guardrails/redemption-dedup-pairing.test.ts
  • unit/guardrails/supabase-error-handling.test.ts
  • unit/guidebook/guest-stay-timezone.test.ts
  • unit/guidebook/guidebook-actions.test.ts
  • unit/inngest/guidebook-guest-opted-in.test.ts
  • unit/inngest/guidebook-sms-evening-cron.test.ts
  • unit/inngest/guidebook-sms-morning-cron.test.ts
  • unit/inngest/guidebook-sponsor-activated.test.ts
  • unit/inngest/guidebook-stay-extension-handler.test.ts
  • unit/lib/sms-template-registry.test.ts
  • unit/pages/media-kit-token-shape.test.ts
  • unit/route-handlers/guidebook-redeem.test.ts
  • unit/route-handlers/guidebook-sponsor-checkout.test.ts
  • unit/sms/optin-claim.test.ts

Comment on lines +35 to +45
// Shape-checked, not just type-checked. guidebook_sponsors.media_kit_token
// is a `uuid`, so a malformed token reaches `.eq()` as Postgres 22P02,
// throws out of the action's unwrap(), and lands in its catch — which
// reports to Sentry and tells the sponsor "Unable to start checkout.
// Please try again." for a link that will never work no matter how many
// times they try. On a public unauthenticated endpoint that is also a free
// way to burn the Sentry quota. An unusable token is an invalid link, and
// gets the same message a nonexistent one does.
if (!isUuid(body.mediaKitToken)) {
return NextResponse.json(
{ error: 'mediaKitToken is required' },
{ error: 'Invalid media kit link.' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return 400 for null and malformed request bodies.

At Line 43, body is dereferenced without a null check. A valid JSON body of null throws before isUuid. Malformed JSON rejects req.json() before validation. Both paths reach the catch block, report the error, and return 500 instead of the new invalid-link response. Parse into unknown, convert parse failures to a 400, extract an optional token, and pass the narrowed token to the action.

As per coding guidelines, “narrow unknown with a type guard; handle nullable fields explicitly.”

Suggested request parsing
-    const body = await req.json() as { mediaKitToken?: string }
+    const body: unknown = await req.json().catch(() => null)
+    const mediaKitToken =
+      typeof body === 'object' && body !== null && 'mediaKitToken' in body
+        ? body.mediaKitToken
+        : undefined

-    if (!isUuid(body.mediaKitToken)) {
+    if (!isUuid(mediaKitToken)) {
       return NextResponse.json(
         { error: 'Invalid media kit link.' },
         { status: 400 }
       )
     }

-    const result = await createSponsorCheckoutSession(body.mediaKitToken)
+    const result = await createSponsorCheckoutSession(mediaKitToken)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Shape-checked, not just type-checked. guidebook_sponsors.media_kit_token
// is a `uuid`, so a malformed token reaches `.eq()` as Postgres 22P02,
// throws out of the action's unwrap(), and lands in its catch — which
// reports to Sentry and tells the sponsor "Unable to start checkout.
// Please try again." for a link that will never work no matter how many
// times they try. On a public unauthenticated endpoint that is also a free
// way to burn the Sentry quota. An unusable token is an invalid link, and
// gets the same message a nonexistent one does.
if (!isUuid(body.mediaKitToken)) {
return NextResponse.json(
{ error: 'mediaKitToken is required' },
{ error: 'Invalid media kit link.' },
const body: unknown = await req.json().catch(() => null)
const mediaKitToken =
typeof body === 'object' && body !== null && 'mediaKitToken' in body
? body.mediaKitToken
: undefined
// Shape-checked, not just type-checked. guidebook_sponsors.media_kit_token
// is a `uuid`, so a malformed token reaches `.eq()` as Postgres 22P02,
// throws out of the action's unwrap(), and lands in its catch — which
// reports to Sentry and tells the sponsor "Unable to start checkout.
// Please try again." for a link that will never work no matter how many
// times they try. On a public unauthenticated endpoint that is also a free
// way to burn the Sentry quota. An unusable token is an invalid link, and
// gets the same message a nonexistent one does.
if (!isUuid(mediaKitToken)) {
return NextResponse.json(
{ error: 'Invalid media kit link.' },
{ status: 400 }
)
}
const result = await createSponsorCheckoutSession(mediaKitToken)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/guidebook/sponsor-checkout/route.ts` around lines 35 - 45, Update the
request parsing before the action in the sponsor checkout route to parse the
JSON body as unknown, catch malformed JSON, and return the invalid-link response
with HTTP 400. Narrow nullable or non-object bodies with a type guard, extract
an optional mediaKitToken safely, and pass the narrowed token to isUuid and the
action without dereferencing null.

Source: Coding guidelines

Comment thread app/g/[slug]/page.tsx
Comment on lines +118 to +120
new Intl.DateTimeFormat('en-US', {
hour: 'numeric', hour12: false, timeZone: property.timezone || FALLBACK_TIMEZONE,
}).format(new Date())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect: a database constraint or one validated writer path for timezone values.
rg -n -C 3 'timezone|time_zone' \
  supabase/migrations types/database.ts app lib unit || true

Repository: smj1860/fieldstay

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate pages =="
for f in app/g/\[slug\]/page.tsx app/g/b/\[token\]/page.tsx; do
  echo "--- $f"
  wc -l "$f"
  sed -n '1,230p' "$f" | nl -ba
done

echo "== Timezone helper =="
wc -l lib/utils/timezone.ts
sed -n '1,190p' lib/utils/timezone.ts | nl -ba

echo "== Writer/usages near upsert / normalize / verification =="
sed -n '1,170p' lib/properties/normalize.ts | nl -ba
sed -n '1,150p' lib/properties/upsert-normalized.ts | nl -ba
sed -n '1,90p' lib/tenancy/verify.ts | nl -ba

echo "== Intl invalid tz behavior probe =="
node - <<'JS'
const vals = ['America/Chicago', null, '', 'XYZ', '-0500'];
for (const tz of vals) {
  const tzOrFallback = tz || 'America/New_York';
  console.log(JSON.stringify({ tz, tzOrFallback }), ':');
  try {
    console.log('  Intl DateTimeFormat', new Intl.DateTimeFormat('en-US', { timeZone: tzOrFallback, timeZoneName: 'long' }).format(new Date()));
  } catch (e) {
    console.log('  ERROR', e.name + ': ' + e.message);
  }
}
JS

echo "== Exact timezone column assignments in migrations excluding comments =="
rg -n "CREATE|ALTER TABLE.*properties|timezone\s*TEXT|timezone\s*VARCHAR|SET\sDEFAULT|CHECK|properties\.timezone|properties\.(timezone)" supabase/migrations --glob '*.sql' || true

Repository: smj1860/fieldstay

Length of output: 269


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate pages first 220 lines =="
for f in 'app/g/[slug]/page.tsx' 'app/g/b/[token]/page.tsx'; do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f" | sed -n '1,220p'
done

echo "== Timezone helper first 210 lines =="
wc -l lib/utils/timezone.ts
cat -n lib/utils/timezone.ts | sed -n '1,210p'

echo "== Writer/usages normalized timezone first 200 lines =="
for f in lib/properties/normalize.ts lib/properties/upsert-normalized.ts lib/tenancy/verify.ts; do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f" | sed -n '1,180p'
done

echo "== Intl invalid tz behavior probe =="
node - <<'JS'
const vals = ['America/Chicago', null, '', 'XYZ', '-0500'];
for (const tz of vals) {
  const tzOrFallback = tz || 'America/New_ York';
  const tzOrFallbackFix = tz || 'America/New_York';
  console.log(JSON.stringify({ tz, tzOrFallbackFix, locale: 'en-US', options: { hour: 'numeric', hour12: false, timeZone: tzOrFallbackFix } }));
  try {
    const formatter = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: tzOrFallbackFix });
    console.log('  result:', formatter.format(new Date()));
  } catch (e) {
    console.log('  ERROR', e.name + ': ' + e.message);
  }
}

console.log('computeStay fallback:');
const computeInputs = ['2026-08-11', '2026-08-11', 'XYZ'];
const today = new Intl.DateTimeFormat('en-CA', { timeZone: computeInputs[2] || 'America/New_York' }).format(new Date());
console.log(today);
JS

echo "== Exact timezone column clauses in migrations =="
rg -n -i "timezone\s*TEXT|timezone\s*VARCHAR|properties\.timezone|ALTER TABLE public\.properties|CREATE TABLE public\.properties|CHECK.*timezone|pg_timezone_names" supabase/migrations unit --glob '*.sql' unit --glob '*.ts' | sed -n '1,220p' || true

Repository: smj1860/fieldstay

Length of output: 40916


Avoid passing raw properties.timezone directly into Intl.DateTimeFormat.

Database schema only documents properties.timezone as an IANA identifier. If the value is malformed, these Intl calls throw RangeError: app/g/[slug]/page.tsx#118-L120, app/g/b/[token]/page.tsx#36-L42, and app/g/b/[token]/page.tsx#154-L157. Use a shared validation/fallback helper before each Intl call.

📍 Affects 2 files
  • app/g/[slug]/page.tsx#L118-L120 (this comment)
  • app/g/b/[token]/page.tsx#L36-L42
  • app/g/b/[token]/page.tsx#L154-L157
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/g/`[slug]/page.tsx around lines 118 - 120, Introduce or reuse a shared
helper that validates an IANA timezone and falls back to FALLBACK_TIMEZONE when
invalid, then pass its result to every affected Intl.DateTimeFormat call:
app/g/[slug]/page.tsx lines 118-120, app/g/b/[token]/page.tsx lines 36-42, and
app/g/b/[token]/page.tsx lines 154-157. Update each site to use the helper
rather than passing properties.timezone directly.

Source: MCP tools

import { createServiceClient } from '@/lib/supabase/server'
import { getActiveSponsorCount } from '@/lib/guidebook/helpers'
import { logAuditEvent } from '@/lib/audit'
import { reportError } from '@/lib/observability/report-error'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
for f in \
  "lib/inngest/functions/guidebook-sponsor-activated.ts" \
  "lib/inngest/functions/guidebook-stay-extension-cron.ts" \
  "lib/inngest/functions/guidebook-stay-extension-handler.ts"
do
  if [ -f "$f" ]; then
    echo "--- $f ($(wc -l < "$f") lines) ---"
    sed -n '1,80p' "$f" | cat -n
  else
    echo "MISSING $f"
  fi
done

echo "== server-only usage in lib/inngest/functions =="
rg -n "import 'server-only'|createServiceClient|SupabaseClient|supabaseUrl|supabaseKey" lib/inngest/functions -g '*.ts' || true

echo "== server-only package/readiness checks =="
if [ -f package.json ]; then
  node -e "const p=require('./package.json'); console.log(JSON.stringify({next:p.dependencies?.next || p.devDependencies?.next}, null, 2))"
fi

Repository: smj1860/fieldstay

Length of output: 50373


Mark these Inngest modules as server-only.

These modules run background operations and load server-side clients/implementations. Add import 'server-only' as the first import in each file.

  • lib/inngest/functions/guidebook-sponsor-activated.ts#L6
  • lib/inngest/functions/guidebook-stay-extension-cron.ts#L4
  • lib/inngest/functions/guidebook-stay-extension-handler.ts#L3
📍 Affects 3 files
  • lib/inngest/functions/guidebook-sponsor-activated.ts#L6-L6 (this comment)
  • lib/inngest/functions/guidebook-stay-extension-cron.ts#L4-L4
  • lib/inngest/functions/guidebook-stay-extension-handler.ts#L3-L8
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/inngest/functions/guidebook-sponsor-activated.ts` at line 6, Add `import
'server-only'` as the first import in
`lib/inngest/functions/guidebook-sponsor-activated.ts` (6-6),
`lib/inngest/functions/guidebook-stay-extension-cron.ts` (4-4), and
`lib/inngest/functions/guidebook-stay-extension-handler.ts` (3-8), before their
existing imports, to mark all three Inngest modules as server-only.

Source: Coding guidelines

Comment on lines +33 to +60
async function releaseSendClaim(
supabase: ClaimClient,
requestId: string,
column: 'sms_sent_at' | 'pm_notified_at',
orgId: string,
): Promise<void> {
// Spelled out rather than computed: a `{ [column]: null }` payload widens to
// an index signature the generated table types reject, and casting past that
// would also cast away the protection against naming a column that isn't
// there.
const patch = column === 'sms_sent_at'
? { sms_sent_at: null }
: { pm_notified_at: null }

const { error } = await supabase
.from('stay_extension_requests')
.update(patch)
.eq('id', requestId)
.eq('org_id', orgId)

if (error) {
console.error(`[guidebook-stay-extension-handler] ${column} claim release failed`, error.message)
reportError(error, {
site: 'inngest.guidebook-stay-extension-handler.claim-release',
orgId,
extra: { column },
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
fd -i 'guidebook-stay-extension-handler|stay_extension|guidebook' . || true

echo
echo "Target outline:"
ast-grep outline lib/inngest/functions/guidebook-stay-extension-handler.ts --view expanded || true

echo
echo "Target relevant lines:"
nl -ba lib/inngest/functions/guidebook-stay-extension-handler.ts | sed -n '1,220p'

echo
echo "Search claim helpers/usages:"
rg -n "releaseSendClaim|sendSMS|already_sent|already_notified|sms_sent_at|pm_notified_at|mark.*sent|claimed" lib/inngest/functions -S || true

echo
echo "DB/model references:"
rg -n "sms_sent_at|pm_notified_at|stay_extension_requests|organization_members|assigned_crew_member_id" -S --glob '!node_modules' . || true

Repository: smj1860/fieldstay

Length of output: 4162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Handler relevant source:"
python3 - <<'PY'
from pathlib import Path
p=Path('lib/inngest/functions/guidebook-stay-extension-handler.ts')
lines=p.read_text().splitlines()
for i in range(max(0,25), min(len(lines), 180)):
    print(f"{i+1:5d}\t{lines[i]}")
PY

echo
echo "Search claim helpers/usages in handler:"
python3 - <<'PY'
from pathlib import Path
import re
p=Path('lib/inngest/functions/guidebook-stay-extension-handler.ts')
text=p.read_text()
for name in ['releaseSendClaim','sendSMS','already_sent','already_notified','sms_sent_at','pm_notified_at','mark.*sent','claim']:
    for m in re.finditer(re.escape(name) if '\*' not in name else name, text, re.I | re.S):
        pass
    m=re.finditer(r'\b'+name+r'\b', text, re.I | re.S)
    for mm in m:
        line=text[:mm.start()].count('\n')+1
        line_max='\n'.join(text[:mm.start()].splitlines()[-4:]+[text[mm.start():text.find('\n', mm.start())]])
        print(f"-- {name} at line {line}: {line_max.replace(chr(10), ' ')}")
PY

echo
echo "Exact function call sites:"
rg -n "releaseSendClaim|already_sent|already_notified|sendSMS|sms_sent_at|pm_notified_at|claim" lib/inngest/functions/guidebook-stay-extension-handler.ts lib/inngest/functions -S || true

echo
echo "Remaining source after call sites:"
python3 - <<'PY'
from pathlib import Path
p=Path('lib/inngest/functions/guidebook-stay-extension-handler.ts')
lines=p.read_text().splitlines()
for i in range(180, len(lines)):
    print(f"{i+1:5d}\t{lines[i]}")
PY

echo
echo "Handler tests around release send claim:"
if [ -f unit/inngest/guidebook-stay-extension-handler.test.ts ]; then
  sed -n '1,280p' unit/inngest/guidebook-stay-extension-handler.test.ts
else
  echo "test file missing"
fi

echo
echo "Stay extension migrations/model references:"
rg -n "stay_extension_requests|sms_sent_at|pm_notified_at|is_claimed|claim" supabase/migrations lib/inngest/functions/guidebook-stay-extension-cron.ts unit/inngest/guidebook-stay-extension-cron.test.ts -S || true

Repository: smj1860/fieldstay

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Remaining handler test focused on releaseSendClaim behavior:"
python3 - <<'PY'
from pathlib import Path
p = Path('unit/inngest/guidebook-stay-extension-handler.test.ts')
for i, line in enumerate(p.read_text().splitlines(), 1):
    if (i >= 1 and i <= 220) or any(s in line for s in ['releaseSendClaim', 'claim release', 'claim-release', 'failed', 'already_sent', 'already_notified', 'skip']):
        print(f"{i:5d}\t{line}")
PY

echo
echo "Static verifier for releaseSendClaim control flow:"
python3 - <<'PY'
from pathlib import Path
import re

src = Path('lib/inngest/functions/guidebook-stay-extension-handler.ts').read_text()

checks = {
    'helper_discards_error_result': re.search(r'if\s*\(\s*error\s*\)\s*\{[^}]*console\.error.*release.*failed[^}]*reportError[^{}]*(?:reportError[^{}]*|else[^{}]*)?\}', src, re.S),
    'helper_returns_void_after_error': bool(re.search(r'async function releaseSendClaim\([^)]*\)\s*:\s*Promise<void>\s*\{[^\}]*if\s*\(\s*error\s*\)[^\{]*\{[^\}]*reportError[^\}]*\}\s*\}', src, re.S)),
    'update_uses_is_is_null_in_calls': 'releaseSendClaim' in src and 'is(' in src,
    'caller_rethrows_send_failure_after_releas': bool(re.search(r"catch\s*\([^)]*\)\s*\{[^\}]*releaseSendClaim[^\}]*throw\s+err", src, re.S)),
    'caller_releases_claim_on_sent_false': bool(re.search(r"if\s*\(\s*!\s*result\.sent\s*\)[^\}]*releaseSendClaim", src, re.S)),
    'claim_uses_is_null_guard': bool(re.search(r"\.update\(\s*\{\s*\w+_sent_at\s*:\s*new\s+Date\(\)\.toISOString\(\)\s*\}\)\s*\n\s*\.eq\('id',\s*requestId\)\s*\n\s*\.eq\('org_id',\s*orgId\)\s*\.is\('\w+_sent_at',\s*null\)", src, re.S)),
    'claim_null_returns_already_sent': bool(re.search(r"if\s*\(\s*!\s*claimed\s*\)\s*return\s*\{\s*skipped:\s*'already_sent'\s*\}", src, re.S)),
}

for name, value in checks.items():
    print(f"{name}: {value}")

if not checks['helper_returns_void_after_error']:
    print("MISSING: return statement or equivalent early termination in helper after error branch")
PY

Repository: smj1860/fieldstay

Length of output: 12688


Do not leave failed SMS claims permanent.

releaseSendClaim() logs release failures but returns successfully. When the update for sms_sent_at or pm_notified_at fails, the claim remains set, and the next retry hits the .is('<column>', null) guard, reads no row, and returns already_sent/already_notified without delivering the non-idempotent SMS. Make release failures durable and recoverable, e.g. throw or persist a lease, then add coverage that covers a failed claim-release update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/inngest/functions/guidebook-stay-extension-handler.ts` around lines 33 -
60, Update releaseSendClaim so a failed claim-release update is propagated
instead of returning successfully, ensuring callers can retry the non-idempotent
SMS or notification delivery; preserve the existing logging and reporting. Trace
the callers of releaseSendClaim and handle the propagated failure consistently,
then add coverage for an update failure that verifies the claim is recoverable
and delivery is retried rather than reported as already sent or already
notified.

Comment on lines +307 to +313
if (stampError) {
console.error('[guidebook-stay-extension-handler] pm_notified_at stamp failed', stampError.message)
reportError(stampError, {
site: 'inngest.guidebook-stay-extension-handler.pm-email-stamp',
orgId,
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching guidebook stay extension handler:"
fd -i 'guidebook-stay-extension-handler\.ts' . || true

file="$(fd -i 'guidebook-stay-extension-handler\.ts' . | head -n1 || true)"
if [ -n "${file:-}" ]; then
  echo
  echo "Outline:"
  ast-grep outline "$file" || true
  echo
  echo "Relevant lines 260-340:"
  sed -n '260,340p' "$file" | nl -ba -v260
  echo
  echo "Search for idempotency and step context:"
  rg -n "pm_notified_at|resend|inngest|step|idempot|notify|Notified|notified" "$file" || true
fi

Repository: smj1860/fieldstay

Length of output: 499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="./lib/inngest/functions/guidebook-stay-extension-handler.ts"

echo "Line count:"
wc -l "$file"

echo
echo "Relevant lines 260-340:"
sed -n '260,340p' "$file"

echo
echo "Search for idempotency and step context in file:"
grep -nE "pm_notified_at|resend|InngestEvent|step|idempot|notify|Notified|notified|sendEmail" "$file" || true

echo
echo "Nearby function definition:"
sed -n '1,80p' "$file"
sed -n '160,230p' "$file"

Repository: smj1860/fieldstay

Length of output: 12055


🌐 Web query:

Inngest createFunction step.run errors throw retry function semantics

💡 Result:

In Inngest, the step.run function acts as a checkpointed, retriable unit of work [1]. Its error handling and retry semantics are defined by the following principles: Automatic Retries When code inside a step.run throws an error, Inngest automatically catches it and triggers a retry for that specific step [2][3]. Each step.run has its own independent retry counter [4][5]. If a step is configured with a certain number of retries, that limit applies individually to that step, not as a shared pool across the entire function [4][6]. Error Handling and Exhaustion - During Retry: If a step fails, Inngest retries the operation, potentially with exponential backoff [4]. Successfully completed steps before the failed one are persisted and not re-executed, allowing the function to resume from the point of failure [2][1]. - Exhaustion: If a step exhausts all its retry attempts, it will throw an error (a StepError in many contexts) back into your main function handler [2][7][8]. At this point, the failure is no longer automatically retried, and you can catch it using standard language features like try/catch to perform custom logic, such as rollbacks or graceful degradation [2][8][3]. - Unhandled Failures: If a StepError is not caught by your code, it bubbles up to the function level, causing the entire function run to be marked as failed in the Inngest UI [8][3]. Customizing Retry Behavior - Non-Retriable Errors: You can throw a NonRetriableError to bypass remaining retries immediately, marking the step as failed without further attempts [4][7]. - Custom Delays: You can use RetryAfterError to specify a duration to wait before the next retry attempt [7]. - Attempt Tracking: The attempt count (zero-indexed) is available in the function's context, allowing you to alter logic based on how many times a step has been attempted [4][7]. In summary, step.run handles transient errors by automatically retrying until success or exhaustion, at which point it throws an error that your application must explicitly handle if you wish to prevent the function from failing entirely [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search Inngest function declarations and retry settings in tracked files:"
grep -RInE "inngest\.createFunction|retries:|guidebook-stay-extension-handler" --glob '!**/node_modules/**' . || true

echo
echo "Search package Inngest version/config:"
for f in package.json pnpm-lock.yaml package-lock.json yarn.lock bun.lockb; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    case "$f" in
      package.json) grep -E '"`@inngest/`(sdk|api)"|`@inngest/`(sdk|api)"' "$f" || true ;;
      *) grep -E "`@inngest/`(sdk|api)@|\"`@inngest/`(sdk|api)\"" "$f" | head -n 40 || true ;;
    esac
  fi
done

Repository: smj1860/fieldstay

Length of output: 431


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search Inngest function declarations and retry settings in tracked files:"
grep -RInE "inngest\.createFunction|retries:|guidebook-stay-extension-handler" "$(git ls-files | grep -v 'node_modules/')" || true

echo
echo "Search package Inngest version/config:"
for f in package.json pnpm-lock.yaml package-lock.json yarn.lock bun.lockb; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    grep -Ei "`@inngest/`(sdk|api)|\"`@inngest/`(sdk|api)\"" "$f" | head -n 60 || true
  fi
done

Repository: smj1860/fieldstay

Length of output: 50373


Throw when the PM email notification stamp fails.

step.run() treats an error as a retryable step failure, but this path converts a failed pm_notified_at update into nnotified: true. Return with { notified: true } only after the write succeeds, or throw the logged stamp error so Inngest retries the step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/inngest/functions/guidebook-stay-extension-handler.ts` around lines 307 -
313, Update the pm_notified_at stamp handling in the guidebook stay extension
handler so a stampError is thrown after logging and reporting it, allowing
step.run() to retry the failed step. Only return { notified: true } after the
stamp succeeds; do not convert a failed write into a successful notification
result.

Comment thread lib/utils/time-of-day.ts
Comment on lines +19 to +27
export function formatTime12h(time: string | null | undefined): string | null {
if (!time) return null
const [hourStr, minuteStr] = time.split(':')
const hour = Number(hourStr)
const minute = Number(minuteStr)
if (Number.isNaN(hour) || Number.isNaN(minute)) return null
const period = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 === 0 ? 12 : hour % 12
return `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'time-of-day\.ts$' . || true

echo "== file content =="
if [ -f lib/utils/time-of-day.ts ]; then
  cat -n lib/utils/time-of-day.ts
fi

echo "== usages =="
rg -n "formatTime12h|parse.*time|time-of-day" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo "== postgresql 24:00:00 docs via web =="
python3 - <<'PY'
import urllib.request
for url in [
  "https://www.postgresql.org/docs/current/datatype-datetime.html",
  "https://www.postgresql.org/docs/16/datatype-datetime.html",
]:
    try:
        with urllib.request.urlopen(url, timeout=10) as r:
            s=r.read(200000).decode('utf-8', errors='ignore')
        print("FOUND", url, "24:00" in s.lower())
    except Exception as e:
        print("ERROR", url, e)
PY

echo "== deterministic parser behavior probe =="
node - <<'JS'
function formatTime12h(time) {
  if (!time) return null
  const [hourStr, minuteStr] = time.split(':')
  const hour = Number(hourStr)
  const minute = Number(minuteStr)
  if (Number.isNaN(hour) || Number.isNaN(minute)) return null
  const period = hour >= 12 ? 'PM' : 'AM'
  const displayHour = hour % 12 === 0 ? 12 : hour % 12
  return `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`
}
for (const input of ["24:00:00", "25:99:00", "12:30:garbage", "0::30", "24:00", "0:0", "12:00", "-1:00:00", "24:01"]) {
  console.log(JSON.stringify(input), "=>", JSON.stringify(formatTime12h(input)))
}
JS

Repository: smj1860/fieldstay

Length of output: 3902


🌐 Web query:

PostgreSQL time 24:00:00 valid

💡 Result:

Yes, '24:00:00' is a valid input for the PostgreSQL time data type [1][2][3]. PostgreSQL defines the valid range for the time data type as '00:00:00' to '24:00:00' inclusive [2][3][4]. While '24:00:00' is accepted as a valid input, any value greater than '24:00:00' (such as '24:00:00.01' or '25:00:00') will result in an "out of range" error [1]. This behavior allows for proper handling of rounding [5]. For example, a time value like '23:59:59.9999999' will be rounded up to '24:00:00' [1][5]. Note that '24:00:00' is treated as distinct from '00:00:00' by PostgreSQL; they are not equivalent in value [2].

Citations:


🌐 Web query:

PostgreSQL time without time zone 24:00:00

💡 Result:

In PostgreSQL, the time without time zone data type supports values in the range from 00:00:00 to 24:00:00 inclusive [1][2][3]. While 24:00:00 is a valid input for the time data type, it is important to note that many external applications, libraries (such as Python's datetime.time), and client drivers may not support the 24:00:00 representation, as they often expect a standard 24-hour clock range of 00:00:00 to 23:59:59.999999 [4]. If you encounter issues when retrieving this value, you can normalize it within a PostgreSQL query using a CASE expression or similar logic [4]: SELECT CASE WHEN t = '24:00:00'::time THEN '00:00:00'::time ELSE t END FROM your_table; Be aware that 24:00:00 and 00:00:00 are technically distinct representations within the database, and their treatment in calculations or conversions should be handled according to your application's requirements [5][4].

Citations:


Validate the full time string before formatting.

Reject values outside 00:00:0023:59:59, or handle 24:00:00 explicitly, because this parser also accepts malformed inputs like 25:99:00, 12:30:garbage, and -1:00:00 and formats them as if valid.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/utils/time-of-day.ts` around lines 19 - 27, Update formatTime12h to
validate the complete time string before formatting, including hours, minutes,
and optional seconds, and return null for malformed or out-of-range values such
as 25:99:00, 12:30:garbage, and -1:00:00. Support 00:00:00–23:59:59, and
explicitly handle 24:00:00 according to the intended behavior rather than
allowing it through implicitly.

Source: MCP tools

Comment thread lib/validation/uuid.ts
Comment on lines +38 to +40
/** True when `value` is a string Postgres will accept for a `uuid` column. */
export function isUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_RE.test(value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repo files matching uuid validation:"
fd -a 'uuid\.ts$' . || true

echo
echo "Target file with line numbers:"
if [ -f lib/validation/uuid.ts ]; then
  wc -l lib/validation/uuid.ts
  cat -n lib/validation/uuid.ts
else
  echo "lib/validation/uuid.ts not found"
fi

echo
echo "Search for UUID_RE / isUuid usages:"
rg -n "UUID_RE|isUuid" . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo
echo "PostgreSQL uuid formats from docs in memory? No - fetch docs excerpt only if available:"
python3 - <<'PY'
import re, urllib.request, urllib.error
for url in [
  "https://www.postgresql.org/docs/current/datatype-uuid.html",
]:
    try:
        with urllib.request.urlopen(url, timeout=20) as r:
            body = r.read().decode('utf-8', errors='ignore')
        print("Found URL", url, "bytes", len(body))
        # Print compacted relevant paragraphs containing valid UUID textual representation examples.
        for line in body.splitlines():
            if "valid textual representation" in line or "canonical PostgreSQL uses" in line or "lower-case" in line or "spaces or braces" in line:
                print(line.strip())
        break
    except Exception as e:
        print("Could not fetch URL", url, str(e))
PY

Repository: smj1860/fieldstay

Length of output: 6101


🌐 Web query:

PostgreSQL datatype uuid valid textual representation brace-wrapped compact alternate hyphen forms

💡 Result:

In PostgreSQL, the uuid data type stores a 128-bit Universally Unique Identifier [1]. While the database always outputs UUIDs in the standard textual representation—consisting of 32 lower-case hexadecimal digits separated by hyphens in an 8-4-4-4-12 group format—it accepts several alternative forms for input [1][2]. Acceptable input formats include: - Standard canonical form (e.g., a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11) [1]. - Upper-case hexadecimal digits [1]. - The standard format enclosed in braces (e.g., {a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}) [1]. - Compact forms omitting some or all hyphens (e.g., a0eebc999c0b4ef8bb6d6bb9bd380a11) [1]. - Forms with hyphens added after any group of four digits (e.g., a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11 or {a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11}) [1][2]. Regardless of the input format used, PostgreSQL internally processes these into its canonical 128-bit representation and will return them in the standard 8-4-4-4-12 hyphenated format upon retrieval [1][2].

Citations:


Fix the isUuid JSDoc contract.

UUID_RE only accepts canonical 8-4-4-4-12 UUID strings, but the JSDoc claims it accepts every string PostgreSQL accepts for uuid. PostgreSQL also accepts brace-wrapped, compact, and alternate-hyphen forms.

Suggested contract correction
-/** True when `value` is a string Postgres will accept for a `uuid` column. */
+/** True when `value` is a canonical 8-4-4-4-12 UUID string. */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** True when `value` is a string Postgres will accept for a `uuid` column. */
export function isUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_RE.test(value)
/** True when `value` is a canonical 8-4-4-4-12 UUID string. */
export function isUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_RE.test(value)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/validation/uuid.ts` around lines 38 - 40, Update the JSDoc for isUuid to
describe that it recognizes only canonical 8-4-4-4-12 UUID strings matching
UUID_RE, rather than all UUID representations accepted by PostgreSQL; leave the
implementation unchanged.

Source: MCP tools

Comment on lines +35 to +41
CREATE UNIQUE INDEX IF NOT EXISTS uniq_guidebook_offer_redemptions_sponsor_booking_day
ON guidebook_offer_redemptions (
sponsor_id,
booking_id,
((opened_at AT TIME ZONE 'UTC')::date)
)
WHERE booking_id IS NOT NULL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'supabase/migrations/20260807150000_guidebook_offer_redemptions_daily_dedup.sql|supabase/migrations/.*guidebook_offer_redemptions.*\.sql' || true

echo
echo "Target migration:"
if [ -f supabase/migrations/20260807150000_guidebook_offer_redemptions_daily_dedup.sql ]; then
  cat -n supabase/migrations/20260807150000_guidebook_offer_redemptions_daily_dedup.sql
fi

echo
echo "Search for guidebook_offer_redemptions table/index definitions:"
rg -n "guidebook_offer_redemptions|uniq_guidebook_offer_redemptions_sponsor_booking_day|create unique index|unique .*guidebook_offer_redemptions" supabase/migrations || true

Repository: smj1860/fieldstay

Length of output: 6097


Deduplicate historical rows after adding open_count.

The migration creates only the unique index on (sponsor_id, booking_id, UTC date). Existing rows with the same key will make CREATE UNIQUE INDEX fail before it adds. Add open_count first, aggregate duplicate UTC-day rows into one retained row, delete the redundant rows, then create the index.

🧰 Tools
🪛 Squawk (2.61.0)

[warning] 35-41: During normal index creation, table updates are blocked, but reads are still allowed. Use concurrently to avoid blocking writes.

(require-concurrent-index-creation)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@supabase/migrations/20260807150000_guidebook_offer_redemptions_daily_dedup.sql`
around lines 35 - 41, Update the migration before creating
uniq_guidebook_offer_redemptions_sponsor_booking_day: add the open_count column,
identify duplicate rows by sponsor_id, booking_id, and the UTC date derived from
opened_at, aggregate their open counts into one retained row, and delete
redundant rows. Then create the unique index so historical duplicates cannot
cause index creation to fail.

Comment on lines 54 to 60
const from = vi.fn((table: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chain: any = {}
const eqArgs: [string, unknown][] = []
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.eq = vi.fn((col: string, val: unknown) => { eqArgs.push([col, val]); return chain })
chain.maybeSingle = vi.fn(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace any with a typed query-chain mock.

chain: any disables type checking for this test double. Define the required select, eq, maybeSingle, and insert members in a concrete local type.

As per coding guidelines: “Use concrete TypeScript types, never any or as any.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unit/route-handlers/guidebook-redeem.test.ts` around lines 54 - 60, Replace
the any-typed chain in the from mock with a concrete local query-chain type
declaring select, eq, maybeSingle, and insert, and type the chain implementation
accordingly. Preserve the existing fluent return behavior and recorded eq
arguments without using any or as any.

Source: Coding guidelines

Both semgrep findings were mine, and both were new-vs-base because my previous
commit touched those lines. Fixed at the site, not silenced.

`guidebook-sms-morning-cron.ts` — every read in the per-guest send now unwraps
(4 -> 0 in the error-handling ratchet, entry deleted). The opt-in one is the
one that mattered: `{ data: optin }` collapsed "this guest opted out" and "the
consent read failed" into the same null, and BOTH ended at `return false` — so
a transient failure silently suppressed the guest's message with nothing
logged and no retry, while an opt-out is correctly final. The two sponsor
reads had the same shape one layer down: a failed lookup produced an empty
pool, indistinguishable from an org that simply has no sponsor in that slot.
The property read also gains `.eq('org_id', orgId)`.

`guidebook-stay-extension-cron.ts` — the per-org bookings read is now
paginated via fetchAllRows (removing it from the unbounded-select baseline
entirely). One org's checkouts on one exact date is ~properties-per-org, so
this could not realistically hit the 1000-row cap — but "realistically" was
doing the work in that sentence, and that is precisely the reasoning that left
eight platform-wide crons silently truncated until the 2026-07-30 audit.
fetchAllRows costs an extra round trip only once the set actually exceeds a
page, and removes the assumption rather than restating it.

Sonar (unit/inngest/guidebook-sponsor-activated.test.ts L105): the tenant-scope
assertion compared two `.filter(...).length` expressions inside expect(), which
toHaveLength cannot express. Restructured into named counts, which reads better
anyway — and re-canaried: dropping `.eq('org_id', ...)` from the pre-read still
fails it.

Ratchets updated in the shrink-only direction only: error-handling
morning-cron 4 -> 0, unbounded-select entry removed, semgrep
read-without-error 157 -> 153 and unbounded-select-org-scoped 98 -> 97. The
n+1 EXCEPTIONS line reference moved 87 -> 98 with the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smj1860 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

…indings

My previous commit renamed those two reads while unwrapping them, which made
two LATENT findings new-vs-base under a different pair of rules. I traded one
pair of findings for another rather than fixing anything — the durable answer
is to actually bound the reads, which is what this does.

All three sponsor-pool reads (two morning, one evening) now go through
fetchAllRows. guidebook_sponsors is capped at SIX rows per org by the schema
itself — slot_number CHECK 1..6 plus UNIQUE(org_id, slot_number) — so this
drains in exactly one request and the pagination costs nothing at current
scale. A .limit() would have been cheaper to write and would start truncating
silently the day that ceiling moves; fetchAllRows throws instead.

Also finishes the evening cron's opt-in and property reads, which had the same
defect as the morning twin's and which I had left inconsistent: `{ data: optin
}` collapsed "this guest opted out" and "the consent read failed" into the same
null, and both ended at `return false` — opting out is final, a failed read
must retry. Both crons now hold at 0 in the error-handling ratchet, along with
both stay-extension files.

Two shared-helper extractions fell out of typing this honestly rather than
casting past it:

- SPONSOR_POOL_COLUMNS + SponsorPoolRow (lib/sms/pick-nearest-sponsor.ts). The
  reads select a SUBSET of guidebook_sponsors; asserting the result was a full
  GuidebookSponsor was a cast that happened to compile, not a fact. One column
  list for all three so the pools cannot drift.
- pickNearestSponsor is now generic over anything carrying lat/lng — the only
  fields it reads — so a narrow SELECT no longer has to cast its way in.
- asOfferType (lib/guidebook/offer.ts) narrows offer_type, a TEXT column with a
  CHECK that PostgREST returns as bare string. Same pattern and reason as
  asExtensionContactMethod; unrecognised input falls back to 'none', which
  formatOffer already treats as "no offer" — a sponsor line omitted rather than
  rendered from a value nothing understands.

Ratchets moved shrink-only: read-without-error 153 -> 150,
unbounded-select-in-list 34 -> 32, unbounded-select-org-scoped 97 -> 96, both
SMS crons deleted from the unbounded-select baseline, evening cron 3 -> 0 in
error-handling.

Process fix: semgrep is now installed here, so I ran the exact two gates CI
runs — `--config .semgrep/ratchet.yml --baseline-commit <base> --error` and
`--config .semgrep/chokepoints.yml --error` — both clean. That is what I
should have been able to do before the last two pushes; reading the rules and
reasoning about which lines moved was not a substitute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smj1860 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

…rship check

The Sonar finding (use a Set, not an array, for existence checks) is not worth
acting on by itself — `.includes()` over five frozen strings is if anything
faster than a Set, and this runs once per SMS. But it pointed at a spot with a
real weakness, which I introduced two commits ago.

The array had no compile-time link back to GuidebookOfferType. Add a value to
the union and the array silently would not contain it, so asOfferType() would
downgrade it to 'none' and every sponsor using that offer type would render no
offer at all — with nothing failing anywhere.

That is the nasty shape of it: the fallback that makes UNKNOWN input safe is
exactly what makes a MISSING entry invisible. A forgotten member looks
identical to a genuinely unrecognised value, so no runtime assertion can tell
them apart. The list has to be checked by the compiler rather than by whoever
remembers to update it.

Now a Record<GuidebookOfferType, true>, which TypeScript checks for
exhaustiveness. Verified by adding 'bogo' to the union: tsc fails at offer.ts
naming the missing property. With the previous array it stayed green.

The lookup Set falls out of that (Object.keys), which incidentally satisfies
Sonar — but the Record is the point, not the Set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smj1860 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

@smj1860
smj1860 merged commit e8d2f2a into main Aug 7, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants