Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ NEXT_PUBLIC_APP_URL=https://app.fieldstay.app
# Dev override (set to http://localhost:3000 in .env.local)
# NEXT_PUBLIC_APP_URL=http://localhost:3000

# Crew Sync v2 (docs/CREW_SYNC_V2_PHASES.md Phase 3): when 'true', the crew
# PWA replaces its postgres_changes subscriptions with one private Realtime
# Broadcast channel (crew:{user_id}) + debounced delta pulls + a 5-minute
# safety poll. Ships dormant — leave unset/false until the Phase 5 rollout.
# Build-time flag (NEXT_PUBLIC_ = inlined at build), not a runtime toggle.
# NEXT_PUBLIC_CREW_SYNC_V2=true

# ------------------------------------------------------------
# KROGER (Grocery Cart Integration)
# From: https://developer.kroger.com/manage/
Expand Down
16 changes: 10 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,15 @@ jobs:
# ── Database invariants (structural enforcement Tier 3) ──────────────────
# Checks the live schema for what no code-side check can see: RLS enabled
# on every public table, no unexpected policy-less (deny-all) tables, a
# covering index on every FK column, zero anon table grants. Runs against
# the DEDICATED E2E project (same secrets as the e2e job — CI never holds
# production credentials); both projects receive every migration, so
# schema invariants verified there hold for production by construction.
# Self-disarming like the e2e job: secrets absent → warning annotation,
# job passes. See scripts/check-db-invariants.mjs for the check details.
# covering index on every FK column, zero anon table grants, and (the
# former enforcement leftover, closed by check-type-drift.mjs) that
# types/database.ts's enums/tables/columns still match the live schema.
# Runs against the DEDICATED E2E project (same secrets as the e2e job —
# CI never holds production credentials); both projects receive every
# migration, so invariants verified there hold for production by
# construction. Self-disarming like the e2e job: secrets absent ->
# warning annotation, job passes. See scripts/check-db-invariants.mjs and
# scripts/check-type-drift.mjs for the check details.
db-invariants:
runs-on: ubuntu-latest
env:
Expand All @@ -141,3 +144,4 @@ jobs:
with:
node-version: 22
- run: node scripts/check-db-invariants.mjs
- run: node scripts/check-type-drift.mjs
24 changes: 23 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ This flag is `false` until 10DLC campaign verification clears. Never send
to guests without this gate in place. The flag lives in `lib/sms/telnyx.ts` —
check that any new SMS-sending code respects it.

The daily nudge budget check in `lib/sms/telnyx.ts` (`claimNudgeBudgetSlot`)
fails CLOSED on a Redis error — the nudge is skipped, not sent — unlike the
abuse-rate limiters in `lib/rate-limit.ts`/`proxy.ts`, which deliberately
fail open; a spend ceiling must not disappear during an outage.

---

## The Table That Breaks Everything If Wrong
Expand Down Expand Up @@ -1058,7 +1063,8 @@ item below" as part of the definition of done for any non-trivial change.

Conventions in this file are enforced in code wherever they can be, so
following them stops being a memory test. Four layers, checked in CI via
`npm run lint` and `vitest run` (plus the `db-invariants` CI job for layer 4):
`npm run lint` and `vitest run` (plus the `db-invariants` CI job for layer
4, which runs two scripts):

1. **ESLint rules** (`eslint.config.mjs`, the "Structural enforcement"
config block) — AST-level bans scoped to `app/`, `lib/`, `components/`:
Expand Down Expand Up @@ -1097,6 +1103,22 @@ following them stops being a memory test. Four layers, checked in CI via
unauthenticated). Self-disarms with a warning when the E2E secrets are
absent, same as the e2e job.

**Type drift gate** (`scripts/check-type-drift.mjs`, same
`db-invariants` CI job, run as its own step after
`check-db-invariants.mjs`) — diffs `types/database.ts` against the live
schema via `public.db_type_shape_report()`: every Postgres enum's labels
vs. its TS union (`ENUM_MAP`), every `public` table vs.
`Database.public.Tables` (`TABLE_ALLOWLIST` for the deliberately
unmodeled — `platform_admins`, `system_job_runs`,
`wo_number_counters`), and column presence for every mapped table
(`COLUMN_ALLOWLIST` for deliberate mismatches — e.g. the deprecated
`work_orders.assigned_crew_id`). Closes the exact class of bug that cost
real debugging time when the E2E project's `wo_status` enum silently
lacked `quote_requested` — see
`20260725043000_add_quote_requested_to_wo_status.sql`. Both allowlists
are shrink-only, same ratchet as `SERVICE_ROLE_ONLY_TABLES`. Self-disarms
the same way as the other two checks.

