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-searchValkey 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 five 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

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.

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.