Skip to content

Data flow

sequenceDiagram
    participant B as Browser
    participant SK as SvelteKit Server
    participant BA as Better Auth
    participant DB as PostgreSQL
    participant VK as Valkey
    participant MS as Meilisearch
    participant IP as imgproxy

    B->>SK: HTTP Request
    SK->>BA: Validate session cookie
    BA-->>SK: Session + User
    SK->>DB: Kysely query (property data)
    SK->>VK: GEOSEARCH (property search)
    SK->>MS: Location autocomplete
    SK->>IP: Sign image URLs (HMAC)
    SK-->>B: SSR HTML + hydration payload

hooks.server.ts validates the Better Auth session cookie on every request and populates event.locals.user / event.locals.session. Protected routes (/portal/*, /dashboard/*) check locals.user in +layout.server.ts.

  1. User enters a location → Meilisearch provides autocomplete suggestions.
  2. Map viewport changes → client sends bounds to server.
  3. Server calls @repo/redis-search → Valkey GEOSEARCH returns property IDs within the viewport.
  4. Additional filters (price, beds, type) are applied in-memory.
  5. Property IDs fetch full records from PostgreSQL via Kysely.
  6. Image URLs are HMAC-signed via imgproxy and returned.

The price filter is multi-unit aware — overlap check against ZIDX_PRICE + ZIDX_MAX_PRICE so any unit’s price falling within the range qualifies the property.

flowchart LR
    Upload["Client upload"] --> API["SvelteKit API route"]
    API --> S3["Cloudflare R2"]
    S3 --> imgproxy["imgproxy (HMAC)"]
    imgproxy --> T["Thumb 400×300"]
    imgproxy --> M["Medium 800×600"]
    imgproxy --> L["Large 1200×800"]
  • Uploads go through a SvelteKit API route to Cloudflare R2 via @aws-sdk/client-s3.
  • imgproxy serves images on-demand: resize, WebP conversion, quality optimization.
  • URLs are HMAC-signed server-side — attackers cannot manipulate dimensions or request arbitrary resources.

The standalone worker at apps/worker/ consumes nine queues:

QueueJobs
search-indexingsync-property (2s debounce, 3 retries) → Valkey (includes priceDropPercent from price_history); sync-agent, sync-team → Meilisearch
leadslead-ingestion (reliable Inquiry creation with DLQ to failed_lead_ingestions); sla-monitor (repeatable */15 * * * *)
alertsprice-drop-notify, status-change-notify (event-driven); saved-search-scan (repeatable */30 * * * *)
aigenerate-description (Gemini 2.5 Flash); one-release compatibility for legacy staging entries
ai-stagingResumable stage-room predictions through Replicate’s official openai/gpt-image-2 wrapper
image-sanitizesanitize-property-image (decode + re-encode a listing photo off the request path, promote its object, flip the row PENDING → READY; DLQ to failed_image_sanitize); image-sanitize-sweep (repeatable */5 * * * *)
billingtrial-scan (T-3d warnings + expiry, repeatable every 30 min); stripe-reconcile (daily Stripe ⇄ local cross-check + receipt pruning)
cdn-purgepurge-property-images — evicts a property’s Cache-Tag from the CDN edge after it leaves ACTIVE (15s delay past the image routes’ 10s status cache). No DLQ table: a lost purge degrades to “the stale copy expires”
storagedrain-deletion-outbox (repeatable * * * * *) — claims storage_deletion_outbox rows, re-checks the key against the table its prefix belongs to, deletes the object, stamps the row; orphan-sweep (repeatable 23 4 * * *) — lists the upload prefixes and reports objects nothing references. No DLQ table: the outbox row is the durable record

Every queue name (including the Bull Board registration) is suffixed with process.env.QUEUE_SUFFIX. Production leaves it empty; local dev sets -dev so jobs enqueued from the dev svelte-web don’t get stolen by deployed workers sharing the same Valkey.

Room staging is isolated from descriptions. ai-staging consumes payloads containing only { aiJobId }, uses that ID as the stable BullMQ ID, and reloads all canonical state from PostgreSQL. A startup/30-second reconciler redispatches missing PENDING, RETRYING, or cancel-requested work. AI_STAGING_CONCURRENCY and AI_STAGING_RATE_LIMIT_PER_MINUTE tune only staging; provider attempts are separately persisted and capped at two paid predictions per logical job.

Listing-photo uploads no longer decode inside the request (PRO-13). POST /api/property-images stores the raw bytes under pending/properties/{propertyId}/{uuid} — outside both hosts’ image-proxy ALLOWED_PREFIXES, so nothing there is servable — inserts the row processingStatus = PENDING, and enqueues a confirmed sanitize-property-image job; an unacknowledged enqueue marks the row FAILED('enqueue_failed') so the agent gets a Retry button rather than a permanently pending photo — and if that status write itself throws, the handler swallows it and leaves the row PENDING, where the 5-minute stale sweep picks it up instead (the response never fails after the row is committed). The worker’s final key is derived from the pending key, so a duplicate run overwrites instead of orphaning, and every status write is guarded on the expected processingStatus. Public readers gate on READY, including both search-index fetchers — the Valkey index stores image urls, so the filter has to be applied at index time. Full state machine and runbook: apps/worker/src/image-sanitize.FEATURE.md.

flowchart LR
    Mobile["Mobile (Expo + native iOS/Android)"] -->|"Cookie: header"| API["apps/api (Hono)"]
    API -->|tRPC| Service["@repo/api services"]
    API -->|Better Auth| Postgres["PostgreSQL"]
    Service --> Postgres
    Service --> Valkey["Valkey"]

The native iOS/Android apps talk to a single host (apps/api) which mounts both /api/auth/* (Better Auth) and /api/trpc/* (tRPC fetch adapter). They use a hand-written tRPC-over-HTTP client (SuperJSON envelope, no batching; response models generated from @repo/contract). Auth state lives as a cookie — in the Keychain (iOS) / EncryptedSharedPreferences (Android) — never a Bearer token. No CSRF token is sent: mobile requests carry no Origin and are exempt from the server’s Origin allowlist.

See Conventions → Mobile (Native) for the rationale and gotchas.