**The meta-rule: a new convention ships WITH its guardrail.** If a rule is
worth adding to this file, add its ESLint rule or `unit/guardrails/` test in
the same PR — and the CLAUDE.md prose for mechanically-checkable rules
Expand Down
9 changes: 9 additions & 0 deletions app/(dashboard)/bookings/bookings-calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ const SOURCE_LABELS: Record<BookingSource, string> = {
booking_com: 'Booking.com',
direct: 'Direct',
manual: 'Manual',
// 'ownerrez' on the booking_source enum predates
// mapOwnerRezChannelToSource() (lib/integrations/providers/ownerrez.ts),
// which normalizes every OwnerRez-synced booking into airbnb/vrbo/
// booking_com/direct/other by channel name — no current write path sets
// this value, but the enum label is live on both projects (see
// 20260616141406_add_ownerrez_booking_source.sql) so it must stay
// handled here rather than silently falling through to `undefined`.
ownerrez: 'OwnerRez',
other: 'Other',
}

Expand All @@ -83,6 +91,7 @@ const SOURCE_STYLE: Record<BookingSource, { bg: string; fg: string; border: stri
booking_com: { bg: 'var(--accent-blue-dim)', fg: 'var(--accent-blue)', border: 'var(--accent-blue)' },
direct: { bg: 'var(--accent-green-dim)', fg: 'var(--accent-green)', border: 'var(--accent-green)' },
manual: { bg: 'var(--accent-gold-dim)', fg: 'var(--accent-gold)', border: 'var(--accent-gold)' },
ownerrez: { bg: 'var(--bg-raised)', fg: 'var(--text-muted)', border: 'var(--border)' },
other: { bg: 'var(--bg-raised)', fg: 'var(--text-muted)', border: 'var(--border)' },
}

Expand Down
5 changes: 5 additions & 0 deletions app/(dashboard)/bookings/bookings-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
booking_com: 'Booking.com',
direct: 'Direct',
manual: 'Manual',
// See bookings-calendar.tsx's SOURCE_LABELS comment — 'ownerrez' is a live
// enum label (20260616141406_add_ownerrez_booking_source.sql) with no
// current write path, kept here so it doesn't fall through to `undefined`.
ownerrez: 'OwnerRez',
other: 'Other',
}

Expand All @@ -102,6 +106,7 @@
booking_com: 'blue',
direct: 'green',
manual: 'gold',
ownerrez: 'slate',
other: 'slate',
}

Expand Down Expand Up @@ -447,7 +452,7 @@

<form id="add-booking-form" action={action} className="space-y-4">
<div>
<label className="label">Property <RequiredMark /></label>

Check warning on line 455 in app/(dashboard)/bookings/bookings-client.tsx

View workflow job for this annotation

GitHub Actions / checks

