1 OAK MLS

Architecture Decisions

Architecture Decision Records (ADRs) documenting key technical decisions for the 1 OAK MLS Platform

Architecture Decision Records

Key technical decisions documented as ADRs. These capture the context, decision, and consequences for important architectural choices.


ADR-001: Bridge API Media — No $expand=Media

Status: Accepted (January 2026) Context: Listing photos were not syncing — MediaURL was always null when fetching property data from Bridge Interactive.

Decision: Remove $expand=Media from all Bridge API queries. Media is already embedded in the Property response as a Media array without needing expansion.

Consequences:

  • Media data (photos) syncs correctly with CloudFront CDN URLs
  • Simpler queries (no $expand parameter needed)
  • Must access photos via the Media array on each Property object, not as a separate resource

Confirmed by Bridge Interactive Support:

"Media on Bridge is returned as a Media array in the property resource not the Media resource."

See Sync Worker and Bridge API for implementation details.


ADR-002: Web App Modals — Custom Pattern, Not Radix Dialog

Status: Accepted (February 2026) Context: Radix Dialog components from @1oak/ui render with blank/invisible content in the web app's dark mode. The dialog content portals outside the themed context.

Decision: Use a custom modal implementation in the web app instead of @1oak/ui Dialog components. The admin app continues to use Radix Dialog normally.

Pattern:

// Custom modal pattern for web app
if (!open) return null

return (
  <div className="fixed inset-0 flex items-center justify-center" style={{ zIndex: 9999 }}>
    <div
      className="absolute inset-0"
      style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)', zIndex: 1 }}
      onClick={handleClose}
      aria-hidden="true"
    />
    <div
      role="dialog"
      aria-modal="true"
      className="relative w-full max-w-md mx-4 rounded-lg border border-border bg-background p-6 shadow-xl"
      style={{ zIndex: 2 }}
    >
      {/* Use semantic CSS variables: bg-background, text-foreground, border-border */}
    </div>
  </div>
)

Required features for custom modals:

  • Escape key handler via useEffect + document.addEventListener('keydown', ...)
  • Body scroll lock: document.body.style.overflow = 'hidden'
  • Backdrop click to close
  • Semantic CSS variables for dark mode compatibility

Consequences:

  • Two modal patterns in the codebase (Radix for admin, custom for web)
  • Web app modals require manual accessibility implementation
  • Consistent dark mode rendering in the web app

Affected files:

  • apps/web/src/components/listing-detail/listing-inquiry-modal.tsx (reference implementation)
  • apps/web/src/components/dashboard/create-import-dialog.tsx
  • apps/web/src/components/dashboard/add-domain-dialog.tsx
  • apps/web/src/app/dashboard/members/invite-member-dialog.tsx

ADR-003: Team Logo — One Team Per Workspace

Status: Accepted (February 2026) Context: Real estate agents often belong to named teams within their brokerage (e.g., "The Jills Zeder Group" within Coldwell Banker Realty). Need to display team branding without over-engineering a full team management system.

Decision: One team per workspace, stored in workspace.settings.team (JSON field). No separate teams table.

Design:

  • TeamInfo interface: { name?, logoUrl?, logoUrlDark? } in packages/shared/src/types/theme.ts
  • Visual hierarchy: Team logo PRIMARY (100%), Brokerage logo SECONDARY (75-80%, subtle opacity)
  • Collapsible UI section, collapsed by default if no team data

Consequences:

  • Simple model that matches the existing workspace-per-agent structure
  • No database migration needed (uses existing settings JSON field)
  • The team feature is "an agent shows their team" — not a team management system
  • Future broker support (a brokerage managing multiple agents) would require a different architecture

ADR-004: Multi-Tenant First — workspace_id on Every Table

Status: Accepted (founding decision) Context: The platform serves multiple real estate agents, each with their own listings, branding, and configuration. Data isolation is critical.

Decision: Every data table includes a workspace_id column as the partition key. No exceptions.

Consequences:

  • All queries must include workspace_id — no cross-tenant data leakage possible
  • RLS policies can enforce workspace boundaries at the database level
  • Typesense collections are per-workspace for search isolation
  • Storage paths are prefixed with workspace ID
  • Slightly more complex queries but absolute tenant isolation

ADR-005: Source of Truth — Postgres Canonical, Typesense Derived

Status: Accepted (founding decision) Context: The platform needs both reliable persistent storage (for listings, media, compliance data) and fast faceted search (for the client-facing property search).

Decision: Supabase Postgres is the canonical data store. Typesense is a derived search index that can be rebuilt from Postgres at any time.

Sync flow:

Bridge API → Postgres (canonical) → Typesense (derived index)

Consequences:

  • Typesense can be rebuilt from scratch without data loss
  • Schema changes happen in Postgres first, then Typesense schema is updated
  • Listings are upserted to Postgres, then indexed to Typesense in the same sync run
  • If Typesense is unavailable, listings are still stored — they just aren't searchable until re-indexed
  • Slight latency between Postgres write and Typesense availability (typically < 1 second)

ADR-006: Incremental Sync via ModificationTimestamp

Status: Accepted (founding decision) Context: Full MLS syncs can involve thousands of listings. Syncing everything on every run is wasteful and slow.

Decision: Use the RESO ModificationTimestamp field to pull only listings modified since the last sync checkpoint.

Implementation:

  • Store last_synced_at on mls_connections (or import_tasks for task-level sync)
  • On incremental sync: $filter=ModificationTimestamp gt {last_synced_at}
  • On success: update checkpoint to the latest ModificationTimestamp in the batch
  • On failure: do not update checkpoint — next run retries the same range

Consequences:

  • Typical incremental syncs process 0-50 listings instead of 2,000+
  • Checkpoint provides exactly-once-or-more semantics (safe to re-process)
  • Full sync mode is available as a manual override when needed

On this page