A form label must be associated with a control
<select name="property_id" required className="input" defaultValue={initialPropertyId ?? ''}>
<option value="">Select property…</option>
{properties.map((p) => (
Expand All @@ -458,7 +463,7 @@

<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="label">Check-in <RequiredMark /></label>

Check warning on line 466 in app/(dashboard)/bookings/bookings-client.tsx

View workflow job for this annotation

GitHub Actions / checks

A form label must be associated with a control
<Input
name="checkin_date"
type="date"
Expand All @@ -469,18 +474,18 @@
/>
</div>
<div>
<label className="label">Check-out <RequiredMark /></label>

Check warning on line 477 in app/(dashboard)/bookings/bookings-client.tsx

View workflow job for this annotation

GitHub Actions / checks

A form label must be associated with a control
<Input name="checkout_date" type="date" required min={checkinVal || todayStr} />
</div>
</div>

<div>
<label className="label">Guest Name</label>

Check warning on line 483 in app/(dashboard)/bookings/bookings-client.tsx

View workflow job for this annotation

GitHub Actions / checks

A form label must be associated with a control
<Input name="guest_name" type="text" placeholder="Optional" />
</div>

<div>
<label className="label">Source</label>

Check warning on line 488 in app/(dashboard)/bookings/bookings-client.tsx

View workflow job for this annotation

GitHub Actions / checks

A form label must be associated with a control
<select name="source" className="input" defaultValue="direct">
<option value="direct">Direct Booking</option>
<option value="airbnb">Airbnb</option>
Expand All @@ -492,7 +497,7 @@
</div>

<div>
<label className="label">Notes</label>

Check warning on line 500 in app/(dashboard)/bookings/bookings-client.tsx

View workflow job for this annotation

GitHub Actions / checks

A form label must be associated with a control
<textarea
name="notes"
rows={2}
Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/support-inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export default async function SupportInboxPage() {
supabase
.from('crew_feedback')
.select(`
id, feedback_text, created_at,
id, feedback_text, submitted_at,
crew_members ( name ),
organizations ( name )
`)
.order('created_at', { ascending: false })
.order('submitted_at', { ascending: false })
.limit(50),
])

Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/support-inbox/support-inbox-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface MessageRow {
interface FeedbackRow {
id: string
feedback_text: string
created_at: string
submitted_at: string
crew_members: { name: string } | { name: string }[] | null
organizations: { name: string } | { name: string }[] | null
}
Expand Down Expand Up @@ -379,7 +379,7 @@ export function SupportInboxClient({
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}> · {orgName(f.organizations)}</span>
</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)', flexShrink: 0 }}>
{new Date(f.created_at).toLocaleDateString()}
{new Date(f.submitted_at).toLocaleDateString()}

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

Make the feedback date deterministic across SSR and hydration.

toLocaleDateString() uses the server and browser locale/timezone defaults, so this can display different dates and trigger a hydration mismatch. Pass an explicit locale/timezone (or format after mount).

Proposed fix
-                {new Date(f.submitted_at).toLocaleDateString()}
+                {new Date(f.submitted_at).toLocaleDateString('en-US', { timeZone: 'UTC' })}
📝 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
{new Date(f.submitted_at).toLocaleDateString()}
{new Date(f.submitted_at).toLocaleDateString('en-US', { timeZone: 'UTC' })}
🧰 Tools
🪛 React Doctor (0.7.6)

[error] 382-382: This can cause a hydration mismatch because toLocaleDateString() formats with the server's locale and timezone during server rendering but the user's in the browser. Format it in a post-mount useEffect, or pass an explicit locale and timeZone.

Format locale/timezone-dependent values in a post-mount useEffect + state, or pass an explicit locale and timeZone so the server and the browser render the same text. Only runs on SSR-capable projects.

(no-locale-format-in-render)

🤖 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/`(dashboard)/support-inbox/support-inbox-client.tsx at line 382, Update
the feedback date rendering in the support-inbox client to use an explicit
locale and timezone with toLocaleDateString, ensuring SSR and hydration produce
identical output while preserving the existing submitted_at value.

Source: Linters/SAST tools

</span>
</div>
<p style={{ fontSize: '13px', color: 'var(--text-primary)', whiteSpace: 'pre-wrap', margin: 0 }}>
Expand Down
11 changes: 9 additions & 2 deletions app/api/inngest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { dailyAssetHealth } from '@/lib/inngest/functions/cron/asse
import { dailyCommsRetention } from '@/lib/inngest/functions/cron/comms-retention'
import { dailyGuestPiiRetention } from '@/lib/inngest/functions/cron/guest-pii-retention'
import { auditRetentionCron } from '@/lib/inngest/functions/cron/audit-retention'
import { notificationsRetentionCron } from '@/lib/inngest/functions/cron/notifications-retention'
import { staleFeedAlert } from '@/lib/inngest/functions/cron/stale-feed-alert'
import { turnoverPriorityDecay } from '@/lib/inngest/functions/cron/turnover-priority-decay'
import { notificationDigest } from '@/lib/inngest/functions/cron/notification-digest'
Expand All @@ -34,7 +35,10 @@ import { ownerRezReconciliationCron } from '@/lib/inngest/functions/ownerrez/
import { ownerRezReconciliationHandler } from '@/lib/inngest/functions/ownerrez/reconciliation-handler'

// Hostaway integration
import { hostawayInitialSync } from '@/lib/inngest/functions/hostaway/initial-sync'
// Disabled — not ready for launch. Re-enable by uncommenting this import and
// the hostawayInitialSync entry in the serve() functions array below. Do not
// delete lib/inngest/functions/hostaway/initial-sync.ts.
// import { hostawayInitialSync } from '@/lib/inngest/functions/hostaway/initial-sync'

// Hospitable integration
import { hospInitialSync } from '@/lib/inngest/functions/hospitable/initial-sync'
Expand Down Expand Up @@ -180,6 +184,7 @@ export const { GET, POST, PUT } = serve({
dailyCommsRetention,
dailyGuestPiiRetention,
auditRetentionCron,
notificationsRetentionCron,
staleFeedAlert,
turnoverPriorityDecay,
notificationDigest,
Expand All @@ -200,7 +205,9 @@ export const { GET, POST, PUT } = serve({
ownerRezReconciliationHandler,

// Hostaway sync
hostawayInitialSync,
// Disabled — not ready for launch. Re-enable by uncommenting this line
// and the import above.
// hostawayInitialSync,

// Hospitable sync
hospInitialSync,
Expand Down
9 changes: 9 additions & 0 deletions app/api/integrations/[provider]/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { getProvider } from '@/lib/integrations/registry'
import { holdPendingOAuthCode } from '@/lib/integrations/vault'
import { finalizeIntegrationConnection } from '@/lib/integrations/finalize-connection'
import { logAuditEvent } from '@/lib/audit'
import { RateLimitError } from '@/lib/integrations/types'

export async function GET(
request: NextRequest,
Expand Down Expand Up @@ -214,6 +215,14 @@ export async function GET(
try {
tokenData = await providerAdapter.exchangeCodeForToken({ code, redirectUri })
} catch (err) {
// This runs outside any Inngest step — there's no retry mechanism to
// lean on here, so a rate limit gets its own clear reason instead of
// the generic failure message, telling the PM it's transient and to
// just try again shortly rather than suggesting something is broken.
if (err instanceof RateLimitError) {
console.warn(`[OAuth:${providerId}] Token exchange rate limited (retry after ${err.retryAfter}s)`)
return errorRedirect('rate_limited')
}
console.error(`[OAuth:${providerId}] Token exchange failed:`, err)
return errorRedirect('token_exchange_failed')
}
Expand Down
1 change: 1 addition & 0 deletions app/connect/error/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const REASON_MESSAGES: Record<string, string> = {
token_exchange_failed: 'We couldn’t complete the connection with the provider. Please try again.',
storage_failed: 'We connected successfully but couldn’t save the connection securely. Please try again.',
claim_failed: 'We couldn’t finish linking your connection to your new account. Please reconnect from Settings.',
rate_limited: 'This integration is temporarily rate-limited. Please wait a few minutes and try connecting again.',
}

export default async function ConnectErrorPage({
Expand Down
12 changes: 12 additions & 0 deletions app/connect/finish/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { claimPendingOAuthCode, cleanupExpiredPendingIntegrationArtifacts } from
import { finalizeIntegrationConnection } from '@/lib/integrations/finalize-connection'
import { logAuditEvent } from '@/lib/audit'
import { revalidatePath } from 'next/cache'
import { RateLimitError } from '@/lib/integrations/types'

export async function GET(request: NextRequest) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
Expand Down Expand Up @@ -98,6 +99,17 @@ export async function GET(request: NextRequest) {
try {
tokenData = await providerAdapter.exchangeCodeForToken({ code, redirectUri })
} catch (err) {
// This runs outside any Inngest step — there's no retry mechanism to
// lean on, and unlike the "code expired" case below, immediately
// bouncing through a fresh /connect flow would likely just hit the
// same rate limit again. Show a clear, actionable error instead.
if (err instanceof RateLimitError) {
console.warn(`[connect/finish] Token exchange rate limited for ${providerId} (retry after ${err.retryAfter}s)`)
const url = new URL('/connect/error', appUrl)
url.searchParams.set('provider', providerId)
url.searchParams.set('error', 'rate_limited')
return NextResponse.redirect(url)
}
// The code expired or was already used on the provider's side (signup —
// especially with email confirmation — can outlive a provider code's
// ~10 min lifetime). Restart the standard connect flow: the user is
Expand Down
Loading
Loading